@swiftbrowser/native 0.17.8 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/dist/bridge.d.ts +27 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/hostRequests.d.ts +10 -4
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -3
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +14 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +12 -1
- package/dist/runtime.js.map +1 -1
- package/dist/site.d.ts +3 -3
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +1 -1
- package/dist/site.js.map +1 -1
- package/dist/undo.d.ts +23 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +44 -0
- package/dist/undo.js.map +1 -0
- package/package.json +3 -3
- package/src/bridge.ts +26 -1
- package/src/hostRequests.ts +72 -4
- package/src/index.ts +1 -0
- package/src/run.ts +11 -1
- package/src/runtime.ts +12 -1
- package/src/site.ts +3 -3
- package/src/undo.ts +47 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +45 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +16 -2
- package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +30 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +347 -12
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
- package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
- package/wasm/SwiftBrowserNative.wasm +2 -2
package/dist/undo.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The window's `UndoManager` for the app's undo manager (`useUndoManager`
|
|
3
|
+
* in @swiftbrowser/react): the host of the renderer environment's
|
|
4
|
+
* `undoManager` (Web/src/undo.ts) in the native app. The stack stays in
|
|
5
|
+
* JavaScript, the truth; the host mirrors what it can do, so the system's
|
|
6
|
+
* undo reaches it: each change goes to the bridge's `undoState(json)` as
|
|
7
|
+
* `{ canUndo, canRedo, undoActionName, redoActionName }` (UndoBridge.swift
|
|
8
|
+
* keeps one proxy action per side on the window's manager), and a shake, a
|
|
9
|
+
* three-finger swipe, ⌘Z or the Edit menu comes back as
|
|
10
|
+
* `__sbUndoCommand('undo' | 'redo')`. In the page (the round trip) the
|
|
11
|
+
* page's keyboard host is there already and nothing is installed over it.
|
|
12
|
+
*/
|
|
13
|
+
let installed = null;
|
|
14
|
+
export function createUndoHost(native) {
|
|
15
|
+
let commands = null;
|
|
16
|
+
const host = {
|
|
17
|
+
update(state) {
|
|
18
|
+
native.undoState(JSON.stringify(state));
|
|
19
|
+
},
|
|
20
|
+
listen(given) {
|
|
21
|
+
commands = given;
|
|
22
|
+
return () => {
|
|
23
|
+
if (commands === given)
|
|
24
|
+
commands = null;
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const receive = (command) => {
|
|
29
|
+
if (command === 'undo')
|
|
30
|
+
commands?.undo();
|
|
31
|
+
else if (command === 'redo')
|
|
32
|
+
commands?.redo();
|
|
33
|
+
};
|
|
34
|
+
return { host, receive };
|
|
35
|
+
}
|
|
36
|
+
/** Keeps the host for `installHostEnvironment` (the runtime, once per install). */
|
|
37
|
+
export function installUndoHost(host) {
|
|
38
|
+
installed = host;
|
|
39
|
+
}
|
|
40
|
+
/** The host the runtime made, or null outside the native app. */
|
|
41
|
+
export function nativeUndoHost() {
|
|
42
|
+
return installed;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=undo.js.map
|
package/dist/undo.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"undo.js","sourceRoot":"","sources":["../src/undo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,IAAI,SAAS,GAAoB,IAAI,CAAC;AAEtC,MAAM,UAAU,cAAc,CAAC,MAAiD;IAC9E,IAAI,QAAQ,GAAwB,IAAI,CAAC;IACzC,MAAM,IAAI,GAAa;QACrB,MAAM,CAAC,KAAgB;YACrB,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;QAC1C,CAAC;QACD,MAAM,CAAC,KAAK;YACV,QAAQ,GAAG,KAAK,CAAC;YACjB,OAAO,GAAG,EAAE;gBACV,IAAI,QAAQ,KAAK,KAAK;oBAAE,QAAQ,GAAG,IAAI,CAAC;YAC1C,CAAC,CAAC;QACJ,CAAC;KACF,CAAC;IACF,MAAM,OAAO,GAAG,CAAC,OAAe,EAAQ,EAAE;QACxC,IAAI,OAAO,KAAK,MAAM;YAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;aACpC,IAAI,OAAO,KAAK,MAAM;YAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAChD,CAAC,CAAC;IACF,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC3B,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,eAAe,CAAC,IAAqB;IACnD,SAAS,GAAG,IAAI,CAAC;AACnB,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,cAAc;IAC5B,OAAO,SAAS,CAAC;AACnB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swiftbrowser/native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "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 that builds app.js for the Xcode target.",
|
|
6
6
|
"repository": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@babel/core": "^7.29.7",
|
|
62
|
-
"@swiftbrowser/react": "0.
|
|
63
|
-
"@swiftbrowser/web": "0.
|
|
62
|
+
"@swiftbrowser/react": "0.18.0",
|
|
63
|
+
"@swiftbrowser/web": "0.18.0",
|
|
64
64
|
"esbuild": "^0.28.2",
|
|
65
65
|
"react-refresh": "^0.17.0"
|
|
66
66
|
},
|
package/src/bridge.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* package defines (`__sbTimerFired`, `__sbFetchResult`, `__sbImageProbed`,
|
|
6
6
|
* `__sbWorkerMessage`, `__sbOpenURL`, `__sbMediaSessionCommand`,
|
|
7
7
|
* `__sbNotificationPermission`, `__sbPushRegistered`, `__sbNotification`,
|
|
8
|
-
* `__sbWebSocketEvent`, `__sbHostResponse`).
|
|
8
|
+
* `__sbWebSocketEvent`, `__sbHostResponse`, `__sbUndoCommand`).
|
|
9
9
|
*/
|
|
10
10
|
export interface NativeBridge {
|
|
11
11
|
/** Applies a render pass's ops (a JSON array). Returns an error message, or nothing. */
|
|
@@ -87,6 +87,31 @@ export interface NativeBridge {
|
|
|
87
87
|
*/
|
|
88
88
|
hostRequest?(id: number, kind: string, json: string): void;
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The app's undo manager's state (undo.ts): `{ canUndo, canRedo,
|
|
92
|
+
* undoActionName, redoActionName }` as JSON, which the host mirrors on the
|
|
93
|
+
* window's `UndoManager` so the system's undo reaches the app; the undo or
|
|
94
|
+
* redo the system asks for arrives at `__sbUndoCommand('undo' | 'redo')`.
|
|
95
|
+
* Hosts before 0.18 lack it.
|
|
96
|
+
*/
|
|
97
|
+
undoState?(json: string): void;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `measureText` (@swiftbrowser/react): the size `text` in `font` (a Font
|
|
101
|
+
* JSON object over the body font) takes wrapped to `width` points (one
|
|
102
|
+
* line when null), from `NSAttributedString`'s bounding rect, synchronously:
|
|
103
|
+
* `{ width, height, lines }`, or nothing when the host cannot measure.
|
|
104
|
+
* Hosts before 0.21 lack it.
|
|
105
|
+
*/
|
|
106
|
+
measureText?(text: string, font: string, width: number | null): { width: number; height: number; lines: number } | null | undefined;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The app's standard directories as `file:` URLs, a JSON object
|
|
110
|
+
* `{ documents, applicationSupport, caches, temporary }` (hostRequests.ts
|
|
111
|
+
* `files`, Host/FilesBridge.swift). Hosts before 0.22 lack it.
|
|
112
|
+
*/
|
|
113
|
+
appDirectories?(): string | null | undefined;
|
|
114
|
+
|
|
90
115
|
// The Keychain (secureStorage.ts). Hosts before 0.20 lack them.
|
|
91
116
|
secureGet?(key: string): string | null;
|
|
92
117
|
secureSet?(key: string, value: string): void;
|
package/src/hostRequests.ts
CHANGED
|
@@ -4,20 +4,27 @@
|
|
|
4
4
|
* renderer's `performHostRequest` with its `browserEnvironment`), answered by
|
|
5
5
|
* the native app instead: the bridge's `hostRequest(id, kind, json)` performs
|
|
6
6
|
* one with UIKit (a share sheet, the photo or document picker, the
|
|
7
|
-
* pasteboard, the color picker, `ASWebAuthenticationSession
|
|
7
|
+
* pasteboard, the color picker, `ASWebAuthenticationSession`, a recording
|
|
8
|
+
* from the microphone with `AVAudioRecorder`), answering
|
|
8
9
|
* `__sbHostResponse(id, { value } | { error })`. `installHostEnvironment`
|
|
9
|
-
* fills the environment's gaps with these,
|
|
10
|
-
*
|
|
10
|
+
* fills the environment's gaps with these, and with the window's undo
|
|
11
|
+
* manager (undo.ts), leaving what the page has (the round trip runs in a
|
|
12
|
+
* browser with a share sheet and a keyboard of its own).
|
|
11
13
|
*
|
|
12
14
|
* `webAuthenticate` is the sign-in window apps open for OAuth: the system's
|
|
13
15
|
* sheet, resolving with the callback URL.
|
|
16
|
+
*
|
|
17
|
+
* `files` is the app's sandbox for `fileManager` (@swiftbrowser/react): the
|
|
18
|
+
* `files` requests of Host/FilesBridge.swift, bytes crossing as base64, with
|
|
19
|
+
* the standard directories from the bridge's `appDirectories()`.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import { objectURL, SimpleBlob, SimpleFile } from './blob';
|
|
17
23
|
import type { NativeBridge } from './bridge';
|
|
18
24
|
import { base64Decode, base64Encode } from './encoding';
|
|
25
|
+
import { nativeUndoHost } from './undo';
|
|
19
26
|
|
|
20
|
-
export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'
|
|
27
|
+
export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>> & Pick<NativeBridge, 'appDirectories'>;
|
|
21
28
|
|
|
22
29
|
/** A file as it crosses the bridge: its name, MIME type and bytes (base64). */
|
|
23
30
|
export interface BridgedFile {
|
|
@@ -105,9 +112,56 @@ export async function webAuthenticate(url: string, options: WebAuthenticateOptio
|
|
|
105
112
|
return result.url;
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
interface FileDirectories {
|
|
116
|
+
documents: string;
|
|
117
|
+
applicationSupport: string;
|
|
118
|
+
caches: string;
|
|
119
|
+
temporary: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The app's directories the bridge reports, or null on a host without `appDirectories`. */
|
|
123
|
+
function appDirectories(): FileDirectories | null {
|
|
124
|
+
try {
|
|
125
|
+
const json = bridge?.appDirectories?.();
|
|
126
|
+
if (!json) return null;
|
|
127
|
+
const parsed = JSON.parse(json) as Partial<FileDirectories>;
|
|
128
|
+
const { documents, applicationSupport, caches, temporary } = parsed;
|
|
129
|
+
if ([documents, applicationSupport, caches, temporary].every((url) => typeof url === 'string')) {
|
|
130
|
+
return { documents: documents!, applicationSupport: applicationSupport!, caches: caches!, temporary: temporary! };
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Malformed: as if the host had none.
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The app's files (`files` requests): `fileManager`'s host over the sandbox. */
|
|
139
|
+
function nativeFiles(directories: FileDirectories): Record<string, unknown> {
|
|
140
|
+
const files = <T>(params: Record<string, unknown>): Promise<T> => hostRequest<T>('files', params);
|
|
141
|
+
return {
|
|
142
|
+
directories,
|
|
143
|
+
read: async (url: string): Promise<Uint8Array> => base64Decode((await files<{ data?: string }>({ action: 'read', url }))?.data ?? ''),
|
|
144
|
+
write: (url: string, data: Uint8Array): Promise<void> => files({ action: 'write', url, data: base64Encode(data) }).then(() => undefined),
|
|
145
|
+
remove: (url: string): Promise<void> => files({ action: 'remove', url }).then(() => undefined),
|
|
146
|
+
createDirectory: (url: string): Promise<void> => files({ action: 'createDirectory', url }).then(() => undefined),
|
|
147
|
+
list: async (url: string): Promise<string[]> => (await files<{ urls?: string[] }>({ action: 'list', url }))?.urls ?? [],
|
|
148
|
+
attributes: async (url: string): Promise<{ size: number; modified: number | null; directory: boolean } | null> => {
|
|
149
|
+
const found = await files<{ size?: number; modified?: number | null; directory?: boolean } | null>({ action: 'attributes', url });
|
|
150
|
+
if (!found || typeof found.size !== 'number') return null;
|
|
151
|
+
return { size: found.size, modified: typeof found.modified === 'number' ? found.modified : null, directory: found.directory === true };
|
|
152
|
+
},
|
|
153
|
+
move: (from: string, to: string): Promise<void> => files({ action: 'move', from, to }).then(() => undefined),
|
|
154
|
+
copy: (from: string, to: string): Promise<void> => files({ action: 'copy', from, to }).then(() => undefined),
|
|
155
|
+
// AVPlayer and UIImage read the file themselves.
|
|
156
|
+
loadURL: async (url: string): Promise<string> => url,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
108
160
|
/** The environment fields the host answers, shaped as the web renderer's `HostRequestEnvironment`. */
|
|
109
161
|
export function nativeHostEnvironment(): Record<string, unknown> {
|
|
162
|
+
const directories = appDirectories();
|
|
110
163
|
return {
|
|
164
|
+
...(directories ? { files: nativeFiles(directories) } : {}),
|
|
111
165
|
share: async (data: { title?: string; text?: string; url?: string; files?: SimpleFile[] }): Promise<void> => {
|
|
112
166
|
const params: Record<string, unknown> = {};
|
|
113
167
|
if (data.title) params['title'] = data.title;
|
|
@@ -136,12 +190,26 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
136
190
|
readText: async (): Promise<string> => (await hostRequest<{ text?: string }>('clipboard', { action: 'readText' }))?.text ?? '',
|
|
137
191
|
},
|
|
138
192
|
pickColor: async (color: string): Promise<string | null> => (await hostRequest<{ color?: string }>('pickColor', { color }))?.color ?? null,
|
|
193
|
+
// `AVAudioRecorder` (AudioRecorderBridge.swift): a recording ends as a file URL in Documents; `trim` writes another.
|
|
194
|
+
audioRecorder: {
|
|
195
|
+
start: (): Promise<void> => hostRequest('audioRecord', { action: 'start' }).then(() => undefined),
|
|
196
|
+
pause: (): Promise<void> => hostRequest('audioRecord', { action: 'pause' }).then(() => undefined),
|
|
197
|
+
resume: (): Promise<void> => hostRequest('audioRecord', { action: 'resume' }).then(() => undefined),
|
|
198
|
+
stop: (): Promise<{ url: string; duration: number; type: string }> => hostRequest('audioRecord', { action: 'stop' }),
|
|
199
|
+
cancel: (): Promise<void> => hostRequest('audioRecord', { action: 'cancel' }).then(() => undefined),
|
|
200
|
+
meter: (): Promise<{ level: number; time: number }> => hostRequest('audioRecord', { action: 'meter' }),
|
|
201
|
+
discard: (url: string): Promise<void> => hostRequest('audioRecord', { action: 'discard', url }).then(() => undefined),
|
|
202
|
+
trim: (url: string, keep: readonly { start: number; end: number }[]): Promise<{ url: string; duration: number; type: string }> =>
|
|
203
|
+
hostRequest('audioRecord', { action: 'trim', url, keep: keep.map((range) => ({ start: range.start, end: range.end })) }),
|
|
204
|
+
},
|
|
139
205
|
};
|
|
140
206
|
}
|
|
141
207
|
|
|
142
208
|
/** Fills what `environment` lacks with the host's answers; what the page has stays. */
|
|
143
209
|
export function installHostEnvironment(environment: Record<string, unknown>): void {
|
|
144
210
|
if (!bridge) return;
|
|
211
|
+
const undo = nativeUndoHost();
|
|
212
|
+
if (undo && environment['undoManager'] === undefined) environment['undoManager'] = undo;
|
|
145
213
|
for (const [key, value] of Object.entries(nativeHostEnvironment())) {
|
|
146
214
|
if (environment[key] === undefined) environment[key] = value;
|
|
147
215
|
}
|
package/src/index.ts
CHANGED
|
@@ -27,4 +27,5 @@ export { SimpleTextEncoder, SimpleTextDecoder, base64Encode, base64Decode, utf8E
|
|
|
27
27
|
export { createWorker, NativeWorker, encodeMessage, decodeMessage, workerName, type NativeWorkerOptions, type WorkerLike, type WorkerMessageEvent, type WorkerErrorEvent } from './worker';
|
|
28
28
|
export { onOpenURL } from './openURL';
|
|
29
29
|
export { onNotification, registerForPush, type NotificationEvent, type NativeNotificationOptions, type RegisterForPushOptions } from './notifications';
|
|
30
|
+
export { createUndoHost, nativeUndoHost } from './undo';
|
|
30
31
|
export { createMediaSession, NativeMediaMetadata, type NativeMediaSession, type MediaMetadataInit, type MediaActionDetails, type MediaActionHandler, type MediaPositionState } from './mediaSession';
|
package/src/run.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* ran and its pass was applied (docs/native.md, "Synchronous calls").
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { createSwiftUIRoot, type SwiftUIRoot } from '@swiftbrowser/react';
|
|
11
|
+
import { createSwiftUIRoot, installTextMeasurer, type SwiftUIRoot, type TextMeasurerFunction } from '@swiftbrowser/react';
|
|
12
12
|
import type { Op, RendererEvent } from '@swiftbrowser/web/src/protocol';
|
|
13
13
|
import type { ReactNode } from 'react';
|
|
14
14
|
import { browserEnvironment } from '@swiftbrowser/web/src/hostRequests';
|
|
@@ -47,6 +47,15 @@ export function run(element: ReactNode, options: RunOptions = {}): Running {
|
|
|
47
47
|
// What `ShareLink`, `fileImporter` and the clipboard ask the browser for,
|
|
48
48
|
// answered by the host where the page has nothing (hostRequests.ts).
|
|
49
49
|
installHostEnvironment(browserEnvironment as unknown as Record<string, unknown>);
|
|
50
|
+
// `measureText` answers from the host's text system (HostBridge.swift, `measureText`).
|
|
51
|
+
const measurer: TextMeasurerFunction | null = native.measureText
|
|
52
|
+
? (text, font, width) => {
|
|
53
|
+
const size = native.measureText!(text, JSON.stringify(font), width);
|
|
54
|
+
if (!size) throw new Error('measureText: the native host could not measure the text');
|
|
55
|
+
return size;
|
|
56
|
+
}
|
|
57
|
+
: null;
|
|
58
|
+
if (measurer) installTextMeasurer(measurer);
|
|
50
59
|
|
|
51
60
|
if (current) {
|
|
52
61
|
// Fast Refresh (devBuild.ts): the app bundle was evaluated again. Its
|
|
@@ -99,6 +108,7 @@ export function run(element: ReactNode, options: RunOptions = {}): Running {
|
|
|
99
108
|
dispatch,
|
|
100
109
|
unmount() {
|
|
101
110
|
root.unmount();
|
|
111
|
+
if (measurer) installTextMeasurer(measurer, true);
|
|
102
112
|
if (g['__sbDispatch']) delete g['__sbDispatch'];
|
|
103
113
|
current = null;
|
|
104
114
|
},
|
package/src/runtime.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* bridge's `__sbImageProbed`, `Worker` (worker.ts) with the bridge's
|
|
17
17
|
* `__sbWorkerMessage` and `__sbWorkerError`, `__sbOpenURL` (openURL.ts),
|
|
18
18
|
* `__sbNotificationPermission`, `__sbPushRegistered` and `__sbNotification` (notifications.ts),
|
|
19
|
+
* `__sbUndoCommand` (undo.ts, on a bridge with `undoState`),
|
|
19
20
|
* and the bridge's callbacks `__sbTimerFired` and `__sbFetchResult`. In a
|
|
20
21
|
* worker's own context (its bridge has
|
|
21
22
|
* `workerPostMessage`), `self`, `postMessage`, `addEventListener` and
|
|
@@ -38,6 +39,7 @@ import { setBodyRandomness } from './fetch';
|
|
|
38
39
|
import { NativeWebSocket } from './webSocket';
|
|
39
40
|
import { createSubtleCrypto } from './subtle';
|
|
40
41
|
import { matchMedia } from './matchMedia';
|
|
42
|
+
import { createUndoHost, installUndoHost } from './undo';
|
|
41
43
|
import { installHostRequests, receiveHostResponse } from './hostRequests';
|
|
42
44
|
|
|
43
45
|
type AnyGlobal = Record<string, unknown>;
|
|
@@ -205,10 +207,19 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
205
207
|
|
|
206
208
|
// Host requests (hostRequests.ts): the share sheet, the pickers, the pasteboard, a sign-in.
|
|
207
209
|
if (native.hostRequest && !native.workerPostMessage) {
|
|
208
|
-
installHostRequests({ hostRequest: native.hostRequest.bind(native) });
|
|
210
|
+
installHostRequests({ hostRequest: native.hostRequest.bind(native), ...(native.appDirectories ? { appDirectories: native.appDirectories.bind(native) } : {}) });
|
|
209
211
|
define('__sbHostResponse', receiveHostResponse);
|
|
210
212
|
}
|
|
211
213
|
|
|
214
|
+
// The window's undo manager (undo.ts), mirroring the app's: the system's undo comes to `__sbUndoCommand`.
|
|
215
|
+
if (native.undoState && !native.workerPostMessage) {
|
|
216
|
+
const undo = createUndoHost({ undoState: native.undoState.bind(native) });
|
|
217
|
+
installUndoHost(undo.host);
|
|
218
|
+
define('__sbUndoCommand', undo.receive);
|
|
219
|
+
} else {
|
|
220
|
+
installUndoHost(null);
|
|
221
|
+
}
|
|
222
|
+
|
|
212
223
|
// `Image`, for code that loads a picture to learn its size (React's
|
|
213
224
|
// `AsyncImage`): the host reads it through its image loader.
|
|
214
225
|
if (native.probeImage && !native.workerPostMessage) {
|
package/src/site.ts
CHANGED
|
@@ -26,8 +26,8 @@ export interface SiteOptions extends Omit<BuildOptions, 'outfile'> {
|
|
|
26
26
|
/**
|
|
27
27
|
* The site's app name (letters, digits, `_` and `-`): the module's file
|
|
28
28
|
* name and the document title. The entry's project folder's name by default
|
|
29
|
-
* (`
|
|
30
|
-
* app out), else `App`.
|
|
29
|
+
* (`src/index.tsx` → the folder holding `src`, as `native init` lays an
|
|
30
|
+
* app out; `js/src/index.tsx` → the folder holding `js`), else `App`.
|
|
31
31
|
*/
|
|
32
32
|
name?: string;
|
|
33
33
|
/** Shown as the document title and in the web manifest; `name` by default. */
|
|
@@ -59,7 +59,7 @@ export interface SiteResult {
|
|
|
59
59
|
|
|
60
60
|
const APP_NAME = /^[A-Za-z0-9_-]+$/;
|
|
61
61
|
|
|
62
|
-
/** The app name an entry suggests: the folder holding its `js/`
|
|
62
|
+
/** The app name an entry suggests: the folder holding its `src/` (or an older layout's `js/`), else the entry's own folder. */
|
|
63
63
|
export function siteNameFor(entry: string): string {
|
|
64
64
|
const dir = path.dirname(path.resolve(entry));
|
|
65
65
|
const parts = dir.split(path.sep);
|
package/src/undo.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The window's `UndoManager` for the app's undo manager (`useUndoManager`
|
|
3
|
+
* in @swiftbrowser/react): the host of the renderer environment's
|
|
4
|
+
* `undoManager` (Web/src/undo.ts) in the native app. The stack stays in
|
|
5
|
+
* JavaScript, the truth; the host mirrors what it can do, so the system's
|
|
6
|
+
* undo reaches it: each change goes to the bridge's `undoState(json)` as
|
|
7
|
+
* `{ canUndo, canRedo, undoActionName, redoActionName }` (UndoBridge.swift
|
|
8
|
+
* keeps one proxy action per side on the window's manager), and a shake, a
|
|
9
|
+
* three-finger swipe, ⌘Z or the Edit menu comes back as
|
|
10
|
+
* `__sbUndoCommand('undo' | 'redo')`. In the page (the round trip) the
|
|
11
|
+
* page's keyboard host is there already and nothing is installed over it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { UndoCommands, UndoHost, UndoState } from '@swiftbrowser/web/src/undo';
|
|
15
|
+
import type { NativeBridge } from './bridge';
|
|
16
|
+
|
|
17
|
+
let installed: UndoHost | null = null;
|
|
18
|
+
|
|
19
|
+
export function createUndoHost(native: Required<Pick<NativeBridge, 'undoState'>>): { host: UndoHost; receive: (command: string) => void } {
|
|
20
|
+
let commands: UndoCommands | null = null;
|
|
21
|
+
const host: UndoHost = {
|
|
22
|
+
update(state: UndoState) {
|
|
23
|
+
native.undoState(JSON.stringify(state));
|
|
24
|
+
},
|
|
25
|
+
listen(given) {
|
|
26
|
+
commands = given;
|
|
27
|
+
return () => {
|
|
28
|
+
if (commands === given) commands = null;
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const receive = (command: string): void => {
|
|
33
|
+
if (command === 'undo') commands?.undo();
|
|
34
|
+
else if (command === 'redo') commands?.redo();
|
|
35
|
+
};
|
|
36
|
+
return { host, receive };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Keeps the host for `installHostEnvironment` (the runtime, once per install). */
|
|
40
|
+
export function installUndoHost(host: UndoHost | null): void {
|
|
41
|
+
installed = host;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The host the runtime made, or null outside the native app. */
|
|
45
|
+
export function nativeUndoHost(): UndoHost | null {
|
|
46
|
+
return installed;
|
|
47
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#if canImport(SwiftUI)
|
|
2
|
+
import Foundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
#if os(iOS) && !canImport(FoundationBridge)
|
|
5
|
+
import AVFoundation
|
|
6
|
+
|
|
7
|
+
/// The `audioRecord` host request (packages/native/src/hostRequests.ts): the
|
|
8
|
+
/// page's microphone recorder (Web/src/audioRecorder.ts) answered with
|
|
9
|
+
/// `AVAudioRecorder`. `start` asks for the microphone the first time
|
|
10
|
+
/// (`NSMicrophoneUsageDescription` in the app's Info.plist) and records AAC
|
|
11
|
+
/// into `Documents/Recordings`; `meter` answers the input's level (0 silent
|
|
12
|
+
/// … 1 loud, from -50 dB, as the page meters it) and the seconds recorded;
|
|
13
|
+
/// `pause`, `resume` and `cancel` do as they say; `stop` answers `{ url,
|
|
14
|
+
/// duration, type }`, the file's URL to play the recording at; `discard`
|
|
15
|
+
/// (`url`) deletes one made before (only a file in `Recordings`); `trim`
|
|
16
|
+
/// (`url`, `keep`: `[{ start, end }]` in seconds) writes a new file in
|
|
17
|
+
/// `Recordings` of those parts in order, through an `AVMutableComposition`
|
|
18
|
+
/// exported as AAC, and answers it like `stop` (the original stays).
|
|
19
|
+
@MainActor
|
|
20
|
+
final class AudioRecorderBridge {
|
|
21
|
+
private var recorder: AVAudioRecorder?
|
|
22
|
+
/// Seconds recorded before a pause (the recorder's own clock reads 0 while paused).
|
|
23
|
+
private var pausedAt: TimeInterval?
|
|
24
|
+
|
|
25
|
+
func handle(_ params: JSONObject) async throws -> JSONValue {
|
|
26
|
+
switch params.string("action") ?? "" {
|
|
27
|
+
case "start": try await start()
|
|
28
|
+
case "pause": pause()
|
|
29
|
+
case "resume": try resume()
|
|
30
|
+
case "stop": return try stop()
|
|
31
|
+
case "cancel": cancel()
|
|
32
|
+
case "meter": return meter()
|
|
33
|
+
case "discard": try discard(params.string("url"))
|
|
34
|
+
case "trim": return try await trim(params)
|
|
35
|
+
default: throw HostRequestFailure("unsupported: audioRecord \(params.string("action") ?? "without an action")")
|
|
36
|
+
}
|
|
37
|
+
return .object([:])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private func start() async throws {
|
|
41
|
+
guard recorder == nil else { throw HostRequestFailure("busy: already recording") }
|
|
42
|
+
guard await AVAudioApplication.requestRecordPermission() else {
|
|
43
|
+
throw HostRequestFailure("permission: the microphone was not allowed")
|
|
44
|
+
}
|
|
45
|
+
let session = AVAudioSession.sharedInstance()
|
|
46
|
+
// Recordings play back through the speaker, not the earpiece.
|
|
47
|
+
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
|
|
48
|
+
try session.setActive(true)
|
|
49
|
+
let folder = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
50
|
+
.appendingPathComponent("Recordings", isDirectory: true)
|
|
51
|
+
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
52
|
+
let file = folder.appendingPathComponent("\(UUID().uuidString).m4a")
|
|
53
|
+
let settings: [String: Any] = [
|
|
54
|
+
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
55
|
+
AVSampleRateKey: 44_100,
|
|
56
|
+
AVNumberOfChannelsKey: 1,
|
|
57
|
+
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
|
58
|
+
]
|
|
59
|
+
let recorder = try AVAudioRecorder(url: file, settings: settings)
|
|
60
|
+
recorder.isMeteringEnabled = true
|
|
61
|
+
guard recorder.record() else { throw HostRequestFailure("unavailable: the recording did not start") }
|
|
62
|
+
self.recorder = recorder
|
|
63
|
+
pausedAt = nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private func active() throws -> AVAudioRecorder {
|
|
67
|
+
guard let recorder else { throw HostRequestFailure("idle: not recording") }
|
|
68
|
+
return recorder
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private func pause() {
|
|
72
|
+
guard let recorder, recorder.isRecording else { return }
|
|
73
|
+
pausedAt = recorder.currentTime
|
|
74
|
+
recorder.pause()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func resume() throws {
|
|
78
|
+
let recorder = try active()
|
|
79
|
+
guard !recorder.isRecording else { return }
|
|
80
|
+
_ = recorder.record()
|
|
81
|
+
pausedAt = nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func stop() throws -> JSONValue {
|
|
85
|
+
let recorder = try active()
|
|
86
|
+
let duration = pausedAt ?? recorder.currentTime
|
|
87
|
+
recorder.stop()
|
|
88
|
+
self.recorder = nil
|
|
89
|
+
pausedAt = nil
|
|
90
|
+
return .object(["url": .string(recorder.url.absoluteString), "duration": .number(duration), "type": .string("audio/mp4")])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private func cancel() {
|
|
94
|
+
guard let recorder else { return }
|
|
95
|
+
recorder.stop()
|
|
96
|
+
_ = recorder.deleteRecording()
|
|
97
|
+
self.recorder = nil
|
|
98
|
+
pausedAt = nil
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func discard(_ url: String?) throws {
|
|
102
|
+
guard let url, let parsed = URL(string: url), parsed.isFileURL else { return }
|
|
103
|
+
let file = AppFiles.current(parsed)
|
|
104
|
+
guard file.deletingLastPathComponent().lastPathComponent == "Recordings" else {
|
|
105
|
+
throw HostRequestFailure("unsupported: only a recording's own file can be discarded")
|
|
106
|
+
}
|
|
107
|
+
if FileManager.default.fileExists(atPath: file.path) { try FileManager.default.removeItem(at: file) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func trim(_ params: JSONObject) async throws -> JSONValue {
|
|
111
|
+
guard let url = params.string("url"), let parsed = URL(string: url), parsed.isFileURL else {
|
|
112
|
+
throw HostRequestFailure("unsupported: only a recording's own file can be trimmed")
|
|
113
|
+
}
|
|
114
|
+
let file = AppFiles.current(parsed)
|
|
115
|
+
let folder = file.deletingLastPathComponent()
|
|
116
|
+
guard folder.lastPathComponent == "Recordings", FileManager.default.fileExists(atPath: file.path) else {
|
|
117
|
+
throw HostRequestFailure("unsupported: only a recording's own file can be trimmed")
|
|
118
|
+
}
|
|
119
|
+
let ranges = AudioTrim.ranges(params.array("keep") ?? [])
|
|
120
|
+
guard !ranges.isEmpty else { throw HostRequestFailure("empty: nothing of the recording was kept") }
|
|
121
|
+
let output = folder.appendingPathComponent("\(UUID().uuidString).m4a")
|
|
122
|
+
let duration = try await Self.export(file, keeping: ranges, to: output)
|
|
123
|
+
return .object(["url": .string(output.absoluteString), "duration": .number(duration), "type": .string("audio/mp4")])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Writes the parts of `source` in `ranges` (seconds, in order) to
|
|
127
|
+
/// `output` as AAC in M4A and answers the seconds written. Off the main
|
|
128
|
+
/// actor: the composition, the asset and the export session are made
|
|
129
|
+
/// and awaited here, so none crosses an isolation boundary.
|
|
130
|
+
nonisolated private static func export(_ source: URL, keeping ranges: [AudioTrim.Part], to output: URL) async throws -> Double {
|
|
131
|
+
let asset = AVURLAsset(url: source)
|
|
132
|
+
let total = try await asset.load(.duration)
|
|
133
|
+
let composition = AVMutableComposition()
|
|
134
|
+
var cursor = CMTime.zero
|
|
135
|
+
for range in ranges {
|
|
136
|
+
let start = CMTime(seconds: range.start, preferredTimescale: 44_100)
|
|
137
|
+
let end = CMTimeMinimum(CMTime(seconds: range.end, preferredTimescale: 44_100), total)
|
|
138
|
+
guard end > start else { continue }
|
|
139
|
+
let part = CMTimeRange(start: start, end: end)
|
|
140
|
+
try await composition.insertTimeRange(part, of: asset, at: cursor)
|
|
141
|
+
cursor = cursor + part.duration
|
|
142
|
+
}
|
|
143
|
+
guard cursor > .zero else { throw HostRequestFailure("empty: nothing of the recording was kept") }
|
|
144
|
+
guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
|
|
145
|
+
throw HostRequestFailure("unsupported: the recording cannot be cut on this device")
|
|
146
|
+
}
|
|
147
|
+
if #available(iOS 18, *) {
|
|
148
|
+
try await session.export(to: output, as: .m4a)
|
|
149
|
+
} else {
|
|
150
|
+
session.outputURL = output
|
|
151
|
+
session.outputFileType = .m4a
|
|
152
|
+
await session.export()
|
|
153
|
+
guard session.status == .completed else {
|
|
154
|
+
throw HostRequestFailure("export: \(session.error?.localizedDescription ?? "the recording could not be written")")
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return cursor.seconds
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private func meter() -> JSONValue {
|
|
161
|
+
guard let recorder else { return .object(["level": .number(0), "time": .number(0)]) }
|
|
162
|
+
guard recorder.isRecording else { return .object(["level": .number(0), "time": .number(pausedAt ?? 0)]) }
|
|
163
|
+
recorder.updateMeters()
|
|
164
|
+
let decibels = Double(recorder.averagePower(forChannel: 0))
|
|
165
|
+
return .object(["level": .number(max(0, min(1, (decibels + 50) / 50))), "time": .number(recorder.currentTime)])
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
#endif
|
|
169
|
+
|
|
170
|
+
/// The `keep` of an `audioRecord` `trim`: `[{ start, end }]` in seconds,
|
|
171
|
+
/// each clamped to start at 0 at the earliest, the empty ones dropped, in
|
|
172
|
+
/// time order, and overlapping ones merged, so the kept parts never repeat
|
|
173
|
+
/// a sound (Web/src/audioRecorder.ts's `keptRanges` does the same, with
|
|
174
|
+
/// the recording's length known; here the export clamps the end).
|
|
175
|
+
enum AudioTrim {
|
|
176
|
+
struct Part: Equatable, Sendable {
|
|
177
|
+
var start: Double
|
|
178
|
+
var end: Double
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
static func ranges(_ keep: [JSONValue]) -> [Part] {
|
|
182
|
+
let clamped = keep.compactMap { value -> Part? in
|
|
183
|
+
guard let start = value["start"].double, let end = value["end"].double, start.isFinite, end.isFinite else { return nil }
|
|
184
|
+
let range = Part(start: max(0, start), end: max(0, end))
|
|
185
|
+
return range.end > range.start ? range : nil
|
|
186
|
+
}.sorted { $0.start < $1.start }
|
|
187
|
+
var merged: [Part] = []
|
|
188
|
+
for range in clamped {
|
|
189
|
+
if let last = merged.last, range.start <= last.end {
|
|
190
|
+
merged[merged.count - 1].end = max(last.end, range.end)
|
|
191
|
+
} else {
|
|
192
|
+
merged.append(range)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return merged
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#endif
|