From a5bf5586c75efdb6dbd16a856ed2be6344742211 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 16 Oct 2026 16:26:40 +0900 Subject: [PATCH 1/3] feat: support app-local Native UI Shell extensions --- demo/e2e/native-ui-extensions.spec.ts | 205 ++++++++++++++++++ demo/ios/App/App.xcodeproj/project.pbxproj | 4 + demo/ios/App/App/AppDelegate.swift | 3 +- demo/ios/App/App/NativeUICounter.swift | 43 ++++ .../NativeUIShellTests.swift | 35 +++ .../native-ui-shell/native-ui-shell.page.html | 11 +- .../native-ui-shell/native-ui-shell.page.ts | 2 + demo/src/main.ts | 2 + demo/src/native-ui-extensions.spec.ts | 25 +++ demo/src/native-ui/counter.ts | 14 ++ docs/native-ui-extensions.md | 106 +++++++++ docs/native-ui-shell.md | 2 + .../Components/ShellComponents.swift | 1 + .../Components/ShellVerticalBars.swift | 5 + .../Extensions/NativeUIShell.swift | 128 +++++++++++ .../IonicNativeUIShellPlugin.swift | 100 ++++++++- .../Shared/ShellSnapshot.swift | 14 +- .../NativeUIExtensionTests.swift | 99 +++++++++ src/native/definitions.ts | 11 +- src/native/extensions.ts | 134 ++++++++++++ src/native/index.ts | 30 ++- src/native/runtime.ts | 63 +++++- 22 files changed, 1013 insertions(+), 24 deletions(-) create mode 100644 demo/e2e/native-ui-extensions.spec.ts create mode 100644 demo/ios/App/App/NativeUICounter.swift create mode 100644 demo/src/native-ui-extensions.spec.ts create mode 100644 demo/src/native-ui/counter.ts create mode 100644 docs/native-ui-extensions.md create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Extensions/NativeUIShell.swift create mode 100644 ios/Tests/IonicNativeUIShellPluginTests/NativeUIExtensionTests.swift create mode 100644 src/native/extensions.ts diff --git a/demo/e2e/native-ui-extensions.spec.ts b/demo/e2e/native-ui-extensions.spec.ts new file mode 100644 index 000000000..35a8ae659 --- /dev/null +++ b/demo/e2e/native-ui-extensions.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from '@playwright/test'; +import { buildSync } from 'esbuild'; + +const bundle = buildSync({ + stdin: { + contents: "export { createRuntime } from '../../src/native/runtime'; export { defineNativeUI } from '../../src/native/extensions';", + resolveDir: __dirname, + }, + bundle: true, + format: 'iife', + globalName: 'extensions', + write: false, +}).outputFiles[0].text; + +test.beforeEach(async ({ page }) => { + await page.setContent( + '
Counter
', + ); + await page.addScriptTag({ content: bundle }); + await page.evaluate(async () => { + const w = window as any; + const source = document.querySelector('[data-counter]')!; + source.querySelector('button')!.addEventListener('click', () => { + source.dataset.count = String(Number(source.dataset.count) + 1); + }); + w.snapshots = []; + w.replies = []; + w.reject = false; + w.badReader = false; + w.externalTarget = false; + w.events = {}; + w.extension = w.extensions.defineNativeUI({ + name: 'app.counter', + selector: '[data-counter]', + read(element: HTMLElement) { + if (w.badReader) throw new Error('Invalid app state'); + return { + props: w.scalarProps ? 42 : { count: Number(element.dataset.count) }, + actions: { increment: w.externalTarget ? document.body : element.querySelector('button') }, + }; + }, + }); + w.plugin = { + async addListener(name: string, listener: Function) { + w.events[name] = listener; + return { remove: async () => delete w.events[name] }; + }, + async update(snapshot: any) { + w.snapshots.push(snapshot); + return { revision: snapshot.revision, rejectedControls: w.reject ? snapshot.controls.map((c: any) => c.id) : [] }; + }, + async clear() {}, + async completeExtensionAction(reply: any) { + w.replies.push(reply); + }, + }; + w.handle = await w.extensions.createRuntime(document, w.plugin, { controls: {}, extensions: [w.extension] }); + w.tap = () => { + const snapshot = w.snapshots.at(-1); + const control = snapshot.controls.find((c: any) => c.kind === 'extension'); + w.events.activate({ + revision: snapshot.revision, + id: control.extension.actions.increment, + sequence: w.replies.length + 1, + requestId: `request-${w.replies.length}`, + }); + }; + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); +}); + +test('projects an app-local reader, dispatches to Web once, and synchronizes new props', async ({ page }) => { + await page.evaluate(() => (window as any).tap()); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-count', '1'); + await expect.poll(() => page.evaluate(() => (window as any).replies)).toEqual([{ requestId: 'request-0', accepted: true }]); + await expect.poll(() => page.evaluate(() => JSON.parse((window as any).snapshots.at(-1).controls[0].extension.props).count)).toBe(1); +}); + +test('revalidates disabled actions before dispatch', async ({ page }) => { + await page.evaluate(() => { + document.querySelector('button')!.disabled = true; + (window as any).tap(); + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-count', '0'); + await expect.poll(() => page.evaluate(() => (window as any).replies[0]?.accepted)).toBe(false); +}); + +for (const shadow of [false, true]) { + test(`clipped element projections return to Web and recover on ${shadow ? 'shadow' : 'light'} DOM scroll`, async ({ page }) => { + await page.evaluate((shadow) => { + const source = document.querySelector('[data-counter]')!; + const host = document.createElement('div'); + source.before(host); + const scroller = document.createElement('div'); + scroller.id = 'scroller'; + scroller.style.cssText = 'position:absolute;top:100px;left:10px;width:300px;height:80px;overflow:auto'; + source.style.marginTop = '100px'; + const tail = document.createElement('div'); + tail.style.height = '200px'; + if (shadow) { + host.id = 'scroll-host'; + host.attachShadow({ mode: 'open' }).append(scroller); + scroller.append(document.createElement('slot'), tail); + host.append(source); + } else { + host.append(scroller); + scroller.append(source, tail); + } + }, shadow); + const source = page.locator('[data-counter]'); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + // Fully outside, partially clipped, then fully inside the same scroll container. + await page.locator('#scroller').evaluate((element) => { + element.scrollTop = 70; + }); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + await page.locator('#scroller').evaluate((element) => { + element.scrollTop = 100; + }); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => (window as any).tap()); + await expect(source).toHaveAttribute('data-count', '1'); + await page.locator('#scroller').evaluate((element) => { + element.scrollTop = 0; + }); + await expect(source).not.toHaveAttribute('data-native-ui-shell', ''); + }); +} + +for (const failure of ['native-rejection', 'reader-error', 'outside-action', 'invalid-props', 'scalar-props']) { + test(`returns to Web on ${failure} without stopping the shell`, async ({ page }) => { + await page.evaluate((failure) => { + const w = window as any; + w.reject = failure === 'native-rejection'; + w.badReader = failure === 'reader-error'; + w.externalTarget = failure === 'outside-action'; + w.scalarProps = failure === 'scalar-props'; + document.querySelector('[data-counter]')!.dataset.count = failure === 'invalid-props' ? 'NaN' : '2'; + }, failure); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + expect(await page.evaluate(() => (window as any).handle.getStatus().state)).not.toBe('stopped'); + }); +} + +test('overlay, manual suspension and teardown restore Web ownership', async ({ page }) => { + await page.evaluate(() => { + const overlay = document.createElement('ion-alert'); + document.body.append(overlay); + overlay.dispatchEvent(new Event('ionAlertWillPresent', { bubbles: true })); + }); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + const overlay = document.querySelector('ion-alert')!; + overlay.dispatchEvent(new Event('ionAlertDidDismiss', { bubbles: true })); + overlay.remove(); + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(async () => { + (window as any).lease = await (window as any).handle.suspend(); + }); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => (window as any).lease.resume()); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => (window as any).handle.destroy()); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('aria-hidden', 'true'); + expect(await page.evaluate(() => Object.keys((window as any).events))).toEqual([]); +}); + +test('an accessory waits for its native tab owner and recovers after competing sources disappear', async ({ page }) => { + await page.evaluate(async () => { + const w = window as any; + await w.handle.destroy(); + const app = document.querySelector('ion-app')!; + app.classList.add('ios-theme-vertical-bars'); + const source = document.querySelector('[data-counter]')!; + const tabs = document.createElement('ion-tabs'); + app.append(tabs); + tabs.append(source); + const bar = document.createElement('ion-tab-bar'); + bar.style.cssText = 'display:flex;width:200px;height:50px'; + tabs.append(bar); + w.handle = await w.extensions.createRuntime(document, w.plugin, { + controls: { tabs: true }, + extensions: [{ ...w.extension, placement: 'tab-accessory' }], + }); + }); + await expect(page.locator('[data-counter]')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + document.querySelector('ion-tab-bar')!.innerHTML = + 'Home'; + }); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + const source = document.querySelector('[data-counter]')!; + const clone = source.cloneNode(true) as HTMLElement; + clone.removeAttribute('data-native-ui-shell'); + clone.removeAttribute('aria-hidden'); + clone.id = 'competing'; + source.after(clone); + }); + await expect(page.locator('[data-counter]').first()).not.toHaveAttribute('data-native-ui-shell', ''); + await expect(page.locator('#competing')).not.toHaveAttribute('data-native-ui-shell', ''); + await page.locator('#competing').evaluate((element) => element.remove()); + await expect(page.locator('[data-counter]')).toHaveAttribute('data-native-ui-shell', ''); +}); diff --git a/demo/ios/App/App.xcodeproj/project.pbxproj b/demo/ios/App/App.xcodeproj/project.pbxproj index 8008c87fe..ebfb139a0 100644 --- a/demo/ios/App/App.xcodeproj/project.pbxproj +++ b/demo/ios/App/App.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AA2010010000000000000001 /* NativeUICounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2010010000000000000002 /* NativeUICounter.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; @@ -19,6 +20,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AA2010010000000000000002 /* NativeUICounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeUICounter.swift; sourceTree = ""; }; 0F2D0778A9AC04BB9CF8EA1D /* debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = ""; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; @@ -66,6 +68,7 @@ children = ( 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + AA2010010000000000000002 /* NativeUICounter.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -159,6 +162,7 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + AA2010010000000000000001 /* NativeUICounter.swift in Sources */, 5A8EC910E2334844A592648D /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/demo/ios/App/App/AppDelegate.swift b/demo/ios/App/App/AppDelegate.swift index 64e216c69..8c920f963 100644 --- a/demo/ios/App/App/AppDelegate.swift +++ b/demo/ios/App/App/AppDelegate.swift @@ -7,7 +7,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + do { try registerNativeUICounter() } + catch { NSLog("Native UI registration failed: %@", String(describing: error)) } return true } diff --git a/demo/ios/App/App/NativeUICounter.swift b/demo/ios/App/App/NativeUICounter.swift new file mode 100644 index 000000000..a2294058a --- /dev/null +++ b/demo/ios/App/App/NativeUICounter.swift @@ -0,0 +1,43 @@ +import SwiftUI +import IonicNativeUIShellPlugin + +private struct CounterProps: Decodable { + let count: Int +} + +private struct CounterView: View { + let props: CounterProps + let actions: NativeUIActions + let accessory: Bool + @State private var busy = false + @State private var error: String? + + var body: some View { + HStack { + Text("\(accessory ? "Accessory count" : "Count"): \(props.count)") + Button(accessory ? "Increment native accessory" : "Increment native counter") { + busy = true + Task { @MainActor in + defer { busy = false } + do { + try await actions.activate("increment") + error = nil + } catch { + self.error = "Action unavailable" + } + } + } + .disabled(busy) + if let error { Text(error).font(.caption) } + } + .padding(.horizontal) + } +} + +@MainActor func registerNativeUICounter() throws { + for name in ["demo.counter", "demo.accessory-counter"] { + try NativeUIShell.register(name) { (props: CounterProps, actions: NativeUIActions) in + CounterView(props: props, actions: actions, accessory: name == "demo.accessory-counter") + } + } +} diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 6e34b4b35..13b41402d 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -604,6 +604,41 @@ final class NativeUIShellTests: XCTestCase { } } + func testAppLocalExtensionsFollowWebStateAndOverlayOwnership() throws { + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + for vertical in [false, true] { + app.launch() + let toggle = app.webViews.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) + if (toggle.value as? String == "1") != vertical { toggle.tap() } + openPage(app, name: "native-ui-shell") + let counter = app.buttons["Increment native counter"].firstMatch + XCTAssertTrue(counter.waitForExistence(timeout: 10), app.debugDescription) + for count in 1...3 { + counter.tap() + XCTAssertTrue(app.staticTexts["Count: \(count)"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + } + if vertical { + let accessory = app.buttons["Increment native accessory"].firstMatch + XCTAssertTrue(accessory.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertTrue(accessory.isHittable, app.debugDescription) + accessory.tap() + XCTAssertTrue(app.staticTexts["Accessory count: 1"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + } else { + XCTAssertTrue(app.webViews.buttons["Increment accessory"].firstMatch.exists, app.debugDescription) + } + app.webViews.buttons["Open modal"].firstMatch.tap() + XCTAssertTrue(app.webViews.buttons["Close modal"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(counter.waitForNonExistence(timeout: 5), app.debugDescription) + app.webViews.buttons["Close modal"].firstMatch.tap() + XCTAssertTrue(counter.waitForExistence(timeout: 10), app.debugDescription) + counter.tap() + XCTAssertTrue(app.staticTexts["Count: 4"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + capture(vertical ? "app-extension-accessory" : "app-extension-element") + app.terminate() + } + } + private func openPage(_ app: XCUIApplication, name: String) { let label = name == "native-ui-shell" ? "native-ui-shell (Preview)" : name let entry = app.webViews.buttons[label].exists ? app.webViews.buttons[label] : app.webViews.links[label] diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.html b/demo/src/app/native-ui-shell/native-ui-shell.page.html index 8ba854c9d..9c8687849 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.html +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.html @@ -32,6 +32,16 @@
+ +

App-local SwiftUI extensions (Web fallback on unsupported hosts):

+
+ Count: {{ counter() }} + +
+
+ Accessory count: {{ accessoryCounter() }} + +

Save count: {{ saves() }} @@ -42,7 +52,6 @@ Selection: {{ selected() }} / Change count: {{ changes() }}

-

Scroll the background to compare transparency and refraction in the top-right button.

diff --git a/demo/src/app/native-ui-shell/native-ui-shell.page.ts b/demo/src/app/native-ui-shell/native-ui-shell.page.ts index 42b4d8113..401e8e46c 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.ts +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.ts @@ -37,6 +37,8 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class NativeUIShellPage { + readonly counter = signal(0); + readonly accessoryCounter = signal(0); readonly saves = signal(0); readonly github = signal(0); readonly refreshes = signal(0); diff --git a/demo/src/main.ts b/demo/src/main.ts index 4680659d7..661c8c493 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -1,3 +1,4 @@ +import { counter, accessoryCounter } from './native-ui/counter'; import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; import { AppComponent } from './app/app.component'; @@ -46,6 +47,7 @@ const startShell = const buttonDefaultFill = stockIonic || new URLSearchParams(window.location.search).get('buttonDefaultFill') === 'solid' ? 'solid' : null; const buttonProjection = new URLSearchParams(window.location.search).get('buttonProjection') === 'source' ? 'source' : 'system'; void startShell({ + extensions: [counter, accessoryCounter], buttonProjection, buttonDefaultFill, controls: { tabs: true, toolbar: true, segment: true, fab: true, popover: true }, diff --git a/demo/src/native-ui-extensions.spec.ts b/demo/src/native-ui-extensions.spec.ts new file mode 100644 index 000000000..fa9956ae4 --- /dev/null +++ b/demo/src/native-ui-extensions.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; +import { defineNativeUI, enableNativeUIShell } from '../../src/native'; + +const counter = () => defineNativeUI({ name: 'app.counter', selector: '[data-counter]', read: () => ({ props: { count: 0 } }) }); + +test('local extensions are stable configuration and require teardown before replacement', async () => { + const extension = counter(); + const first = await enableNativeUIShell({ extensions: [extension] }); + try { + expect(await enableNativeUIShell({ extensions: [extension] })).toBe(first); + await expect(enableNativeUIShell({ extensions: [counter()] })).rejects.toThrow('different controls'); + await expect(enableNativeUIShell()).rejects.toThrow('different controls'); + } finally { + await first.destroy(); + } + const replacement = await enableNativeUIShell({ extensions: [counter()] }); + await replacement.destroy(); +}); + +test('duplicate names and invalid selectors reject before acquiring shell resources', async () => { + await expect(enableNativeUIShell({ extensions: [counter(), counter()] })).rejects.toThrow('unique'); + await expect(enableNativeUIShell({ extensions: [{ ...counter(), selector: '[' }] })).rejects.toThrow(); + const handle = await enableNativeUIShell({ extensions: [counter()] }); + await handle.destroy(); +}); diff --git a/demo/src/native-ui/counter.ts b/demo/src/native-ui/counter.ts new file mode 100644 index 000000000..ea9dd9cb1 --- /dev/null +++ b/demo/src/native-ui/counter.ts @@ -0,0 +1,14 @@ +import { defineNativeUI, type NativeUIExtension } from '../../../src/native'; + +const read: NativeUIExtension['read'] = (element) => ({ + props: { count: Number(element.getAttribute('data-count') ?? 0) }, + actions: { increment: element.querySelector('button') }, +}); + +export const counter = defineNativeUI({ name: 'demo.counter', selector: '[data-native-counter]', read }); +export const accessoryCounter = defineNativeUI({ + name: 'demo.accessory-counter', + selector: '[data-native-accessory-counter]', + placement: 'tab-accessory', + read, +}); diff --git a/docs/native-ui-extensions.md b/docs/native-ui-extensions.md new file mode 100644 index 000000000..bd2c8bde6 --- /dev/null +++ b/docs/native-ui-extensions.md @@ -0,0 +1,106 @@ +--- +title: App-local Native UI extensions +--- + +# App-local Native UI extensions + +An extension projects your existing Web UI into a SwiftUI View. Keep both definitions in your application: no npm package, separate library, or Capacitor plugin is required. The Web element remains the source of truth and the fallback UI. + +## 1. Define a DOM reader + +```ts +// src/native-ui/counter.ts +import { defineNativeUI } from '@rdlabo/ionic-theme-ios27/native'; + +export const counter = defineNativeUI({ + name: 'app.counter', + selector: '[data-counter]', + read(element) { + return { + props: { count: Number(element.getAttribute('data-count') ?? 0) }, + actions: { increment: element.querySelector('button') }, + }; + }, +}); +``` + +For example, render `
` with your framework. Its click handler updates the Web state and `data-count` attribute. The shell observes DOM changes and sends the new props to SwiftUI. State held only in JavaScript variables must first be reflected in the DOM. + +The reader is synchronous and read-only. Return `null` when the source should stay on the Web. Props are a JSON object (finite numbers, strings, booleans, null, arrays and objects), limited to 64K characters. Action names map to the source itself or its descendants; missing, hidden and disabled targets cannot be activated. Up to 30 actions are supported per source. + +## 2. Register a SwiftUI View in the app target + +Add a Swift file to the application's Xcode target. Import `IonicNativeUIShellPlugin`, the module supplied by the theme's existing Capacitor installation. + +```swift +import SwiftUI +import IonicNativeUIShellPlugin + +struct CounterProps: Decodable { + let count: Int +} + +struct CounterView: View { + let props: CounterProps + let actions: NativeUIActions + @State private var error: String? + + var body: some View { + HStack { + Text("Count: \(props.count)") + Button("Increment") { + Task { @MainActor in + do { + try await actions.activate("increment") + error = nil + } catch { + self.error = "Action unavailable" + } + } + } + if let error { Text(error) } + } + } +} + +@MainActor func registerAppNativeUI() throws { + try NativeUIShell.register("app.counter") { + (props: CounterProps, actions: NativeUIActions) in + CounterView(props: props, actions: actions) + } +} +``` + +Call `registerAppNativeUI()` once from `application(_:didFinishLaunchingWithOptions:)`, before JavaScript enables the shell. Handle a registration error there; duplicate or empty names are rejected. Swift source is compiled into the app, so adding or changing a View requires an iOS rebuild. Registration is synchronous setup, not a native bridge command. + +## 3. Enable with local definitions + +```ts +import { enableNativeUIShell } from '@rdlabo/ionic-theme-ios27/native'; +import { counter } from './native-ui/counter'; + +const shell = await enableNativeUIShell({ extensions: [counter] }); +``` + +Keep extension objects stable across repeated enable calls. Changing extensions while running requires `await shell.destroy()` first. Extension names must be unique within an activation. Do not register overlapping selectors for the same source. An extension owns its source subtree, so built-in controls inside it are not projected separately. + +## Content and placement are independent + +| `placement` | Behavior | +| --- | --- | +| Omitted or `'element'` | Hosts the View in the source element's Web frame on iOS 26+. | +| `'tab-accessory'` | Attaches the View to the system SwiftUI tab accessory on iOS 26.1+. | + +For `tab-accessory`, place the source inside its owning `ion-tabs`. The shell resolves that tabs element's `ion-tab-bar`; applications do not pass native controller references. Currently only the **Vertical Bars SwiftUI tab host** supports this placement. Ordinary UIKit tab bars, missing native tabs, iOS 26.0, or multiple competing accessories keep the source on the Web. The shell does not approximate a system accessory with a floating overlay. Keep an equivalent Web layout (for example, a fixed footer); the source retains its layout space while hidden. + +`enableVerticalControlArea({ extensions: [...] })` also accepts extensions. In this standalone mode only accessories belonging to native Vertical Bars are eligible; ordinary element projections require `enableNativeUIShell`. + +## Lifecycle and action completion + +The shell owns discovery, geometry, state synchronization, native controller containment, overlay retirement, suspension and teardown. Extensions do not implement attach/detach hooks. Web sources are hidden only after native accepts the projection. An unregistered renderer, incompatible Swift props, unsupported placement or failed reader leaves that instance on the Web. Existing shell overlay policy applies; extensions return to the Web while Web overlays cover the page. + +Element placement requires the source to fit fully inside the viewport and its ancestors' overflow clipping bounds, including Ionic's shadow scroll containers. Partially clipped sources stay on the Web and become eligible again when scrolled fully into view. Tab accessories use the system host's placement instead. + +`try await actions.activate(name)` completes after the original Web element receives its click. It does **not** wait for application work launched by the click handler (network requests, navigation or modal presentation). Disabled, removed or stale actions reject; teardown, Swift task cancellation and bridge timeouts settle pending calls. The bridge acknowledgement is a Capacitor Promise method. + +The [demo reader](../demo/src/native-ui/counter.ts) and [demo SwiftUI View](../demo/ios/App/App/NativeUICounter.swift) show both placements using app-local files. Open the Native UI Shell demo page to compare native projection with its Web fallback. diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 34e0df3ce..b25bdf7ba 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -2,6 +2,8 @@ title: Native UI Shell (Preview) --- +For application-specific SwiftUI content, see [App-local Native UI extensions](native-ui-extensions.md). + # Native UI Shell (Preview) Native UI Shell is available as a **preview** in `1.2.0`. Its API and supported controls may change before stable status. Stable status is planned after the official release of Xcode 27.1. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift index bb7caeac5..c9e12fee0 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellComponents.swift @@ -13,6 +13,7 @@ enum ShellComponents { case .tabBar: return ShellTabBar.make(node, rendering: rendering, delegate: tabDelegate) case .segment: return ShellSegment.make(node, scale: scale, rendering: rendering, activate: activate) case .fab: return ShellFab() + case .extension: return nil // App-local SwiftUI content is hosted by the shell. } } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index 78efc3e9a..b4c747fec 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -7,6 +7,7 @@ protocol ShellVerticalBarsControlling: AnyObject { func measuredFrames(relativeTo webView: UIView) -> [String: CGRect] @MainActor func attach(to owner: UIViewController, in parent: UIView) @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String, animated: Bool) + @MainActor func setAccessory(_ content: AnyView?) var ownsKeyboardChrome: Bool { get } func presentPopover(anchor: String, host: ShellOverlayHost, completion: @escaping (Bool) -> Void) -> Bool @MainActor func detach(duration: TimeInterval) @@ -34,6 +35,7 @@ final class ShellVerticalBarsModel: ObservableObject { let slot: ShellControl.ToolbarSlot? } + @Published var accessory: AnyView? @Published var popoverAnchor: String? var popoverHost: ShellOverlayHost? var popoverPresented: ((Bool) -> Void)? @@ -235,6 +237,7 @@ private struct ShellVerticalBarsView: View { .accessibilityIdentifier(item.id) } } + .modifier(ShellExtensionAccessory(content: model.accessory)) } } .modifier(ShellVerticalBarsSearch(model: model)) @@ -438,6 +441,8 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { private weak var owner: UIViewController? private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int + func setAccessory(_ content: AnyView?) { model.accessory = content } + var view: UIView { container } func measuredFrames(relativeTo webView: UIView) -> [String: CGRect] { var frames: [String: CGRect] = [:] diff --git a/ios/Sources/IonicNativeUIShellPlugin/Extensions/NativeUIShell.swift b/ios/Sources/IonicNativeUIShellPlugin/Extensions/NativeUIShell.swift new file mode 100644 index 000000000..12e3e54e5 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Extensions/NativeUIShell.swift @@ -0,0 +1,128 @@ +import SwiftUI +import UIKit + +/// App-local SwiftUI renderers. Register once before enabling the shell from JavaScript. +@MainActor public enum NativeUIShell { + private static var renderers: [String: (String, NativeUIActions) throws -> AnyView] = [:] + + /// The name must match defineNativeUI in the application's TypeScript source. + public static func register( + _ name: String, @ViewBuilder content: @escaping (Props, NativeUIActions) -> Content + ) throws { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, renderers[name] == nil else { + throw NativeUIError.duplicateRegistration + } + renderers[name] = { json, actions in + let props = try JSONDecoder().decode(Props.self, from: Data(json.utf8)) + return AnyView(content(props, actions)) + } + } + + static var names: [String] { renderers.keys.sorted() } + + static func content(_ data: ShellExtension, actions: NativeUIActions) throws -> AnyView { + guard let renderer = renderers[data.name] else { throw NativeUIError.unavailable } + return try renderer(data.props, actions) + } +} + +public enum NativeUIError: Error { + case duplicateRegistration, unavailable, rejected, timedOut +} + +/// Completion means the original Web element received its click, not that the +/// application's asynchronous work triggered by that click has finished. +@MainActor public struct NativeUIActions { + let dispatch: (String) async throws -> Void + + public func activate(_ name: String) async throws { + try Task.checkCancellation() + try await dispatch(name) + } +} + +struct ShellExtension: Decodable, Equatable { + let name: String + let props: String + let actions: [String: String] + let tabBarId: String? + + var isValid: Bool { + !name.isEmpty && props.utf8.count <= 262144 && actions.count <= 30 && + actions.allSatisfy { !$0.key.isEmpty && !$0.value.isEmpty } && + (tabBarId.map { !$0.isEmpty } ?? true) && + (try? JSONSerialization.jsonObject(with: Data(props.utf8))) is [String: Any] + } +} + +/// Owns pending native -> Web calls independently of the View's lifetime. +@MainActor final class ShellExtensionActions { + private struct Pending { + let owner: String + let continuation: CheckedContinuation + let deadline: DispatchWorkItem + } + private var pending: [String: Pending] = [:] + + func perform(owner: String, timeout: TimeInterval = 4, send: (String) -> Void) async throws { + let request = UUID().uuidString + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { continuation in + let deadline = DispatchWorkItem { [weak self] in self?.finish(request, error: NativeUIError.timedOut) } + pending[request] = Pending(owner: owner, continuation: continuation, deadline: deadline) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: deadline) + send(request) + } + } onCancel: { + Task { @MainActor [weak self] in self?.finish(request, error: CancellationError()) } + } + } + + func finish(_ request: String, error: Error? = nil) { + guard let call = pending.removeValue(forKey: request) else { return } + call.deadline.cancel() + if let error { call.continuation.resume(throwing: error) } + else { call.continuation.resume() } + } + + func cancel(owner: String? = nil) { + for (request, call) in pending where owner == nil || call.owner == owner { + finish(request, error: NativeUIError.unavailable) + } + } +} + +/// Normal UIViewController containment keeps SwiftUI appearance and teardown intact. +@MainActor final class ShellExtensionController { + let controller: UIHostingController + var view: UIView { controller.view } + + init(content: AnyView, owner: UIViewController) { + controller = UIHostingController(rootView: content) + controller.view.backgroundColor = .clear + if #available(iOS 16.4, *) { controller.safeAreaRegions = [] } + owner.addChild(controller) + // Capacitor can use WKWebView as its controller's root view. A sibling + // shell surface is outside that controller's containment hierarchy. + owner.view.addSubview(controller.view) + controller.didMove(toParent: owner) + } + + func update(_ content: AnyView) { controller.rootView = content } + func detach(duration: TimeInterval) { + controller.willMove(toParent: nil) + ShellCrossfade.retire(controller.view, duration: duration) + controller.removeFromParent() + } +} + +@available(iOS 26.0, *) +struct ShellExtensionAccessory: ViewModifier { + let content: AnyView? + func body(content base: Content) -> some View { + if #available(iOS 26.1, *) { + base.tabViewBottomAccessory(isEnabled: content != nil) { content } + } else { base } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index dbf6c015d..cd2fd8555 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -1,5 +1,6 @@ import Capacitor import UIKit +import SwiftUI import WebKit @objc(IonicNativeUIShellPlugin) @@ -7,6 +8,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele public let identifier = "IonicNativeUIShellPlugin" public let jsName = "IonicNativeUIShell" public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "completeExtensionAction", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getVerticalBarsGeometry", returnType: CAPPluginReturnPromise), @@ -23,6 +25,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] private var searchControllers: [String: ShellSearchControlling] = [:] + private var extensionControllers: [String: ShellExtensionController] = [:] + private var extensionSessions: [String: UUID] = [:] + private var extensionNodes: [String: ShellControl] = [:] + private let extensionActions = ShellExtensionActions() private var fingerprints: [String: ShellControl] = [:] private let rendering = ShellRendering() private var configurationGeneration = 0 @@ -53,6 +59,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if !keyboard { self.host?.isHidden = true self.verticalBars?.view.isHidden = true + self.extensionControllers.values.forEach { $0.view.isHidden = true } } var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true self.searchControllers.values.forEach { controller in @@ -188,7 +195,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele effect.isHidden = true self.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden } } - call.resolve(["supported": true]) + call.resolve(["supported": true, "extensions": NativeUIShell.names]) } else { call.resolve(["supported": false]) } } @@ -219,6 +226,36 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } + @objc func completeExtensionAction(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let request = call.getString("requestId"), let accepted = call.getBool("accepted") else { + call.reject("Invalid extension acknowledgement"); return + } + self?.extensionActions.finish(request, error: accepted ? nil : NativeUIError.rejected) + call.resolve() + } + } + + @MainActor private func extensionContent(_ node: ShellControl) throws -> AnyView { + guard let data = node.extensionContent else { throw NativeUIError.unavailable } + let session = extensionSessions[node.id] ?? UUID() + extensionSessions[node.id] = session + let actions = NativeUIActions { [weak self] name in + guard let self, self.extensionNodes[node.id] == node, self.extensionSessions[node.id] == session, let target = data.actions[name] else { + throw NativeUIError.unavailable + } + try await self.extensionActions.perform(owner: node.id) { request in + self.sequence += 1 + self.notifyListeners("activate", data: ["id": target, "revision": self.revision, + "sequence": self.sequence, "requestId": request]) + } + } + return AnyView(try NativeUIShell.content(data, actions: actions) + .environment(\.layoutDirection, node.rtl ? .rightToLeft : .leftToRight) + .environment(\.colorScheme, node.dark ? .dark : .light) + .id(node.id)) + } + @objc func clear(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard let self else { call.resolve(); return } @@ -234,14 +271,23 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func removeControl(_ id: String, duration: TimeInterval = 0) { + extensionActions.cancel(owner: id) + extensionNodes.removeValue(forKey: id) + extensionSessions.removeValue(forKey: id) + let extensionController = extensionControllers.removeValue(forKey: id) searchControllers.removeValue(forKey: id)?.detach() - if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) } + let control = controls.removeValue(forKey: id) + if let extensionController { extensionController.detach(duration: duration) } + else if let control { ShellCrossfade.retire(control, duration: duration) } fingerprints.removeValue(forKey: id) pendingTabSelections.removeValue(forKey: id) pendingTabExpiryWorks.removeValue(forKey: id)?.cancel() } private func removeControls(duration: TimeInterval = 0) { + extensionActions.cancel() + extensionNodes.removeAll() + extensionSessions.removeAll() Array(controls.keys).forEach { removeControl($0, duration: duration) } host?.removeFromSuperview() host = nil @@ -297,11 +343,18 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let duration = ShellCrossfade.duration(snapshot.transitionDuration) let existing = Set(self.controls.keys) let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } - let snapshots = snapshot.controls.filter { $0.placement != .verticalBars } + let accessories = snapshot.controls.filter { $0.extensionContent?.tabBarId != nil } + let snapshots = snapshot.controls.filter { $0.placement != .verticalBars && $0.extensionContent?.tabBarId == nil } + let extensionIDs = Set(snapshot.controls.filter { $0.kind == .extension }.map(\.id)) + for id in Array(self.extensionNodes.keys) where !extensionIDs.contains(id) { + self.extensionActions.cancel(owner: id) + self.extensionNodes.removeValue(forKey: id) + self.extensionSessions.removeValue(forKey: id) + } let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false } let width = snapshot.viewportWidth self.revision = next - if snapshots.isEmpty && verticalBars.isEmpty { + if snapshots.isEmpty && verticalBars.isEmpty && accessories.isEmpty { self.removeControls(duration: duration) call.resolve(["revision": next]); return } @@ -339,6 +392,22 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } else { rejectedControls.append(contentsOf: verticalBars.map(\.id)) } + // A system tab host has one accessory slot. Ambiguous owners stay on the Web. + var accessoryContent: AnyView? + for node in accessories { + guard #available(iOS 26.1, *), accessories.count == 1, !self.keyboardVisible, self.verticalBars != nil, + verticalBars.filter({ $0.kind == .tabBar }).map(\.id) == [node.extensionContent!.tabBarId!], + let content = try? self.extensionContent(node) else { + rejectedControls.append(node.id) + self.extensionNodes.removeValue(forKey: node.id) + self.extensionSessions.removeValue(forKey: node.id) + self.extensionActions.cancel(owner: node.id) + continue + } + self.extensionNodes[node.id] = node + accessoryContent = content + } + self.verticalBars?.setAccessory(accessoryContent) if snapshots.isEmpty { self.host?.removeFromSuperview() self.host = nil @@ -355,6 +424,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if host.superview !== parent { parent.addSubview(host) } for node in snapshots { let id = node.id + if node.kind != .extension, self.extensionControllers[id] != nil { self.removeControl(id) } let previous = self.controls[id] let previousBounds = previous?.bounds let previousCenter = previous?.center @@ -374,6 +444,28 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let local = node.frame.rect let bounds = webView.convert(CGRect(x: local.minX * scale, y: local.minY * scale, width: local.width * scale, height: local.height * scale), to: parent) + if node.kind == .extension { + guard let owner = self.bridge?.viewController, let content = try? self.extensionContent(node) else { + self.extensionNodes.removeValue(forKey: id) + self.extensionSessions.removeValue(forKey: id) + self.extensionActions.cancel(owner: id) + reject(); continue + } + if let controller = self.extensionControllers[id] { + controller.update(content) + } else { + if let previous = self.controls.removeValue(forKey: id) { previous.removeFromSuperview() } + self.searchControllers.removeValue(forKey: id)?.detach() + let controller = ShellExtensionController(content: content, owner: owner) + self.extensionControllers[id] = controller + self.controls[id] = controller.view + } + self.extensionNodes[id] = node + self.controls[id]?.frame = parent.convert(bounds, to: owner.view) + self.controls[id]?.isHidden = false + self.controls[id]?.overrideUserInterfaceStyle = node.dark ? .dark : .light + continue + } if let search = node.search { guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue } let controller: ShellSearchControlling diff --git a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift index 58b09ec61..86ffad89d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift @@ -2,6 +2,7 @@ import Foundation import CoreGraphics enum ShellComponent: String, Decodable { + case `extension` case button = "ion-button", buttons = "ion-buttons" case backButton = "ion-back-button", menuButton = "ion-menu-button" case tabBar = "ion-tab-bar", segment = "ion-segment", fab = "ion-fab" @@ -47,8 +48,12 @@ struct ShellControl: Decodable, Equatable { let rtl: Bool let tabBarAnchor: ShellTabBar.Anchor? let search: ShellSearch? + let extensionContent: ShellExtension? - private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search } + private enum CodingKeys: String, CodingKey { + case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search + case extensionContent = "extension" + } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -62,10 +67,15 @@ struct ShellControl: Decodable, Equatable { rtl = try values.decode(Bool.self, forKey: .rtl) tabBarAnchor = try values.decodeIfPresent(ShellTabBar.Anchor.self, forKey: .tabBarAnchor) search = try values.decodeIfPresent(ShellSearch.self, forKey: .search) + extensionContent = try values.decodeIfPresent(ShellExtension.self, forKey: .extensionContent) } var isValid: Bool { - ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 && + if kind == .extension { + return !id.isEmpty && frame.isValid && items.isEmpty && placement == nil && toolbarSlot == nil && + search == nil && tabBarAnchor == nil && extensionContent?.isValid == true + } + return extensionContent == nil && ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 && (!ShellButton.kinds.contains(kind) || items.count == 1) && (toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) && Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) && diff --git a/ios/Tests/IonicNativeUIShellPluginTests/NativeUIExtensionTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/NativeUIExtensionTests.swift new file mode 100644 index 000000000..41956e232 --- /dev/null +++ b/ios/Tests/IonicNativeUIShellPluginTests/NativeUIExtensionTests.swift @@ -0,0 +1,99 @@ +import XCTest +import SwiftUI +import WebKit +@testable import IonicNativeUIShellPlugin + +final class NativeUIExtensionTests: XCTestCase { + struct Props: Decodable { let title: String } + + @MainActor func testLocalRegistrationDecodesPropsAndRejectsDuplicateAndMissingRenderers() throws { + let name = UUID().uuidString + var title = "" + try NativeUIShell.register(name) { (props: Props, _: NativeUIActions) in + title = props.title + return Text(props.title) + } + let actions = NativeUIActions { _ in } + _ = try NativeUIShell.content(ShellExtension(name: name, props: "{\"title\":\"Local View\"}", actions: [:], tabBarId: nil), actions: actions) + XCTAssertEqual(title, "Local View") + XCTAssertThrowsError(try NativeUIShell.register(name) { (props: Props, _: NativeUIActions) in Text(props.title) }) + XCTAssertThrowsError(try NativeUIShell.content(ShellExtension(name: name, props: "{}", actions: [:], tabBarId: nil), actions: actions)) + XCTAssertThrowsError(try NativeUIShell.content(ShellExtension(name: "missing", props: "{}", actions: [:], tabBarId: nil), actions: actions)) + } + + @MainActor func testExtensionSnapshotAcceptsEmptyActionsButRejectsInvalidProps() throws { + func decode(_ props: String) throws -> ShellControl { + let json: [String: Any] = ["id": "counter", "kind": "extension", "x": 0, "y": 0, "width": 100, "height": 44, + "dark": false, "rtl": false, "items": [], "extension": ["name": "app.counter", "props": props, "actions": [:]]] + return try JSONDecoder().decode(ShellControl.self, from: JSONSerialization.data(withJSONObject: json)) + } + XCTAssertTrue(try decode("{\"title\":\"Ready\"}").isValid) + XCTAssertFalse(try decode("[]").isValid) + XCTAssertFalse(try decode("broken").isValid) + } + + @MainActor func testActionWaitsForWebAcknowledgementAndIgnoresDuplicateReplies() async throws { + let broker = ShellExtensionActions() + var request: String? + var completed = false + let sent = expectation(description: "Web action sent") + let task = Task { @MainActor in + try await broker.perform(owner: "source") { request = $0; sent.fulfill() } + completed = true + } + defer { task.cancel() } + await fulfillment(of: [sent], timeout: 2) + let requestID = try XCTUnwrap(request) + XCTAssertFalse(completed) + broker.finish(requestID) + broker.finish(requestID, error: NativeUIError.rejected) + try await task.value + XCTAssertTrue(completed) + } + + @MainActor func testRetirementRejectsPendingCallsOnlyForThatSource() async throws { + let broker = ShellExtensionActions() + var requests: [String: String] = [:] + let sent = expectation(description: "Both actions sent") + sent.expectedFulfillmentCount = 2 + let first = Task { @MainActor in try await broker.perform(owner: "first") { requests["first"] = $0; sent.fulfill() } } + let second = Task { @MainActor in try await broker.perform(owner: "second") { requests["second"] = $0; sent.fulfill() } } + defer { first.cancel(); second.cancel() } + await fulfillment(of: [sent], timeout: 2) + broker.cancel(owner: "first") + do { try await first.value; XCTFail("Retired source must reject") } catch NativeUIError.unavailable {} catch { XCTFail("\(error)") } + broker.finish(try XCTUnwrap(requests["second"])) + try await second.value + } + + @MainActor func testBridgeLossAndTaskCancellationAlwaysSettle() async { + let broker = ShellExtensionActions() + do { + try await broker.perform(owner: "lost", timeout: 0.01) { _ in } + XCTFail("Lost bridge must time out") + } catch NativeUIError.timedOut {} catch { XCTFail("\(error)") } + let sent = expectation(description: "Cancelable action sent") + let task = Task { @MainActor in try await broker.perform(owner: "canceled") { _ in sent.fulfill() } } + await fulfillment(of: [sent], timeout: 2) + task.cancel() + do { try await task.value; XCTFail("Canceled task must reject") } catch is CancellationError {} catch { XCTFail("\(error)") } + broker.cancel() + } + + @MainActor func testHostedViewUsesControllerContainmentAndDetaches() { + let owner = UIViewController() + owner.view = WKWebView() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + window.rootViewController = owner + window.makeKeyAndVisible() + defer { window.isHidden = true } + let host = ShellExtensionController(content: AnyView(Text("Local content")), owner: owner) + XCTAssertTrue(host.view.superview === owner.view) + XCTAssertTrue(host.controller.parent === owner) + host.update(AnyView(Text("Updated"))) + host.detach(duration: 0) + XCTAssertNil(host.controller.parent) + XCTAssertNil(host.view.superview) + XCTAssertTrue(owner.children.isEmpty) + } +} diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 7e58d3a63..40646e2d2 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -1,3 +1,4 @@ +import type { NativeUIExtension } from './extensions'; import type { PluginListenerHandle } from '@capacitor/core'; import type { NativeUIShellComponent } from './components'; @@ -11,6 +12,8 @@ export interface NativeUIShellStatus { } export interface VerticalControlAreaOptions { + /** App-local DOM readers with matching SwiftUI registrations. */ + extensions?: readonly NativeUIExtension[]; /** Opt-in popovers anchored to native Vertical Bars buttons. */ controls?: Pick; /** Appearance of native vertical ion-button and ion-menu-button actions. @@ -128,7 +131,8 @@ export interface ShellItem extends Frame { export interface ShellControl extends Frame { id: string; - kind: NativeUIShellComponent; + kind: NativeUIShellComponent | 'extension'; + extension?: { name: string; props: string; actions: Record; tabBarId?: string }; /** Lets the native host own adaptive placement instead of mirroring the DOM frame. */ placement?: 'vertical-bars'; /** Logical Ionic toolbar slot, preserved when projecting to the vertical rail. */ @@ -177,6 +181,8 @@ export interface ShellActivation { revision: number; id: string; sequence: number; + /** Native extension action awaiting a Web dispatch acknowledgement. */ + requestId?: string; } export interface WebViewMetrics { @@ -185,7 +191,8 @@ export interface WebViewMetrics { } export interface NativeUIShellPlugin { - configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; + configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean; extensions?: string[] }>; + completeExtensionAction(options: { requestId: string; accepted: boolean }): Promise; getWebViewMetrics(): Promise; getVerticalBarsGeometry(options: { viewportWidth: number; ids: string[] }): Promise<{ revision?: number; items: Record }>; prepareOverlay(options: { id: string }): Promise; diff --git a/src/native/extensions.ts b/src/native/extensions.ts new file mode 100644 index 000000000..8adc01eeb --- /dev/null +++ b/src/native/extensions.ts @@ -0,0 +1,134 @@ +import type { Candidate, Identify } from './shared/candidate'; +import { frame, isDark, visible } from './shared/dom'; + +export type NativeUIValue = string | number | boolean | null | NativeUIValue[] | { [key: string]: NativeUIValue }; + +/** An app-local DOM projection. Swift registers its View under the same name. */ +export interface NativeUIExtension { + readonly name: string; + readonly selector: string; + /** Defaults to the source element's frame. Unsupported hosts keep the Web source visible. */ + readonly placement?: 'element' | 'tab-accessory'; + /** Return null to keep this instance on the Web. Read only DOM-backed state. */ + readonly read: (element: HTMLElement) => { + props: { [key: string]: NativeUIValue }; + /** Click targets must belong to the source element. Null targets are unavailable. */ + actions?: Record; + } | null; +} + +/** Defines a projection locally; it does not start native work or register a Capacitor plugin. */ +export const defineNativeUI = (extension: NativeUIExtension): NativeUIExtension => Object.freeze({ ...extension }); + +export const validateExtensions = (extensions: readonly NativeUIExtension[], doc: Document): void => { + const names = new Set(); + for (const extension of extensions) { + if (!extension.name.trim() || names.has(extension.name)) throw new Error('Native UI extension names must be nonempty and unique'); + names.add(extension.name); + if (!extension.selector.trim()) throw new Error('Native UI extensions require a selector'); + doc.querySelector(extension.selector); + if (extension.placement && !['element', 'tab-accessory'].includes(extension.placement)) throw new Error('Invalid native UI placement'); + } +}; + +const json = (value: unknown): string => { + if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) + throw new Error('Native UI props must be a JSON object'); + const result = JSON.stringify(value, (_key, item: unknown) => { + if ( + item === undefined || + typeof item === 'function' || + typeof item === 'symbol' || + (typeof item === 'number' && !Number.isFinite(item)) || + (item !== null && typeof item === 'object' && !Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype) + ) + throw new Error('Native UI props must contain only JSON values'); + return item; + }); + if (!result || result.length > 65536) throw new Error('Native UI props exceed 64K characters'); + return result; +}; + +/** Follow slot assignment too: Ionic scroll containers live inside shadow roots. */ +export function* extensionAncestors(element: HTMLElement): Generator { + let current: HTMLElement | null = element; + while (current) { + const root = current.getRootNode(); + current = current.assignedSlot ?? current.parentElement ?? (root instanceof ShadowRoot ? (root.host as HTMLElement) : null); + if (current) yield current; + } +} + +const fitsClip = (element: HTMLElement): boolean => { + const rect = element.getBoundingClientRect(); + for (const ancestor of extensionAncestors(element)) { + const style = getComputedStyle(ancestor); + if (style.transform !== 'none' && !new DOMMatrixReadOnly(style.transform).isIdentity) return false; + const bounds = ancestor.getBoundingClientRect(); + const left = bounds.left + ancestor.clientLeft; + const top = bounds.top + ancestor.clientTop; + if (style.overflowX !== 'visible' && (rect.left < left - 1 || rect.right > left + ancestor.clientWidth + 1)) return false; + if (style.overflowY !== 'visible' && (rect.top < top - 1 || rect.bottom > top + ancestor.clientHeight + 1)) return false; + } + return true; +}; + +export const readExtension = (element: HTMLElement, extensions: readonly NativeUIExtension[], id: Identify): Candidate | undefined => { + const matches = extensions.filter((extension) => element.matches(extension.selector)); + if ( + matches.length !== 1 || + !visible(element) || + element.closest('ion-modal, ion-popover, ion-menu') || + element.contains(element.ownerDocument.activeElement) + ) + return; + const extension = matches[0]; + if (extension.placement !== 'tab-accessory' && !fitsClip(element)) return; + try { + const state = extension.read(element); + if (!state) return; + const props = json(state.props); + const actions = new Map(); + const targets: Record = {}; + for (const [name, target] of Object.entries(state.actions ?? {})) { + if (!name || (target && !element.contains(target))) return; + if ( + !target || + !visible(target) || + (target as HTMLElement & { disabled?: boolean }).disabled || + target.matches(':disabled, [disabled]') || + target.closest('[aria-disabled="true"]') || + getComputedStyle(target).pointerEvents === 'none' + ) + continue; + targets[name] = id(target); + actions.set(id(target), target); + } + if (Object.keys(targets).length > 30) return; + const tabBar = + extension.placement === 'tab-accessory' + ? Array.from(element.closest('ion-tabs')?.querySelectorAll('ion-tab-bar') ?? []).find( + (bar) => bar.closest('ion-tabs') === element.closest('ion-tabs'), + ) + : undefined; + if (extension.placement === 'tab-accessory' && !tabBar) return; + const style = getComputedStyle(element); + return { + element, + actions, + icons: [], + control: { + id: id(element), + kind: 'extension', + ...frame(element.getBoundingClientRect()), + items: [], + dark: isDark(style), + rtl: style.direction === 'rtl', + extension: { name: extension.name, props, actions: targets, tabBarId: tabBar ? id(tabBar) : undefined }, + }, + }; + } catch { + // A broken app reader must not retire otherwise valid built-in controls. + return; + } +}; diff --git a/src/native/index.ts b/src/native/index.ts index 6c419ed80..7b0c8c073 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,3 +1,4 @@ +import { validateExtensions, type NativeUIExtension } from './extensions'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { setConfig } from '../transition/ios.transition'; import type { @@ -16,6 +17,8 @@ import { createOverlayController } from './overlays/controller'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; import { observeVerticalBarsModals } from './shared/modal'; +export { defineNativeUI } from './extensions'; +export type { NativeUIExtension, NativeUIValue } from './extensions'; export type { NativeUIShellComponent, NativeUIShellControls, @@ -34,6 +37,7 @@ const plugin = registerPlugin('IonicNativeUIShell'); export const IonicNativeUIShell = plugin; let active: Promise | undefined; let activeConfiguration: string | undefined; +let activeExtensions: readonly NativeUIExtension[] = []; let stopping: Promise | undefined; // Every stop entry point releases the activation immediately but reserves the @@ -42,6 +46,7 @@ const stopActivation = (owner: Promise, destruction: Promis if (active !== owner) return; active = undefined; activeConfiguration = undefined; + activeExtensions = []; stopping = destruction; const release = () => { if (stopping === destruction) stopping = undefined; @@ -189,6 +194,7 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver */ export const enableVerticalControlArea = async (options: VerticalControlAreaOptions = {}): Promise => { const handle = await enableNativeUIShell({ + extensions: options.extensions, buttonProjection: options.buttonProjection, buttonDefaultFill: options.buttonDefaultFill, controls: { tabs: true, toolbar: true, popover: options.controls?.popover === true }, @@ -214,6 +220,12 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise return (stopping ?? Promise.resolve()).then(() => web('Disabled')); } if (typeof document === 'undefined') return Promise.resolve(web('Requires a document')); + const extensions = [...(options.extensions ?? [])]; + try { + validateExtensions(extensions, document); + } catch (error) { + return Promise.reject(error); + } const controls = options.controls; const configuration = JSON.stringify([ options.verticalBarsOnly === true, @@ -223,12 +235,18 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), controls?.popover === true, ]); - if (active && activeConfiguration !== configuration) + if ( + active && + (activeConfiguration !== configuration || + extensions.length !== activeExtensions.length || + extensions.some((extension, index) => extension !== activeExtensions[index])) + ) return Promise.reject( - new Error('Native UI Shell is already running with different controls; destroy it before changing configuration.'), + new Error('Native UI Shell is already running with different controls or extensions; destroy it before changing configuration.'), ); if (active) return active; activeConfiguration = configuration; + activeExtensions = extensions; const previousStop = stopping; let start: Promise; const stop = (destruction: Promise) => stopActivation(start, destruction); @@ -276,7 +294,13 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise ); await configureNativeTransition().catch(() => undefined); } - const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); + const native = await createRuntime( + document, + plugin, + { ...options, extensions: extensions.filter((extension) => capabilities.extensions?.includes(extension.name)) }, + nativeVerticalBars, + options.verticalBarsOnly === true, + ); runtime = combine( native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().projected === 0), diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 9f867f6e3..6ae7ce93e 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { extensionAncestors, readExtension } from './extensions'; import { bounded, boundedListener } from './shared/bridge'; import { popoverTrigger } from './overlays/popover'; import { @@ -53,6 +54,8 @@ export const createRuntime = async ( verticalBarsOnly = false, ): Promise boolean) => () => void }> => { const win = doc.defaultView!; + const extensions = options.extensions ?? []; + const discovery = [selector, ...extensions.map((extension) => extension.selector)].join(', '); const icons = createIconRenderer(); const crossfade = createCrossfade(win); const ids = new WeakMap(); @@ -108,6 +111,7 @@ export const createRuntime = async ( return 'toolbar'; }; const controlEnabled = (candidate: Candidate) => { + if (candidate.control.kind === 'extension') return true; const controls = options.controls; return controls === undefined || controls[control(candidate.control.kind)] === true; }; @@ -173,6 +177,11 @@ export const createRuntime = async ( const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { if (element.matches('ion-back-button') && element.closest('ion-app.ios-theme-vertical-bars') && !isVerticalBarsCandidate(element)) return; + if (extensions.some((extension) => element.matches(extension.selector))) { + const candidate = readExtension(element, extensions, id); + if (verticalBarsOnly && (!candidate?.control.extension?.tabBarId || !nativeVerticalBars())) return; + return candidate; + } if (verticalBarsOnly && !isVerticalBarsCandidate(element)) return; const pointerPage = isVerticalBarsCandidate(element) ? element.closest('.ion-page') : undefined; let candidate: Candidate | undefined; @@ -256,7 +265,7 @@ export const createRuntime = async ( const candidates = unprojected(measurableSources, () => search .decorate( - Array.from(doc.querySelectorAll(selector)) + Array.from(doc.querySelectorAll(discovery)) .filter( (element) => inVerticalBarsSurface(element) && @@ -269,13 +278,26 @@ export const createRuntime = async ( ) .filter((candidate) => !rejected.has(candidate.element) || rejected.get(candidate.element) !== signature(candidate)), ); + // An extension owns its entire source subtree. Never project a nested built-in twice. + const extensionSources = candidates.filter((candidate) => candidate.control.kind === 'extension'); + const exclusive = candidates.filter((candidate) => { + if (extensionSources.some((owner) => owner !== candidate && owner.element.contains(candidate.element))) return false; + const tabBarId = candidate.control.extension?.tabBarId; + if (!tabBarId) return true; + // Wait for the owning native tab host; do not cache an early rejection while its icons load. + return ( + candidates.some( + (owner) => owner.control.id === tabBarId && owner.control.kind === 'ion-tab-bar' && owner.control.placement === 'vertical-bars', + ) && extensionSources.filter((source) => source.control.extension?.tabBarId === tabBarId).length === 1 + ); + }); const back = preferredVerticalBarsBack( candidates .filter((candidate) => candidate.control.kind === 'ion-back-button' && isVerticalBarsCandidate(candidate.element)) .map((candidate) => candidate.element), doc, ); - const selected = candidates.filter( + const selected = exclusive.filter( (candidate) => candidate.control.kind !== 'ion-back-button' || !isVerticalBarsCandidate(candidate.element) || candidate.element === back, ); @@ -283,9 +305,16 @@ export const createRuntime = async ( }; const observe = () => { const wanted = new Set(); - for (const element of Array.from(doc.querySelectorAll(selector))) { + for (const element of Array.from(doc.querySelectorAll(discovery))) { wanted.add(element); if (element.parentElement) wanted.add(element.parentElement); + if (extensions.some((extension) => element.matches(extension.selector))) { + for (const ancestor of extensionAncestors(element)) { + wanted.add(ancestor); + const root = ancestor.getRootNode(); + if (root instanceof ShadowRoot) wanted.add(root); + } + } const inspectShadow = (node: Element) => { if (node.shadowRoot) { wanted.add(node.shadowRoot); @@ -299,12 +328,15 @@ export const createRuntime = async ( if (wanted.size === observed.size && Array.from(wanted).every((element) => observed.has(element))) return; observer.disconnect(); resize.disconnect(); + for (const element of observed) if (element instanceof ShadowRoot) element.removeEventListener('scroll', schedule, true); observed.clear(); observer.observe(doc.documentElement, observation); for (const element of wanted) { observed.add(element); - if (element instanceof ShadowRoot) observer.observe(element, observation); - else resize.observe(element); + if (element instanceof ShadowRoot) { + observer.observe(element, observation); + on(element, 'scroll'); + } else resize.observe(element); } }; const fail = async (error: unknown) => { @@ -611,7 +643,7 @@ export const createRuntime = async ( }; const motion: EventListener = (event) => { const target = event.target as HTMLElement; - if (!target.matches?.(motionSelector)) return; + if (!target.matches?.(`${motionSelector}, ${discovery}`)) return; const key = (event as TransitionEvent).propertyName ?? (event as AnimationEvent).animationName; if (event.type === CSS_MOTION_EVENTS.transitionRun || event.type === CSS_MOTION_EVENTS.animationStart) { const keys = moving.get(target) ?? new Set(); @@ -790,7 +822,14 @@ export const createRuntime = async ( }, }; const activate = (event: ShellActivation) => { - if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence) return; + const accepted = dispatchActivation(event); + if (event.requestId && accepted !== undefined) { + void bounded(plugin.completeExtensionAction({ requestId: event.requestId, accepted })).catch(() => {}); + } + }; + const dispatchActivation = (event: ShellActivation): boolean | undefined => { + if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence) + return false; const element = actions.get(event.id); const owner = element && Array.from(sources.keys()).find((source) => source === element || source.contains(element)); const ignored = owner && isVerticalBarsSource(owner) ? (retentions.size ? 'ion-toast, ion-picker' : railPreservingOverlays) : undefined; @@ -801,7 +840,7 @@ export const createRuntime = async ( retention.activation = event; } } - return; + return false; } // Native may send input before update() resolves on the JS bridge. // Revalidate it after ownership is accepted, using the same action path. @@ -810,18 +849,20 @@ export const createRuntime = async ( return; } lastSequence = event.sequence; - if (!element) return; - if (!owner || !inVerticalBarsSurface(owner)) return; + if (!element) return false; + if (!owner || !inVerticalBarsSurface(owner)) return false; const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner)); const candidate = direct || read().find((candidate) => candidate.actions.has(event.id)); const item = candidate?.control.items.find((item) => item.id === event.id); const searchAction = candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); - if (!searchAction && (!item || item.disabled || item.visible === false)) return; + const extensionAction = candidate?.control.kind === 'extension' && candidate.actions.get(event.id) === element; + if (!extensionAction && !searchAction && (!item || item.disabled || item.visible === false)) return false; // The original Ionic host owns form submission, routerLink and selection events. activateProjectedElement(element); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. schedule(); + return true; }; try { listener = await boundedListener(plugin.addListener('activate', activate)); From 78bade8f0fdd77d9ae13c8e666227f5cd8f83324 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:49:45 +0000 Subject: [PATCH 2/3] chore: update E2E screenshots --- .../vertical-bars-toolbar-actions.png | Bin 63744 -> 66311 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png index 0886e70d6c64403c02580ef1df1309fdc11fc636..fbd05e94b1f6a1fb5bbe8c715822ee58c02f38ae 100644 GIT binary patch literal 66311 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 literal 63744 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

PIx2V>@QQ)Qo)Z4i3zEOh zztNvOSKP{z`PBD@{A)VqD7BNbzjGXzzc1h&EdPFOOxl#1W*|74=yztjd4Z{ zX7XS;iNfy`=1HPY882$i9E@lVuJ>t<7(PKA>jRUq2o0rCjMeLr=&`8ok#LhNB}3FX zt!;i;KIpo$AnfEi`HNVn(|0S|(RJHk z`vDQ#aF2^Sb{&O~6LUl(*4J%8Q&_T4zy;v#dBy&Df zzdgc#dV@-L;i|s-!nG7XmRTkW<(o9ST|Dv|=f=cCweV|D|sK)z5<&|Ls@%p8_{zH=f-Zy87fFlgfXkunJ;yZW)S7 zaH!vx7`^`2SEo;egG|7|)VM#WCIKo{WGsli6)=gi_?`jMooet8|G%cR&gYeO6CTaj ztVuSd5R(R_(iZ0I$=+iiO~e0Q30*Wx>e@&NlZAxV+e~I8?uNqz_Fb47n9;Xgg7N%1 zD2(8D&?UL?FH>m;$b82Ko15z7>rJ)H?CdKkPm4yu9gL0rSH_-UWMVP_i5?ppTYLc= z$*0go-+1(HTF(vq)8H~36N)|hhxLK=p!yID4}fv>%xng&@NHfm|I^{MkDtEW(ED6| zpb59+4A@p}cj(0anN0+s9M}h>@t6c1bihm>al|6AqHcPAe)x=^LGFwU3)|JVfHo0C zCV?YmT3Je%KzV|G0<4&7IAtJ-`x^!<>*f#JL7lxqOl-7+;`$gwT4&B((2}Y^?Hv3B zn&~NY(=L#OcY6dOZ5YDJ+jJjQdY>H=itR-cOC3QQ4o83&WwCq|Eh2XjO9xBig#-ax zz&%lPf5Zd?LunFG$ov}_8Igfb47bsX$`py0-dMa>Yp&r3+<$il>5U?oJ414Qj2iDc z$c3q0YAni04nW8pMP>rKS^{YxW8TsD)__1!K|ve<`BpJb6_j-d0FBi?zeRi_K_w<1 zt`NiolJK!a6>R;0>GR>1{{TkX4ArNgv(r0}*fj3*4S6QIK)N0r5Rhn|f+`18we{Vr zFy%PVw*WcWPF9BNULF6c)(Z#TB2;fkU-iB=;1?0gXqX>6E{@cZ<`xU>BX8R2Evt0n~HI={Z-!d`g^&t7R{_Q7L#k|-JQwb6+iXgpunhQ=EiK# zZ0AFs@{F>Z-687jzv0_Gfpp$(mxNFs3$SltT>XbV6J9DvD#9aUd(Hm<+k2}CGtKYl zQCfRSldNdT43{jFJ!wKeC46>qf`v}qFC0sT%uxCfo3KNL>^s-XWh|d zex2s|xKV?XJG+W`;@9`?d5K`VS%g>hnZ_J)5D2?HW{2STo#T+Q#W?_!-o<`>@E&^= zfKU>U!Z7U%XBBKRn8oMXIey5nQ!vysE9ecS@*A#gAyXaJ~gbSg6ePQxt^YoWeXFSP#NphYm#wz^Iro=$1_6 zJba5S8~x#z(iXs|fI{&K9Sx^2LVR}=_0N($kYLwnjHi5pj-}Qp;SpyTp|{cCA>;mi z%v?}X9LZtFK(NmDf@m`le_}1udftuE(C#kk8=*)+RaO~S@T7Ay=SHWOcLPurVTT~2 zWyD-CYs}quZ>}TiU=(|ydPj?RU$Xfes-o7L=2~iCWB6)PR-zBHkQF#V?+H?N@Z2|p z8BQ=kqgE;J2Iny4_arx;Ik~y*DM+W0CzbUIOlUtS>9U0{Q%?|t zC(H5;=Qs*vA{W6ZQ68s9ESV%g8cYNO+)|z9OQv+ulyLKm!aZ;r)!bV%a=rjA2#9*w zC3r+fqrPq(89a2G@7+rVuLZdfuVo*7EG1~p7Z5T-22jE}j>lm2$$jn6q{vGAC~FIz zNePGPOBVNJ9$cp!{IWD&{UzR)OFJQv1uI$O2j-4?T0z53nRF%C;rE%IUwwc%=N|Y0 z4qYNYvH(XHp|?KS11C)Gk53IrwC>PU@zu_2+@8iUi3Z9< zpkCc#%2V<;A><^gRfOT-XCg{{4ue6#XKhz!yiV58Ov1l8r(BuvxfPrl5i(KZ%(^(7 zQ1qa3}rK*4hj6ltUAXuSU_qT~twB*%_Q}65^f^ z1uY#I3 z$_G??m4a62MVF_H(XqK5WX~z1F7YO($DZC%0gLVt!Cb{)0cNM%IW+AUFPAPY=cBb$Mx*dt9m|1flXi z6=bDxUQ+b^SoFEOHtkXu+}Qq{yF6Qyr*PNlrXcx8>>?Y$&-7S3GXSWqUMVev-;V3; zks8jSL8*-%rHBE88hxCx=qzEIjIAwECp#JNnxy&?4Mm54nH6X2*L%#z!z@QqQn=F^ zl08X)E3f=YJy&0O8#NEx>+xdIINN|UCo>VDymJ@Y_8I@ z$XIrs+NUt77pv{xj(N79JA#_mv(%PZ8I-)p622s$rZ^KdG$pS(+ z9eHWjjU{3^x?Bcw2nEk{)jLYLz}A_}y}a`f^i+xTX=o@Hk%3tIt@rhC0={jXFkKuq^IJuN} zU=nr+_Q~NtPOO)yAHjz*f>(O2;dBRgcp0u_rjgk*X_+1viHicixfRm`r`GA)ouA`Q zJvUp`imyXK=P`tOLI);IP||G;;zAssl$v(IZ)}t1bJGL!Gng?s^`;4p44rH?WHQ0| zDcFPY88jUj=WZBGf?GkF{x;9bTPUAnAAR8J8nAReZWu(kgi#Na0TG*2y4l?IYe zR%JJ(s`mzzy3if24zLACY8B6Z|9c{CDfu&I$3A*3Mo7iaSzS=+;L2>UY`-UbmDP3t zikg=dP3Ilq6f#+#peh9SA``mYM<*A^u`;|O=BeU88(wiQlMjSMZWHsTA68)_y2Zx!5l zA&Tw496iV-PDL}*(>yn7z>5!ViLGv^FHY|NZFs z`mY}ys>R|#q-XumF0T!al$h{HYiFQ8Bbc8+gtI8o9ju>L%>mfen+SO3XP1QGG+`Hd zZ?N-AV0b6oGKdV#$@b!JEigWMQf^6X$y)vuu&}*drG+otVka0yM+>oWlCg@Kp*&Y+ zJwa~!Fi>t8(ES=5!wA0pfppS!bMMo`ZDy%zoZfI8IDgQ-m`ZWszZZ$;m40;}Gw~Yg z=2=dpBod;&O^M=yTX$vDKD6EyU_neo3l~NYN4po^*9ys`HB^S{ZGPXR*R8=fk8}c;#=Bro!?s7 zF74TP*59y&LX%s*8#hA}EyqD<)8FvSRV-$9=*Z?zV;#h$6ilEIaes~fH%|Fa5FxhE zV7iBbElAYM@yp*dM3WD;oIpH>CRazpW10ZSv4C^sae)?3VjNvPvi}0QP`rXI2!w*2 zZO63N7hKcQ_wM01F%w?%|JQ#{17^)vQGRhw&Z0e2P=_YCz zW$y@d%v`R#PS;ckruJ`Rm;C+zl|BY?AVTkk+zTKXPVQg!4Lv+OgdYXsic8`e#DM2P ztV#~PRxCP3Q=I^u{6EdFW#-^c{>zdT&qTX6mi(XdYX=gnF*pC+)Sq?#lX(1JBs$*B z2nhlUHYJ0TEJO(M7JffUzVfaHe2Wc#zv%zDK-_=T82*Ruor+&ZF;+dj&N^% zZ)I@hEm&D`II>CbfyQD1K}9%JzW_lBiehiLi{bnibD9tC5+Qeno?y{H9KeFG@52}b z2tWlu*G14AX!hGM0JEULqzid15#BmAQ(qX+wy0=n&nG=M`#;gb$_SwA)qjdpTI(@thtMhXTlRffmt*{aXHf^HAdoZ|Ueb9iGi-j-EYdyd=J*7b)(U7#A)pEE zI7kAk>{5yj<4_+%znUQ^n<@U{NtN9TNCwaXL8S0L-%zx_EA0C~G?x;SS89cz@5u!s z5&*v_*h#oGZTt$2?bA>g@QrC9qluX8Sixep4u)JmblkN{D8t2;{B*p=Y^(5TMGwJh z_ht*0bj$^V0jeAKR$Zg~jdA!5gW+@Q0Hmb~DyJ{x&sv88p3q7FGKJ2s^{;&2bAN$V z$WCM0OKAB$Xewa*dy^(O3eNaph^DXizd;B+X$q*XW#=l)ywbSzh|Ly12cq`ViC)dQUl|pBprnQ@yHVGt#niqHXOk| zmUC15Xr83@ao$-(-kSwmA-x5-whx4c!LuV`Gs3N!$^ zdjp<#@q6ZGJ%fw{X&BEjR9mJl-he}AV>WN3k5p5(%mzh>Sv(pAuj#Y`qvbMH&;yC1 z`M7%Al2!<>X;nKIX^r%g8lrV5IO{sG{bRBZSdBq9SJ+VwzUy)w!KCgi3DS^cR<8 zK!|>baj4(Bw+adi@@sy#_p>as;6=goZ$-g~B836QCIX2@3`pN_kSb+AHEwnYX@%OG zhhfb?B?x`-h92o8Uhu+>U^hp;7O?%*8Z?CXzO6<}uiN&TNzZa>m$qI=o5%ygG!2c& zVhD26ewSJ3vhb1A(`GFAfDI38N-OUm45ZxYD}6F{@Th=c4&%3dK{zv`v6Rq_C1jb0 zP{IV2#kVQADjc3Ihr0BDrLRA{fN0ri3ZNXz(v$%lN~|TGyA4Fh8!rZL{DFJpm)T0= zppp%8y>5z9n85^U%gIe&XW~pN#Nq>P9jZ=Sv>sS?gY*hG7n1g&N1qmF6z^jkW;DlP zNi&*gr~{*8r01)kMkf>d;uP6B_8GSDKe8-Zi_7E;`*UTlNK^$c3&fy5>H#`=*!|)K z@|)IX99=8%TzV;H%2;w_GQCBDw^!AJKYBpqTu20DEL`jdBcF}(fkn_#SzU;R-8nw? zjJu26aFzn`v#^4-Ys8aL0FomvWzC>*`~jg1$xo1Dcnit%Tt-k)Hmy1E$#qd?1v-KF zsAb^V8R9$)=gZzybi#byeJW08GsK4ry>hD8rT4Fnb3Mct(Bjgp@T(6i&xmn`GWQ7w zrtV+a;~|BE)~ME(5KD?O!P`&tZqPvZTAbNfWwO3qfFY=m)$vycEM;G;6^$E!VV+Gi zU~eU)N32+~JhPFv2l0n7yuh$*H@3Kg;bAe#lK)+3iF)&lXs*qWdzzBW@Q|YRtVf9L zJV&n*wSK$$WH^5c4n!it-s4tCH5IMbV_G=V+c|DcOV#QE46wFQm945Ln6}sP6D9{c zCmW%~s1+L3GD}({VRz|vuF{&O;C3%Ty4i>HgoH)&K+v!Kb5WhX4U>+Uqew0kn(qYN zAoitqiE6N0ORNibNK8_ILUEXWt*2sbu>E>t~%xL61l&(VCNL-96OKm5|at?5-Sf}uJv1KBNU%T zzpQVeQYUwwbF)s94Y-7%P1`I`%wgwZrl^O+$zP>eUDZ1TMXiJmSS!%qlk=Rj(E z`o*wRwP4Sqc7xVplKfb5hH#2}f9xg+*WvBVn?|DVFH;*EZ=zNGEYz{s2?ms|_={sA z40`ECiU|-!r8M@7L>&pjsk7se{sOl{o?ikocqq1Em})}-@2;$WC003?Bw7F?crgP$ z<}Y{1k`I?*QucB6iLKp%k-3u$e7@JyVg^matUCXw^@I#p@CEf-tGc-ObSc%|O@9-Z zYAv5;LKaYYF_YWP^8t#Pf#dLuaRRoGJJ?Z?vE%-h=TOm)Xd^M z9hKo2(sywh9sJy0CR?4RBqj~OZ>X0+7w67v_%p2ITFrW&2e<++Kcx(Xr0d8Np-@eM zjW+WdbS)u@W$IXpPvTtYH&lE)z|svD`rGKf`naL3Af{L{t4`E-e%> z_JmS@))oj{kjEjKBxet;-5h2FhZ~SaX&qo9+uA9a<-gU4c8l`iLg-wodX>}y*;ib)YX@V#d7Jhe`;r%Uy(0OVWp1N_A%aWqhO%z}yD zA2YzddIkdp?w37DEh6%2xO~>)742c+L@D@0V>y%53a;ifZ9lyU*FbBmp2x~tEq2Bq zY4j@&y%XXWUA!nc@P3`k(*X`{)G>#tKt{}pej8S<1k4$RqQjcI zU|W}uIYZBe9;PaFecR{*WWvJKW7!3scM`V_YP_$+x&PJzCSAvD=kFtCj+GMNm&6*i z1jT4NCEx9)V3VH56>H5Shd^-2I23e+aSA!xR<_MTX+40K!&XfyIM#{m+H)eY$)frE zeA637FK|sQ^Ig92%D#ahS`P4 z`rRwepL?02MqOt4lz<7AiZ@xVAmPFu(uMYCDqv#;+lYbK=R|cmV*%<9sDnODl1M}m zDoEGE?_F>l<|r}HC^^<;O=V!+3Fre$_z5JWVt5$5&IXijxi`?v2u?$~Qg%X7hbW}v zRuS|Nj$V+VW^>zN7jvx3fhk>1);|pY|K+uYjNktzCW7avkX?5F|6Z3E${mN~ul~pB zmr6*N2q4#Bd>B=>1qCRi^-XJ_l=*{{rE$1LpMQS{;x8bo#d-g=1s@I>0j^IjTW%`*%(lf4V4FT)ZC0A*Q6@YBlgH&KVM7=V1;q**20eELyh(^}+dDig3?^)JzY;iG zKseFLY=+ccG4?~$?^)0lD7Dh)<2Q z?~cH(J5&Dy@gt|?!N3{Gs{}L(^_G~jK-&!BjU$tpChAT`6z49#z zWRtLQdvd8D-2s;@I&$8!c{9z)Swkrno%k6*;tyg!T ziBQmoeG(HF;Gl=5X>iDeK|I#w&1E>aY*jCyYk4~lUsnE~5%=|96-U<41G&m5lhGM? ztX%JfoJ=X;nVp2c!{ZZ#M=?-trQRFH6EpaAr*0Ys6h>zJR>K9O&{8Nd-|BO>g@^_O z?Pi{kpyu24E)YDto+ReUU&OEg13}0@vs_1((7jSpfq-{5Q@{QCgSzZb`d~xXW%`W- z+=UBo=D+&+;c?hx_xWDK`lG!Z@p;a9{ zAfHx}xj%o+DdL*<#w_poTibuE>UHH*ExnL2;jLE>H_+TuRYRj(;Do+#Ex}GLP-qWY za$YgB2k$xyEe%Z#Kv6qj9(@mSxsy+gmSNh)~4m z4lQvm>x$U;X?*p2ge3{CB_&ew3W{(W29^YW0{9I<-7>DQHk`mG34?}L&5-qQ{?6xt zfip;)Ob1ZEgCFo>RRRA$=-F8Ie;UJ4hn?)heA}7DL3DivDrYaalb1KfZNx>!uu>qJV@SES8QO}rTo)$%*mcLOAh!l|iPsCy z*a2#a5mflj9jq3RAM0iyZ>}yt6kryTikX+BAt52B3j;7R>=@pHqLQ&FXytASeOO2q zv}LbLV)=f-27oG`38I=Kc$OU_M)+RKM&nZp_IW?+xmk7pWf6Pr@4jr*1>j;3cl(6Dn;zIy%r$whSI?`e`yzZutvJC0Ie6x9Az2ta-HzC;rs@MQ) z`miMHgX_wE&q?}Ys3$M->cliN25^XYRTrg-6BF>wp+xrBoE5}hrS4acWlr#-T- zs+b>6p|fcUGn1ukylPO*EI$xo^#l(9ve2iT+%*h;<)#aF0ZTqbZY)dV^1Sm(J+=!j zn$VPF^>}LoZ$Zmh+d?r8;itxg+Le*>H)dJ2vA?$yqr7^$cCNjV&x-d|)7(41(Z^;n zBWvmK#7DLF@${Ion)cNjPN^ALYQPGN>J11qw8>F_Qq?Z}$t_vX^c_x}sW_wN3SH}! z^~xmMRUCTN6?mv4O;34gJD$QO0XD}CwoURNPlw6^0YbdMM~!afyMXTv7_;VRc_BSP z9#SXv40D5|UA+LMr@BGV0ooEKjff1DbQppv3F+fjT^3+?!)J2)r0Ei$4MkLfw6xLO zdP+Nq)LWLdrrsaFU_{u0Pe2`cht5iDxZuH?Aoa`nj)p+m4ZqJ{+(q7Pt4zWpH=Ul8 zHH|ix`m2zf$ksHl1mjz4^n(f1mh0X@T{x;F>waM^%Qm7W_(7&1eTS=0enhv%TvORn z3jZi&VC-8$n@M$m>)R&Z{vy)Pw}e&6;?13cyGD1I(NCJRFg|ltY9x7u*Xu2Qp38T- zCdf7Mx*vy1v8}qhNykUnFwXlk!Jji5uLci2IAnVE`>1Yx-UyK+#Z zd>!k+J{<3?SDokbsCHL!sm-qBAsXwNH93E4^c$+W?_pm8gt9-*_3RMek&%sXEk5SG z|9Wd;C1LM!_sV>$v>@)jBm)k1kE~E-gi;jCt`$)v@~#2)XtWH*Wj8CkACxB%9s8_L z!omm4MPI7u$>txhxS3JYH?J&V2W(l`LyNt7k`2ZrXjR{&=B@HK?FVfK=*zyU zqCLn?`^1SsMcRi!3`xes58?{O{ zBZfOcEdDckiDjY zd&YNQzqPNx*x`4Gvz6#`&I9su0gq%chl3|VU)ddA2|7L^kX5+!!=A8J?;DX^8PWH0 zCu@j5?cZKM#+zc6s^geWzuGF2`=M7}Bqo$!K-*Vi%MOzYXHoW@Kh2?yj*uoZmSC`r z+4s)o(w8(S6*38lBiK)_bj{zoxpvmCFV*NJtkxHOdFB>MC)yV-(#>{LE#|8;!4HBo zV>Rp+*MF7!>E*F=PmYMrqtup~6uk)|Hj11nNgX+6GRD$1I@)@-6;wlW|5?-bunfcj zH7LlB9juq_&L?&&oQ`Gq1_nLOH}CriO&?oTW^_5{LLTXSlq|;d5B1@ueWOI!b-Uvf z8ps?8Zq%WOJZ@Tvk)z?7ZJL{A-&e!=dW(&5>qIIgCS}n2QO0_EgVNHU6SPq+^-0~J z@N(Hhx|{ zowfbA)`JRWJsradipU^vAUBBonHy<<}9_jKNwT}az=coGU4&RZpFk5R=+ z@9zK9t}G-w{d{N>*wSr%xTi`-uWu{*eJ#0Q>kHc9_F<`(1|Ie+BpY4-^K1F&<=rTe zz(VPofyH;PrkC2Lkaz)wG{=1z0@-Tu!Cl^rgbqTkMfB#08p&jhu1dMQ$UBS4VP+O_ zL`98sTah;)&UCoSoD1DK)duHIqiKVe~MuVjW7>3-L#+)>8Tya*ZK7)M5gr=vkCR$4oxZb0-RB&qQVjoJ;3hhUL1`ZE&(T9p$a1+R7~saP44*f->b9Pwg4DxkJ6LLQpMY(c#j zOaGI;*j$J>;JAoTDi&m06pR6DHDK|=gEr$jZIb#9B+MCq*OwX8y!ycVgh#pRgz$=u zZwBslrs+NVAh$V1_g@ZabBS}cf+j=)`3LOu`B>KwtJpr%0eK9$9M?{ktFdo-OL#>Y zhv8aCAKC|@_xCp28AwdkI^67(L=^jKKQztsRqOVix^umH-YSCZtv&b>-@eNnd|{ed zrDXnZEg)DD`G!YMK7;#>j^*R(ETKDv@h^C6bH7(-PFOq0+LMiLSNSz2DS0)25H6w- z^W~aRe(`;;&LND#A$@n&bvLGN(!qBGRmgK`DEc-3ATiqP_eruq1&mR-+VE;As+h7R z+Af!2+z6jV2h*w^Oo&4Te-_%^TDfainPV_h99#3eH}Xb|_HMFkV&T2d)alf^lPKtS z34~n}p1(cN48N&S!sC;6Lf2%0V)itiqywks3O<&aXeu$29D;BLEW!jdcI2Osz2c?W zXD0_1fdI`1g3&fKdTnzYU3p<=>az+hx!~hS;8+(Fbpw$Yb3NdZop`! zT%V;qRo!g}RfWg2lSq0voOkks8pN6tC{b=D_?YsGP)m4=&ZrYek&IGtss{`6dNqJ> zOiueGHu@P9g!iwsn|Qy&{xcH}HpYbaKbKLT8NO?alKsLh3ou0P=-TfJ5l=;6F9PVT zPO@CrU$xi8bifFQ%s=?TlNq%_hU-=d>NYA@PCXa%bXgtOx#;Ou3-DXyRt7Y}Lu700 zVU6KMpRh=QL4){Nvi^FeVE(AktuIf&512lsA4YF6^GK|coV-#;=P_4m;u)2oJ^6b4BtYQ^r{wMu z^O|X?Or=~6by_JKk&{UN49xD%prN8bD+xBGrZ8Kk+tXup67k%(31SYy2)~X(I=7Tb zjqbNU{(0{Z{L%gXpc<`y{qXII2YD4HQ>j|}Pr8AY@L$mI*n?N6J4mWE*?1k_jz~ED zwh$_bKY_QaoG5F)6*GcTkghGSY+-* zrLL2pP4Oe}sX{fOiO(A%wivY*z!`)Gy>(m*-v*s<^b;hB?*<7Ku&^szD==YtB>ZYx zv{cBe+?G@r{H%Na^jC+Nf<1|nK!#FRx9Kd95W23h{zzY`U5A_Bj;jQfEV7?SL?u<- zLI6(2-*u3nuA@7B)QheeQr5HgXC4^^Y2fcJ)=@V33SMRjz;%SVq0>ws2VU^rTS>Jg zs;JR7Jw^%3Pex2`e3ks#>z|gm-O644Fjud7BYY+IhQc#q>BiaPHM$x(Rt8Sr!$#^W zPiJn35N@LR6;%KZ$L}$9`p2Me)8T3M{fVuiDD~zsgB{T(kE&NoI9i!uHkJYI!!CDu zr7R}nwFe)EB4V1_+4VtwF}vKFLQC+2lUY* z^i2=I(TQN;B-&U4Ja2c?y!>nT$I|@kfcOujU`;yK%2{m*jF1BaBiWPkSpnm*T9;xA z$Q76m@gjfDHhwkSa_ii-OR|BF9fA8u-=z%>0&_6%ma>sGB{v>_hIRJon#-^w#I0co zUQT;6nz^GpWr$-N6gEhTLBU=R{^_Wy!@gxtA_onRLm;uitA1KGVI23}K`Up2k_%mVTfFYgX) z0w}q4UFF`r6u39R?zvV4deRh}41jOy!$+tZuq?!%bY;B8+0E?`jNf$b^Dz*y0)0Tu z3tW)VG@!uXo`ich`GNs0Wt9IEm+wuSo*{B*Hsg4}JtJ26uYOg0p@7$twHIE@B`UGfYgMId`o& zz{$`Hq%?$@MT9Oz`_&3)1wP+-S5Pm0bh0EBywk1O0ZQ8oS8(P%LpB2h`>_x*;sZj( zAptORQruZ^zO?V|$?EE+?f!tFO7cPxC^{K{V1(ra`KITmMzNBKMF|e|LH&ecv2$CBpw1`;(?mXa=gTPV^ z4+?mf3-xL%5gMk5D>WcqkOB;T%mD=PM8LUTR)BFig!=mz1cMqt_0KL|WT}An!b9YnE&5AQ z`u}>N(*G0fK2($hAHMwCjPkH-!1PHG%gy=aW|~jS-A_;|{Mowy>udi{^yUBU*4Dd8 z>{u9hec;GNzxUD}@!$n_l18>;N}U1NfXtA(vx@gEA58-VD2zoZBfZAVp#Vz8l?d zVA&E{Cz{3RiU%n*lJZVyAXH%|Q#I1n**;A8hNdoJfD5y3 zFSwcDWUK|WE14ze3f=QMiTQ7G8oI>IdnE<4iBY>3N zf+q|VgT^ms%40ddWZFld@_*0fr-Wi6!OBg_f%DpEq&V~y02VN%$MF@ujVOg)`H07UXLeA;K=P4G$+er63@ zhXhGqLP|(I)5Pq_x-v)f6ha>bweD+YXK_=Ia;n|o)=Ys+AFs zItD`u1{z>lRU*CxWbAX>lE}E()9mXmob)XOYC;QFt;5t6{%v)-A^9|%oTvG{R`3yc zc;1bG>)j2Yh-20kPk6?71tYPD9{l6JkiM+#@h~;8pnpo(j37B z{Jqr|6r)gEgC%{2Q)xH)fveEcRuol|%hU$u9I-uzqIcpRjtOJaX zqL&S^$ZTP(bt8MIG!Z945nhG{h&kZC+IU6l5p0dvM)5kxl^zK#zIuZG$u$cn0J+ww zzV{_t4Q}5ALwiVs6b?84%-X{p(4WeF0c}^Cm6H>%WeZB#2fdvqpiSSElQYhRv35^| z$VzUUS0s3%=#0CzGI*E^*3N#wG0i<$&cC<>M}1|WuLlY$m#qRrLb#q|y^YyBA=j8s z!5g!U3|MR(c8<^LqVBq+s24r{Na|@hkc-`)I5hOgwN0Mi9lrf~%|$J|aX}qee2dw_ zBa-g`J09OrhYy1q*R`-uYbt|{&ucZ13+XOn{(?W>%8e_{f>lZ4SnC3)TMhe?c`o>O z;>{#D)2f&mqmB>DLr!#JpN68J!HnKgOv4+ddlR?s>PxXje(4r7^L#|lOXI5SgVDA3 z#bJs*x%nEitYfdxl7k5)L#bl0Yzv$U5bn4>PCrLM)+@TJylGF74UxgdLcih2;9%sT zf@3QrP^OIUl2Oq}F5HD)Pk*w$j-2v|UnF3EkP~1M9nR_){G_AJv<6|Ir3zS}8F?Wn z#zIe(I}V*|-Qjg^PoM7h3WEk_;6dA-xI<)}BH_7$%N_#rJ z%1;23QEQV%V$ZDn`@&1Q^CW3RbFIW4C#v#KA3RxM`CbAJ-O| zbVqPAZ_& z8Qu_u&@pfO_Zb*6AO5WcXqDs~RD}ms^Nw!`j60wjsTpSnj7G3xC>0k$J&AX{8n=>& zymw6+*7<1d_tI=k)~E6`CmXbPcG8%YwW*?eMbe^022ZuKwX+~4Zic1N790VW8_8s8Av{(|mdCpe&pj&BnT|pEf%2+aNW|3Uu3Mi4`UQM`-_w?4tQGtRvyv)H zl45Y>DeDi;Q;kS0<%rnQzGHm^vmW!kZp8w5^31Q#kEv4*E_i06{Xhnx79XBCej?<7)6Bc8|>%S2S|^!IIZl^uktpueum?CO1~cLdDg*m$73R($5RJnq z*v@2=obowt*B>Zpcuo7-fak5?!o$z+I*_Z#Nt9?Z!k8-xC9})z1H9BI2!JmOW30d_ z!@Bf)E%Xdo%cg}cr!&VB!LR%M*iC7KaO3UMqMCNq#VU5&Ac@r12NsZ3onNDngue7m zwMM+4;)3!=75BVrSl1JEofPv_mg))S*ym25AM1&h4NWmG!QN3edSne2;*4cbS&Y8- zOgh*@za*IYr7=vAW|{VfIWv7=OSb0<=$7MiO>fY%M-Xk#2qp^sWw zR883qld+v-JNKl*J+^dW&&wcMdc!=BNi*R~x|w=Fl4Of&9)9e?3*vWD%kxn9-1&|F z2iyr{Ckhe#w!iA2Jj3ch%%@$!*fT-K?yoPIY~5j$cgs(xe-KCN0)-%_m|XF zvpq^Hq)Vy(H?#l0wN(feJ!!gu{PomR|7(@|zwGxZbHT6kmNk^{w zQky7RIy(CuUodmHLaqi1$Y=Ms4h-RozDmRpdVAXSlTOtJMD8K`s7&;78yft=hi^k) z9zn+97HsqcdmXZ~)!@aS*9V%AD{m@C!Uq3QwHoyxo)ft4@4(hm_*|gCSumN#XrH%<7 z()E9!oB9b`8o-{%1-b8eLi_qZq?dhQBAN}>B;Q$T(i_TsZTZo!VP96~4^}4!7{4P_ z{brEzo^6beYQIuyuR$C_Nb&d*R+`H!apW%{Tmn!@kT>u>1`ft+b4@{brSzUp+;s*y z*AB2rM?VR`Fn1d}#wAw<_+hnhPR$ogtVy1LZ;=dk7b0@Qi9lUv|y ztAmC2OFCaiU_I6d&c~4YCKigQ%k7h3XmJ8ogO>55HduPa20I=PVO?q`u@_7YSm-*x zSwYAZaYTS9(+2BHN0EeLz5|@vxv0g15aE#HX7J~|GXRB9?MhxMLQQlH`;jK{yr_!~ zL4yok;?LkG{K9fX%$2M*1Kj2AgqvaJIYEFnxcw;WTa7hrY(K4^4dpm(Fw@~;yOT}H zhTTjH(o2pP6qJB~Si6A668yn|4c4H3#862KV2X1^_4k_hq>C(~8#`T`%pMyfA!ks{m=w3dOyU_V~nUo-{PnU}hHmZ?Wde}8}O z7WjVFAsU0E_0j*~V!#eQ*e;b~b`{k)I4kUe;N|fO>#tnkAho|yK*~$?zP?^y1nh80 zZA8~na2xt;YrWXRBt}QM%OVe6?IW;Y31m>KfOduLNn{Qbe2f=9=^(=3Y|qmm8J-wV zoJ7mcc=#>rs6?}qKe=*wLCo+i=rX|LF%%`Xkv;>VF8>tqcJ$i+TzbPEZ@5@f;(*($2t4+3RnC)#8>Mrq(;GQkPr=)e_o6bJ9fv-!>#@!dIvS8-b`Lu%o^ zQ2-ve(}Ci)#4PCW#W4n6xQBi?X*7O~-bzVEOTFox^cVOgt&>)`rPrWy%V9SiVUHM! z2qwlwbTNVem&SRKSb{M+Ku9|U*ixV|mSBSN#Yo4d8-sDG%D65bC_7Q|26`l(jQ1y6 z>h=f3`KKT^l(LX;C}~jPwxY~D{YY;9J)<6=64DEf<{l})PptvZlQx@10XcR&Y+t-W zTHOP43l=psA?z@a3K%(0pSyv^0E3Ik4QF_)Em_q2PN7wTZ22E9DGt~Vv=AXM$xVB? z_30O+QV;wevWqwLv;z5K{2`MIqtgYY=yV_tN5eCp7x7I}{dwOV85;at!!1 z)xu1}CT-MLat~f4WK~G%v{pPKTc7m%Q`uEBh>L##M#I(F+*pA7zl3Z!?4b@3lrXX` zSc5%xFafECnN3QwR9=1;J7RA9aHX9!r_pLQ7|up+9-jW+06!vs(vj!kA14Nf7Dx=B z=yk!fw+@d>J6soa5%8*-e~D-C&Kd&cavlpTjalPCaJt0 zxS6)3KE;qCQrv&<3iBK--;2^1coP^RvHvay{BQmT{nMHdqk;@hb zcm-Vsj6^nVm2j$7GMoPWOS6N}WI=V3&IYu-DuXpW zi1jQiuo)_C53K|tEv#`L1g)&$k6rbevuYHKb{QSt077>HXQj$5BYo@&kt!PJZCM2`` z-l4w7&uxL1q!0fnz3^{gD1CYMl8Hf?xk^HpI1#_3Oj4S$KM5Pg&ei!_BkOPh^f3lw z$Jzh=3`ddQ#7E-co9jlDN@^ivC;S@hc+9RyxG_t2VgEMHu9`pq#XllBD{NWYH^NK+ zMGeB=Nvxqw%|#>G_)p?tf`TsM&nxlwpWh)4KZX<+76$ouWjXGnOFS4(bes-Zb=R&# z8Z>y#@uE!WWBS463%i$Y*`8iiTJ zw|mj|=)W57;LD=EGK76%IKYDuIFi)wwbH6I-i&5VEcoTn>FqV+TXy17XpbZCl%Mu^ zt0zL|dHd#gYPHqa%o(Qo4NbPk@4Z?>o^|U_zSSQq`*37mdWT`Z4xMgvfrK%Kw5oo< zG&7O)6ZOgO&E5`Ln$E^+6*Zb)$G zb*0Cl!J_$I6oi>d1}H#p1MbG@ri%jkgt?D39zI2 z{pdz!#AqeQPr7nk6{^k;2xzNPJ&Hks(;l}LN(p7lE4yTquQZ1wqu z;&{EpdzVLU3pm6os~zLdciAgGTI_fyt=G;O{2#RaRa{hE+y{!Ih$tyi(n>ed(k0#9 zAl=;{j7Yb1N$1dALr8abcMaXmoUPCMo||+2aV`!w^BMNuYxe53e(M{*FK^1}u8$Wa zMkUMeF;uxo&kxV1ZObNqKVvqlZO%M%`)tB)rbT!475UCrMILK+1bYcuQCjadMMfH0 z&B!u3P_>QBMqy_CBwjeMX3i$}5Upqv#uBC70sM#oe;BNKhtKEv`+OE3C7m8`1t0|5Q;tDP1B{cLZ;ye5|9%cM{{#zAr3RD+we%^NpUHS9e zYiHKfmRY*q`Lo%<1r=co6TmVOfUO{jO-$9GoSYv)FmG>qq3|T5%i+!+)Jtu`9OGs6 zF2%$>IiM_|(UV{T@@s=&_r$b20pS;U8eJ*YSMfUf_Q(GEnX2^e} zUzk3xfW#hK%%u%yjHDc;79z+%_onNv1>`hb-Y34fZL=gD?*&|M@YMAu{&+oc7L*e| zouAj#z)s*{cUgQVws&5SG&an0zLB-0uB&vZ&u=b|+B@gn<%SG)W>bTB@0<)L z+PKtJ4kNexDG=RS>aR|~W_l`QEI~}Y6d!oZ4@qscPdXSDH}7r^LcPmD4fn(1BNxZJ zVW366!+m{11H_S%3Qqu9qe5o}S!zp=6&bqX~om1bUmn zi`R_0?qGZTN>6I^UJgJuYrWa8%>7{BeEKWh+bkf;@YMH3-YAok=^b9P&iO-;cle1b zSN7Jp^snDB%vLKif(N6zr=z7gxSn)@MHFPCkzg$b(gCkphl51fC$n0&er2mfxw*@N zdc!{+eAPPfRTR(qY4&>uB)1ORP4GPk2lZV&6ps%n(XsfZHg9l`S69vo@G?xMf2E@IUTp96WjZiQBzejHg^EGE4nFm3O0)zhku<9A`GZWO~c zko+SKC*&TD#cGUTJCv3qe4PCbS<3Zk z6muE$zFx*r-t$;%)StD!hn;2nl(OLd_7|NqW3}g>KU@kco~ApBMs^01aF=v8{fguAjFj9z@oqW}$udhW z(KySf`;z5o93QIbU%NS>6!=uTCJ2S``r@UoVggu2Q)p7xRc&euY_w=A{0D0WfHrixPfR^@>`n>} zI^k_GDuxC(qPNZo7Dfp>pC%BUxEM=3>%f>~;&%i$UG_+7_F(Dv6TKwJp>p|$W z&HB_lK^k^Cq-`5p7mCe9j6;y<*h?1Hrd4rdZiAlpt`gE>$C}V9uhr<`)vTNBWxrL* zFk+#IFwbpxYd6Tpg+a=kaXe=)IxHp@{QIwCF9wFMVFi=Q=TH8f4wo^+fM3)3scf`I zH5u=0dp#RuJG2Kgzm)bd`)|*0eRN3Gjf|vsfH4&4%(fV~yE|x4WKEAVQ!~>5QY>j)?nso|rW&l+qFg4sYl07GBvBtCRp<$M$T zl9rk}-CZA?XEC2=p_PzNls~`TRx^Y)SyH1cJ@_!A#5%0qPN4V|YwKO(N$U19$juC| z2O;4_Vz-a?O8M|?1rLO${g`)k_rW{eYyi9M8H@ORf{>7^YkJ3m@D9{`f@$peu2Rod zPFQ(HXKankT2O?9u2=s1gJ-IyO{)NKRx2UFQvcMSx;(G1e#2OIJd$rc_J+Uh8-HLQ%W5_W{aeSh}W>d)z?Pmp=R>v}Iv47NXheIp~T_V$^|R)FAM#ESCSWxkr4TAVc? zJ7VwbZ0!6MQK<7(@AZktjdR9jd2;aCLo3e`u)}X>MMdifdw=2XzASNoS{<>C z%>;}`Z{jNLzv{J>|8RV~V=hdkP$yM&VBfLGbZ>R2uc?_9mw)`e-^4J)cjshYgMNA3 z2}Sh1oFk@>S8{e?>NFW{*a3+or+(^YTSQfT^r9AfH0zo@&CQ4`syTIO$y|rkW9!~T zhEK$mTTazk>JhwJZvZ->k-*13<}YLE7;8Bv9X<=L`h!v-NKI+TqjR>&5-tvvg~#=A z`PAv(w@-Mw?_7G-^paJ}G7}uN9WSxeSLL~{w#B7YlsvYfl^sP*JD=IB<7^X2s|phz zml3_G!tL$O-`x2LqztjmeHduFB4JzU55xFV-?GiaIz{q66dtPAB}F_pzXO#uxsV6v zgcBl&DjxloogCqxT{}2s6=ZZAmIy||JP{472q>j!^_DZC;JhWpDsxy&*pYpOiWRJk z5xeY@+lDNPvLs^Lv!lG|V(Oplw#oJ|o7IJ#sLsk6twixty6O__KlTTWQR-m;U6@N(iSFVuk*)7M|Ykob-D!4X4do~ zr`VJhxX85eLUo6v^}TL-=ts*A(Wcqi0mwtYD2nc|jhEJfa@D%9t(I8hIT08~kZ1{y z*yj*!N@DEUI`ykZTKaR{@^@?7?%g!>QF_MGT#gXv1~DV0Z<SL)@i*W+g^)nnrZ1hY<>#|qjnwXVaqBo5AJPHP+!tp-tGu>X9rB+5l zB8rmB;EIxF%aNF|Vnwcf%{&#)%wKU$w}(`}MM;Pmb&jW}%W`XeR#7I}ZY!_n#DZPi zq#uOp%ChqvbqO$Y=7e7#)z>&J`;&=?Fi(>eiggxNp~VGwy2^M-ag5z~U-)X+)`vv7 zn>T;NHM6%yJ@9df`y(<2$DpjCVQy^vH49!9DJ9i3bD7R+@6tuTPg)->xj~aD=l8lK zhF@hRvk4JC#jg=B>l{%kDp)Oye-W8@!w=h(<4{jlO! zH>M|`wUiM0(I0uKd~m|X^DX>1+`}0DL-E%vGIXMikHIAYk4&Xwt&Y7GnqN zN0gR|2Wbx2+Y5D;b`T{7rHBhFS*a zSdX|soOEt}z7ifk<*JK-E0C@%+1~`;l+TsG+mO89=D%@z307<;u{nE4<-^e zN$Kg4nOOm4;IgLv%&O!cNNjJOH$sG0`IW zuP`vsmr$FTJt}wjd69xf-gw6*Izu0{0CiubxKL>K@Q@daH(z3+jA!ZQB;iH)es%m)ztu>$D@lE5HzYs zZ;t)PMtUFi6vYU`oW=4GPQ-GAMO)&Bfd4~{P#>TAs+{1Ktj0y1#u&ZG%S z$KO7?9dnH{Dri%Tb$Cz0d7Bekq4wxlO zf5pAedAd}`&mli7Mr0qtuyAH&rE=je{WM=3Ez5w-$dO^oms|Sv@_bUsH5EXfc zm{jvaLD}a}7mU>F-YO%8JG9@;xY&s|^Gn1_kC7Ib+)CGxsLmZZj-I@C1#R`JL(4CV z;FP{}59`tNlt`OL7ZuUElD}A-#bqoLbS&2>dAjsu*1g|&Tf7>$g9KJnNcW5Mk3n^o z;C@TAz;CWm105KiYZ9`YKI8tNaOdk*ek{|;WdIFwSlplYBnpa7QgS|P;=Z`sTYHe} zHL~)1%^gO>ou&gSBvLiYz?)3BHIWXt#W4=r$yHK)R?_1Z`0*tzjrU+7WSEb59UZ=g zC2sWcvOrtZWjb%k`C3!m{lzPUb^+TK6Kd=hm=vd2F|{pP){okcX~E?VU@A+1GPLlU>E1dk83OxtJ%Ka`3!E>1F+ z>}YwL^epbP%bYtSRc-r?7LJqVQyWcuixSW4fRo|T5vTUa$9*69aQgzi_H6=9x|!uS zkYu7%joZV=*hO_Tyh5#=>5idaMcBw~tsOo})Peb)NH!9vUzutTI!et<%x(p0*FNmY zii#p!oy@C#Ougy~e_!eNSwp#)IZZOQ&2a&mDAYKSk1ul#M{S!K0gKLDSJQc(r`r#g z1Lxxd;RZk6sR+w zuGMSPKOVb`|6!R1JLF;hOLgpmG%ejT)Z?J|Dtd{&){b*2HJgbCw<} zoS&8Zio04rfmI7~UiAEI+i~hThT=c-eeAS~#7|IK95A(WrdxX{j;f?kFlsS%3PyT7I+GFyC_OoB<0`^;+=a6&tj@VW9~Dx z01kE^=!pNP*2{seu{K3fuE+hvbp%b;jwmIfH0e=iwp#8fx5w$q5VP{!<%U>Xyx2ie zxNt`msq7(z$KeisEwmVc+ng3j%t;xoKp*r+7i^m8HYn^qDVdFL?x)DGfxL8(QISTd z6uevL1vNZInG~908!QpRxz}^_TSE(1Px%!kM#z0>eX_u^{@}qyfn8iYUDRZER@sY$ znFA88zVP`>-x#y_>FM|5-aOXwkvnx6O5L3Ja(X3$@(OanuwRfciy+sXLw1J_+nueQ zL`}^?qsUfYG{^~w@Ym#KT^bh>QN&lMW27ornW1oL*Wh8>8BBO@PkS2@w!>RfYMA)4tb_F} z?$pTqlmw&cXx)@_&d3;*ovWD^iHY)i5Nusc5O_DL33&iLgdGG>ha5ZfmHqrHvKpuS>$xyGO zsF&v>M5`OXrbPA;SB1|90#Vvls_n-1K-j($-Nwle#K#?w8dbAjBvReyGxIW9f&JXM zAI39*=BguNB1Z)KKI;XsPGu;mH`3g=ZEd1=#!0Y|y?)x_0_E6{06fxBN>X)d(SqC$m2vBrWg1#0|#FGk|)EZ;zaCAe&Pp!uRjWqgeFb zDct%9W}Pk}q*`QVW>qP1!aF}ZCBHr&nBp6rO?wv5^@mejDN5?3mEJBIH=5CmyOcnL(!Y z)^?JnvLDzuiOOAnBDOT{O>bVSQyDdKrF{KbJW1^J-1x8&>yqKy5qkc4h0RyabS5hT z5eXFulKiwc1e!bY@xAs^L*FFM7C>wvy2&Z09jN)tn!7AqPP=;fSUWV=_0rbb!rT>! z=Uq$<=M0KulqqF`O7W;$CjnmS{-)*)eMDFi^#JW=>=fv=5t)ORYS~%2^38QdgE&dS z?N&!A-D@Y8gwy75uiE15t9sYEcM3T#3Zo*h{dH()8~eWPs}5f5R}Cq-R7g{mpN3da z##lmeOZHOLRfi9y6*9tnT5Ov3Bf%LajeVJn)=5X|EFG%aIKF6pa<)xc-=J+D*y!EI zGe&e0N#kt7vr8=7&!vMzVi0ct6+CuB$j3ufMzBbKsi;Ue@wX#eDD9Jn*m9XN@>F@2 z3p&HFN$35S>Vsvm?j7;3ySH9+h%53LW6{X?b%eoTmGqu7-37&sHatevhmHvBK@XFg zcm0ck(>0FwaWW1UPkYx0*CX`+p>9OZW2v$wDmgwaOR*^X_PTOP_Q7aWr;I@M{Z>Kg z{Rl%S{pm&m)_lnx%eTV-f%&YI8!6=PeDXcc;>bcE^{`JY}lfxi@F80 z2--Z675Qda&@u&X-v^<(uUR1vzjgB*NH_ztLqq z@#_`K#9>~gNmv{ejhh+fH4@ro!2RhW-AUVoDk+X`)QD~~cQ^qfYhi)W!$Qj15?FUG z)3>>q+P_GesOUH2PHu}AtG`$I+bgx6<^hOcU4}4#9IU(aLN95kxQx7PEkb zk)x&W|MWw#DVh#WX|`-GZF~^X&3Cc3C<2&;?iXh9@$se;ma`^>u9IVmCH5 z0d54`rmOF|_HM~_c%RNZQ+X)Hk-Wlnv^j3kRe%Y>`AV>)UD5H!({Hal$ADFp!N_~n zLGqMhl#&Kkgq$WNG`AP#Oq{HrW<@5@2lRYygjQ)z(l_K<#YL3yA=V#r?s*u__m@W( z8lOeLCKSiwGt3c-Ge#=<1iK>pZ0C5o8=ttckOd#h<&aHpd+4G(1;c#f->tI6x-Pen*NV`Kf&3P8wr z_BF>tkKNi5LBV`OmRMsFC@$g2hTi0W|HzJf_BuJ&G-mPHb#wT=XkH_DJ!?`@(ZNFi zv}Ctqctsn8oFkkuH0nOm{lH%SBe9G)&x_i5&)ZV_SG-?+h>4zwhp`ZcTYG>(o$Vo1 zY$ou0clrBlq2*oMQHux&*;0a6cX%Tfsa$N^KFmWoUuqD;67BijJGSqx52P?Cdgy|l zi>Ky(?qJZI&Am?xPmYzkU2!eX!OO4E1A)7qv)B3;IkPX#_zvjeL)&!5oJL}t4%57% zD349>8`y?;h`c^CR2?3rj}8m;r-$wRx$W(V8SEOwo$@{de>z$1OxNZ2byPRf=PRlN zRn#g!v8cVWtEI>gV`Pyd83ZC&WA%ZGSFh524*X-&FtESw`L%mr?d9@wnW%e3;G_Lyi1IU0PNG5eV|w23J9MKzA90`$7?WyH;iF zUv~U_E|9q|-F4I2lkuy3i3UtA*CX$t^28+QyM32fI1I;%^y%DZ+i@$ z)QWfG!Jcm!B#K<&CpjM^jyo=y3yNta57MT^;pOhDT@FOleVJ_e;?VLg96B^jl{tRf zIrWvBWiG&`aH1NkceFr>pr4=3%+btpcHiIK*hx!G(?TvFJF}`o)3!tJ!{?l{!l?0m1Cn;8Eggu+?H zRxE$C@fM{G)Xo?pQPW}fXOfZVhHE98Rz$3hlvUio#6ZFX13Eb3*;CNZ6@l+3aOugu zBql?Az%0E3Y{Rn zq@blQJ3Xt(9U@f~ODEdY1hHEgk@ZKBARxNAWpX3r8E+UOFGmb{50~68Ht_B**>9yN z>)mLx#jgZ_RyxNA6wK!3^P1XHKZ)vG2NC6~( zcV@cq%Y`4^SW!PN-L4Zm8wvgx-9;FWcmZdQQ6$OKWL)Vfxbm~?4jfu$S0A+enq& ziQ%C&T$m|Hj4A2PE`<-c0tdr*gAaT>Gq6p*W{K8A#`^DT8p;QOVn?y4cWl7LM>^RH zMyNS>wiXlP-*;qcvu`sWDmL+TWsCHVl-L@LvVzCyUZDc_mi}z%AkQHqg*WXDMmfGB zf4PD1NqxZ@=f8`>KUoUr)a7 zmiNT!#g>?q>S9OL74I1F-~Xm%IyaG@f%apZ=6~LsG+Mzovq@{w{#8iuSzSpcL5|hs zj5A!gTox}-iep+RsEdbEGu-m5pZ}GgmOR00)Y7w=|GtFjFwS~J2)+TUgv!9f!To6q z2ZL#H+yIv{89KqSzb_tIC?ojfBNm0Cnxyux#xh+rH8l1fS>8otak6l7nrR{a1d8Fu z48{8pdIko(mQ2oxJLov!e{UB_i)CtRDnpl3Z$7Q`=I@Pg-k+5Wv?4-6B#T@5{r)up zJ8H}^DR{I!6K#v0S0oA1z+Gb`jXI1tVHXz{wMh_Y7Z!f-uM?}!HXV!!9{$=ShBE>^lSsCyGKqA6ILYVv>SD9%qw2Rv|gC+#zP;fDm za&>9xcNKyOZG3#ZEWapebf3zCv5V!uq-`X#g)RJm*e4QEkxEf<@xHzownp$*(n_Rm zBu_a8b7EQ`4LI}{NN=bQ{_uAFYlA;N{`+CUcmGoO_+KuGCKo&2dItW@AKWXUza^$#dvi+* zp7+0G$6f<+z<{HmAOWBz0gwyxgALX@0%TYN0s?FRp7J>EfB7nDYSLm%WE~dofBipQ zr^ffkj~@WJIA#D7;M~Sall}MbCIz&X8pcptLct-tK3N~cxB+nmqbNR8Gqa$1OyHj| zQLGnijq;9;j`sGSZN3AVr~uJ>0f3MKJc=aK6u?FZpj-{^A^&TP9QL~-M6P7a%$2#h zbz>B1g3kp%w!|E_L5g0@|G6<;#EVI2Bx2_^x3WAaNDQrP1Pq?bBTBjsl!wX=J^v7yriI z(+XA&j&xoR76?+V3T+yP4gMs~zqD_03&G&^IwwcRq`Dt}$K4;1c;?-!vTMiy1RM~h zYHn&uNmtU;^Z+!}6IwRmh4X>FwzmGBkdW5}It5VM068ZLCZ?#CzXq834}5%lfNLfp zG4V(Z!oU2ZNB=cp0dzGFxugEdzW~xRC(97=MWlcYpbTVEg*|}YwwCoVUh1!jCSX3{ zNGIau?hfc;jrRA4czpRcP?>uHl>w;6dIC%-_Tz$vzrLPmXlP4(05dTc#b1Zf>~iGW z48yX_agF1b_B~&~ZhLa)uO`g$Ml7QI!p&_t@7p4b-Hiz7 zHCzCKvow@I{yG6`6mCHJU``nTr}F>?;sb(zz0OoB-i37o60DO2@@DF>C1CJ4e+DVw zGCDjQ;qk5S?^M4R9}$5H6Sq7EH2e!F3^V)I06u1ppaH-K0t}VnB*?Lx8Xyor6ZVG=*Sn($jHYaj1F|mjvKz7+=Kyqt$w_Lw1-4O+UR12WfwY3T4FeUw6eq^cx%B_(h zAsAJ=cNe>VjTEb@ssPG5VCq%!j24&onS;ah65-zppvBR_A#0!mU;ut7J>W?LoVWH1 zGRg`I0TP(99|rv7@9iw-e{JSRtD@%`puYf2LF41m&%=FvV{_{O_a&B6Hah~)Q|G{8 zekUc|)zhPd)v<>#@ORkbrsRF`*`UK8pt~6W6$*%^Zj~`AYHHpA;s>-?p`Szof2&xW zHcn4Z12Q$E0|TA_r~fL}@xNi%^d&qzP{XXiPzvbU27)AMXB@B@EtprSHWLKWI=sIRY&h=|w{Vf|l1JuVzd<(GHs{E+D1{YU9_khx3|~Du(*w>F0#>Uqxx-Exg0lL z!<3VICPc>axsj*2=gL+;^`V*PxX_rYIROzvv=WBFe)_>dMy9^#6s^7z>ut5Vx)04Y^QqLZa6f3xCC&AR6P4(^cD4~{ii(clBGVW-@;VHx ztxr8Q%L{yrb+~hu6qBM^o`@-;9oU|64a8BKNqfoHE6;8<>wA}>teD*wC2dg8(9DhO zA!U-haeZQ8z_Ibau>k#EYkg&9O(B7l2G7W)@A;K!saFwnQp1rNULQ9X(z0tN&L0WM zv=NhQ(`@SbiPDoLGf0laT`p#~>p8yg!NpH9J;zrEelY7&wSeV#*sbZweasr zyL)Tl25bCHjFaYR5!uz()zv=?hEYfLrpKlvIXwLS?xJ%%{5hXSD6G|u;QGsrk=Sh- zK^xIPlr&Hg#nfsa7BvTK7CQj;h+vK*(Z# zJj#bbiOlFV9CTc0OhHx8)nJ@%e>ZY^Ifq)^7QM`@Lv@uI%Y@ z_o^1Lh>rH|2De~mjDd&clP#z)Akpa$k5#ERgt-al!3;P-;r30y#+nrXJ8ZyO-<_{F zop-!{PTR)f-ZH`MSsg(^4$-R5o|H!ZUeF3Y>mm^F_B88FNhzXzPBWR`H<_0iDecNt zo@HcOf<^bN&@t_ZT_&6mc-7Fi1zWVv!S4$5 zKju}{{C=veDin-PPVs&+TxwlK{9fHT*B)RIosyEGeDe5JU;8;NKr-2R5Ty;XasA}e zYI8g6cY$szc9pxgk2R8;yr`mukg(RmeCPgd)Ybj%E)L1tKd{dj7k|pi+!INdPsbY% zmwMaGM0;4#@u2QcXI(+-0*8~qAK1p;ZXc#%#>yRR(!PMea$q8-Q zjH2C154RpkZZZ6ZdfM}~?HWtTK=pk)y9-KOS$VLP-%I4CLmS<~w^+pRUMWqgw1nKHp6lTAC!oJ}s3`)#!>JEvt#T z_T*7G3FFG(yl4LPOZ4Cl1782hPG_i<6_U$m@jr8DY-aYBBb zPx@=aE1F3D`pZ@=}6BW;C!p?hq;k9T|CxSJzC4sifkE7;+Ec*S@-VbZ4ouB6F<#$qq);N73u zQS3KflBKoV+3>8`ow4Q9%-2t$W>w~}Q~1=L=qM_EZEHIJsdG#ms&3tMU9~qRN*{~c zC62oik;SMhYQWE@5*O#O)2ZKM0}H;>kJi@it=t>?YV)({+*(JJE96?f^+TJTh|A`% z>cCy!oWMxcqHwDN=M0gOz~rCI#i7cCWjzj?UC{``p}Gtfri^?@u~Zl)Y-Jy239lNs zn5u@m6q1W-WOnl!KBcO;Nk(L}KEvUyEvde0Ofv4-ppfh0z#@Zte^~hT)Pm~=g7+H8h2$-fg~hKd+gDS(C_<&Va$lnBO=AOxS$lk zWMaI9*eKluPV3d61ZQ~b`jYF(O~ws=G5z(T+rhUTfM44ov2!(76e?ADX@KV}IX~WI z1l0m}eO)m+{<>-w&E$MsmuWNsZ^vuWTo@8asjkVUa)+!c$Gzmu zR)d-3MroL+nX9VVT&m9Sajj=TzkDTFL29as4mWEvTbRJ{Wj;;SBsiF{-l^*R+Kv-S zLjX&^(r>00xH->10^{aLu{dIL1+dVuDw`1$q^C7!5L@pU_P*&veXe+?kopmrP$46_ zmh1bmr@NF45Q_AM0UOS{2s(*@HAk&uvY(zq-n5*2zv_rQ(b%e-c&|GYuCpGK2!JE) zjqyc-R)aVY&BDF=;8P$e^IpD4NX62GZjOfzd`QjX}S9%jreBK0a*>S^xW90>NUGui2B zl}86KbTOEDk)EubMh&=xx7{+Sa>q3Wv%|%syKO|?J|7ACdXDaZP4tgIM4P^0K5y=p zTMnB(+0Ffuu4Ox88{5TN@0qJFV$;zK!$Yhtbtc4U^>x$O%8&YllO|H|2)f|tk{hpB z$LwP*7d&HvxzViMk7D96qdEoWtdOHSa}rDcy;lQjlE;sD6Hm2NY}e&wCA3oghV|Vn zBGF$juB`nR2ce{XGvO<;0r>e_Ad@1N+v`MEU+;qxF%zRz+?|z$iUFG+5LT0FFBijC4HeeXTlXT5Uet<0^)=G< z)=grUFjK0`3DCJvw6 zHG3bdoikd@hI@M zQr928uf9KL{c%4SlzrzVJ-b>U!$y`E#w5!?|hPI2LK#AyW z4!uw%`SVevs1s=WCDz5fU&39i3Bk{TYJrVUTU&Es%}0LOCt^y+oP4EuPi0-zcwMj7 z&sEi@sr*fqeEYv2rUdl2^U)2QLyKiK9_oy!KDvjagz^-YFm)aC5PUKF2>{E5%R?7M>6StM!D#Ns^ifyv8eixe&1$$p`>sd6`J>xa{UdPWMq7Rb#O421DXE0cCZFeWgbb{D8 z*E?5}%AX75MPaG6)%7RPyr++UsR6&a*Lk*G#436Mu{jS25;5^`o~OMI>*o5ptTw{_ zqRD@026_=RB2uDSHCJkJ(sTl297dtG?ePH8pt5s}l@6qkb=(7K`l^ImP z5lxWRh1yYdWyQpv!0M@@rtn)MmFCL*YuX1llKEIvd?Z!x?fVuR=z?SXS`vdm*O8vt zbROdNv<>8{@)(Pn*|s6b?099o$myeYX5RI~gm}3gtG@YiZKBQW%B7CefhlfyA@esQ zTroip(wiA}+tdXMF}Dr0DO6X2=S^N$-e#C|)Un6q^1%CT1-QRZaS?kTI*^{dP;ksGL6}DKKUyfiExw68}9+tFswV#hYYPDG^dai zhwT1L->|YmwCv9D&suNpdK7br>p@5`;?Uiu=JWK%AWqmwa}q&4XIVCCV`BH`G# z_F&QRm52;}llo;6Z#go2tV>DRp9sAgFL4C1?MyB;elN-CKu2F4G--4u+9W= zcIcERyr?6F$R#nux-X)Lz*s9KS;~UtHJoLwl(x^&u0Z2^xT_d=J@4Nh=BJ1Wsf|db z@&{5&zD`r0jOv$zik$|_;30~$6-w;y_^t)bpE5t4)>7?txt-+R>!_*b_aPx?5%6p+ zcPy^fY1<_VMa>8-Uh7fF7VUEF1TDd?w_1+HM#wTe-DCf z-sI&wBFj@9@mvgU^+d|%Ql#W99-cWH)iq1$#UBHd;Lmes7f3Uj5z;5qCaw%P2nGn`u8!|9sgX9a8GSCHr>Io7#q6h|j}0|a>}*Q(oX2cO3=!hs0S;g($;nC7 z*lG%j&;A`rb@eEF{RwFdB15!2mcz*5NbtVzHEOsc$3PQ?z;I51GR^5s&omb&qZ35Y zChUbdG7whJ%gfVkqL%*%C=~4M>>M8-4~Yo&3Wfy$ zy6w7@+V4;qJAyI$vIbz1R(? z<@ekshgVdq(9sMk$_1(z=XG1d8mldn;sOr|5@Vm|J8ivWzxVgaU5pCJK2M+Ad_Cdq zkF4F2{_>rMiW_`_sN8Tzl$K85x|!%_ohd>QPb9Khb$pmkGGyC_{=cz+R4Hshq=JTd zI@Ji7RXB4BY;0^La7|GWw02ex;(2AMp`l@7vJI#=`}z3+wr`?l7Waw+1jyx}CKUX= zuO5agH%n7y$$9&QS0`r|KB(NVJ4#zed@wyM#N(S!wLpz!?uGTnw_%$^(=BGtuhT){ z73Qv~Qtuywc!~GBU!ywX{N1TPWx1-P9epwQxa>F?w6z>EgoHfY?8?7;FX{sq?XW); zAX*Oa7UBg^6if}apAPApR8>`s#eq$mtM7AOtBNk1qOz0GvVPp~@!5=WxEfdO=4q*S ziXQSr-w0QtV$_BDUJXPnJ|LHMmd1Y|LJ|o4&w)T}| z?==Our$OGjR{8s+rhOhNk>5hUV-QA(fsA9`Jg`~#i1L5Pe zb;!_X&gHH~7@47n$KKEWLy<$0;?};5X|fL}!=>E(JjKsw1*`g<2=XtSD8~{dY>lEZ zh%uj^R)A$V!WlToj2^T#>!x#0ljsqjoazj++PIVFU@0<)!4gsC3*bN@pFooz4qhqI z8sz6x{g#9cmEF8}f2SVsZz{wF7p+dgF6?7U)EG|A?Rb71&d;*AHF9EuAz&(rg zxXz@OsNBf-$HWjv%l0U1#pI+$9u)1Xz+Voo9?X(DIEw1Dp-2Q?!K?c8G6R6c()CE} z()F||WFG*6t5nbO^JlQEI3Ju|4o+|BZ-0_rI%Tp~u5bHVIli@2>EP)MK+LWH6 zR*aX0<}JEcqo!rtsx3@IpYd+|Bje;ZijU@>U5X!Ma0}jOaYdBkDv=g$%0^OU~s@&cMFrhv?eMwAbzBwhnA6&2U z5-3QI8j6(4UE#%j@vFgax3HIYtva!m5Fj=NC2M1fP-_1N{?%7Q$r$TcvP8p}&qd!q;`{6U!`zR#-t(C2eZQ~g{e0CU5z_-l z?>D!H##Nb1Toeym@3Pc1hs=zZez5QnWGKQ&zSR&Ko5=R$1U31BUhRd&mwY~Vrp{w( zT}{!FvOe+S=FQ%7KY1kl&_(X@frR(vF4ZxO_v;HVLHH4L^6=*VXoBZ3`8SX1b#tYT zU@C4Lux8KC&tDO>A}%ed8$~}rr9Ak1&=;ISRg{-Av#{*1PpMy`2^8SJHtUaPu(tj_ ziH1uC6ou!6mW~?=FAiAGEa_Z$Ga8qekT=%UP0{x=xXPK;4qtQ@5SMt8fDCXNVP8e- z@t}ANMZ`@TURL;y@MCt=Ur}Q1CU}ySd5bl)kRxg@Mw7}`T6EZPG z3J%?WosutLAcDc<<{rxSttsmBN_%gtb<;8&jgnXKmYPaW2s68_Y6f)}7d^rZ1{t)S z(>JG{b_VIopFXuA;RXTN_k%=(96~gbi@e;0Fh)Y)Rt(VxKa%J92*ne*fz_rzmWYA7 zOTL9y`s#Jz7|?(@u<~7P_FpMmC)yaraFT+=RQnn;92kxrLmNM(*i}_AZ^y^Z1`9dw z41Nfk-#(gemChz^A88!t(KSZa%PH1jEXZMonBGRu+G@WddRDT_;zZQ^+;;oWhp^o3 zFlJ>hyZH0=`gaE0%xjH*jcaGFN-+HPDB%z->(PNr_fN|@t6bRKhso!?hQxEtd}_IF zeb?PPB2;C^Pb6vNGt|&$9#G!}k=%q@f-jb>hZ-kAA7Kl$ch8~FUs<`SF~#yyue9E^4jk^k!731TEEIEIH0S{KtQqs~ z*dZm${b@yh6bx082j%>V_&)2=KGZk4pZ0FguM8^O7xT)_n-4jDuCN{LWsOzZi#f`z zXsfCoKl=&2d6V^4Cr>k56P~X@7%jFUxAc>=u4krUxiK?69Hp;gj#90#HZ_#x80=E) z39eZzC|E)FSX<#92D;_?KFL-MgkRe+tdKqPjc&J~>kav5mu_~i8<8_p+c-Vd%3pVR zvwqpiQdM)H;SLd%lrlnSqZ$N4R-`X+o=ttMLAoziEo+hSm@DDkhMh)2?m>m z96m}d7wzAqde%V&XP=fQM(1G?B|nI7-g3Ir^qEkV!g`~CRL5JVVUELPw(($%tV}$Q z;*F*?7}vkwF<_k{W}GAXg3kV}z<5QE=J#pP+)XE3v3e`nasSO?Pve!qxygh{w{EMq zU9KiQfgZHa9d2f{C8cHH5KgSAG?_kc(jNxK&mr zdTgGQG5WBgdgJRid2gh~Y=^1%sPKv-P5q(cqM8Rw60~6v`x5jUg$Wi7FUrshDiNss zQpHtYC7RW7B`R+n5fm^OdH_=)SIuz?0fPWRbabq6==^u(k>gpPd<8;n@y?sg|97&@s=g+rCv6as@k99H9ZNj z^R+AcUy1{!x~+A zZ?PHdAxk4uikNuxyUL)pw>{=9&pSR2cfGERTtueOr#~(I;Sy;{ImS+uLr_@Nk7D*M4m$Da7r4 zT*BBHY~VMAoa?HbDCALBlMc#w7EWb02{MN&EgKvV2&)1!26sS|7$%)%WGqbT1Ob=c zbFF`QxDiqYR)Kb~Nai!>z++=#a`WD0KX*UN7lW#0T&t+R)(|++gQl(7HX{SqZ49hJ81cpXt_T}N`Oij>@yCHDQ!}) zj18?JUy3O6bmh-pZ(y@$T9= z%}FofP8#a#QyQw|*W`Bu=j2d#xe@?^Pp3i-T#lUJ^9y76a3i1@ewQ_`Z@^LqZ$gp@ zV-Cv3O@g|4Qz3HkA@aNv6%1_9HN)_&vBP^a&iA0SsK?fy>wKqUp(F$vg~Rqf&fTchJH`{`_C7N4Mb^vD`VSSxc4HZ$>g6+z zx}pn`LU^=go9@Y9kDj0V6wUyNmT5WS9_9^Z)s@}e_LQnpKGUTrg)~7-Y~oj@6Gr}% z-COBr3@Q}coy*r{TVy#t_LV1%i`o%pn+M!70;S|QBa&pS@L?3Otka>EMS)n3)KRcNY8)Q zK=b`+3mAK|Wc^-;MRxPFR2?ki@jE~W970+ta&IcID1F%;3;3m2PA3Di4w}mrB_r)e zvigdy(;NIVU~bajiLkqRs3@`35X^O>xl`^%AcaV%t&jL;%|*KWjI^-uM%B*f$38K? z;NR6bb=g0@nv{LTO_!28Q30alRP)DXu8tFH_-xAzOm1j>l-*VmD~${4f%8QQjGkJO z;b!-&awOTe->DV4-Vkl?`F$xeex|-tyhr^oYd!Ms07ZF|U_+zP#5E>M%a5|Ms~dAw z4s|@k-6gfl%2>DE#ZA~yQYI<>tbZVTAl%;LG3htnua>M5%`Rg5e&t))QtoE`c+r4; z!#5d5{!HfMX8&Mp2niL-CZo%)`J7Q$ePX(eC(ctp&pgd|z*D-i_F(~(u#unnA8%E0 zA!ZU4zab`d=O7t%P;ga&ggyLf+Ao|kX;Dcg9|nmPREs7?pmyGmx;w}DupM_a{z{ON zyZeeavXz$MLbKqarOj{;I388m5)d7NcjWjUP?LW{>)TZ?K$I_-9Sb#tK=W2>1`}Kz z^utXMR}Tg}DU&=BR26X?hkyxDBo)i(Wr00HmL?N+nTGB{PCS66kf}3%So3e8wV z0fGYb!x;+K=?eFdi6IA?Pa@@KGnW+_)rrTr`I^k6IJs3lp%2@W>gx@D`L?0muW~qs z&v4x^O;1aIM!3+R4HxOoQ4`qUOy{U)IG2ATM!(`(qBlwAVJr=-a+z(i;SEMe^n&AzAnuvR*gb2&?ER&wqGX86+dB`1Nbep!^;tsA>fCwH zf7nI%Fzs6EXqbvh`{ZvD05$gUKKH9A%R0GF=4Vwtt~@7HA8AKCyh-N&z0lUbg&W#U zNs5CA8NFxqyU5;K46J6iJMDYybebbA>kk}E5feFigGV#B2cXw;!ptF@! zX8IuB9`fVv-bPA*ILwEgq(!=AR-rtE;l9 zkjmmCEh?4PJmM{ev>Grh{K+w;i?@Ns?{>D?*VS@8V2p;s26DIBul017mph;g!%P13qSbk{G11hC&(Po6>AwJB`42JdyDn1wq!U_{b=M~U)ysW9uKD*@*6zQFc3#eg}ZO) zZf22u*G5Rodt;iNa1KCilwgn!_NBL%o`Hc&8ew5*2*AF}tTHMpDy~z77Q?OpLIMG4 z6_u5oN*aXdacg6%>(MbBfX`!&wwm(zL-Ji05P$>ue@!AVie+TDxK~}hqczyku-W{8 zLOM7&ao9^t27C3+@8lpZ;wHHPyCf7ALuZUX=)_@uFL7}bo6CyA!4yqpN4<#WJ^ zqL-JKoox#0&eq=E-p-C))gx7=&s|;3z|Kr;)eNY~&9w!PEytRI{M_TSFHb?j4X5%d z99x=}6nr2P2q!_-lEU7I0kEp)$%5WP)jk%KAC;0~(Gnsr1PdNSa?1y}&S3UHgt?EG z*D%}0){CfX7f(5D%Um;>+oSCb2O!K{Dlc8Lt&lw|EUa?W)-NaEWlPI*6B9OCmdMCR z;Pn6#jQ0SZTAuXi3Dp6ZkkfN zl8Q#78PcN})r`jn)lSyz9ttY12eeGn15F7DO!geh^&?%wUL|SI*DC591Myw)HQH_z z;G{7};~4J8d;N)Z4R^o|RQY;V86cumj3 z!qwKcefjL44{)%txn~))cFpoSIr95X9YL7+b;YW{~UjCyx*8*!j`5RM=&qxLJU+ry`N7 z&U=oGr`N~kiNwsr#K8|n1?F+|EWnt%I~G^@2W}RoqoW(~hdVe(1f(F5YVM^Snk@f^ z>}&8QnwXoz;b0Jvn3`(u;xaYu;!6MW6!-RF=iuOAXTJ=Ah*jKFRQwL4yvE}-VoNoU?ij{;?%}h-}-0pJF&q(l(*Wa%t6jt-lC@kDQ z9~6GKv$eH#U_b;Qt<+}$ysyR@6B8r!@4s$tZsv9id32}iLQ>Y2C2&qmx6(Xf0e0n> z*w{T5k@tEh{5Ve9d9kFLHTJV%1r!R+F$~>W8XDM50nB#*25h->CzR$3DW&)(ts8mH zsy;qG2n52(sl-C0s54wem5;f4kcz Date: Sat, 17 Oct 2026 09:59:22 +0900 Subject: [PATCH 3/3] feat(demo): add app-local Web and SwiftUI Native UI Extension demo (#277) --- demo/e2e/mini-player.spec.ts | 76 ++++++++++++ demo/ios/App/App.xcodeproj/project.pbxproj | 4 + demo/ios/App/App/AppDelegate.swift | 5 +- demo/ios/App/App/NativeMiniPlayer.swift | 102 ++++++++++++++++ .../NativeUIShellTests.swift | 51 ++++++++ demo/src/app/index/index-page.component.html | 3 + demo/src/app/index/index.routes.ts | 4 + demo/src/app/mini-player/mini-player-demo.ts | 6 + .../mini-player/mini-player.component.html | 33 +++++ .../mini-player/mini-player.component.scss | 38 ++++++ .../app/mini-player/mini-player.component.ts | 26 ++++ .../mini-player/native-ui-extension.page.html | 64 ++++++++++ .../mini-player/native-ui-extension.page.scss | 66 ++++++++++ .../mini-player/native-ui-extension.page.ts | 14 +++ demo/src/app/tabs/tabs.page.html | 1 + demo/src/app/tabs/tabs.page.ts | 6 +- demo/src/assets/mini-player-artwork.svg | 6 + demo/src/main.ts | 3 +- demo/src/native-ui/mini-player.spec.ts | 44 +++++++ demo/src/native-ui/mini-player.ts | 113 ++++++++++++++++++ docs/native-mini-player.md | 43 +++++++ docs/native-ui-extensions.md | 2 + 22 files changed, 707 insertions(+), 3 deletions(-) create mode 100644 demo/e2e/mini-player.spec.ts create mode 100644 demo/ios/App/App/NativeMiniPlayer.swift create mode 100644 demo/src/app/mini-player/mini-player-demo.ts create mode 100644 demo/src/app/mini-player/mini-player.component.html create mode 100644 demo/src/app/mini-player/mini-player.component.scss create mode 100644 demo/src/app/mini-player/mini-player.component.ts create mode 100644 demo/src/app/mini-player/native-ui-extension.page.html create mode 100644 demo/src/app/mini-player/native-ui-extension.page.scss create mode 100644 demo/src/app/mini-player/native-ui-extension.page.ts create mode 100644 demo/src/assets/mini-player-artwork.svg create mode 100644 demo/src/native-ui/mini-player.spec.ts create mode 100644 demo/src/native-ui/mini-player.ts create mode 100644 docs/native-mini-player.md diff --git a/demo/e2e/mini-player.spec.ts b/demo/e2e/mini-player.spec.ts new file mode 100644 index 000000000..225693f59 --- /dev/null +++ b/demo/e2e/mini-player.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/main/index/native-ui-extension'); + await page.getByRole('switch', { name: 'Native extension', exact: true }).click(); + await expect(page.getByRole('switch', { name: 'Native extension', exact: true })).toBeChecked(); +}); + +test('playback stays Web-owned, is isolated from opening, and survives tab changes', async ({ page }) => { + const player = page.locator('[data-mini-player]'); + await expect(player).toBeVisible(); + await player.getByRole('button', { name: 'Pause', exact: true }).click(); + await expect(player).toHaveAttribute('data-playing', 'false'); + await expect(page.getByRole('button', { name: 'Close Now Playing' })).not.toBeVisible(); + await page.locator('ion-tab-button[tab="album"]').click(); + await expect(player).not.toBeVisible(); + await page.locator('ion-tab-button[tab="index"]').click(); + await page.getByRole('link', { name: 'Native UI Extension', exact: true }).click(); + await expect(player).toHaveAttribute('data-playing', 'false'); + await player.getByRole('button', { name: 'Open Now Playing' }).click(); + const modal = page.locator('ion-modal'); + await expect(modal.getByText('Paused · 1:24 / 3:45')).toBeVisible(); + await modal.getByRole('button', { name: 'Play', exact: true }).click(); + await modal.getByRole('button', { name: 'Close Now Playing' }).click(); + await expect(player).toHaveAttribute('data-playing', 'true'); +}); + +test('upward swipe opens once and keyboard activation still works after dismissal', async ({ page }) => { + const open = page.locator('[data-player-open]'); + const box = await open.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move(box!.x + 40, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move(box!.x + 40, box!.y - 60, { steps: 8 }); + await page.mouse.up(); + const close = page.getByRole('button', { name: 'Close Now Playing' }); + await expect(close).toBeVisible(); + await close.click(); + await expect(close).not.toBeVisible(); + await open.focus(); + await page.keyboard.press('Enter'); + await expect(close).toBeVisible(); +}); + +test('disable keeps the Web UI and re-enable preserves playback state', async ({ page }) => { + const player = page.locator('[data-mini-player]'); + await player.getByRole('button', { name: 'Pause', exact: true }).click(); + await page.getByRole('switch', { name: 'Native extension', exact: true }).click(); + await expect(page.getByRole('switch', { name: 'Native extension', exact: true })).not.toBeChecked(); + await expect(player).toBeVisible(); + await expect(player).toHaveAttribute('data-shell', 'disabled'); + await expect(page.getByRole('status')).toHaveText('Extension: Disabled'); + await page.getByRole('switch', { name: 'Native extension', exact: true }).click(); + await expect(page.getByRole('switch', { name: 'Native extension', exact: true })).toBeChecked(); + await expect(player).toBeVisible(); + await expect(player).toHaveAttribute('data-playing', 'false'); +}); + +for (const edge of ['left', 'right'] as const) { + test(`Web player controls stay inside the ${edge} rail safe area`, async ({ page }) => { + await page.locator('ion-app').evaluate((app, edge) => { + app.classList.add('ios-theme-vertical-bars'); + app.classList.toggle('ios-theme-vertical-bars-left', edge === 'left'); + app.style.setProperty('--ios-theme-vertical-bars-native-inset', '80px'); + }, edge); + const player = page.locator('[data-mini-player]'); + const box = await player.boundingBox(); + expect(box).not.toBeNull(); + const width = page.viewportSize()!.width; + expect(box!.x).toBeGreaterThanOrEqual(edge === 'left' ? 92 : 12); + expect(box!.x + box!.width).toBeLessThanOrEqual(width - (edge === 'right' ? 92 : 12)); + expect(box!.height).toBe(48); + await player.getByRole('button', { name: 'Pause', exact: true }).click(); + await expect(player).toHaveAttribute('data-playing', 'false'); + }); +} diff --git a/demo/ios/App/App.xcodeproj/project.pbxproj b/demo/ios/App/App.xcodeproj/project.pbxproj index ebfb139a0..7003fe377 100644 --- a/demo/ios/App/App.xcodeproj/project.pbxproj +++ b/demo/ios/App/App.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ AA2010010000000000000001 /* NativeUICounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2010010000000000000002 /* NativeUICounter.swift */; }; + AA2010020000000000000001 /* NativeMiniPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2010020000000000000002 /* NativeMiniPlayer.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; @@ -21,6 +22,7 @@ /* Begin PBXFileReference section */ AA2010010000000000000002 /* NativeUICounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeUICounter.swift; sourceTree = ""; }; + AA2010020000000000000002 /* NativeMiniPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMiniPlayer.swift; sourceTree = ""; }; 0F2D0778A9AC04BB9CF8EA1D /* debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = ""; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; @@ -69,6 +71,7 @@ 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, AA2010010000000000000002 /* NativeUICounter.swift */, + AA2010020000000000000002 /* NativeMiniPlayer.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -163,6 +166,7 @@ files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, AA2010010000000000000001 /* NativeUICounter.swift in Sources */, + AA2010020000000000000001 /* NativeMiniPlayer.swift in Sources */, 5A8EC910E2334844A592648D /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/demo/ios/App/App/AppDelegate.swift b/demo/ios/App/App/AppDelegate.swift index 8c920f963..4e0e40aa5 100644 --- a/demo/ios/App/App/AppDelegate.swift +++ b/demo/ios/App/App/AppDelegate.swift @@ -7,7 +7,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - do { try registerNativeUICounter() } + do { + try registerNativeUICounter() + try registerNativeMiniPlayer() + } catch { NSLog("Native UI registration failed: %@", String(describing: error)) } return true } diff --git a/demo/ios/App/App/NativeMiniPlayer.swift b/demo/ios/App/App/NativeMiniPlayer.swift new file mode 100644 index 000000000..28a8317b9 --- /dev/null +++ b/demo/ios/App/App/NativeMiniPlayer.swift @@ -0,0 +1,102 @@ +import SwiftUI +import IonicNativeUIShellPlugin + +private struct MiniPlayerProps: Decodable { + let title: String + let artist: String + let playing: Bool +} + +@available(iOS 26.0, *) +private struct MiniPlayerView: View { + let props: MiniPlayerProps + let actions: NativeUIActions + @State private var busy = false + @State private var showError = false + + var body: some View { + HStack(spacing: 4) { + Button { activate("open") } label: { + HStack(spacing: 10) { + AlbumArtwork() + .frame(width: 32, height: 32) + .clipShape(.rect(cornerRadius: 6)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(props.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(props.artist) + .font(.caption) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + } + .layoutPriority(1) + .accessibilityLabel("Open Now Playing") + .accessibilityIdentifier("mini-player-open") + .simultaneousGesture(DragGesture(minimumDistance: 16).onEnded { value in + if value.translation.height < -32 && abs(value.translation.height) > abs(value.translation.width) { + activate("open") + } + }) + Button { activate("toggle") } label: { + Image(systemName: props.playing ? "pause.fill" : "play.fill") + .font(.system(size: 18, weight: .semibold)) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel(props.playing ? "Pause" : "Play") + .accessibilityIdentifier("mini-player-playback") + } + .buttonStyle(.plain) + .disabled(busy) + .padding(.leading, 10) + .padding(.trailing, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .glassEffect(.regular, in: .capsule) + .alert("Action unavailable", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { + Text("Please try again.") + } + } + + private func activate(_ name: String) { + guard !busy else { return } + busy = true + Task { @MainActor in + defer { busy = false } + do { try await actions.activate(name) } + catch { showError = true } + } + } +} + +private struct AlbumArtwork: View { + var body: some View { + GeometryReader { geometry in + ZStack { + LinearGradient(colors: [Color(red: 0.13, green: 0.16, blue: 0.35), Color(red: 0.7, green: 0.31, blue: 0.43)], + startPoint: .topLeading, endPoint: .bottomTrailing) + Circle().fill(Color(red: 1, green: 0.76, blue: 0.55)) + .frame(width: geometry.size.width * 0.44) + .offset(x: geometry.size.width * 0.12, y: -geometry.size.height * 0.08) + Rectangle().fill(Color(red: 0.1, green: 0.17, blue: 0.27)) + .frame(height: geometry.size.height * 0.38) + .frame(maxHeight: .infinity, alignment: .bottom) + } + } + } +} + +@MainActor func registerNativeMiniPlayer() throws { + guard #available(iOS 26.0, *) else { return } + try NativeUIShell.register("demo.mini-player") { (props: MiniPlayerProps, actions: NativeUIActions) in + MiniPlayerView(props: props, actions: actions) + } +} diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 13b41402d..c7e030031 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -639,6 +639,57 @@ final class NativeUIShellTests: XCTestCase { } } + func testAppLocalMiniPlayerPlaybackAndPresentation() throws { + try verifyMiniPlayer(vertical: false) + } + + func testAppLocalMiniPlayerWithVerticalBars() throws { + try verifyMiniPlayer(vertical: true) + } + + private func verifyMiniPlayer(vertical: Bool) throws { + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + let toggle = app.webViews.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) + if (toggle.value as? String == "1") != vertical { toggle.tap() } + openPage(app, name: "Native UI Extension") + XCTAssertTrue(app.webViews.buttons["Pause"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + let webFrame = app.webViews.buttons["Pause"].firstMatch.frame + capture(vertical ? "web-mini-player-vertical" : "web-mini-player-ordinary") + app.webViews.switches["Native extension"].tap() + let pause = app.buttons.matching(NSPredicate(format: "identifier == %@ AND label == %@", "mini-player-playback", "Pause")).firstMatch + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + XCTAssertEqual(pause.frame.midX, webFrame.midX, accuracy: 4) + XCTAssertEqual(pause.frame.midY, webFrame.midY, accuracy: 4) + pause.tap() + let play = app.buttons.matching(NSPredicate(format: "identifier == %@ AND label == %@", "mini-player-playback", "Play")).firstMatch + XCTAssertTrue(play.waitForExistence(timeout: 5), app.debugDescription) + let open = app.buttons["mini-player-open"] + XCTAssertTrue(open.exists, app.debugDescription) + capture("swiftui-mini-player-paused") + open.tap() + let close = app.webViews.buttons["Close Now Playing"].firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(open.waitForNonExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(app.webViews.staticTexts["Paused · 1:24 / 3:45"].firstMatch.exists, app.debugDescription) + app.webViews.buttons["Play"].firstMatch.tap() + close.tap() + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + let start = open.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -90))) + XCTAssertTrue(close.waitForExistence(timeout: 5), app.debugDescription) + close.tap() + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + app.webViews.switches["Native extension"].tap() + XCTAssertTrue(pause.waitForNonExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(app.webViews.buttons["Pause"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + app.webViews.switches["Native extension"].tap() + XCTAssertTrue(pause.waitForExistence(timeout: 10), app.debugDescription) + capture("swiftui-mini-player-restored") + app.terminate() + } + private func openPage(_ app: XCUIApplication, name: String) { let label = name == "native-ui-shell" ? "native-ui-shell (Preview)" : name let entry = app.webViews.buttons[label].exists ? app.webViews.buttons[label] : app.webViews.links[label] diff --git a/demo/src/app/index/index-page.component.html b/demo/src/app/index/index-page.component.html index 6552938b3..40454cdb6 100644 --- a/demo/src/app/index/index-page.component.html +++ b/demo/src/app/index/index-page.component.html @@ -36,6 +36,9 @@ native-ui-shell (Preview) + + Native UI Extension + button-projection diff --git a/demo/src/app/index/index.routes.ts b/demo/src/app/index/index.routes.ts index 5f0b056c2..d74b1622d 100644 --- a/demo/src/app/index/index.routes.ts +++ b/demo/src/app/index/index.routes.ts @@ -2,6 +2,10 @@ import { Routes } from '@angular/router'; import { IndexPageComponent } from './index-page.component'; export const routes: Routes = [ + { + path: 'native-ui-extension', + loadComponent: () => import('../mini-player/native-ui-extension.page').then((m) => m.NativeUIExtensionPage), + }, { path: 'button-projection', loadComponent: () => import('../button-projection/button-projection.page').then((m) => m.ButtonProjectionPage), diff --git a/demo/src/app/mini-player/mini-player-demo.ts b/demo/src/app/mini-player/mini-player-demo.ts new file mode 100644 index 000000000..ac93d9d5c --- /dev/null +++ b/demo/src/app/mini-player/mini-player-demo.ts @@ -0,0 +1,6 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class MiniPlayerDemo { + readonly enabled = signal(false); +} diff --git a/demo/src/app/mini-player/mini-player.component.html b/demo/src/app/mini-player/mini-player.component.html new file mode 100644 index 000000000..dea7ad67b --- /dev/null +++ b/demo/src/app/mini-player/mini-player.component.html @@ -0,0 +1,33 @@ +

+ + + + + Now Playing + + Close + + + + +
+ Album artwork +

{{ title }}

+

{{ artist }}

+

{{ playing() ? 'Playing' : 'Paused' }} · 1:24 / 3:45

+ + {{ playing() ? 'Pause' : 'Play' }} + +
+
+
+
diff --git a/demo/src/app/mini-player/mini-player.component.scss b/demo/src/app/mini-player/mini-player.component.scss new file mode 100644 index 000000000..55200f817 --- /dev/null +++ b/demo/src/app/mini-player/mini-player.component.scss @@ -0,0 +1,38 @@ +:host { + display: block; + padding: 6px 12px; + padding-left: calc( + var(--player-edge-gap, 12px) + max(env(safe-area-inset-left, 0px), var(--ios-theme-vertical-bars-safe-area-left-resolved, 0px)) + ); + padding-right: calc( + var(--player-edge-gap, 12px) + max(env(safe-area-inset-right, 0px), var(--ios-theme-vertical-bars-safe-area-right-resolved, 0px)) + ); + margin-bottom: calc(72px + var(--ion-safe-area-bottom, 0px)); +} +:host-context(ion-app.ios-theme-vertical-bars) { + --player-edge-gap: 48px; + margin-bottom: var(--ion-safe-area-bottom, 0px); +} +:host([hidden]) { + display: none; +} +.cover { + width: min(100%, 320px); + border-radius: 20px; +} + +.now-playing { + max-width: 360px; + margin: 24px auto; + text-align: center; + + h2 { + margin-bottom: 8px; + } + p { + color: var(--ion-color-medium); + } + ion-button { + margin-top: 24px; + } +} diff --git a/demo/src/app/mini-player/mini-player.component.ts b/demo/src/app/mini-player/mini-player.component.ts new file mode 100644 index 000000000..1f862a954 --- /dev/null +++ b/demo/src/app/mini-player/mini-player.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, AfterViewInit, Component, DestroyRef, ElementRef, inject, signal, viewChild } from '@angular/core'; +import { MiniPlayerDemo } from './mini-player-demo'; +import { mountNowPlaying } from '../../native-ui/mini-player'; +import { IonButton, IonButtons, IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@demo/ionic'; + +@Component({ + selector: 'app-mini-player', + imports: [IonButton, IonButtons, IonContent, IonHeader, IonModal, IonTitle, IonToolbar], + templateUrl: './mini-player.component.html', + styleUrl: './mini-player.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MiniPlayerComponent implements AfterViewInit { + readonly demo = inject(MiniPlayerDemo); + readonly source = viewChild.required>('source'); + readonly #destroyRef = inject(DestroyRef); + + ngAfterViewInit() { + this.#destroyRef.onDestroy(mountNowPlaying(this.source().nativeElement)); + } + + readonly playing = signal(true); + readonly opened = signal(false); + readonly title = 'Midnight Drive'; + readonly artist = 'The Coast'; +} diff --git a/demo/src/app/mini-player/native-ui-extension.page.html b/demo/src/app/mini-player/native-ui-extension.page.html new file mode 100644 index 000000000..c99b16672 --- /dev/null +++ b/demo/src/app/mini-player/native-ui-extension.page.html @@ -0,0 +1,64 @@ + + + + Native UI Extension + + + +
+
+

App-local SwiftUI

+

One player, two renderers

+

Now Playing demonstrates how your Web UI can become a native SwiftUI view, using the same state and actions.

+
+ +
+

1. Try Now Playing

+
+ Native extension +

Extension: {{ demo.enabled() ? 'Enabled' : 'Disabled' }}

+

Off: Web. On: SwiftUI on supported iOS; Web elsewhere. Ordinary tabs and Duo Mode both work.

+
+
    +
  1. Use the player at the bottom to pause or resume.
  2. +
  3. Switch the toggle. Playback state stays the same.
  4. +
  5. Tap the track or swipe up to open the Web modal. Close it using the button at the top left.
  6. +
+

UI demo only. No audio is played.

+
+ +
+

2. Shared state and actions

+
+
Web owns the state
+
data-title, data-artist and data-playing supply both renderers.
+
SwiftUI sends actions back
+
Play and open activate the original Web actions. The app updates its state and attributes.
+
Layout stays with the app
+
This example uses element projection: SwiftUI follows the Web element's position and size.
+
+
+ +
+

3. Build your own

+

Keep the Web renderer, DOM reader and SwiftUI view in your project. No separate npm package or Capacitor plugin is needed.

+
    +
  1. Read your element with defineNativeUI().
  2. +
  3. Register the matching view with NativeUIShell.register().
  4. +
  5. Pass the definition to enableNativeUIShell().
  6. +
+

+ tab-accessory is an optional placement chosen by your app. This example uses the default + element placement. +

+
+
+
+ diff --git a/demo/src/app/mini-player/native-ui-extension.page.scss b/demo/src/app/mini-player/native-ui-extension.page.scss new file mode 100644 index 000000000..b754f3b10 --- /dev/null +++ b/demo/src/app/mini-player/native-ui-extension.page.scss @@ -0,0 +1,66 @@ +.extension-demo { + max-width: 800px; + margin: 0 auto; + padding: 24px 16px 32px; + line-height: 1.6; + + h1 { + margin: 4px 0 12px; + font-size: 28px; + line-height: 1.2; + } + h2 { + margin: 0 0 16px; + font-size: 20px; + } + section { + margin-top: 32px; + } + .eyebrow { + margin: 0; + color: var(--ion-color-medium); + font-size: 13px; + } + .demo-control { + padding: 16px; + border-radius: 16px; + background: var(--ion-color-light); + } + ion-toggle { + width: 100%; + } + [role='status'] { + margin: 12px 0 4px; + font-weight: 600; + } + .hint { + margin: 8px 0 0; + font-size: 14px; + color: var(--ion-color-medium); + } + ol { + padding-inline-start: 24px; + } + li + li { + margin-top: 8px; + } + dt { + margin-top: 16px; + font-weight: 600; + } + dd { + margin: 4px 0 0; + } + code { + font-size: 0.9em; + overflow-wrap: anywhere; + } + nav { + display: grid; + gap: 8px; + } + a { + display: block; + padding: 10px 0; + } +} diff --git a/demo/src/app/mini-player/native-ui-extension.page.ts b/demo/src/app/mini-player/native-ui-extension.page.ts new file mode 100644 index 000000000..4132d261c --- /dev/null +++ b/demo/src/app/mini-player/native-ui-extension.page.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { IonBackButton, IonToggle, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar } from '@demo/ionic'; +import { MiniPlayerDemo } from './mini-player-demo'; + +@Component({ + selector: 'app-native-ui-extension-page', + imports: [IonBackButton, IonToggle, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar], + templateUrl: './native-ui-extension.page.html', + styleUrl: './native-ui-extension.page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NativeUIExtensionPage { + readonly demo = inject(MiniPlayerDemo); +} diff --git a/demo/src/app/tabs/tabs.page.html b/demo/src/app/tabs/tabs.page.html index 72b06e0d4..d6bbfb86f 100644 --- a/demo/src/app/tabs/tabs.page.html +++ b/demo/src/app/tabs/tabs.page.html @@ -26,6 +26,7 @@
+ diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index c6db3f9a7..475e23ca4 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core'; +import { AfterViewInit, Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit, signal, viewChild } from '@angular/core'; import { IonContent, IonIcon, @@ -21,6 +21,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { registeredEffect, registerTabBarEffect } from '../../../../src'; import { applyFoldStateClasses } from '../../../../src/vertical-bars'; import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; +import { MiniPlayerComponent } from '../mini-player/mini-player.component'; import { Capacitor } from '@capacitor/core'; @Component({ @@ -28,6 +29,7 @@ import { Capacitor } from '@capacitor/core'; templateUrl: 'tabs.page.html', styleUrls: ['tabs.page.scss'], imports: [ + MiniPlayerComponent, IonTabs, IonTabBar, IonTabButton, @@ -49,6 +51,7 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, #hingeListener?: { remove(): Promise }; readonly #destroyRef = inject(DestroyRef); readonly registeredGestures: registeredEffect[] = []; + readonly showMiniPlayer = signal(false); ngOnInit() { this.#router.events .pipe( @@ -61,6 +64,7 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, return; } const path = params.urlAfterRedirects.split(/[?#]/, 1)[0]; + this.showMiniPlayer.set(path === '/main/index/native-ui-extension'); if (['/main/settings', '/main/index/toolbar', '/main/index/button-projection'].includes(path)) { tabBar.classList.add('tab-bar-hidden'); } else { diff --git a/demo/src/assets/mini-player-artwork.svg b/demo/src/assets/mini-player-artwork.svg new file mode 100644 index 000000000..61d549c12 --- /dev/null +++ b/demo/src/assets/mini-player-artwork.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/demo/src/main.ts b/demo/src/main.ts index 661c8c493..72c5b032d 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -1,3 +1,4 @@ +import { miniPlayer } from './native-ui/mini-player'; import { counter, accessoryCounter } from './native-ui/counter'; import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; @@ -47,7 +48,7 @@ const startShell = const buttonDefaultFill = stockIonic || new URLSearchParams(window.location.search).get('buttonDefaultFill') === 'solid' ? 'solid' : null; const buttonProjection = new URLSearchParams(window.location.search).get('buttonProjection') === 'source' ? 'source' : 'system'; void startShell({ - extensions: [counter, accessoryCounter], + extensions: [counter, accessoryCounter, miniPlayer], buttonProjection, buttonDefaultFill, controls: { tabs: true, toolbar: true, segment: true, fab: true, popover: true }, diff --git a/demo/src/native-ui/mini-player.spec.ts b/demo/src/native-ui/mini-player.spec.ts new file mode 100644 index 000000000..382ee1c3c --- /dev/null +++ b/demo/src/native-ui/mini-player.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from 'vitest'; +import { miniPlayer, mountNowPlaying } from './mini-player'; + +test('plain data attributes drive both renderers and Web actions without native', async () => { + const source = document.createElement('section'); + source.setAttribute('data-mini-player', ''); + source.setAttribute('data-title', 'Track'); + source.setAttribute('data-artist', 'Artist'); + source.setAttribute('data-playing', 'true'); + document.body.append(source); + const destroy = mountNowPlaying(source); + try { + expect(source.querySelector('strong')?.textContent).toBe('Track'); + expect(source.querySelector('strong b')).toBeNull(); + expect(miniPlayer.read(source)?.props).toEqual({ title: 'Track', artist: 'Artist', playing: true }); + let opens = 0; + let toggles = 0; + source.addEventListener('openNowPlaying', () => { + opens++; + }); + source.addEventListener('togglePlayback', () => { + toggles++; + source.setAttribute('data-playing', 'false'); + }); + const actions = miniPlayer.read(source)!.actions!; + actions['toggle']!.click(); + await vi.waitFor(() => expect(actions['toggle']!.getAttribute('aria-label')).toBe('Play')); + expect(toggles).toBe(1); + expect(opens).toBe(0); + expect(miniPlayer.read(source)?.props['playing']).toBe(false); + actions['open']!.click(); + expect(opens).toBe(1); + destroy(); + actions['open']!.click(); + expect(opens).toBe(1); + expect(source.childNodes).toHaveLength(0); + const remount = mountNowPlaying(source); + expect(source.querySelector('[data-player-toggle]')?.getAttribute('aria-label')).toBe('Play'); + remount(); + } finally { + destroy(); + source.remove(); + } +}); diff --git a/demo/src/native-ui/mini-player.ts b/demo/src/native-ui/mini-player.ts new file mode 100644 index 000000000..35d8bd766 --- /dev/null +++ b/demo/src/native-ui/mini-player.ts @@ -0,0 +1,113 @@ +import { defineNativeUI } from '../../../src/native'; + +const props = (element: HTMLElement) => ({ + title: element.getAttribute('data-title') ?? '', + artist: element.getAttribute('data-artist') ?? '', + playing: element.getAttribute('data-playing') === 'true', +}); + +/** The Web element is the source and fallback for the matching app-local SwiftUI View. */ +export const miniPlayer = defineNativeUI({ + name: 'demo.mini-player', + selector: '[data-mini-player]', + read(element) { + return { + props: props(element), + actions: { + open: element.querySelector('[data-player-open]'), + toggle: element.querySelector('[data-player-toggle]'), + }, + }; + }, +}); + +/** Render an empty ordinary element from data-* state. Return the matching teardown. */ +export function mountNowPlaying(element: HTMLElement): () => void { + if (element.hasChildNodes()) throw new Error('Now Playing needs an empty render target'); + const listeners = new AbortController(); + const { signal } = listeners; + let start: { id: number; x: number; y: number } | undefined; + let swiped = false; + // Only static markup is HTML. Application values are assigned with textContent. + element.innerHTML = ` + + + `; + const children = Array.from(element.childNodes); + const open = element.querySelector('[data-player-open]')!; + const toggle = element.querySelector('[data-player-toggle]')!; + const emitOpen = () => element.dispatchEvent(new CustomEvent('openNowPlaying', { bubbles: true })); + open.addEventListener( + 'click', + (event) => { + if (!swiped || event.detail === 0) emitOpen(); + swiped = false; + }, + { signal }, + ); + toggle.addEventListener( + 'click', + () => { + element.dispatchEvent(new CustomEvent('togglePlayback', { bubbles: true })); + }, + { signal }, + ); + open.addEventListener( + 'pointerdown', + (event) => { + if (!event.isPrimary || event.button !== 0) return; + swiped = false; + start = { id: event.pointerId, x: event.clientX, y: event.clientY }; + open.setPointerCapture(event.pointerId); + }, + { signal }, + ); + open.addEventListener( + 'pointerup', + (event) => { + if (start?.id !== event.pointerId) return; + const dy = event.clientY - start.y; + swiped = dy < -32 && Math.abs(dy) > Math.abs(event.clientX - start.x); + start = undefined; + if (swiped) emitOpen(); + }, + { signal }, + ); + open.addEventListener( + 'pointercancel', + () => { + start = undefined; + }, + { signal }, + ); + const render = () => { + const state = props(element); + element.querySelector('strong')!.textContent = state.title; + element.querySelector('small')!.textContent = state.artist; + toggle.setAttribute('aria-label', state.playing ? 'Pause' : 'Play'); + element.querySelector('path')!.setAttribute('d', state.playing ? 'M6 4h4v16H6zm8 0h4v16h-4z' : 'M7 3v18l15-9z'); + }; + const observer = new MutationObserver(render); + observer.observe(element, { attributes: true, attributeFilter: ['data-title', 'data-artist', 'data-playing'] }); + render(); + return () => { + observer.disconnect(); + listeners.abort(); + children.forEach((child) => child.parentNode === element && child.remove()); + }; +} diff --git a/docs/native-mini-player.md b/docs/native-mini-player.md new file mode 100644 index 000000000..f95871188 --- /dev/null +++ b/docs/native-mini-player.md @@ -0,0 +1,43 @@ +--- +title: Native UI Extension: Now Playing +--- + +# Native UI Extension: Now Playing + +The demo's Index → Native UI Extension page (below native-ui-shell) reimplements the mini-player proposed in PR #248 using the [app-local extension API](./native-ui-extensions.md). Its implementation lives in the application; there is no mini-player component or playback API in the theme package. + +- [Web component](../demo/src/app/mini-player/mini-player.component.ts): owns playback state and the Now Playing modal. Play/pause is separate from opening the player. Tap or swipe up on the track opens the same modal. +- [Web renderer + DOM reader](../demo/src/native-ui/mini-player.ts): renders the Web UI from attributes, sends title, artist and playback state to SwiftUI, and maps `open` / `toggle` to the original Web buttons. +- [SwiftUI View](../demo/ios/App/App/NativeMiniPlayer.swift): uses `Button`, SF Symbols, `DragGesture`, and a capsule glass background. It awaits `NativeUIActions.activate()` rather than duplicating playback state or presenting its own modal. + +Register the View once in the app delegate and pass `miniPlayer` in `enableNativeUIShell({ extensions: [miniPlayer] })`. Copy these app-local files to adapt the sample to your own playback state and artwork. The original sunset artwork is drawn with SwiftUI shapes, with a matching local SVG for the Web. The compact player shows only artwork, track metadata and playback; elapsed time stays in Now Playing. + +The sample uses the default **element projection**: SwiftUI follows the Web element’s position and size on iOS 26+, with ordinary tabs or Vertical Bars. Browsers and unsupported environments retain the Web player. Use the **Native extension** toggle to switch native projection on or off. The player starts in Web mode; disabling the extension restores its Web UI. It is shown only on this demo page; leaving the page hides it without resetting playback state. + +The Web fallback reserves its own bottom-slot space and keeps play state across tab changes. Opening Now Playing retires the native player through the shell's existing overlay policy; dismissing it restores the current Web state. + +This is a UI example: elapsed time is fixed and no audio engine is started. Applications own playback and reflect current values into the Web DOM. This implementation replaces #248's dedicated UIKit accessory/controller and theme-level mini-player API; it does not include that PR's unrelated segment/browser-support changes or add a system accessory host for ordinary UIKit tabs. + +## Use the Web + SwiftUI set in your application + +The local TypeScript file includes both a renderer for an ordinary DOM element and its native reader. Copy it together with `NativeMiniPlayer.swift`; in your app, replace the demo’s relative `defineNativeUI` import with `@rdlabo/ionic-theme-ios27/native`. The Web renderer works without Angular, Capacitor or an enabled Native UI Shell; SwiftUI replaces it only after native accepts the projection. + +```ts +import { miniPlayer, mountNowPlaying } from './native-ui/mini-player'; +import { enableNativeUIShell } from '@rdlabo/ionic-theme-ios27/native'; + +const player = document.querySelector('[data-mini-player]')!; +const destroyWeb = mountNowPlaying(player); // Run on every platform, including the Web. +player.addEventListener('togglePlayback', () => { + player.setAttribute('data-playing', String(player.getAttribute('data-playing') !== 'true')); +}); +player.addEventListener('openNowPlaying', () => openNowPlaying()); +await enableNativeUIShell({ extensions: [miniPlayer] }); +// Call destroyWeb() when the application's source element is destroyed. +``` + +```html +
+``` + +The sample does not specify `placement`; the default is `'element'`. Applications can opt into `placement: 'tab-accessory'` when they want a system tab accessory; see the [placement options and host requirements](./native-ui-extensions.md#content-and-placement-are-independent). Its surrounding Web layout belongs to the application. The same attributes feed the Web view and SwiftUI; both dispatch the same events. The application updates attributes after its playback state changes. Mount into an empty element. The Web renderer owns only its generated children and releases its observer and handlers on teardown. Frameworks own the host's attributes, not those children. Text is rendered as text, never interpreted as HTML. diff --git a/docs/native-ui-extensions.md b/docs/native-ui-extensions.md index bd2c8bde6..aa595d314 100644 --- a/docs/native-ui-extensions.md +++ b/docs/native-ui-extensions.md @@ -104,3 +104,5 @@ Element placement requires the source to fit fully inside the viewport and its a `try await actions.activate(name)` completes after the original Web element receives its click. It does **not** wait for application work launched by the click handler (network requests, navigation or modal presentation). Disabled, removed or stale actions reject; teardown, Swift task cancellation and bridge timeouts settle pending calls. The bridge acknowledgement is a Capacitor Promise method. The [demo reader](../demo/src/native-ui/counter.ts) and [demo SwiftUI View](../demo/ios/App/App/NativeUICounter.swift) show both placements using app-local files. Open the Native UI Shell demo page to compare native projection with its Web fallback. + +For a complete application feature, see the [SwiftUI mini-player example](./native-mini-player.md): artwork, track metadata, playback controls and swipe-to-open, all defined in the demo application.