diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 7a8cb75..ce3eea0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -543,6 +543,47 @@ jobs: if-no-files-found: error retention-days: 7 + build-ios: + name: iOS device and simulator (experimental) + needs: release-version + if: needs.release-version.outputs.should_build == 'true' + runs-on: macos-latest + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build experimental iOS XCFramework + run: bun run build:ios + working-directory: packages/webview + + - name: Upload iOS XCFramework + uses: actions/upload-artifact@v4 + with: + name: webview-ios-xcframework + path: packages/webview/target/ios/release/Webview.xcframework + if-no-files-found: error + retention-days: 7 + publish: name: Publish to npm runs-on: ubuntu-latest @@ -550,6 +591,7 @@ jobs: needs: - build - build-freebsd + - build-ios - release-version if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/apps/docs/content/docs/api/notification.md b/apps/docs/content/docs/api/notification.md index 4cf903a..b1d8eb0 100644 --- a/apps/docs/content/docs/api/notification.md +++ b/apps/docs/content/docs/api/notification.md @@ -107,6 +107,6 @@ Native response events depend on the platform and notification server. On Window notification.close(): void ``` -Programmatic native close is implemented on Unix notification backends such as Linux and FreeBSD. It is a no-op on Windows and macOS, where the backend does not expose an equivalent close operation. Android and iOS construct the JavaScript object but do not display notifications or emit native lifecycle events. +Programmatic native close is implemented on Unix notification backends such as Linux and FreeBSD. It is a no-op on Windows and macOS, where the backend does not expose an equivalent close operation. Android constructs the JavaScript object but does not display notifications or emit native lifecycle events. The experimental iOS binding reports native notifications as unsupported. See the runnable [notification example](https://github.com/webviewjs/webview/blob/main/apps/examples/notification.ts). diff --git a/apps/docs/content/docs/getting-started/installation.md b/apps/docs/content/docs/getting-started/installation.md index f74d482..cae37e6 100644 --- a/apps/docs/content/docs/getting-started/installation.md +++ b/apps/docs/content/docs/getting-started/installation.md @@ -69,7 +69,7 @@ The embedded browser engine is supplied by the operating system. The app does no | Linux | WebKitGTK 4.1, GTK 3, libsoup 3, and `libxdo`. | | FreeBSD | GTK 3, WebKitGTK 4.1, libsoup 3, and `xdotool`/`libxdo`; see [FreeBSD](../platform/freebsd). | | Android | Android native webview support; Android addons are published for arm64 and armv7, with more limited feature coverage. | -| iOS | There is no published iOS N-API package. | +| iOS | There is no published iOS N-API package. Experimental app-embedding builds are available on macOS; see [iOS](../platform/ios). | On Debian and Ubuntu, install the packages used by the repository's Linux build: diff --git a/apps/docs/content/docs/platform/ios.md b/apps/docs/content/docs/platform/ios.md index 7eb37c4..e3a1bcc 100644 --- a/apps/docs/content/docs/platform/ios.md +++ b/apps/docs/content/docs/platform/ios.md @@ -1,10 +1,47 @@ --- title: 'iOS' -description: 'iOS native code exists, but no iOS N-API package is published.' +description: 'Experimental iOS cross-build output for apps with an embedded Node-API host.' --- -The repository contains Rust code behind iOS configuration gates, including orientation, scale-factor, home-indicator, system-gesture, and status-bar methods. The @webviewjs/webview package does not publish an iOS N-API target, so applications cannot use those methods through the supported npm package. +WebviewJS has experimental Rust binding code for iOS, but it does not publish an iOS N-API package. The generated package loader and the locked `@napi-rs/cli` target list do not select iOS artifacts, so the regular npm installation path is not available on iOS. -The generated declarations contain iOS-related option and method names for native compatibility. They are not evidence of an installable iOS runtime or a supported iOS application target. Current native CI builds desktop targets, FreeBSD x64, and Android addons; it does not build or test an iOS artifact. +## Build an experimental XCFramework -See [BrowserWindow](../api/browser-window#ios-creation-options) for the declarations and their availability limit. +On macOS with Xcode and the stable Rust toolchain installed, run this from the repository root: + +```sh +bun install +bun --filter @webviewjs/webview build:ios +``` + +The build compiles an iOS device library for `aarch64-apple-ios`, plus simulator libraries for `aarch64-apple-ios-sim` and `x86_64-apple-ios`. It combines the simulator architectures and creates: + +```text +packages/webview/target/ios/release/Webview.xcframework +``` + +Use `bun --filter @webviewjs/webview build:ios -- --debug` for a debug build. The output is an app-embedding artifact, not an npm package. It is not included in `@webviewjs/webview`'s published files or N-API target list. + +## Host requirements + +The XCFramework contains the WebviewJS N-API addon only. Its framework executable is `Webview`, with the install name `@rpath/Webview.framework/Webview`. Apple's framework rules require the `CFBundleExecutable` value and executable filename to match the framework name without `.framework`; the executable therefore does not have a `.node` suffix. See [Apple's `CFBundleExecutable` reference](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleexecutable). + +Your iOS app must supply a JavaScript host with a compatible Node-API runtime, embed and sign `Webview.xcframework`, and provide a loader for the addon's framework binary. The generated package loader uses `require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)`, so setting that variable to the extensionless `Webview.framework/Webview` executable does not load it through Node's normal CommonJS addon loader. + +For a host that supports [`process.dlopen()`](https://nodejs.org/api/process.html#processdlopenmodule-filename-flags) on iOS, the raw addon exports can be loaded explicitly: + +```js +const nativeModule = { exports: {} }; +process.dlopen(nativeModule, frameworkExecutablePath); +const nativeBindings = nativeModule.exports; +``` + +This obtains the N-API exports, but does not by itself connect them to `@webviewjs/webview`'s generated binding loader. An app needs a host-specific adapter for the package's JavaScript API layer. The host must also provide the right XCFramework slice, satisfy Node-API ABI requirements, sign the embedded framework, and verify that its runtime permits dynamic loading on iOS. This build does not provide an iOS JavaScript runtime or an app entry point. + +The host owns the UIKit application lifecycle. UIKit requires UI-related work to run on the main thread or main dispatch queue, so any future WebviewJS iOS runtime integration must connect its UI and event handling to that thread. See [Apple's UIKit guidance](https://developer.apple.com/documentation/uikit) and [XCFramework packaging guidance](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle). + +The host must expose the required Node-API symbols to process-wide dynamic symbol lookup. NAPI-RS resolves those symbols from the host process during module registration; embedding a runtime without exporting its Node-API symbols is insufficient. + +The current iOS binding does not provide an operational UIKit application or event-loop integration. Its `Application` event-pump and synchronous-loop entry points report unsupported on iOS. The JavaScript `run()` and automatic `whenReady()` methods throw synchronously before installing a timer or ready listener. Child webviews, native menus, tray icons, file dialogs, and native notifications also report unsupported. The macOS CI job only cross-compiles and packages the device and simulator slices; it does not run the addon in an iOS app or verify runtime behavior on a device or simulator. Treat the XCFramework as a compile artifact, and the iOS API surface and feature coverage as experimental. + +See [BrowserWindow](../api/browser-window#ios-creation-options) for iOS-specific declarations and their availability limits. diff --git a/packages/webview/Cargo.toml b/packages/webview/Cargo.toml index 4a18e53..0fbe83b 100644 --- a/packages/webview/Cargo.toml +++ b/packages/webview/Cargo.toml @@ -19,12 +19,10 @@ wry = { version = "0.55.1", features = ["devtools", "fullscreen", "protocol"] } dpi = "0.1" image = "0.25.10" -[target.'cfg(not(target_os = "android"))'.dependencies] +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] rfd = "0.15.4" muda = { version = "0.19.3", features = ["libxdo"] } tray-icon = "0.24.1" - -[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] notify-rust = { version = "4.18.0", features = ["images_no_default_features"] } tempfile = "3" diff --git a/packages/webview/README.md b/packages/webview/README.md index 17a2add..659170d 100644 --- a/packages/webview/README.md +++ b/packages/webview/README.md @@ -69,6 +69,8 @@ WebviewJS uses the webview provided by each platform: Android, iOS, and FreeBSD targets are experimental. +An experimental iOS XCFramework can be cross-built on macOS with `bun run build:ios`. It is a compile artifact, not an npm iOS package or a working UIKit runtime, and requires an app-provided Node-API host. See the [iOS platform notes](https://webview.js.org/platform/ios) for host requirements and limits. + See the [platform documentation](https://webview.js.org) for requirements and platform-specific behavior. ## Build executables diff --git a/packages/webview/__test__/cli/cli.test.ts b/packages/webview/__test__/cli/cli.test.ts index 60aafa3..a50a362 100644 --- a/packages/webview/__test__/cli/cli.test.ts +++ b/packages/webview/__test__/cli/cli.test.ts @@ -134,10 +134,10 @@ test('executable names preserve sensible punctuation and reject path separators' expect(normalizeExecutableName('foo.bar', '/tmp', 'src/main.js')).toBe('foo.bar'); expect(() => normalizeExecutableName('../escape', '/tmp', 'src/main.js')).toThrow(/Invalid executable name/u); expect(getOutputPath({ outDir: '/tmp/release', name: 'my-app' }, { os: 'win32', arch: 'x64' })).toBe( - '/tmp/release/my-app.exe', + join('/tmp/release', 'my-app.exe'), ); expect(getOutputPath({ outDir: '/tmp/release', name: 'my-app.exe' }, { os: 'win32', arch: 'x64' })).toBe( - '/tmp/release/my-app.exe', + join('/tmp/release', 'my-app.exe'), ); expect(parseArguments(['--help']).kind).toEqual('help'); expect(parseArguments(['build', '--help']).kind).toEqual('help'); diff --git a/packages/webview/__test__/internal/build-ios.test.ts b/packages/webview/__test__/internal/build-ios.test.ts new file mode 100644 index 0000000..c346b55 --- /dev/null +++ b/packages/webview/__test__/internal/build-ios.test.ts @@ -0,0 +1,194 @@ +import { expect, test } from 'bun:test'; +import { join, resolve } from 'node:path'; +import { + FRAMEWORK_BINARY, + FRAMEWORK_INSTALL_NAME, + TARGETS, + assertInstallName, + buildIos, + frameworkInfo, + getArtifactPath, + getBuildPlan, + getCargoArgs, + parseArgs, +} from '../../scripts/build-ios.js'; + +test('iOS build options default to release and accept debug builds', () => { + expect(parseArgs([])).toEqual({ profile: 'release', help: false }); + expect(parseArgs(['--debug'])).toEqual({ profile: 'debug', help: false }); + expect(parseArgs(['--release', '--help'])).toEqual({ profile: 'release', help: true }); + expect(() => parseArgs(['--target', 'aarch64-apple-ios'])).toThrow('Unknown iOS build option: --target'); +}); + +test('the build plan covers the iOS device and both simulator architectures', () => { + const root = resolve('packages/webview'); + const plan = getBuildPlan(root, 'release'); + + expect(TARGETS).toEqual(['aarch64-apple-ios', 'aarch64-apple-ios-sim', 'x86_64-apple-ios']); + expect(plan.cargoCommands.map(({ args }) => args[args.indexOf('--target') + 1])).toEqual(TARGETS); + expect(plan.deviceLibrary).toBe(getArtifactPath(root, 'aarch64-apple-ios', 'release')); + expect(plan.simulatorLibraries).toEqual([ + getArtifactPath(root, 'aarch64-apple-ios-sim', 'release'), + getArtifactPath(root, 'x86_64-apple-ios', 'release'), + ]); + expect(plan.frameworkBinary).toBe(FRAMEWORK_BINARY); + expect(plan.frameworkBinary).toBe('Webview'); + expect(plan.xcframework.endsWith('Webview.xcframework')).toBe(true); +}); + +test('cargo iOS build passes N-API dynamic lookup linker arguments', () => { + const releaseArgs = getCargoArgs('aarch64-apple-ios', 'release'); + const debugArgs = getCargoArgs('aarch64-apple-ios-sim', 'debug'); + + expect(releaseArgs).toContain('--release'); + expect(debugArgs).not.toContain('--release'); + expect(releaseArgs.slice(-2)).toEqual(['-C', 'link-arg=-Wl,-undefined,dynamic_lookup']); +}); + +test('iOS frameworks use their framework name as the executable and identify their platform variant', () => { + expect(frameworkInfo('device')).toContain('iPhoneOS'); + expect(frameworkInfo('simulator')).toContain('iPhoneSimulator'); + expect(frameworkInfo('device')).toContain('CFBundleExecutable\n Webview'); + expect(frameworkInfo('device')).not.toContain('webview.node'); +}); + +test('iOS framework install names must match the framework rpath', () => { + expect(() => + assertInstallName('Webview.framework/Webview', `Webview.framework/Webview:\n${FRAMEWORK_INSTALL_NAME}\n`), + ).not.toThrow(); + expect(() => + assertInstallName('Webview.framework/Webview', 'Webview.framework/Webview:\n@rpath/libwebview.dylib\n'), + ).toThrow(`Expected install name ${FRAMEWORK_INSTALL_NAME}`); + expect(() => + assertInstallName( + 'Webview.framework/Webview', + 'Webview.framework/Webview (architecture arm64):\n@rpath/Webview.framework/Webview\nWebview.framework/Webview (architecture x86_64):\n@rpath/libwebview.dylib\n', + ), + ).toThrow(`Expected install name ${FRAMEWORK_INSTALL_NAME}`); +}); + +test('iOS builds stop before running tools on non-macOS hosts', () => { + const commands: string[] = []; + + expect(() => + buildIos('release', { + packageRoot: resolve('packages/webview'), + platform: 'win32', + runCommand: (command: string) => commands.push(command), + }), + ).toThrow('Building the iOS addon requires macOS with Xcode installed.'); + expect(commands).toEqual([]); +}); + +test('iOS build runs rust targets and packages device plus simulator outputs', () => { + const root = resolve('packages/webview'); + const commands: Array<{ command: string; args: string[] }> = []; + const removed: string[] = []; + const written: string[] = []; + const copied: string[] = []; + const made: string[] = []; + const captured: string[] = []; + + const output = buildIos('debug', { + packageRoot: root, + platform: 'darwin', + runCommand: (command: string, args: string[]) => { + commands.push({ command, args }); + }, + runCommandCapture: (_command: string, args: string[]) => { + captured.push(args[1]); + return `${args[1]}:\n${FRAMEWORK_INSTALL_NAME}\n`; + }, + existsSync: (path: string) => path.endsWith('libwebview.dylib') || path.endsWith('Webview.xcframework'), + rmSync: (path: string) => removed.push(path), + mkdirSync: (path: string) => made.push(path), + copyFileSync: (_source: string, destination: string) => copied.push(destination), + writeFileSync: (path: string) => written.push(path), + }); + + expect(output).toBe(getBuildPlan(root, 'debug').xcframework); + expect(commands[0]).toEqual({ + command: 'rustup', + args: ['target', 'add', ...TARGETS], + }); + expect(commands.slice(1, 4).every(({ command }) => command === 'cargo')).toBe(true); + expect(commands.slice(1, 4).every(({ args }) => !args.includes('--release'))).toBe(true); + expect(commands.some(({ command, args }) => command === 'lipo' && args.includes('-create'))).toBe(true); + expect(commands.filter(({ command }) => command === 'install_name_tool').map(({ args }) => args)).toEqual([ + ['-id', FRAMEWORK_INSTALL_NAME, join(getBuildPlan(root, 'debug').deviceFramework, 'Webview')], + ['-id', FRAMEWORK_INSTALL_NAME, join(getBuildPlan(root, 'debug').simulatorFramework, 'Webview')], + ]); + expect(captured).toHaveLength(2); + expect(commands.at(-1)?.command).toBe('xcodebuild'); + expect(commands.at(-1)?.args).toContain('-create-xcframework'); + expect(removed).toHaveLength(2); + expect(made).toHaveLength(2); + expect(copied[0].endsWith('Webview')).toBe(true); + expect(written.some((path) => path.endsWith('Info.plist'))).toBe(true); +}); + +test('iOS packaging stops before XCFramework creation when an input artifact is missing', () => { + const root = resolve('packages/webview'); + const plan = getBuildPlan(root, 'release'); + const commands: string[] = []; + const removed: string[] = []; + + expect(() => + buildIos('release', { + packageRoot: root, + platform: 'darwin', + runCommand: (command: string) => commands.push(command), + existsSync: (path: string) => path !== plan.deviceLibrary, + rmSync: (path: string) => removed.push(path), + }), + ).toThrow(`Expected iOS build output was not created: ${plan.deviceLibrary}`); + + expect(commands).toEqual(['rustup', 'cargo', 'cargo', 'cargo']); + expect(removed).toEqual([]); +}); + +test('iOS packaging propagates tool failures without running later packaging steps', () => { + const root = resolve('packages/webview'); + const commands: string[] = []; + + expect(() => + buildIos('release', { + packageRoot: root, + platform: 'darwin', + runCommand: (command: string) => { + commands.push(command); + if (command === 'lipo') throw new Error('simulator lipo failed'); + }, + runCommandCapture: (_command: string, args: string[]) => `${args[1]}:\n${FRAMEWORK_INSTALL_NAME}\n`, + existsSync: () => true, + rmSync: () => {}, + mkdirSync: () => {}, + copyFileSync: () => {}, + writeFileSync: () => {}, + }), + ).toThrow('simulator lipo failed'); + + expect(commands).toEqual(['rustup', 'cargo', 'cargo', 'cargo', 'lipo']); +}); + +test('iOS packaging rejects a framework with an unexpected install name before creating the XCFramework', () => { + const root = resolve('packages/webview'); + const commands: string[] = []; + + expect(() => + buildIos('release', { + packageRoot: root, + platform: 'darwin', + runCommand: (command: string) => commands.push(command), + runCommandCapture: (_command: string, args: string[]) => `${args[1]}:\n@rpath/libwebview.dylib\n`, + existsSync: () => true, + rmSync: () => {}, + mkdirSync: () => {}, + copyFileSync: () => {}, + writeFileSync: () => {}, + }), + ).toThrow(`Expected install name ${FRAMEWORK_INSTALL_NAME}`); + + expect(commands).toContain('install_name_tool'); + expect(commands).not.toContain('xcodebuild'); +}); diff --git a/packages/webview/__test__/internal/event-loop.test.ts b/packages/webview/__test__/internal/event-loop.test.ts index 0a534c5..4bfe4df 100644 --- a/packages/webview/__test__/internal/event-loop.test.ts +++ b/packages/webview/__test__/internal/event-loop.test.ts @@ -46,3 +46,26 @@ test('event loop stops itself when native pumping returns false', () => { jest.useRealTimers(); } }); + +test('a native pump error stops polling and preserves the original error', () => { + jest.useFakeTimers(); + const intervalSpy = jest.spyOn(globalThis, 'setInterval'); + try { + const failure = new Error('iOS does not support event-loop pumping'); + let pumps = 0; + const loop = new ApplicationEventLoop(() => { + pumps += 1; + throw failure; + }); + + loop.start(); + const tick = intervalSpy.mock.calls[0][0] as () => void; + expect(tick).toThrow(failure); + expect(jest.getTimerCount()).toBe(0); + jest.advanceTimersByTime(100); + expect(pumps).toBe(1); + } finally { + intervalSpy.mockRestore(); + jest.useRealTimers(); + } +}); diff --git a/packages/webview/__test__/native/bindings.ts b/packages/webview/__test__/native/bindings.ts index 8a2ddc0..b89b1c5 100644 --- a/packages/webview/__test__/native/bindings.ts +++ b/packages/webview/__test__/native/bindings.ts @@ -186,6 +186,13 @@ class Application { return state.ready; } + _assertEventLoopSupported(): void { + const state = recordCall(this, '_assertEventLoopSupported'); + if (state.values.eventLoopSupported === false) { + throw new Error('iOS does not support event-loop pumping'); + } + } + exit(): void { const state = recordCall(this, 'exit'); state.exited = true; diff --git a/packages/webview/__test__/native/harness.ts b/packages/webview/__test__/native/harness.ts index cc41d8d..b16523c 100644 --- a/packages/webview/__test__/native/harness.ts +++ b/packages/webview/__test__/native/harness.ts @@ -67,6 +67,10 @@ const native = { if (typeof result !== 'boolean') throw new TypeError('setPumpResult requires a boolean'); binding.__stateFor(application, 'application').pumpResult = result; }, + setEventLoopSupported(supported: boolean): void { + if (typeof supported !== 'boolean') throw new TypeError('setEventLoopSupported requires a boolean'); + binding.__stateFor(application, 'application').values.eventLoopSupported = supported; + }, setReady(ready = true): void { binding.__stateFor(application, 'application').ready = ready; }, diff --git a/packages/webview/__test__/runtime/application.test.ts b/packages/webview/__test__/runtime/application.test.ts index 72e36fe..2de013e 100644 --- a/packages/webview/__test__/runtime/application.test.ts +++ b/packages/webview/__test__/runtime/application.test.ts @@ -37,6 +37,21 @@ test('native application events subscribe once and use isolated EventEmitters', expect(first.listenerCount('custom-menu-click')).toBe(0); }); +test('unsupported event loops fail before run or automatic whenReady installs a timer or listener', () => { + jest.useFakeTimers(); + try { + const app = new Application(); + native.application(app).setEventLoopSupported(false); + expect(() => app.run()).toThrow('iOS does not support event-loop pumping'); + expect(() => app.whenReady()).toThrow('iOS does not support event-loop pumping'); + expect(jest.getTimerCount()).toBe(0); + expect(app.listenerCount('ready')).toBe(0); + expect(calls(app, 'pumpEvents')).toHaveLength(0); + } finally { + jest.useRealTimers(); + } +}); + test('run is idempotent, defaults to 16ms, honors ref false, and stop clears its timer', () => { jest.useFakeTimers(); try { diff --git a/packages/webview/js-bindings.d.ts b/packages/webview/js-bindings.d.ts index 7ac791e..aee6dad 100644 --- a/packages/webview/js-bindings.d.ts +++ b/packages/webview/js-bindings.d.ts @@ -5,6 +5,8 @@ export declare class Application { onEvent(handler?: ((arg: ApplicationEvent) => void) | undefined | null): void bind(handler?: ((arg: ApplicationEvent) => void) | undefined | null): void isReady(): boolean + /** Checks whether the JavaScript timer can drive this platform's event loop. */ + _assertEventLoopSupported(): void exit(): void /** Creates a new WebContext with the given options. */ createWebContext(options?: WebContextOptions | undefined | null): JsWebContext diff --git a/packages/webview/lib/application/lifecycle.ts b/packages/webview/lib/application/lifecycle.ts index a8b3f1e..1d7efbb 100644 --- a/packages/webview/lib/application/lifecycle.ts +++ b/packages/webview/lib/application/lifecycle.ts @@ -71,6 +71,7 @@ export function bind(this: Application, handler?: ((event: ApplicationEvent) => * @param options Polling interval and Node.js timer reference behavior. */ export function run(this: Application, options?: ApplicationRunOptions | null): void { + this._assertEventLoopSupported(); ensureApplicationEvents(this); stateFor(this).eventLoop.start(options ?? undefined); } @@ -95,6 +96,10 @@ export function whenReady(this: Application, options: ApplicationWhenReadyOption } } + if (autoRun) { + this._assertEventLoopSupported(); + } + const ready = this.isReady() ? Promise.resolve() : new Promise((resolve) => { diff --git a/packages/webview/lib/internal/application/event-loop.ts b/packages/webview/lib/internal/application/event-loop.ts index 32bb09a..7bfe4f7 100644 --- a/packages/webview/lib/internal/application/event-loop.ts +++ b/packages/webview/lib/internal/application/event-loop.ts @@ -26,8 +26,13 @@ export class ApplicationEventLoop { const interval = options.interval ?? 16; const shouldRef = options.ref ?? true; const timer = setInterval(() => { - if (!this.#pumpEvents()) { + try { + if (!this.#pumpEvents()) { + this.stop(); + } + } catch (error) { this.stop(); + throw error; } }, interval); diff --git a/packages/webview/package.json b/packages/webview/package.json index a5f49eb..88dbc71 100644 --- a/packages/webview/package.json +++ b/packages/webview/package.json @@ -56,6 +56,7 @@ "artifacts": "napi artifacts", "build": "node ./scripts/build-all.js --release", "build:native": "node ./scripts/build.js --release", + "build:ios": "node ./scripts/build-ios.js", "build:lib": "run-s build:lib:clean build:lib:tsc", "build:lib:clean": "node ./scripts/clean-lib.js", "build:lib:tsc": "tsc -p tsconfig.json", diff --git a/packages/webview/scripts/build-ios.js b/packages/webview/scripts/build-ios.js new file mode 100644 index 0000000..54a8c74 --- /dev/null +++ b/packages/webview/scripts/build-ios.js @@ -0,0 +1,260 @@ +const { spawnSync } = require('node:child_process'); +const { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } = require('node:fs'); +const { isAbsolute, join, relative, resolve, sep } = require('node:path'); + +const packageRoot = resolve(__dirname, '..'); +const TARGETS = ['aarch64-apple-ios', 'aarch64-apple-ios-sim', 'x86_64-apple-ios']; +const NAPI_LINK_ARGS = ['-C', 'link-arg=-Wl,-undefined,dynamic_lookup']; +const FRAMEWORK_BINARY = 'Webview'; +const FRAMEWORK_INSTALL_NAME = '@rpath/Webview.framework/Webview'; + +function parseArgs(args) { + const options = { profile: 'release', help: false }; + + for (const arg of args) { + if (arg === '--debug') { + options.profile = 'debug'; + } else if (arg === '--release') { + options.profile = 'release'; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else { + throw new Error(`Unknown iOS build option: ${arg}`); + } + } + + return options; +} + +function getArtifactPath(root, target, profile) { + return join(root, 'target', target, profile, 'libwebview.dylib'); +} + +function getCargoArgs(target, profile) { + const args = ['rustc', '--lib', '--manifest-path', 'Cargo.toml', '--target', target]; + + if (profile === 'release') { + args.push('--release'); + } + + return [...args, '--', ...NAPI_LINK_ARGS]; +} + +function getBuildPlan(root, profile) { + const outputRoot = join(root, 'target', 'ios', profile); + const stagingRoot = join(outputRoot, 'staging'); + const deviceFramework = join(stagingRoot, 'device', 'Webview.framework'); + const simulatorFramework = join(stagingRoot, 'simulator', 'Webview.framework'); + const xcframework = join(outputRoot, 'Webview.xcframework'); + const frameworkBinary = FRAMEWORK_BINARY; + + return { + outputRoot, + stagingRoot, + deviceFramework, + simulatorFramework, + xcframework, + frameworkBinary, + targets: TARGETS, + cargoCommands: TARGETS.map((target) => ({ + command: 'cargo', + args: getCargoArgs(target, profile), + })), + simulatorLibraries: [ + getArtifactPath(root, 'aarch64-apple-ios-sim', profile), + getArtifactPath(root, 'x86_64-apple-ios', profile), + ], + deviceLibrary: getArtifactPath(root, 'aarch64-apple-ios', profile), + }; +} + +function runCommand(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + stdio: 'inherit', + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 1}`); + } +} + +function runCommandCapture(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 1}`); + } + + return result.stdout ?? ''; +} + +function assertInstallName(binary, output) { + const installNames = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line && !line.endsWith(':')); + + if (installNames.length === 0 || installNames.some((installName) => installName !== FRAMEWORK_INSTALL_NAME)) { + throw new Error( + `Expected install name ${FRAMEWORK_INSTALL_NAME} for ${binary}; otool reported ${installNames.join(', ') || 'none'}`, + ); + } +} + +function frameworkInfo(platform) { + const supportedPlatform = platform === 'device' ? 'iPhoneOS' : 'iPhoneSimulator'; + + return ` + + + + CFBundleExecutable + ${FRAMEWORK_BINARY} + CFBundleIdentifier + org.webviewjs.webview + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Webview + CFBundlePackageType + FMWK + CFBundleSupportedPlatforms + + ${supportedPlatform} + + + +`; +} + +function assertGeneratedPath(path, root) { + const resolvedPath = resolve(path); + const resolvedRoot = resolve(root); + const relativePath = relative(resolvedRoot, resolvedPath); + + if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error(`Refusing to remove generated path outside ${resolvedRoot}: ${resolvedPath}`); + } +} + +function buildIos(profile = 'release', options = {}) { + const root = options.packageRoot ?? packageRoot; + const hostPlatform = options.platform ?? process.platform; + + if (hostPlatform !== 'darwin') { + throw new Error('Building the iOS addon requires macOS with Xcode installed.'); + } + + const plan = getBuildPlan(root, profile); + const run = options.runCommand ?? runCommand; + const capture = options.runCommandCapture ?? runCommandCapture; + const fileExists = options.existsSync ?? existsSync; + const copyFile = options.copyFileSync ?? copyFileSync; + const makeDirectory = options.mkdirSync ?? mkdirSync; + const removePath = options.rmSync ?? rmSync; + const writeFile = options.writeFileSync ?? writeFileSync; + + run('rustup', ['target', 'add', ...plan.targets], root); + + for (const { command, args } of plan.cargoCommands) { + run(command, args, root); + } + + for (const artifact of [plan.deviceLibrary, ...plan.simulatorLibraries]) { + if (!fileExists(artifact)) { + throw new Error(`Expected iOS build output was not created: ${artifact}`); + } + } + + assertGeneratedPath(plan.stagingRoot, join(root, 'target', 'ios')); + assertGeneratedPath(plan.xcframework, join(root, 'target', 'ios')); + removePath(plan.stagingRoot, { recursive: true, force: true }); + removePath(plan.xcframework, { recursive: true, force: true }); + + makeDirectory(plan.deviceFramework, { recursive: true }); + makeDirectory(plan.simulatorFramework, { recursive: true }); + + const deviceBinary = join(plan.deviceFramework, plan.frameworkBinary); + const simulatorBinary = join(plan.simulatorFramework, plan.frameworkBinary); + copyFile(plan.deviceLibrary, deviceBinary); + writeFile(join(plan.deviceFramework, 'Info.plist'), frameworkInfo('device')); + writeFile(join(plan.simulatorFramework, 'Info.plist'), frameworkInfo('simulator')); + + run('lipo', ['-create', ...plan.simulatorLibraries, '-output', simulatorBinary], root); + + for (const binary of [deviceBinary, simulatorBinary]) { + run('install_name_tool', ['-id', FRAMEWORK_INSTALL_NAME, binary], root); + assertInstallName(binary, capture('otool', ['-D', binary], root)); + } + + run('lipo', ['-verify_arch', 'arm64', deviceBinary], root); + run('lipo', ['-verify_arch', 'arm64', 'x86_64', simulatorBinary], root); + run( + 'xcodebuild', + [ + '-create-xcframework', + '-framework', + plan.deviceFramework, + '-framework', + plan.simulatorFramework, + '-output', + plan.xcframework, + ], + root, + ); + + if (!fileExists(plan.xcframework)) { + throw new Error(`Expected XCFramework was not created: ${plan.xcframework}`); + } + + return plan.xcframework; +} + +function printHelp() { + process.stdout.write( + 'Usage: node scripts/build-ios.js [--release|--debug]\n\n' + + 'Builds experimental iOS device and simulator N-API artifacts on macOS.\n' + + 'The XCFramework is written to target/ios//Webview.xcframework.\n', + ); +} + +if (require.main === module) { + try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + } else { + const outputPath = buildIos(options.profile); + process.stdout.write(`Created ${outputPath}\n`); + } + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { + FRAMEWORK_BINARY, + FRAMEWORK_INSTALL_NAME, + TARGETS, + assertInstallName, + buildIos, + frameworkInfo, + getArtifactPath, + getBuildPlan, + getCargoArgs, + parseArgs, +}; diff --git a/packages/webview/src/app.rs b/packages/webview/src/app.rs index 3d28899..7a1a427 100644 --- a/packages/webview/src/app.rs +++ b/packages/webview/src/app.rs @@ -4,14 +4,13 @@ use std::rc::Rc; use std::sync::{Arc, Mutex}; use crate::browser_window::{BrowserWindow, WindowCloseState, WindowResource}; -#[cfg(target_os = "android")] use crate::tray::JsTrayIcon; -#[cfg(not(target_os = "android"))] -use crate::tray::{event_payload, JsTrayIcon, TrayEventHandler, TrayResource}; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use crate::tray::{event_payload, TrayEventHandler, TrayResource}; use crate::types::*; use crate::web_context::{JsWebContext, WebContextOptions, WebContextResource}; use crate::webview::WebviewResource; -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use muda::Menu; use napi::bindgen_prelude::*; use napi::Result; @@ -78,11 +77,11 @@ struct AppState { cursor_positions: HashMap, /// Last known modifier state. current_modifiers: ModifiersState, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] menu_event_receiver: Option, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] tray_handlers: HashMap, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] tray_resources: Vec, web_contexts: Vec, } @@ -141,7 +140,7 @@ impl AppState { if self.should_exit { return; } - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { for resource in self.tray_resources.drain(..) { release_resource(&resource); @@ -152,7 +151,7 @@ impl AppState { release_resource(&context); } self.handler.borrow_mut().take(); - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { self.menu_event_receiver = None; } @@ -740,7 +739,7 @@ fn handle_window_event(state: &mut AppState, window_id: WindowId, event: WindowE pub struct Application { event_loop: Option>, state: AppState, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] global_menu: Rc>>, window_ids: Arc>>, } @@ -760,7 +759,7 @@ impl Application { // functional from the start. Store it in global_menu so the ObjC delegate // is kept alive (it would be freed if the Menu were dropped here). // set_menu() will replace this with the user-supplied menu. - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] let initial_global_menu: Option = { #[cfg(target_os = "macos")] { @@ -788,7 +787,7 @@ impl Application { webview_lifecycles: HashMap::new(), cursor_positions: HashMap::new(), current_modifiers: ModifiersState::default(), - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] menu_event_receiver: { // On macOS we always have a menu from startup so start receiving events // immediately. On other platforms the receiver is set when set_menu is called. @@ -801,13 +800,13 @@ impl Application { None } }, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] tray_handlers: HashMap::new(), - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] tray_resources: Vec::new(), web_contexts: Vec::new(), }, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] global_menu: Rc::new(RefCell::new(initial_global_menu)), window_ids: Arc::new(Mutex::new(HashMap::new())), }) @@ -828,6 +827,17 @@ impl Application { self.state.ready } + /// Checks whether the JavaScript timer can drive this platform's event loop. + #[napi(js_name = "_assertEventLoopSupported")] + pub fn assert_event_loop_supported(&self) -> Result<()> { + #[cfg(target_os = "ios")] + return Err(crate::mobile::unsupported_feature_error( + "event-loop pumping", + )); + #[cfg(not(target_os = "ios"))] + Ok(()) + } + #[napi] pub fn exit(&mut self) { if self.state.should_exit || self.state.exit_requested { @@ -842,7 +852,7 @@ impl Application { let window_ids: Vec<_> = self.state.windows.keys().copied().collect(); if window_ids.is_empty() { self.state.finalize_shutdown(); - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] self.global_menu.borrow_mut().take(); return; } @@ -875,7 +885,7 @@ impl Application { )); } let tray = JsTrayIcon::create(options)?; - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { self .state @@ -907,19 +917,19 @@ impl Application { #[allow(unused_mut)] let mut window_options = options.unwrap_or_default(); - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] if window_options.menu.is_none() && self.global_menu.borrow().is_some() { window_options.show_menu = Some(true); } - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] let window = BrowserWindow::new( event_loop, Some(window_options), false, self.global_menu.clone(), )?; - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "ios"))] let window = BrowserWindow::new( event_loop, Some(window_options), @@ -974,9 +984,9 @@ impl Application { ) })?; - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] let window = BrowserWindow::new(event_loop, options, true, self.global_menu.clone())?; - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "ios"))] let window = BrowserWindow::new(event_loop, options, true, Rc::new(RefCell::new(None)))?; let wid = window.tao_window_id(); @@ -1010,171 +1020,192 @@ impl Application { "Application has been disposed", )); } - #[cfg(not(target_os = "android"))] + + #[cfg(target_os = "ios")] { - if let Some(options) = menu_options { - let m = crate::menu::create_menu_from_options(options)?; - #[cfg(target_os = "macos")] - m.init_for_nsapp(); - self.state.menu_event_receiver = Some(muda::MenuEvent::receiver().clone()); - *self.global_menu.borrow_mut() = Some(m); - } else { - // On macOS restoring the default menu keeps the app menu bar functional. - #[cfg(target_os = "macos")] - { - let default_menu = crate::menu::make_default_macos_menu(); - *self.global_menu.borrow_mut() = Some(default_menu); - // Keep the receiver — menu events can still arrive from predefined items. - } - #[cfg(not(target_os = "macos"))] - { - *self.global_menu.borrow_mut() = None; - self.state.menu_event_receiver = None; + let _ = menu_options; + return Err(crate::mobile::unsupported_feature_error("native menus")); + } + + #[cfg(not(target_os = "ios"))] + { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if let Some(options) = menu_options { + let m = crate::menu::create_menu_from_options(options)?; + #[cfg(target_os = "macos")] + m.init_for_nsapp(); + self.state.menu_event_receiver = Some(muda::MenuEvent::receiver().clone()); + *self.global_menu.borrow_mut() = Some(m); + } else { + // On macOS restoring the default menu keeps the app menu bar functional. + #[cfg(target_os = "macos")] + { + let default_menu = crate::menu::make_default_macos_menu(); + *self.global_menu.borrow_mut() = Some(default_menu); + // Keep the receiver — menu events can still arrive from predefined items. + } + #[cfg(not(target_os = "macos"))] + { + *self.global_menu.borrow_mut() = None; + self.state.menu_event_receiver = None; + } } } + #[cfg(target_os = "android")] + let _ = menu_options; + Ok(()) } - #[cfg(target_os = "android")] - let _ = menu_options; - Ok(()) } /// Pump the tao event loop once without blocking. Returns `true` while /// the app is alive, `false` when it should stop. Drive this from the /// public JavaScript Application.run() wrapper. #[napi] - pub fn pump_events(&mut self) -> bool { - use tao::event::Event; + pub fn pump_events(&mut self) -> Result { + #[cfg(target_os = "ios")] + { + let _ = self; + return Err(crate::mobile::unsupported_feature_error( + "event-loop pumping", + )); + } - #[cfg(target_os = "macos")] - use tao::platform::macos::{EventLoopExtPumpEvents, PumpStatus}; + #[cfg(not(target_os = "ios"))] + { + use tao::event::Event; - #[cfg(not(target_os = "macos"))] - use tao::platform::run_return::EventLoopExtRunReturn; + #[cfg(target_os = "macos")] + use tao::platform::macos::{EventLoopExtPumpEvents, PumpStatus}; - if self.state.should_exit { - return false; - } + #[cfg(not(target_os = "macos"))] + use tao::platform::run_return::EventLoopExtRunReturn; - // Fire the ready event on the first pump. - if !self.state.ready { - self.state.ready = true; - self.state.fire(ApplicationEvent { - event: WebviewApplicationEvent::Ready.name().to_owned(), - custom_menu_event: None, - }); - } + if self.state.should_exit { + return Ok(false); + } - // Drain menu events before pumping the window event loop. - #[cfg(not(target_os = "android"))] - { - if let Some(rx) = &self.state.menu_event_receiver { - while let Ok(ev) = rx.try_recv() { - self.state.fire(ApplicationEvent { - event: WebviewApplicationEvent::CustomMenuClick.name().to_owned(), - custom_menu_event: Some(CustomMenuEvent { - id: ev.id().0.clone(), - window_id: 0, - }), - }); + // Fire the ready event on the first pump. + if !self.state.ready { + self.state.ready = true; + self.state.fire(ApplicationEvent { + event: WebviewApplicationEvent::Ready.name().to_owned(), + custom_menu_event: None, + }); + } + + // Drain menu events before pumping the window event loop. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if let Some(rx) = &self.state.menu_event_receiver { + while let Ok(ev) = rx.try_recv() { + self.state.fire(ApplicationEvent { + event: WebviewApplicationEvent::CustomMenuClick.name().to_owned(), + custom_menu_event: Some(CustomMenuEvent { + id: ev.id().0.clone(), + window_id: 0, + }), + }); + } } } - } - #[cfg(not(target_os = "android"))] - while let Ok(event) = tray_icon::TrayIconEvent::receiver().try_recv() { - if let Some(handler) = self.state.tray_handlers.get(&event.id().0) { - let callback = handler.borrow(); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + while let Ok(event) = tray_icon::TrayIconEvent::receiver().try_recv() { + if let Some(handler) = self.state.tray_handlers.get(&event.id().0) { + let callback = handler.borrow(); - if let Some(callback) = callback.as_ref() { - if let Ok(function) = callback.borrow_back(&self.state.env) { - if let Some(payload) = event_payload(event) { - let _ = function.call(payload); + if let Some(callback) = callback.as_ref() { + if let Ok(function) = callback.borrow_back(&self.state.env) { + if let Some(payload) = event_payload(event) { + let _ = function.call(payload); + } } } } } - } - - if self.state.should_exit { - return false; - } - // Split borrows so the event handler can mutate application state. - let event_loop = match &mut self.event_loop { - Some(event_loop) => event_loop, - None => return false, - }; - - let state = &mut self.state; - - /* - * macOS - * - * Use WebviewJS's patched Tao pump API. Returning from one pump does not - * destroy the event loop or set ControlFlow::Exit. - */ - #[cfg(target_os = "macos")] - { - let status = event_loop.pump_events(|event, _target, control_flow| { - use tao::event_loop::ControlFlow; - - *control_flow = ControlFlow::Poll; - - if let Event::WindowEvent { - window_id, - event: window_event, - .. - } = event - { - handle_window_event(state, window_id, window_event); - } + if self.state.should_exit { + return Ok(false); + } - // ControlFlow::Exit is reserved for an actual application exit. - if state.should_exit { - *control_flow = ControlFlow::Exit; - } - }); + // Split borrows so the event handler can mutate application state. + let event_loop = match &mut self.event_loop { + Some(event_loop) => event_loop, + None => return Ok(false), + }; - if let PumpStatus::Exit(_exit_code) = status { - state.should_exit = true; - } - } + let state = &mut self.state; - /* - * Other desktop platforms - * - * Continue using Tao's existing run_return implementation. - */ - #[cfg(not(target_os = "macos"))] - { - use tao::{event::StartCause, event_loop::ControlFlow}; + /* + * macOS + * + * Use WebviewJS's patched Tao pump API. Returning from one pump does not + * destroy the event loop or set ControlFlow::Exit. + */ + #[cfg(target_os = "macos")] + { + let status = event_loop.pump_events(|event, _target, control_flow| { + use tao::event_loop::ControlFlow; - event_loop.run_return(|event, _target, control_flow| { - *control_flow = ControlFlow::Poll; + *control_flow = ControlFlow::Poll; - match event { - Event::WindowEvent { + if let Event::WindowEvent { window_id, event: window_event, .. - } => { + } = event + { handle_window_event(state, window_id, window_event); } - // On these platforms run_return still needs Exit to return control - // to Node.js after the current event-loop iteration. - Event::MainEventsCleared => { + // ControlFlow::Exit is reserved for an actual application exit. + if state.should_exit { *control_flow = ControlFlow::Exit; } + }); - Event::NewEvents(StartCause::Poll) => {} - - _ => {} + if let PumpStatus::Exit(_exit_code) = status { + state.should_exit = true; } - }); - } + } + + /* + * Other desktop platforms + * + * Continue using Tao's existing run_return implementation. + */ + #[cfg(not(any(target_os = "macos", target_os = "ios")))] + { + use tao::{event::StartCause, event_loop::ControlFlow}; + + event_loop.run_return(|event, _target, control_flow| { + *control_flow = ControlFlow::Poll; + + match event { + Event::WindowEvent { + window_id, + event: window_event, + .. + } => { + handle_window_event(state, window_id, window_event); + } - !state.should_exit + // On these platforms run_return still needs Exit to return control + // to Node.js after the current event-loop iteration. + Event::MainEventsCleared => { + *control_flow = ControlFlow::Exit; + } + + Event::NewEvents(StartCause::Poll) => {} + + _ => {} + } + }); + } + + Ok(!state.should_exit) + } } /// Run Tao's native event loop continuously on the current thread. @@ -1183,48 +1214,59 @@ impl Application { /// Node.js event loop must remain available. #[napi] pub fn run_sync(&mut self) -> Result<()> { - use tao::event::Event; - use tao::event_loop::ControlFlow; - use tao::platform::run_return::EventLoopExtRunReturn; - - if self.state.should_exit { - return Ok(()); - } - - if !self.state.ready { - self.state.ready = true; - self.state.fire(ApplicationEvent { - event: WebviewApplicationEvent::Ready.name().to_owned(), - custom_menu_event: None, - }); + #[cfg(target_os = "ios")] + { + let _ = self; + return Err(crate::mobile::unsupported_feature_error( + "Application.runSync", + )); } - let event_loop = self.event_loop.as_mut().ok_or_else(|| { - napi::Error::new( - napi::Status::GenericFailure, - "Event loop is not initialized", - ) - })?; - let state = &mut self.state; - - event_loop.run_return(|event, _target, control_flow| { - *control_flow = ControlFlow::Wait; + #[cfg(not(target_os = "ios"))] + { + use tao::event::Event; + use tao::event_loop::ControlFlow; + use tao::platform::run_return::EventLoopExtRunReturn; - if let Event::WindowEvent { - window_id, - event: window_event, - .. - } = event - { - handle_window_event(state, window_id, window_event); + if self.state.should_exit { + return Ok(()); } - if state.should_exit { - *control_flow = ControlFlow::Exit; + if !self.state.ready { + self.state.ready = true; + self.state.fire(ApplicationEvent { + event: WebviewApplicationEvent::Ready.name().to_owned(), + custom_menu_event: None, + }); } - }); - Ok(()) + let event_loop = self.event_loop.as_mut().ok_or_else(|| { + napi::Error::new( + napi::Status::GenericFailure, + "Event loop is not initialized", + ) + })?; + let state = &mut self.state; + + event_loop.run_return(|event, _target, control_flow| { + *control_flow = ControlFlow::Wait; + + if let Event::WindowEvent { + window_id, + event: window_event, + .. + } = event + { + handle_window_event(state, window_id, window_event); + } + + if state.should_exit { + *control_flow = ControlFlow::Exit; + } + }); + + Ok(()) + } } /// Run the application event loop. @@ -1232,7 +1274,7 @@ impl Application { pub fn run(&mut self, _options: Option) -> Result<()> { // Note: this is intentionally calling pump_events() once // the js side overrides this with a setInterval to keep the event loop alive. - self.pump_events(); + self.pump_events()?; Ok(()) } } @@ -1271,11 +1313,11 @@ mod tests { webview_lifecycles: HashMap::new(), cursor_positions: HashMap::new(), current_modifiers: ModifiersState::default(), - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] menu_event_receiver: None, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] tray_handlers: HashMap::new(), - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] tray_resources: Vec::new(), web_contexts: Vec::new(), } diff --git a/packages/webview/src/browser_window.rs b/packages/webview/src/browser_window.rs index 8d5c179..4931ff0 100644 --- a/packages/webview/src/browser_window.rs +++ b/packages/webview/src/browser_window.rs @@ -1,12 +1,12 @@ use crate::types::*; use dpi::Size; use image::GenericImageView; -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use muda::Menu; use napi::Either; use napi::{bindgen_prelude::FunctionRef, Env, Result}; use napi_derive::*; -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use rfd::FileDialog; use std::cell::{Cell, Ref, RefCell}; use std::collections::{hash_map::DefaultHasher, HashMap}; @@ -65,7 +65,7 @@ pub(crate) type WindowCloseState = Rc; #[cfg(target_os = "windows")] use tao::platform::windows::WindowExtWindows; -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::menu::{create_menu_from_options, init_menu_for_window}; use crate::webview::{ protocol_error_response, JsWebview, ProtocolCounterRef, ProtocolPendingMap, ProtocolRegistration, @@ -152,7 +152,7 @@ pub struct BrowserWindow { is_child_window: bool, pub(crate) window: WindowResource, window_id: u32, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] window_menu: Option, webviews: Rc>>, event_handler: Rc>>>, @@ -170,11 +170,18 @@ impl BrowserWindow { event_loop: &EventLoop<()>, options: Option, child: bool, - #[cfg(not(target_os = "android"))] global_menu: Rc>>, - #[cfg(target_os = "android")] _global_menu: Rc>>, + #[cfg(not(any(target_os = "android", target_os = "ios")))] global_menu: Rc< + RefCell>, + >, + #[cfg(any(target_os = "android", target_os = "ios"))] _global_menu: Rc>>, ) -> Result { let options = options.unwrap_or_default(); + #[cfg(target_os = "ios")] + if options.menu.is_some() { + return Err(crate::mobile::unsupported_feature_error("native menus")); + } + let mut builder = WindowBuilder::new(); if let Some(resizable) = options.resizable { @@ -358,7 +365,7 @@ impl BrowserWindow { window.id().hash(&mut hasher); let window_id = hasher.finish() as u32; - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] let window_menu = if let Some(menu_options) = options.menu { let menu = create_menu_from_options(menu_options)?; init_menu_for_window(&menu, &window)?; @@ -378,7 +385,7 @@ impl BrowserWindow { window: Rc::new(RefCell::new(Some(window))), is_child_window: child, window_id, - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] window_menu, webviews: Rc::new(RefCell::new(Vec::new())), event_handler: Rc::new(RefCell::new(None)), @@ -674,7 +681,7 @@ impl BrowserWindow { #[napi] pub fn open_file_dialog(&self, options: Option) -> Result> { - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { let mut dialog = FileDialog::new(); if let Some(opts) = options.as_ref() { @@ -709,6 +716,11 @@ impl BrowserWindow { let _ = options; Ok(vec![]) } + #[cfg(target_os = "ios")] + { + let _ = options; + Err(crate::mobile::unsupported_feature_error("file dialogs")) + } } #[napi] @@ -718,11 +730,11 @@ impl BrowserWindow { #[napi] pub fn has_menu(&self) -> bool { - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { self.window_menu.is_some() } - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "ios"))] { false } @@ -768,7 +780,7 @@ impl BrowserWindow { responder.respond(protocol_error_response("BrowserWindow has been disposed")); } self.protocols.clear(); - #[cfg(not(target_os = "android"))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] self.window_menu.take(); } @@ -1063,7 +1075,7 @@ impl BrowserWindow { { use tao::platform::ios::{ScreenEdge, WindowExtIOS}; self - .window + .window() .set_preferred_screen_edges_deferring_system_gestures(ScreenEdge::from_bits_truncate( edges, )); diff --git a/packages/webview/src/lib.rs b/packages/webview/src/lib.rs index 3357ff6..0628ca0 100644 --- a/packages/webview/src/lib.rs +++ b/packages/webview/src/lib.rs @@ -8,10 +8,12 @@ pub mod version; pub mod app; pub mod browser_window; pub mod menu; +#[cfg(any(target_os = "android", target_os = "ios"))] +pub(crate) mod mobile; pub mod notifications; -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod tray; -#[cfg(target_os = "android")] +#[cfg(any(target_os = "android", target_os = "ios"))] #[path = "tray_stub.rs"] pub mod tray; pub mod web_context; diff --git a/packages/webview/src/menu.rs b/packages/webview/src/menu.rs index b9a2ef1..dcb8f38 100644 --- a/packages/webview/src/menu.rs +++ b/packages/webview/src/menu.rs @@ -2,13 +2,13 @@ //! - **Windows**: per-window menu bar attached via Win32 HWND. //! - **macOS**: app-level NSApplication menu bar (no per-window attachment). //! - **GTK Unix (Linux and BSD)**: per-window GTK menu bar attached through Tao's GTK handles. -//! - **Android**: menu system is completely disabled. +//! - **Android and iOS**: menu system is unavailable. -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::types::{MenuItemOptions, MenuOptions}; -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use muda::{accelerator::Accelerator, Menu, MenuItem, PredefinedMenuItem, Submenu}; -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] use napi::Result; /// Build a minimal macOS-style app menu (App > About/Hide/Quit) and install it @@ -42,7 +42,7 @@ pub fn make_default_macos_menu() -> muda::Menu { /// On macOS the caller is expected to call `menu.init_for_nsapp()` to make /// this the active menu bar. A macOS-style "App" submenu (About/Hide/Quit) /// is prepended automatically so it appears as the first item. -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn create_menu_from_options(options: MenuOptions) -> Result { let menu = Menu::new(); @@ -74,7 +74,7 @@ pub fn create_menu_from_options(options: MenuOptions) -> Result { } /// Attach `menu` to a native window on the platforms that support it. -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn init_menu_for_window(menu: &Menu, window: &tao::window::Window) -> Result<()> { #[cfg(target_os = "windows")] { @@ -101,7 +101,7 @@ pub fn init_menu_for_window(menu: &Menu, window: &tao::window::Window) -> Result // ── Recursive item builders ─────────────────────────────────────────────────── -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn add_item_to_menu(menu: &Menu, item: MenuItemOptions) -> Result<()> { if let Some(sub_opts) = item.submenu { let sub = Submenu::new(item.label.as_deref().unwrap_or(""), true); @@ -117,7 +117,7 @@ fn add_item_to_menu(menu: &Menu, item: MenuItemOptions) -> Result<()> { Ok(()) } -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn add_item_to_submenu(submenu: &Submenu, item: MenuItemOptions) -> Result<()> { if let Some(sub_opts) = item.submenu { let nested = Submenu::new(item.label.as_deref().unwrap_or(""), true); @@ -135,7 +135,7 @@ fn add_item_to_submenu(submenu: &Submenu, item: MenuItemOptions) -> Result<()> { Ok(()) } -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn role_to_predefined(role: &str) -> Result { Ok(match role { // Editing @@ -169,7 +169,7 @@ fn role_to_predefined(role: &str) -> Result { }) } -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn make_menu_item(item: &MenuItemOptions) -> Result { Ok(MenuItem::with_id( muda::MenuId( @@ -187,7 +187,7 @@ fn make_menu_item(item: &MenuItemOptions) -> Result { )) } -#[cfg(not(target_os = "android"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn menu_err(e: impl std::fmt::Display) -> napi::Error { napi::Error::new(napi::Status::GenericFailure, e.to_string()) } diff --git a/packages/webview/src/mobile.rs b/packages/webview/src/mobile.rs new file mode 100644 index 0000000..cdae864 --- /dev/null +++ b/packages/webview/src/mobile.rs @@ -0,0 +1,14 @@ +use napi::{Error, Status}; + +pub(crate) fn unsupported_feature_error(feature: &str) -> Error { + let platform = if cfg!(target_os = "ios") { + "iOS" + } else { + "Android" + }; + + Error::new( + Status::GenericFailure, + format!("{platform} does not support {feature}"), + ) +} diff --git a/packages/webview/src/notifications.rs b/packages/webview/src/notifications.rs index 70bb208..3acfc0e 100644 --- a/packages/webview/src/notifications.rs +++ b/packages/webview/src/notifications.rs @@ -296,18 +296,27 @@ impl JsNotification { } } - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(target_os = "ios")] { let _ = (options, callback); + return Err(crate::mobile::unsupported_feature_error( + "native notifications", + )); } - Ok(Self { - #[cfg(all( - unix, - not(any(target_os = "macos", target_os = "android", target_os = "ios")) - ))] - handle: Arc::new(Mutex::new(None)), - }) + #[cfg(not(target_os = "ios"))] + { + #[cfg(target_os = "android")] + let _ = (options, callback); + + Ok(Self { + #[cfg(all( + unix, + not(any(target_os = "macos", target_os = "android", target_os = "ios")) + ))] + handle: Arc::new(Mutex::new(None)), + }) + } } #[napi] diff --git a/packages/webview/src/tray_stub.rs b/packages/webview/src/tray_stub.rs index 10d0198..4ad26fb 100644 --- a/packages/webview/src/tray_stub.rs +++ b/packages/webview/src/tray_stub.rs @@ -5,13 +5,14 @@ use napi_derive::napi; use crate::types::{TrayEventPayload, TrayIconOptions}; #[napi(js_name = "TrayIcon")] -pub struct JsTrayIcon; +pub struct JsTrayIcon { + id: String, +} impl JsTrayIcon { pub(crate) fn create(_options: TrayIconOptions) -> Result { - Err(napi::Error::new( - napi::Status::GenericFailure, - "System tray icons are not supported on Android", + Err(crate::mobile::unsupported_feature_error( + "system tray icons", )) } } @@ -32,10 +33,95 @@ impl JsTrayIcon { }) } + #[napi(getter)] + pub fn id(&self) -> String { + self.id.clone() + } + /// Keeps the unsupported tray binding's generated type compatible with the JS wrapper. #[napi(js_name = "_onTrayEvent")] pub fn on_tray_event(&self, _handler: Option>) {} + #[napi] + pub fn set_icon( + &self, + _icon: napi::Either<&[u8], Vec>, + _width: Option, + _height: Option, + ) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn remove_icon(&self) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn set_menu(&self, _menu: Option) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray menus", + )) + } + + #[napi] + pub fn set_tooltip(&self, _tooltip: Option) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn set_title(&self, _title: Option) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn set_visible(&self, _visible: bool) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn set_icon_as_template(&self, _value: bool) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn set_show_menu_on_left_click(&self, _value: bool) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn set_show_menu_on_right_click(&self, _value: bool) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray icon updates", + )) + } + + #[napi] + pub fn show_menu(&self) -> Result<()> { + Err(crate::mobile::unsupported_feature_error( + "system tray menus", + )) + } + + #[napi] + pub fn rect(&self) -> Option { + None + } + #[napi] pub fn dispose(&self) {} diff --git a/packages/webview/src/webview.rs b/packages/webview/src/webview.rs index eb2b9f8..459adc3 100644 --- a/packages/webview/src/webview.rs +++ b/packages/webview/src/webview.rs @@ -80,6 +80,16 @@ fn new_window_response(navigation_allowed: bool, request_allowed: bool) -> NewWi } } +fn validate_child_webview(ios: bool, child: bool) -> Result<()> { + if ios && child { + return Err(napi::Error::new( + napi::Status::GenericFailure, + "iOS does not support child webviews", + )); + } + Ok(()) +} + pub(crate) fn protocol_error_response( message: &str, ) -> wry::http::Response> { @@ -185,6 +195,7 @@ impl JsWebview { web_context: Option<&mut crate::web_context::JsWebContext>, create_context: WebviewCreateContext<'_>, ) -> Result { + validate_child_webview(cfg!(target_os = "ios"), options.child.unwrap_or(false))?; let WebviewCreateContext { protocols, protocol_responders, @@ -1062,7 +1073,15 @@ fn js_to_cookie(c: &WebviewCookie) -> wry::cookie::Cookie<'static> { #[cfg(test)] mod tests { - use super::{new_window_response, NewWindowResponse}; + use super::{new_window_response, validate_child_webview, NewWindowResponse}; + + #[test] + fn ios_rejects_child_webviews_before_native_creation() { + assert!(validate_child_webview(true, false).is_ok()); + assert!(validate_child_webview(false, true).is_ok()); + let error = validate_child_webview(true, true).unwrap_err(); + assert_eq!(error.reason, "iOS does not support child webviews"); + } #[test] fn new_window_requires_both_navigation_and_popup_handlers_to_allow() {