@swiftbrowser/native 0.17.7 → 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 (100) hide show
  1. package/README.md +99 -11
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +65 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +55 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +196 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/notifications.d.ts +108 -0
  26. package/dist/notifications.d.ts.map +1 -0
  27. package/dist/notifications.js +251 -0
  28. package/dist/notifications.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +23 -2
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +2 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +69 -0
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/secureStorage.d.ts +20 -0
  37. package/dist/secureStorage.d.ts.map +1 -0
  38. package/dist/secureStorage.js +28 -0
  39. package/dist/secureStorage.js.map +1 -0
  40. package/dist/site.d.ts +3 -3
  41. package/dist/site.d.ts.map +1 -1
  42. package/dist/site.js +1 -1
  43. package/dist/site.js.map +1 -1
  44. package/dist/subtle.d.ts +38 -0
  45. package/dist/subtle.d.ts.map +1 -0
  46. package/dist/subtle.js +72 -0
  47. package/dist/subtle.js.map +1 -0
  48. package/dist/undo.d.ts +23 -0
  49. package/dist/undo.d.ts.map +1 -0
  50. package/dist/undo.js +44 -0
  51. package/dist/undo.js.map +1 -0
  52. package/dist/webSocket.d.ts +73 -0
  53. package/dist/webSocket.d.ts.map +1 -0
  54. package/dist/webSocket.js +145 -0
  55. package/dist/webSocket.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/blob.ts +186 -0
  58. package/src/bridge.ts +74 -1
  59. package/src/fetch.ts +29 -3
  60. package/src/hostRequests.ts +216 -0
  61. package/src/index.ts +8 -0
  62. package/src/matchMedia.ts +79 -0
  63. package/src/notifications.ts +317 -0
  64. package/src/run.ts +21 -2
  65. package/src/runtime.ts +71 -0
  66. package/src/secureStorage.ts +39 -0
  67. package/src/site.ts +3 -3
  68. package/src/subtle.ts +87 -0
  69. package/src/undo.ts +47 -0
  70. package/src/webSocket.ts +175 -0
  71. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  72. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  73. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
  75. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  78. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  79. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  80. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  81. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
  82. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  90. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  91. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  92. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  93. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  94. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  95. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  96. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  97. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  98. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  99. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  100. package/wasm/SwiftBrowserNative.wasm +2 -2
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,175 @@
1
+ /**
2
+ * `WebSocket` for the native app over the bridge's `wsOpen`, `wsSend` and
3
+ * `wsClose` (`URLSessionWebSocketTask` on the Swift side). What the socket
4
+ * receives arrives at `__sbWebSocketEvent(id, json)`: `{ type: "open",
5
+ * protocol }`, `{ type: "message", text }` or `{ type: "message", binary }`
6
+ * (base64), `{ type: "error", message }` and `{ type: "close", code, reason,
7
+ * wasClean }`. The browser's event model as apps use it: `onopen`,
8
+ * `onmessage`, `onerror`, `onclose`, `addEventListener`, `readyState`,
9
+ * `binaryType` (`blob` by default, or `arraybuffer`).
10
+ */
11
+
12
+ import { SimpleBlob } from './blob';
13
+ import type { NativeBridge } from './bridge';
14
+ import { base64Decode, base64Encode } from './encoding';
15
+
16
+ export interface WebSocketEventLike {
17
+ type: string;
18
+ target: NativeWebSocket;
19
+ data?: unknown;
20
+ code?: number;
21
+ reason?: string;
22
+ wasClean?: boolean;
23
+ message?: string;
24
+ }
25
+
26
+ type Handler = (event: WebSocketEventLike) => void;
27
+
28
+ /** The bridge's event for socket `id`. */
29
+ export interface NativeWebSocketEvent {
30
+ type: 'open' | 'message' | 'error' | 'close';
31
+ protocol?: string;
32
+ text?: string;
33
+ /** Base64. */
34
+ binary?: string;
35
+ message?: string;
36
+ code?: number;
37
+ reason?: string;
38
+ wasClean?: boolean;
39
+ }
40
+
41
+ export type WebSocketBridge = Required<Pick<NativeBridge, 'wsOpen' | 'wsSend' | 'wsClose'>>;
42
+
43
+ const sockets = new Map<number, NativeWebSocket>();
44
+ let nextId = 1;
45
+ let bridge: WebSocketBridge | null = null;
46
+
47
+ export class NativeWebSocket {
48
+ static readonly CONNECTING = 0;
49
+ static readonly OPEN = 1;
50
+ static readonly CLOSING = 2;
51
+ static readonly CLOSED = 3;
52
+ readonly CONNECTING = 0;
53
+ readonly OPEN = 1;
54
+ readonly CLOSING = 2;
55
+ readonly CLOSED = 3;
56
+
57
+ readonly url: string;
58
+ readyState = 0;
59
+ protocol = '';
60
+ extensions = '';
61
+ bufferedAmount = 0;
62
+ binaryType: 'blob' | 'arraybuffer' = 'blob';
63
+ onopen: Handler | null = null;
64
+ onmessage: Handler | null = null;
65
+ onerror: Handler | null = null;
66
+ onclose: Handler | null = null;
67
+
68
+ private readonly id: number;
69
+ private readonly listeners = new Map<string, Set<Handler>>();
70
+
71
+ constructor(url: string | { href: string }, protocols: string | string[] = []) {
72
+ if (!bridge) throw new TypeError('WebSocket: no native bridge');
73
+ const href = typeof url === 'string' ? url : url.href;
74
+ if (!/^wss?:\/\//i.test(href)) throw new SyntaxError(`WebSocket: the URL's scheme must be ws or wss: ${href}`);
75
+ this.url = href;
76
+ this.id = nextId++;
77
+ sockets.set(this.id, this);
78
+ bridge.wsOpen(this.id, href, typeof protocols === 'string' ? [protocols] : [...protocols]);
79
+ }
80
+
81
+ send(data: string | ArrayBuffer | ArrayBufferView | SimpleBlob): void {
82
+ if (this.readyState === NativeWebSocket.CONNECTING) throw new DOMExceptionLike('InvalidStateError', 'WebSocket: still connecting');
83
+ if (this.readyState !== NativeWebSocket.OPEN) return;
84
+ if (typeof data === 'string') {
85
+ bridge?.wsSend(this.id, JSON.stringify({ text: data }));
86
+ return;
87
+ }
88
+ const bytes = data instanceof SimpleBlob ? data.bytes() : data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
89
+ bridge?.wsSend(this.id, JSON.stringify({ binary: base64Encode(bytes) }));
90
+ }
91
+
92
+ close(code?: number, reason?: string): void {
93
+ if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999)) throw new DOMExceptionLike('InvalidAccessError', 'WebSocket: the close code must be 1000 or in 3000–4999');
94
+ if (this.readyState === NativeWebSocket.CLOSING || this.readyState === NativeWebSocket.CLOSED) return;
95
+ this.readyState = NativeWebSocket.CLOSING;
96
+ bridge?.wsClose(this.id, code ?? 1000, reason ?? '');
97
+ }
98
+
99
+ addEventListener(type: string, handler: Handler): void {
100
+ let set = this.listeners.get(type);
101
+ if (!set) {
102
+ set = new Set();
103
+ this.listeners.set(type, set);
104
+ }
105
+ set.add(handler);
106
+ }
107
+
108
+ removeEventListener(type: string, handler: Handler): void {
109
+ this.listeners.get(type)?.delete(handler);
110
+ }
111
+
112
+ dispatchEvent(event: WebSocketEventLike): boolean {
113
+ const own = { open: this.onopen, message: this.onmessage, error: this.onerror, close: this.onclose }[event.type];
114
+ own?.(event);
115
+ for (const handler of this.listeners.get(event.type) ?? []) handler(event);
116
+ return true;
117
+ }
118
+
119
+ /** The bridge's event for this socket. */
120
+ receive(event: NativeWebSocketEvent): void {
121
+ switch (event.type) {
122
+ case 'open':
123
+ if (this.readyState !== NativeWebSocket.CONNECTING) return;
124
+ this.readyState = NativeWebSocket.OPEN;
125
+ this.protocol = event.protocol ?? '';
126
+ this.dispatchEvent({ type: 'open', target: this });
127
+ return;
128
+ case 'message': {
129
+ if (this.readyState !== NativeWebSocket.OPEN) return;
130
+ let data: unknown = event.text ?? '';
131
+ if (event.binary !== undefined) {
132
+ const bytes = base64Decode(event.binary);
133
+ data = this.binaryType === 'arraybuffer' ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : new SimpleBlob([bytes]);
134
+ }
135
+ this.dispatchEvent({ type: 'message', target: this, data });
136
+ return;
137
+ }
138
+ case 'error':
139
+ this.dispatchEvent({ type: 'error', target: this, message: event.message ?? '' });
140
+ return;
141
+ case 'close':
142
+ if (this.readyState === NativeWebSocket.CLOSED) return;
143
+ this.readyState = NativeWebSocket.CLOSED;
144
+ sockets.delete(this.id);
145
+ this.dispatchEvent({ type: 'close', target: this, code: event.code ?? 1006, reason: event.reason ?? '', wasClean: event.wasClean ?? false });
146
+ return;
147
+ }
148
+ }
149
+
150
+ /** The bridge's `__sbWebSocketEvent(id, json)`. */
151
+ static receive(id: number, json: string | NativeWebSocketEvent): void {
152
+ const socket = sockets.get(id);
153
+ if (!socket) return;
154
+ let event: NativeWebSocketEvent;
155
+ try {
156
+ event = typeof json === 'string' ? (JSON.parse(json) as NativeWebSocketEvent) : json;
157
+ } catch {
158
+ return;
159
+ }
160
+ socket.receive(event);
161
+ }
162
+
163
+ /** Points the sockets at the bridge (the runtime, once per install). */
164
+ static install(native: WebSocketBridge): void {
165
+ bridge = native;
166
+ }
167
+ }
168
+
169
+ /** A `DOMException` stand-in: an error with the browser's name. */
170
+ class DOMExceptionLike extends Error {
171
+ constructor(name: string, message: string) {
172
+ super(message);
173
+ this.name = name;
174
+ }
175
+ }
@@ -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
@@ -0,0 +1,154 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+
4
+ #if !canImport(FoundationBridge)
5
+
6
+ /// The app's files for the page's `fileManager` (@swiftbrowser/react): the
7
+ /// `files` host requests (packages/native/src/hostRequests.ts), answered with
8
+ /// Foundation's `FileManager` in the app's sandbox. A URL must be in one of
9
+ /// the app's directories (`Documents`, `Library`, `tmp`); bytes cross the
10
+ /// bridge as base64; the standard directories go to the page through the
11
+ /// bridge's `appDirectories()`. Failures are `kind: detail`, as the page's own
12
+ /// host fails: `unsupported`, `notFound`, `exists`, `isDirectory`,
13
+ /// `notDirectory`.
14
+ struct FilesBridge: Sendable {
15
+ /// Not stored: a struct without stored properties is `Sendable`, and `handle` runs off the main actor.
16
+ private var manager: FileManager { .default }
17
+
18
+ /// The standard directories, made where they are not yet (`Application Support` is not, on iOS).
19
+ static func directories() -> JSONValue {
20
+ let manager = FileManager.default
21
+ let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first!
22
+ let support = manager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
23
+ let caches = manager.urls(for: .cachesDirectory, in: .userDomainMask).first!
24
+ for directory in [documents, support, caches] {
25
+ try? manager.createDirectory(at: directory, withIntermediateDirectories: true)
26
+ }
27
+ return .object([
28
+ "documents": .string(documents.standardizedFileURL.absoluteString),
29
+ "applicationSupport": .string(support.standardizedFileURL.absoluteString),
30
+ "caches": .string(caches.standardizedFileURL.absoluteString),
31
+ "temporary": .string(manager.temporaryDirectory.standardizedFileURL.absoluteString),
32
+ ])
33
+ }
34
+
35
+ /// Where the app may read and write: its documents, its library (Application Support, Caches) and its temporary directory.
36
+ private var roots: [URL] {
37
+ var roots = [URL]()
38
+ for directory in [FileManager.SearchPathDirectory.documentDirectory, .libraryDirectory] {
39
+ if let url = manager.urls(for: directory, in: .userDomainMask).first { roots.append(url.standardizedFileURL) }
40
+ }
41
+ roots.append(manager.temporaryDirectory.standardizedFileURL)
42
+ return roots
43
+ }
44
+
45
+ /// The file URL `params[key]` names, under today's container, inside the app's directories.
46
+ private func url(_ params: JSONObject, _ key: String) throws -> URL {
47
+ guard let string = params[key]?.string, let parsed = URL(string: string) else {
48
+ throw HostRequestFailure("unsupported: files: \(key) is not a URL")
49
+ }
50
+ guard parsed.isFileURL else { throw HostRequestFailure("unsupported: \(string) is not a file URL") }
51
+ let url = AppFiles.current(parsed).standardizedFileURL
52
+ let path = url.path
53
+ guard roots.contains(where: { path == $0.path || path.hasPrefix($0.path + "/") }) else {
54
+ throw HostRequestFailure("unsupported: \(string) is not in the app's directories")
55
+ }
56
+ return url
57
+ }
58
+
59
+ private func isDirectory(_ url: URL) -> Bool? {
60
+ var directory: ObjCBool = false
61
+ guard manager.fileExists(atPath: url.path, isDirectory: &directory) else { return nil }
62
+ return directory.boolValue
63
+ }
64
+
65
+ private func requireDirectory(_ directory: URL, for url: URL) throws {
66
+ switch isDirectory(directory) {
67
+ case nil: throw HostRequestFailure("notFound: no directory at \(directory.absoluteString) (for \(url.absoluteString))")
68
+ case false?: throw HostRequestFailure("notDirectory: \(directory.absoluteString) is a file")
69
+ case true?: break
70
+ }
71
+ }
72
+
73
+ func handle(_ params: JSONObject) async throws -> JSONValue {
74
+ switch params["action"]?.string {
75
+ case "read":
76
+ let url = try url(params, "url")
77
+ switch isDirectory(url) {
78
+ case nil: throw HostRequestFailure("notFound: no file at \(url.absoluteString)")
79
+ case true?: throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory")
80
+ case false?: break
81
+ }
82
+ let data = try Data(contentsOf: url)
83
+ return .object(["data": .string(data.base64EncodedString())])
84
+ case "write":
85
+ let url = try url(params, "url")
86
+ guard let data = Data(base64Encoded: params["data"]?.string ?? "") else {
87
+ throw HostRequestFailure("unsupported: files: data is not base64")
88
+ }
89
+ if isDirectory(url) == true { throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory") }
90
+ try requireDirectory(url.deletingLastPathComponent(), for: url)
91
+ try data.write(to: url, options: .atomic)
92
+ return .object([:])
93
+ case "remove":
94
+ let url = try url(params, "url")
95
+ guard isDirectory(url) != nil else { throw HostRequestFailure("notFound: nothing at \(url.absoluteString)") }
96
+ guard !roots.contains(where: { $0.path == url.path }) else {
97
+ throw HostRequestFailure("unsupported: \(url.absoluteString) is one of the app's directories")
98
+ }
99
+ try manager.removeItem(at: url)
100
+ return .object([:])
101
+ case "createDirectory":
102
+ let url = try url(params, "url")
103
+ if isDirectory(url) == false { throw HostRequestFailure("exists: \(url.absoluteString) is a file") }
104
+ try manager.createDirectory(at: url, withIntermediateDirectories: true)
105
+ return .object([:])
106
+ case "list":
107
+ let url = try url(params, "url")
108
+ try requireDirectory(url, for: url)
109
+ let names = try manager.contentsOfDirectory(atPath: url.path).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
110
+ return .object(["urls": .array(names.map { .string(url.appendingPathComponent($0).absoluteString) })])
111
+ case "attributes":
112
+ let url = try url(params, "url")
113
+ guard let directory = isDirectory(url) else { return .null }
114
+ let attributes = try manager.attributesOfItem(atPath: url.path)
115
+ let size = directory ? 0 : ((attributes[.size] as? NSNumber)?.doubleValue ?? 0)
116
+ let modified = (attributes[.modificationDate] as? Date).map { JSONValue.number(($0.timeIntervalSince1970 * 1000).rounded()) } ?? .null
117
+ return .object(["size": .number(size), "modified": modified, "directory": .bool(directory)])
118
+ case "move", "copy":
119
+ let from = try url(params, "from")
120
+ let to = try url(params, "to")
121
+ guard isDirectory(from) != nil else { throw HostRequestFailure("notFound: nothing at \(from.absoluteString)") }
122
+ guard isDirectory(to) == nil else { throw HostRequestFailure("exists: something is at \(to.absoluteString) already") }
123
+ try requireDirectory(to.deletingLastPathComponent(), for: to)
124
+ if to.path.hasPrefix(from.path + "/") { throw HostRequestFailure("unsupported: \(to.absoluteString) is inside \(from.absoluteString)") }
125
+ if params["action"]?.string == "move" { try manager.moveItem(at: from, to: to) } else { try manager.copyItem(at: from, to: to) }
126
+ return .object([:])
127
+ default:
128
+ throw HostRequestFailure("unsupported: files: unknown action \(params["action"]?.string ?? "")")
129
+ }
130
+ }
131
+ }
132
+
133
+ #endif
134
+
135
+ /// Files the app keeps (a recording's, `fileManager`'s) are named by their
136
+ /// URL, and iOS moves an app's container when it is installed again: a file
137
+ /// URL into an old `Documents` that is not there anymore is the same path
138
+ /// under today's. (Against the shim nothing moves.)
139
+ enum AppFiles {
140
+ static func current(_ url: URL) -> URL {
141
+ #if canImport(FoundationBridge)
142
+ return url
143
+ #else
144
+ let manager = FileManager.default
145
+ guard url.isFileURL, !manager.fileExists(atPath: url.path) else { return url }
146
+ guard let range = url.path.range(of: "/Documents/"),
147
+ let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first
148
+ else { return url }
149
+ let moved = documents.appendingPathComponent(String(url.path[range.upperBound...]))
150
+ return manager.fileExists(atPath: moved.path) ? moved : url
151
+ #endif
152
+ }
153
+ }
154
+ #endif