@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.
- package/README.md +99 -11
- package/dist/blob.d.ts +60 -0
- package/dist/blob.d.ts.map +1 -0
- package/dist/blob.js +153 -0
- package/dist/blob.js.map +1 -0
- package/dist/bridge.d.ts +65 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +11 -3
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +26 -2
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +55 -0
- package/dist/hostRequests.d.ts.map +1 -0
- package/dist/hostRequests.js +196 -0
- package/dist/hostRequests.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/matchMedia.d.ts +43 -0
- package/dist/matchMedia.d.ts.map +1 -0
- package/dist/matchMedia.js +63 -0
- package/dist/matchMedia.js.map +1 -0
- package/dist/notifications.d.ts +108 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +251 -0
- package/dist/notifications.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +23 -2
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +69 -0
- package/dist/runtime.js.map +1 -1
- package/dist/secureStorage.d.ts +20 -0
- package/dist/secureStorage.d.ts.map +1 -0
- package/dist/secureStorage.js +28 -0
- package/dist/secureStorage.js.map +1 -0
- 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/subtle.d.ts +38 -0
- package/dist/subtle.d.ts.map +1 -0
- package/dist/subtle.js +72 -0
- package/dist/subtle.js.map +1 -0
- 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/dist/webSocket.d.ts +73 -0
- package/dist/webSocket.d.ts.map +1 -0
- package/dist/webSocket.js +145 -0
- package/dist/webSocket.js.map +1 -0
- package/package.json +3 -3
- package/src/blob.ts +186 -0
- package/src/bridge.ts +74 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +216 -0
- package/src/index.ts +8 -0
- package/src/matchMedia.ts +79 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +21 -2
- package/src/runtime.ts +71 -0
- package/src/secureStorage.ts +39 -0
- package/src/site.ts +3 -3
- package/src/subtle.ts +87 -0
- package/src/undo.ts +47 -0
- package/src/webSocket.ts +175 -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 +211 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
- 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/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
|
+
}
|
package/src/webSocket.ts
ADDED
|
@@ -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
|