@swiftbrowser/native
v0.28.10
Published
Run a @swiftbrowser/react app natively: the runtime a JavaScriptCore-hosted app needs (timers, console, fetch and the other web globals over the SwiftBrowser Native bridge), the `run` entry that renders into the native SwiftUI renderer, and the bundler th
Readme
@swiftbrowser/native
Runs a @swiftbrowser/react app natively: inside an
iOS app, in JavaScriptCore, drawn by real SwiftUI through the
SwiftBrowser Native Swift package. The package is
three things:
run(<App />), the entry an app calls: a@swiftbrowser/reactroot whose ops go to the native renderer and whose events come back from it, the native counterpart ofmount.- The runtime: the web globals bare JavaScriptCore lacks, built on the
bridge's primitives (
setTimeoutand friends,requestAnimationFrame,console,performance,fetchwithHeaders,Request,ResponseandAbortController,URL,URLSearchParams,TextEncoder,TextDecoder,atob,btoa,crypto.getRandomValuesandrandomUUID,localStorage,structuredClone,navigator(withonLinefrom the host's path monitor, and theonlineandofflineevents; withwakeLockover the host's idle timer; withgeolocationover the host's location manager),speechSynthesisandSpeechRecognitionover the system's voices and recognizer,Imageover the host's image loader, for the size of a picture without drawing it, which is howAsyncImage'splaceholderandfailureshow natively). React's scheduler reads some of these when it loads, so the bundle starts with the runtime. - The bundler:
swiftbrowser-native buildwrites oneapp.jsfor the Xcode target, andswiftbrowser-native devserves it with rebuild on save for the app'sNativeApp.watch(url:).
The design is in docs/native.md. The package is
experimental, like @swiftbrowser/react.
The package also carries the Swift side: swift/ is the
SwiftBrowser Native Swift package (its
Package.swift and Sources, without the tests), so an Xcode project can
depend on node_modules/@swiftbrowser/native/swift as a local package and
build against the exact version of the renderer this JavaScript was written
for. swiftbrowser native init lays a project out that way:
npx @swiftbrowser/cli native init MyApp # MyApp.xcodeproj (iOS and macOS), apple/, src/, web/
cd MyApp && npm install # React, @swiftbrowser/react, @swiftbrowser/web and this package
npm run build # apple/app.js, the bundle the app ships
open MyApp.xcodeproj # run it on a simulator, an iPhone or this Mac
npm run dev:web # the same app as a web pageFor a hand-made project:
npm install @swiftbrowser/native @swiftbrowser/react react react-reconcilerAn app
// src/index.tsx
import { run } from '@swiftbrowser/native';
import { List, NavigationStack, Section, Text, Toggle } from '@swiftbrowser/react';
import { useState } from 'react';
function App() {
const [on, setOn] = useState(false);
return (
<NavigationStack>
<List navigationTitle="Settings">
<Section header="General">
<Toggle title="Notifications" isOn={[on, setOn]} />
<Text foregroundStyle="secondary">{on ? 'On' : 'Off'}</Text>
</Section>
</List>
</NavigationStack>
);
}
run(<App />);npx swiftbrowser-native build src/index.tsx --out ../apple/app.js # the bundle Xcode ships
npx swiftbrowser-native dev src/index.tsx --out ../apple/app.js # http://localhost:5173/, Fast Refresh on saveOn the Swift side, NativeApp().load() runs the bundled app.js and
NativeRootView(app:) draws it (see the Swift package's README). The
examples under Native/Examples are complete apps laid out this way.
What runs where
Every call across the bridge is synchronous. A tap is one call chain:
SwiftUI's button action calls __sbDispatch(json), the handler runs,
React flushes the pass, the sink calls __sbNative.applyOps(json), the Swift
tree mutates, and everything returns. A controlled TextField therefore
never shows a stale frame: when its binding's setter returns, the app's
state already holds the new text.
There is no window and no document. Code that checks for them takes its
headless path, which is right here; @swiftbrowser/react's Host (React DOM
inside the tree) renders nothing, as react-dom is aliased to a stub.
fetch sends the request over the bridge and URLSession performs it. A
body may be a Blob or a FormData (sent as multipart: a file upload works
as written for the page), and one holding picked or dropped files still on
disk (the files themselves, their slices, a Blob built from slices of
several, a form with them) is streamed from disk: the body goes to the host
as parts, bytes beside the request and ranges of files, and an InputStream
reads them in order as the request sends, so a 2 GB upload holds nothing in
memory; a host before 0.20 reads the files first. Responses are read whole
(response.body is null; arrayBuffer(), text(), json(), blob()).
An AbortController's abort cancels the task (fetchCancel). URL is a small
parser, enough for absolute and relative URLs and query strings, not the
whole WHATWG algorithm; URL.createObjectURL gives a data: URL. WebSocket
runs on URLSessionWebSocketTask, crypto.subtle digests (SHA-1 to
SHA-512) and signs HMACs on CryptoKit, matchMedia answers the
prefers-color-scheme queries from the host's environment,
navigator.vibrate plays a haptic, and navigator.onLine is the network
path's status from NWPathMonitor, with the online and offline events
on the global (addEventListener('offline', …), as there is no window;
ononline and onoffline handlers too). navigator.wakeLock.request('screen')
keeps the screen awake while its sentinel is held
(UIApplication.isIdleTimerDisabled; a native sentinel stays held in the
background, where the system owns the screen, and holds again on return).
signInWithApple and SignInWithAppleButton show the system's Sign in with
Apple sheet (ASAuthorizationController, with the app's
com.apple.developer.applesignin entitlement) and answer the credential;
appleCredentialState asks the provider.
fileManager reaches the app's sandbox through files requests (a
range read is FileHandle's seek and read, so a large file goes a piece
at a time; a picked or dropped file written is copied on disk, importItem,
never through the bridge), and its
iCloud Drive container (iCloudDirectory()) where the app has the
entitlement: the container's files are read and written under file
coordination, downloaded first when another device wrote them, and their
changes come to __sbFilesChanged for fileManager.watch, as the app's
own writes do.
navigator.geolocation asks CLLocationManager: getCurrentPosition for
one fix, watchPosition for every fix until clearWatch, with the API's
GeolocationPositionError codes (the first ask prompts the viewer; the
app's Info.plist names NSLocationWhenInUseUsageDescription).
speechSynthesis.speak(new SpeechSynthesisUtterance(text)) reads through
AVSpeechSynthesizer (the utterance's lang, rate, pitch and volume,
its start, boundary, pause, resume, end and error events;
cancel, pause and resume on the queue), and new SpeechRecognition()
listens through SFSpeechRecognizer (lang, continuous,
interimResults; start, stop, abort; result events with
results[i][0].transcript and isFinal, error and end; the app's
Info.plist names NSSpeechRecognitionUsageDescription and
NSMicrophoneUsageDescription).
A page's useScenePhase() is
the app's scene phase here (active, inactive, background), as
NativeRootView sends it.
Workers
npx swiftbrowser-native build src/index.tsx --out ../apple/app.js --worker render=src/render.worker.tsimport { createWorker } from '@swiftbrowser/native';
const worker = createWorker('render'); // render.worker.js, beside app.js
worker.onmessage = (event) => show(event.data);
worker.postMessage({ html, width, height });A page keeps heavy work off its UI thread in a Web Worker; a JavaScriptCore
app has the same through the host. --worker <name>=<entry> (repeatable;
build, dev and site take it) bundles the entry as <name>.worker.js
with the runtime first, as app.js is built, and createWorker(name) runs
it in a second JSContext in a JSVirtualMachine of its own on a thread
of its own, with a run loop: the worker's timers and fetch answers come
through it, and so does the engine's deferred work, so an emscripten
module's WebAssembly.instantiate settles there as it does in a browser.
The worker's scope has self, postMessage, onmessage,
addEventListener and the runtime's globals, but no tree: it computes and
answers. Messages cross as text, since values cannot be shared between
virtual machines: JSON, with typed arrays and ArrayBuffers carried as
bytes and Errors as their name and message (encodeMessage and
decodeMessage are exported); Maps, functions and the rest JSON drops do
not cross, and a Date arrives as its ISO string. terminate() ends the
worker. Drawn in a page (site), createWorker returns the page's own
Worker over the same bundle, published with the site, so the app's code
is the same in all three places. The iOS app's WebAssembly is the engine's
interpreter (a third-party app has no JIT), and SIMD in it needs iOS 26.2.
const worker = createWorker('satoru', { engine: 'webview' });engine: 'webview' runs the same bundle in a hidden WKWebView instead,
whose web content process does compile JavaScript and WebAssembly with its
JIT: much faster for heavy WebAssembly, for a process and a web
view's memory. Its fetch still goes through the app's URLSession, with
the app's cookies; a page's own Worker ignores the option.
URLs the app is opened with
import { onOpenURL } from '@swiftbrowser/native';
useEffect(() => onOpenURL((url) => take(new URL(url).searchParams.get('token'))), []);A universal link or a custom scheme opens the app through SwiftUI's
onOpenURL, which the App file hands to NativeApp.open(url:) (the
project native init writes does); onOpenURL here delivers it to the
app's listeners. A URL that launched the app waits for the bundle and then
for the first listener, so nothing is lost on a cold start.
Now playing
navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork: [{ src }] });
navigator.mediaSession.playbackState = 'playing';
navigator.mediaSession.setPositionState({ duration, position, playbackRate: 1 });
navigator.mediaSession.setActionHandler('nexttrack', () => player.advanceToNextItem());The browser's Media Session API, as an audio app writes it for the page,
works in the native app too: the runtime's navigator.mediaSession and
MediaMetadata hand what is set to the host, which puts it on the lock
screen and the system's player controls through MediaPlayer, and the
buttons there (and the media keys) run the handlers setActionHandler
registered: play, pause, stop, nexttrack, previoustrack,
seekto (with seekTime) and seekforward / seekbackward (with
seekOffset, 15 seconds when the system names none). Setting
playbackState to playing activates a playback audio session, which the
lock screen needs; for the audio to go on with the screen locked, the app's
Xcode target also needs the audio background mode (Signing &
Capabilities → Background Modes → Audio, AirPlay, and Picture in Picture).
Notifications
import { onNotification, registerForPush } from '@swiftbrowser/native';
if ((await Notification.requestPermission()) === 'granted') {
const token = await registerForPush();
await api.post('/devices', { token });
}
useEffect(() => onNotification((n) => { if (n.event === 'opened') openPost(n.data.postId); }), []);
new Notification('Saved', { body: 'The draft is on the server', tag: 'draft', data: { draftId } });
navigator.setAppBadge(unread);The browser's Notification works in the native app over UserNotifications:
Notification.permission (read at launch and after each
requestPermission), requestPermission(), new Notification(title, { body,
tag, data, silent, badge }) shown at once (tag is the notification's
identifier; a later one with the same tag replaces it), close(), and
onclick when the viewer opens it. navigator.setAppBadge and
clearAppBadge set the app badge.
registerForPush() registers with APNs and resolves with the device token as
hex, or rejects with the system's reason (the Xcode target needs the Push
Notifications capability, and the App file the delegate adaptor the project
native init writes has: @UIApplicationDelegateAdaptor(NativeAppDelegate.self);
an app with a delegate of its own calls NativeApp.didRegisterForRemoteNotifications(deviceToken:),
didFailToRegisterForRemoteNotifications(error:) and
didReceiveRemoteNotification(userInfo:) from it). onNotification
delivers what happens: { event: 'opened' | 'received', remote, id, title,
body, data, action? }, with the push payload or the Notification's data
as data. opened is the viewer tapping a notification (one that launched
the app waits for the first listener, like a URL); received is one that
arrived while the app is open (shown as it would be in the background) or a
silent push the app delegate was handed.
In the page (the round trip, or any browser) the same calls work with Web
Push: Notification is the browser's, registerForPush({
applicationServerKey }) subscribes through the site's service worker with
the server's VAPID public key and resolves with the subscription's JSON as
the token, and the worker's pushes and clicks become onNotification
events. A server tells the two tokens apart by their shape and sends one
payload to both: APNs' (aps.alert, aps.badge), which the worker reads
too (docs/ops-protocol.md, "Push notifications").
Sharing, picking, the clipboard and signing in
ShareLink, fileImporter, fileExporter, the clipboard and the color
chooser from @swiftbrowser/react ask the browser for a share sheet, a file
chooser, a save dialog, the pasteboard and a color picker; in the native app
the host answers instead, with UIActivityViewController, the photo picker
(for pictures and videos) or the document picker (which also saves an export
to Files), UIPasteboard and UIColorPickerViewController.
Nothing to write: run fills the web renderer's environment with what the
page has nothing for, so a screen written for the page shares and picks as
it did. A picked file is a LazyFile: a File whose name, type and size
are known and whose bytes stay on disk (a document in the app's tmp, a
photo copied there, a folder opened in place with its security scope kept)
until arrayBuffer(), text() or a slice reads them by range through the
files bridge; fileManager.write copies it, a fetch body or FormData
streams it from disk, ShareLink and fileExporter copy it. Files dropped on a
dropDestination from another app arrive the same way, a dropped folder's
named by their paths in it. The app keeps them readable until it asks the
host to forget them (files release, or the next folder pick).
import { webAuthenticate, secureStorage } from '@swiftbrowser/native';
const callback = await webAuthenticate('https://x.example/oauth/authorize?…', { callbackScheme: 'myapp' });
secureStorage.setItem('session', new URL(callback).searchParams.get('token')!);webAuthenticate(url, { callbackScheme, ephemeral }) is the system's
sign-in sheet (ASWebAuthenticationSession): it resolves with the callback
URL the sign-in ends with, and rejects with an AbortError when the viewer
closes it. In the page it runs on the shim's session (the popup flow of
docs/ops-protocol.md, "Signing in"). secureStorage has localStorage's
shape over the Keychain, for what must not sit in plain text; in the page
it is localStorage under a prefix.
Undo
useUndoManager() from @swiftbrowser/react keeps its stack in
JavaScript; in the native app the window's UndoManager mirrors it (the
host's undoState, Host/UndoBridge.swift: one proxy action per side the
app can use, named as the app names it), so the shake, the three-finger
swipe, ⌘Z from a keyboard and the Edit menu undo and redo the app's
changes, as they do in a SwiftUI app. Nothing to write: run puts the
host in the web renderer's environment where the page's keyboard would be.
The dev loop and Fast Refresh
swiftbrowser-native dev serves the bundle in two halves and a manifest:
| path | what |
|---|---|
| /manifest.json | { "vendor": <hash>, "app": <hash> }, which NativeApp.watch(url:) polls every second |
| /vendor.js | the runtime, React Fast Refresh's runtime, React, @swiftbrowser/react and this package: evaluated once |
| /app.js | the app's own modules, each run through react-refresh/babel, importing React and the packages through shims that read __sbVendor: evaluated again on every save |
On a save the app half is evaluated in the running engine: its modules
register their components with the refresh runtime, and run, called again
at its end, performs the refresh and renders the new tree into the same
root. A component whose hooks are unchanged keeps its state; one whose
hooks changed remounts, as React Fast Refresh decides from the signatures
the transform records. A new vendor half (a dependency changed) starts the
app over. No module loader or WebSocket is involved, which bare
JavaScriptCore lacks. test/fastRefresh.test.ts runs the loop in Node.
launchArguments() reads the process's arguments (-screen list from an
Xcode scheme or simctl launch), as the Fidelity example does to open one
screen.
In CI
npx swiftbrowser-native ios-profile com.example.MyApp --profile "MyApp Ad Hoc" --name MyApp
npx swiftbrowser-native ios-install-page build/MyApp.ipa --out site --url https://ios.my-app.pages.dev \
--bundle-id com.example.MyApp --version 1.0.42 [--web https://my-app.pages.dev/] [--commit $GITHUB_SHA]ios-profile makes an ad hoc provisioning profile again through the App
Store Connect API (ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 from the
environment, a key with the Admin or App Manager role) with every enabled
iOS device and valid distribution certificate of the team, registering the
bundle ID if the team has none, and installs it where Xcode looks.
ios-install-page writes the over-the-air install page around an exported
IPA: the IPA, its manifest.plist, an index.html whose button installs it,
and the _headers Cloudflare Pages serves them with. SwiftBrowser's reusable
workflow sb-ios-adhoc.yml runs both (docs/native.md,
"Building and deploying from GitHub Actions").
In the browser
npx swiftbrowser-native site src/index.tsx --out dist # the app as a static site
npx swiftbrowser-native site src/index.tsx --out ../web/dist/native --base /native/The same bundle, drawn by the native views in a browser: the package ships
the SwiftBrowser Native Swift package compiled for WebAssembly against
SwiftBrowser's SwiftUI shim (wasm/SwiftBrowserNative.wasm, the round trip
of docs/native.md), and site writes it, the bundle
and the web renderer's page as one static directory, the layout
swiftbrowser build writes for a Swift app. The page runs the module, the
module evaluates the bundle at the page's global scope (so fetch, timers
and localStorage are the browser's own, same origin and cookies included),
the native views draw the app's ops with the shim's SwiftUI, and the web
renderer draws what the shim emits. One app, three renderers: the browser's
DOM through @swiftbrowser/react directly, real SwiftUI in the iOS app, and
this: the iOS app's own views, in a page. --base /path/ when the site is
served under a path of another site, --module <file> for a module built
from a checkout (swift build --swift-sdk <wasm sdk> -c release --product
NativeRoundTrip), --name <App> for the site's app name (the project's
folder by default), --catalog <dir.xcassets> (repeatable) for the
app's named images and colors (the Xcode target's catalog, or one kept for
the site: a .symbolset holding any SVG draws as a symbol there), and
--worker <name>=<entry> (repeatable) for the app's workers, published
beside the bundle for the page's Worker.
Registered views
import { RegisteredView } from '@swiftbrowser/react';
<RegisteredView name="MapView" props={{ region }} onEvent={(value) => console.log('map said', value)}>
<Text>Children are SwiftUI views inside the native view</Text>
</RegisteredView>The Swift app registers the view once:
ViewRegistry.register("MapView") { context in
MapView(region: context.props["region"]) { context.send(["tapped": true]) }
}and a page can register its own element under the same name
(ViewRegistry.register('MapView', (context) => ({ element, update, dispose })),
also from @swiftbrowser/react), so the same element draws on both.
Swift that is not a view registers as a function, before the bundle loads
(swiftbrowser native init writes an apple/NativeFunctions.swift for them):
NativeFunctions.register("purchase") { call in // async
let product = try await Product.products(for: [call["id"].string ?? ""]).first
...
return ["state": "purchased"]
}
NativeFunctions.registerSync("deviceName") { _ in .string(UIDevice.current.name) }
NativeFunctions.send("transaction", ["id": "pro"]) // Swift reporting on its ownimport { callNative, callNativeSync, onNativeEvent, hasNativeFunction, nativeFunctions } from '@swiftbrowser/native';
const { state } = await callNative<{ state: string }>('purchase', { id: 'pro' });
const name = callNativeSync<string>('deviceName');
const stop = onNativeEvent('transaction', (value) => console.log('bought', value));Params and results cross as JSON (call["id"] reads a member, call.decode(MyParams.self) the
whole object). An error a function throws rejects the promise with its description;
NativeFunctionCancelled rejects with an AbortError, as the system pickers' cancellations do.
callNative is a host request (app:<name>), so it needs nothing the other requests do not;
callNativeSync runs inline, for a cheap lookup used during render. On the web build of the same
app callNative rejects unsupported:, nativeFunctions() is empty and hasNativeFunction(name)
is false, so the app can fall back to the web's way. Everything runs on the main actor.
Testing without a device
installRuntime(native, target) installs the runtime on any object over
any bridge, and run(element, { native }) renders through one; the tests
in test/ drive both with a fake bridge. test/bundle.test.ts builds the
example apps and runs the bundles in a bare vm context with only
__sbNative, as JavaScriptCore runs them, and replays the Todos example's
snapshot script against the tree the ops build.
