Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -543,13 +543,55 @@ 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
timeout-minutes: 30
needs:
- build
- build-freebsd
- build-ios
- release-version
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/api/notification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion apps/docs/content/docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
45 changes: 41 additions & 4 deletions apps/docs/content/docs/platform/ios.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 1 addition & 3 deletions packages/webview/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 2 additions & 0 deletions packages/webview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/webview/__test__/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
194 changes: 194 additions & 0 deletions packages/webview/__test__/internal/build-ios.test.ts
Original file line number Diff line number Diff line change
@@ -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('<string>iPhoneOS</string>');
expect(frameworkInfo('simulator')).toContain('<string>iPhoneSimulator</string>');
expect(frameworkInfo('device')).toContain('<key>CFBundleExecutable</key>\n <string>Webview</string>');
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');
});
23 changes: 23 additions & 0 deletions packages/webview/__test__/internal/event-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
7 changes: 7 additions & 0 deletions packages/webview/__test__/native/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading