@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.
Files changed (60) hide show
  1. package/README.md +20 -9
  2. package/dist/bridge.d.ts +27 -1
  3. package/dist/bridge.d.ts.map +1 -1
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/hostRequests.d.ts +10 -4
  6. package/dist/hostRequests.d.ts.map +1 -1
  7. package/dist/hostRequests.js +65 -3
  8. package/dist/hostRequests.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/run.d.ts.map +1 -1
  14. package/dist/run.js +14 -1
  15. package/dist/run.js.map +1 -1
  16. package/dist/runtime.d.ts +1 -0
  17. package/dist/runtime.d.ts.map +1 -1
  18. package/dist/runtime.js +12 -1
  19. package/dist/runtime.js.map +1 -1
  20. package/dist/site.d.ts +3 -3
  21. package/dist/site.d.ts.map +1 -1
  22. package/dist/site.js +1 -1
  23. package/dist/site.js.map +1 -1
  24. package/dist/undo.d.ts +23 -0
  25. package/dist/undo.d.ts.map +1 -0
  26. package/dist/undo.js +44 -0
  27. package/dist/undo.js.map +1 -0
  28. package/package.json +3 -3
  29. package/src/bridge.ts +26 -1
  30. package/src/hostRequests.ts +72 -4
  31. package/src/index.ts +1 -0
  32. package/src/run.ts +11 -1
  33. package/src/runtime.ts +12 -1
  34. package/src/site.ts +3 -3
  35. package/src/undo.ts +47 -0
  36. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  37. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  38. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +45 -0
  39. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +16 -2
  40. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  41. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  42. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +30 -0
  43. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +347 -12
  44. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  45. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  46. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  47. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  48. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  49. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  52. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  53. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  54. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  55. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  56. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  57. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  58. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  59. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  60. 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
@@ -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.17.8",
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.17.8",
63
- "@swiftbrowser/web": "0.17.8",
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;
@@ -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`), answering
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, leaving what the page has (the
10
- * round trip runs in a browser with a share sheet of its own).
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
- * (`js/src/index.tsx` → the folder holding `js`, as `native init` lays an
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/` project, else the entry's own folder. */
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