@swiftbrowser/native 0.19.0 → 0.21.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 +34 -11
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +33 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +19 -11
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +105 -41
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +3 -1
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +47 -6
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime.js +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +13 -1
- package/dist/webSocket.js.map +1 -1
- package/package.json +3 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +27 -2
- package/src/fetch.ts +99 -41
- package/src/hostRequests.ts +62 -8
- package/src/index.ts +1 -2
- package/src/runtime.ts +1 -1
- package/src/webSocket.ts +10 -1
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +122 -19
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1247 -209
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +314 -19
- package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +11 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
package/src/fetch.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `fetch`, `Headers`, `Request`, `Response` and `AbortController` over the
|
|
3
|
-
* bridge's `fetch` primitive (`URLSession` on the Swift side).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* bridge's `fetch` primitive (`URLSession` on the Swift side). A request
|
|
4
|
+
* body is a list of parts (blob.ts): bytes here, or ranges of files on disk
|
|
5
|
+
* (picked or dropped files, and blobs and forms holding them), which a host
|
|
6
|
+
* with `fetchStreams` sends as one stream straight from disk, and an older
|
|
7
|
+
* host is given read into memory. A `Response` reads its body back as
|
|
8
|
+
* text, JSON, bytes or a `Blob`; responses are not streamed.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import type { NativeBridge, NativeFetchResult } from './bridge';
|
|
10
|
-
import { encodeMultipart, SimpleBlob, SimpleFormData } from './blob';
|
|
11
|
+
import type { NativeBridge, NativeFetchRequest, NativeFetchResult } from './bridge';
|
|
12
|
+
import { encodeMultipart, partsAreLazy, partsBytes, partSize, SimpleBlob, SimpleFormData, type BodyPart } from './blob';
|
|
11
13
|
import { base64Encode, bridgedBytes, utf8Decode, utf8Encode } from './encoding';
|
|
12
14
|
|
|
13
15
|
type HeadersInitLike = SimpleHeaders | Record<string, string> | [string, string][];
|
|
@@ -129,50 +131,51 @@ export function setBodyRandomness(random: (count: number) => Uint8Array): void {
|
|
|
129
131
|
randomBytes = random;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
/**
|
|
133
|
-
function encodeBody(body: BodyInitLike): {
|
|
134
|
-
if (body === null || body === undefined) return {
|
|
135
|
-
if (typeof body === 'string') return { bytes: utf8Encode(body), contentType: 'text/plain;charset=UTF-8' };
|
|
136
|
-
if (body instanceof SimpleBlob) return {
|
|
134
|
+
/** The content type a body implies when the caller set none, and its parts. */
|
|
135
|
+
function encodeBody(body: BodyInitLike): { parts: BodyPart[] | null; contentType: string | null } {
|
|
136
|
+
if (body === null || body === undefined) return { parts: null, contentType: null };
|
|
137
|
+
if (typeof body === 'string') return { parts: [{ bytes: utf8Encode(body) }], contentType: 'text/plain;charset=UTF-8' };
|
|
138
|
+
if (body instanceof SimpleBlob) return { parts: body.parts(), contentType: body.type || null };
|
|
137
139
|
if (body instanceof SimpleFormData) return encodeMultipart(body, randomBytes);
|
|
138
|
-
if (body instanceof ArrayBuffer) return { bytes: new Uint8Array(body), contentType: null };
|
|
139
|
-
if (ArrayBuffer.isView(body)) return { bytes: new Uint8Array(body.buffer, body.byteOffset, body.byteLength), contentType: null };
|
|
140
|
+
if (body instanceof ArrayBuffer) return { parts: [{ bytes: new Uint8Array(body) }], contentType: null };
|
|
141
|
+
if (ArrayBuffer.isView(body)) return { parts: [{ bytes: new Uint8Array(body.buffer, body.byteOffset, body.byteLength) }], contentType: null };
|
|
140
142
|
if (typeof body === 'object' && Symbol.iterator in body && typeof body.toString === 'function') {
|
|
141
|
-
return { bytes: utf8Encode(body.toString()), contentType: 'application/x-www-form-urlencoded;charset=UTF-8' };
|
|
143
|
+
return { parts: [{ bytes: utf8Encode(body.toString()) }], contentType: 'application/x-www-form-urlencoded;charset=UTF-8' };
|
|
142
144
|
}
|
|
143
|
-
return { bytes: utf8Encode(String(body)), contentType: 'text/plain;charset=UTF-8' };
|
|
145
|
+
return { parts: [{ bytes: utf8Encode(String(body)) }], contentType: 'text/plain;charset=UTF-8' };
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
class Body {
|
|
147
|
-
|
|
149
|
+
/** The body's parts: in memory, or on disk until read. */
|
|
150
|
+
protected parts: BodyPart[] | null;
|
|
148
151
|
bodyUsed = false;
|
|
149
|
-
constructor(
|
|
150
|
-
this.
|
|
152
|
+
constructor(parts: BodyPart[] | null) {
|
|
153
|
+
this.parts = parts;
|
|
151
154
|
}
|
|
152
155
|
get body(): null {
|
|
153
156
|
// No streams; read the body whole with the methods below.
|
|
154
157
|
return null;
|
|
155
158
|
}
|
|
156
|
-
private take(): Uint8Array {
|
|
159
|
+
private async take(): Promise<Uint8Array> {
|
|
157
160
|
if (this.bodyUsed) throw new TypeError('body used already');
|
|
158
161
|
this.bodyUsed = true;
|
|
159
|
-
return this.
|
|
162
|
+
return this.parts ? partsBytes(this.parts) : new Uint8Array(0);
|
|
160
163
|
}
|
|
161
164
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
162
|
-
const bytes = this.take();
|
|
165
|
+
const bytes = await this.take();
|
|
163
166
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
164
167
|
}
|
|
165
168
|
async bytesArray(): Promise<Uint8Array> {
|
|
166
169
|
return this.take();
|
|
167
170
|
}
|
|
168
171
|
async text(): Promise<string> {
|
|
169
|
-
return utf8Decode(this.take());
|
|
172
|
+
return utf8Decode(await this.take());
|
|
170
173
|
}
|
|
171
174
|
async json(): Promise<unknown> {
|
|
172
|
-
return JSON.parse(utf8Decode(this.take()));
|
|
175
|
+
return JSON.parse(utf8Decode(await this.take()));
|
|
173
176
|
}
|
|
174
177
|
async blob(): Promise<SimpleBlob> {
|
|
175
|
-
return new SimpleBlob([this.take()], { type: this.contentType() });
|
|
178
|
+
return new SimpleBlob([await this.take()], { type: this.contentType() });
|
|
176
179
|
}
|
|
177
180
|
/** The body's content type, for `blob()`; a Request or Response reads its headers. */
|
|
178
181
|
protected contentType(): string {
|
|
@@ -196,24 +199,28 @@ export class SimpleRequest extends Body {
|
|
|
196
199
|
|
|
197
200
|
constructor(input: string | SimpleRequest | { href: string }, init: RequestInitLike = {}) {
|
|
198
201
|
const base = input instanceof SimpleRequest ? input : null;
|
|
199
|
-
const {
|
|
200
|
-
super(
|
|
202
|
+
const { parts, contentType } = encodeBody(init.body);
|
|
203
|
+
super(parts ?? base?.rawBody() ?? null);
|
|
201
204
|
this.url = typeof input === 'string' ? input : input instanceof SimpleRequest ? input.url : input.href;
|
|
202
205
|
this.method = (init.method ?? base?.method ?? 'GET').toUpperCase();
|
|
203
206
|
this.headers = new SimpleHeaders(init.headers ?? base?.headers);
|
|
204
207
|
if (contentType && !this.headers.has('content-type')) this.headers.set('content-type', contentType);
|
|
205
208
|
this.signal = init.signal ?? base?.signal ?? new SimpleAbortSignal();
|
|
206
209
|
}
|
|
207
|
-
/** @internal */
|
|
208
|
-
rawBytes(): Uint8Array | null {
|
|
209
|
-
return this.
|
|
210
|
+
/** @internal The body's bytes, read from disk first where they come from there. */
|
|
211
|
+
async rawBytes(): Promise<Uint8Array | null> {
|
|
212
|
+
return this.parts === null ? null : await partsBytes(this.parts);
|
|
213
|
+
}
|
|
214
|
+
/** @internal The body's parts. */
|
|
215
|
+
rawBody(): BodyPart[] | null {
|
|
216
|
+
return this.parts;
|
|
210
217
|
}
|
|
211
218
|
protected override contentType(): string {
|
|
212
219
|
return this.headers.get('content-type') ?? '';
|
|
213
220
|
}
|
|
214
221
|
|
|
215
222
|
clone(): SimpleRequest {
|
|
216
|
-
return new SimpleRequest(this, {
|
|
223
|
+
return new SimpleRequest(this, {});
|
|
217
224
|
}
|
|
218
225
|
}
|
|
219
226
|
|
|
@@ -233,8 +240,8 @@ export class SimpleResponse extends Body {
|
|
|
233
240
|
readonly redirected = false;
|
|
234
241
|
|
|
235
242
|
constructor(body: BodyInitLike = null, init: ResponseInitLike = {}) {
|
|
236
|
-
const {
|
|
237
|
-
super(
|
|
243
|
+
const { parts, contentType } = encodeBody(body);
|
|
244
|
+
super(parts);
|
|
238
245
|
this.status = init.status ?? 200;
|
|
239
246
|
this.statusText = init.statusText ?? '';
|
|
240
247
|
this.headers = new SimpleHeaders(init.headers);
|
|
@@ -249,7 +256,9 @@ export class SimpleResponse extends Body {
|
|
|
249
256
|
}
|
|
250
257
|
|
|
251
258
|
clone(): SimpleResponse {
|
|
252
|
-
|
|
259
|
+
const copy = new SimpleResponse(null, { status: this.status, statusText: this.statusText, headers: this.headers, url: this.url });
|
|
260
|
+
copy.parts = this.parts;
|
|
261
|
+
return copy;
|
|
253
262
|
}
|
|
254
263
|
static json(data: unknown, init: ResponseInitLike = {}): SimpleResponse {
|
|
255
264
|
const response = new SimpleResponse(JSON.stringify(data), init);
|
|
@@ -261,6 +270,22 @@ export class SimpleResponse extends Body {
|
|
|
261
270
|
}
|
|
262
271
|
}
|
|
263
272
|
|
|
273
|
+
/** In-memory parts as one array (none is on disk); one part is itself. */
|
|
274
|
+
function joinBytes(parts: readonly BodyPart[]): Uint8Array {
|
|
275
|
+
const [only] = parts;
|
|
276
|
+
if (parts.length === 1 && only && 'bytes' in only) return only.bytes;
|
|
277
|
+
const total = parts.reduce((sum, part) => sum + partSize(part), 0);
|
|
278
|
+
const out = new Uint8Array(total);
|
|
279
|
+
let at = 0;
|
|
280
|
+
for (const part of parts) {
|
|
281
|
+
if ('bytes' in part) {
|
|
282
|
+
out.set(part.bytes, at);
|
|
283
|
+
at += part.bytes.byteLength;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
264
289
|
export interface FetchGlobals {
|
|
265
290
|
fetch: (input: string | SimpleRequest | { href: string }, init?: RequestInitLike) => Promise<SimpleResponse>;
|
|
266
291
|
Headers: typeof SimpleHeaders;
|
|
@@ -275,9 +300,11 @@ export interface FetchGlobals {
|
|
|
275
300
|
/**
|
|
276
301
|
* The fetch globals over the bridge. With `bytes` (a host that carries
|
|
277
302
|
* typed arrays, docs/notes/bytes.md) bodies cross as views beside the JSON
|
|
278
|
-
* both ways; without, as base64.
|
|
303
|
+
* both ways; without, as base64. With `streams` (the host `fetchStreams()`,
|
|
304
|
+
* which needs `bytes`) a body holding files on disk goes as parts the host
|
|
305
|
+
* streams from there; without, such a body is read into memory first.
|
|
279
306
|
*/
|
|
280
|
-
export function createFetch(native: Pick<NativeBridge, 'fetch'>, options: { bytes?: boolean } = {}): FetchGlobals {
|
|
307
|
+
export function createFetch(native: Pick<NativeBridge, 'fetch' | 'fetchCancel'>, options: { bytes?: boolean; streams?: boolean } = {}): FetchGlobals {
|
|
281
308
|
let nextId = 1;
|
|
282
309
|
const pending = new Map<number, { resolve: (response: SimpleResponse) => void; reject: (error: unknown) => void }>();
|
|
283
310
|
|
|
@@ -290,16 +317,47 @@ export function createFetch(native: Pick<NativeBridge, 'fetch'>, options: { byte
|
|
|
290
317
|
}
|
|
291
318
|
const id = nextId++;
|
|
292
319
|
pending.set(id, { resolve, reject });
|
|
320
|
+
let sent = false;
|
|
293
321
|
request.signal.addEventListener('abort', () => {
|
|
294
|
-
if (pending.delete(id))
|
|
322
|
+
if (!pending.delete(id)) return;
|
|
323
|
+
// The host stops the task where it can (0.20); an older host finishes it and its answer is ignored.
|
|
324
|
+
if (sent) native.fetchCancel?.(id);
|
|
325
|
+
reject(request.signal.reason);
|
|
295
326
|
});
|
|
296
|
-
const bytes = request.rawBytes();
|
|
297
327
|
const head = { url: request.url, method: request.method, headers: request.headers.toObject() };
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
328
|
+
const send = (bytes: Uint8Array | null): void => {
|
|
329
|
+
if (!pending.has(id)) return; // aborted while the body was read
|
|
330
|
+
sent = true;
|
|
331
|
+
if (options.bytes) {
|
|
332
|
+
if (bytes) native.fetch(id, { ...head, body: { $bytes: 0 }, bytes: true }, bytes);
|
|
333
|
+
else native.fetch(id, { ...head, body: null, bytes: true });
|
|
334
|
+
} else {
|
|
335
|
+
native.fetch(id, { ...head, body: bytes ? base64Encode(bytes) : null });
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const parts = request.rawBody();
|
|
339
|
+
if (parts === null || !partsAreLazy(parts)) {
|
|
340
|
+
// Bytes already here go out in this turn (tests and callers see the request at once).
|
|
341
|
+
send(parts === null ? null : joinBytes(parts));
|
|
342
|
+
} else if (options.streams && options.bytes) {
|
|
343
|
+
// Files on disk stay there: the host streams the parts in order, their total length known.
|
|
344
|
+
sent = true;
|
|
345
|
+
const views: Uint8Array[] = [];
|
|
346
|
+
const bodyParts: NonNullable<NativeFetchRequest['bodyParts']> = parts.map((part) => {
|
|
347
|
+
if ('bytes' in part) {
|
|
348
|
+
views.push(part.bytes);
|
|
349
|
+
return { $bytes: views.length - 1 };
|
|
350
|
+
}
|
|
351
|
+
return { url: part.file.url, start: part.file.start, end: part.file.end };
|
|
352
|
+
});
|
|
353
|
+
const headers = { ...head.headers };
|
|
354
|
+
if (!('content-length' in headers)) headers['content-length'] = String(parts.reduce((sum, part) => sum + partSize(part), 0));
|
|
355
|
+
native.fetch(id, { ...head, headers, body: null, bodyParts, bytes: true }, ...views);
|
|
301
356
|
} else {
|
|
302
|
-
|
|
357
|
+
// An older host: the files are read here first.
|
|
358
|
+
partsBytes(parts).then(send, (error: unknown) => {
|
|
359
|
+
if (pending.delete(id)) reject(error instanceof Error ? error : new TypeError(`fetch failed: ${String(error)}`));
|
|
360
|
+
});
|
|
303
361
|
}
|
|
304
362
|
});
|
|
305
363
|
};
|
package/src/hostRequests.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* the standard directories from the bridge's `appDirectories()`.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { objectURL, SimpleBlob, SimpleFile } from './blob';
|
|
28
|
+
import { blobBytes, LazyFile, objectURL, SimpleBlob, SimpleFile } from './blob';
|
|
29
29
|
import type { NativeBridge } from './bridge';
|
|
30
30
|
import { base64Encode, bridgedBytes } from './encoding';
|
|
31
31
|
import { nativeUndoHost } from './undo';
|
|
@@ -36,7 +36,9 @@ export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>> & Pi
|
|
|
36
36
|
export interface BridgedFile {
|
|
37
37
|
name: string;
|
|
38
38
|
type: string;
|
|
39
|
-
|
|
39
|
+
/** The bytes; absent when the file is still on disk, named by `url`. */
|
|
40
|
+
data?: string | Uint8Array;
|
|
41
|
+
url?: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface WebAuthenticateOptions {
|
|
@@ -131,12 +133,33 @@ export function resetHostRequests(): void {
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
async function bridgedFile(file: SimpleBlob & { name?: string }): Promise<BridgedFile> {
|
|
134
|
-
|
|
136
|
+
// A picked file still on disk goes as its URL: the host reads it itself.
|
|
137
|
+
if (file instanceof LazyFile && file.isWhole) return { name: file.name ?? 'file', type: file.type, url: file.url };
|
|
138
|
+
return { name: file.name ?? 'file', type: file.type, data: await blobBytes(file) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A file as a host answers it: bytes in place (`data`), or still on disk (`url`, `size`), read on demand through the files bridge. */
|
|
142
|
+
interface AnsweredFile {
|
|
143
|
+
name: string;
|
|
144
|
+
type: string;
|
|
145
|
+
data?: string | Uint8Array;
|
|
146
|
+
url?: string;
|
|
147
|
+
size?: number;
|
|
148
|
+
lastModified?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Reads a range of a file on disk through the files bridge (FilesBridge.swift `readRange`). */
|
|
152
|
+
async function readFileRange(url: string, start: number, end: number): Promise<Uint8Array> {
|
|
153
|
+
return bridgedBytes((await hostRequest<{ data?: string | Uint8Array }>('files', { action: 'readRange', url, start, end }))?.data ?? '');
|
|
135
154
|
}
|
|
136
155
|
|
|
137
156
|
function filesOf(value: unknown): SimpleFile[] {
|
|
138
|
-
const files = (value as { files?:
|
|
139
|
-
return files.map((file) =>
|
|
157
|
+
const files = (value as { files?: AnsweredFile[] } | undefined)?.files ?? [];
|
|
158
|
+
return files.map((file) => {
|
|
159
|
+
const options = { type: file.type, ...(typeof file.lastModified === 'number' ? { lastModified: file.lastModified } : {}) };
|
|
160
|
+
if (file.data === undefined && typeof file.url === 'string') return LazyFile.make(file.url, file.name, file.size ?? 0, readFileRange, options);
|
|
161
|
+
return new SimpleFile([bridgedBytes(file.data ?? '')], file.name, options);
|
|
162
|
+
});
|
|
140
163
|
}
|
|
141
164
|
|
|
142
165
|
/**
|
|
@@ -205,7 +228,21 @@ function nativeFiles(directories: FileDirectories): Record<string, unknown> {
|
|
|
205
228
|
return () => void fileWatchers.delete(listener);
|
|
206
229
|
},
|
|
207
230
|
read: async (url: string): Promise<Uint8Array> => bridgedBytes((await files<{ data?: string | Uint8Array }>({ action: 'read', url }))?.data ?? ''),
|
|
208
|
-
|
|
231
|
+
// A range read through `FileHandle`, so a large file is read a piece at a time (FilesBridge.swift `readRange`).
|
|
232
|
+
readRange: async (url: string, start: number, end: number): Promise<Uint8Array> =>
|
|
233
|
+
bridgedBytes((await files<{ data?: string | Uint8Array }>({ action: 'readRange', url, start, end }))?.data ?? ''),
|
|
234
|
+
// A file the viewer picked or dropped (a `LazyFile`, still on disk) is copied there by the host (`importItem`); other bytes cross the bridge.
|
|
235
|
+
write: async (url: string, data: Uint8Array | SimpleBlob): Promise<void> => {
|
|
236
|
+
if (data instanceof LazyFile) {
|
|
237
|
+
if (data.isWhole) {
|
|
238
|
+
await files({ action: 'importItem', from: data.url, to: url });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await files({ action: 'write', url, data: await data.bytesAsync() });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await files({ action: 'write', url, data: data instanceof SimpleBlob ? data.bytes() : data });
|
|
245
|
+
},
|
|
209
246
|
remove: (url: string): Promise<void> => files({ action: 'remove', url }).then(() => undefined),
|
|
210
247
|
createDirectory: (url: string): Promise<void> => files({ action: 'createDirectory', url }).then(() => undefined),
|
|
211
248
|
list: async (url: string): Promise<string[]> => (await files<{ urls?: string[] }>({ action: 'list', url }))?.urls ?? [],
|
|
@@ -235,6 +272,9 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
235
272
|
const directories = appDirectories();
|
|
236
273
|
return {
|
|
237
274
|
...(directories ? { files: nativeFiles(directories) } : {}),
|
|
275
|
+
// A file dropped on the app, left on disk by the host (Drawing.swift's drop destination): a `LazyFile` read by range.
|
|
276
|
+
fileFromReference: (item: { type: string; name?: string; path?: string; url: string; size?: number }): SimpleFile =>
|
|
277
|
+
LazyFile.make(item.url, item.path ?? item.name ?? 'file', item.size ?? 0, readFileRange, { type: item.type }),
|
|
238
278
|
share: async (data: { title?: string; text?: string; url?: string; files?: SimpleFile[] }): Promise<void> => {
|
|
239
279
|
const params: Record<string, unknown> = {};
|
|
240
280
|
if (data.title) params['title'] = data.title;
|
|
@@ -250,6 +290,19 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
250
290
|
},
|
|
251
291
|
canShare: () => true,
|
|
252
292
|
download: (blob: SimpleBlob, filename: string): void => {
|
|
293
|
+
// A picked file still on disk goes by URL (the host copies it); bytes go in the same turn.
|
|
294
|
+
const file = blob instanceof LazyFile && blob.isWhole ? { name: filename, type: blob.type, url: blob.url } : null;
|
|
295
|
+
if (file) {
|
|
296
|
+
void hostRequest('share', { files: [file] }).catch(() => undefined);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (blob instanceof LazyFile) {
|
|
300
|
+
void blob
|
|
301
|
+
.bytesAsync()
|
|
302
|
+
.then((data) => hostRequest('share', { files: [{ name: filename, type: blob.type, data }] }))
|
|
303
|
+
.catch(() => undefined);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
253
306
|
void hostRequest('share', { files: [{ name: filename, type: blob.type, data: blob.bytes() }] }).catch(() => undefined);
|
|
254
307
|
},
|
|
255
308
|
pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> =>
|
|
@@ -259,12 +312,13 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
259
312
|
const result = await hostRequest<{ outcome?: string; urls?: string[] } | null>('exportFiles', { files: await Promise.all(files.map(bridgedFile)) });
|
|
260
313
|
return { outcome: result?.outcome ?? 'cancelled', ...(result?.urls ? { urls: result.urls } : {}) };
|
|
261
314
|
},
|
|
262
|
-
|
|
315
|
+
// A picked file still on disk is its own URL (the image loader and AVPlayer read files); bytes become a `data:` URL.
|
|
316
|
+
objectURL: (file: SimpleBlob): string => (file instanceof LazyFile && file.isWhole ? file.url : objectURL(file)),
|
|
263
317
|
clipboard: {
|
|
264
318
|
writeText: (text: string): Promise<void> => hostRequest('clipboard', { action: 'write', text }).then(() => undefined),
|
|
265
319
|
writeImage: async (png: Promise<SimpleBlob>): Promise<void> => {
|
|
266
320
|
const file = await png;
|
|
267
|
-
await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data: file
|
|
321
|
+
await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data: await blobBytes(file) }] });
|
|
268
322
|
},
|
|
269
323
|
readText: async (): Promise<string> => (await hostRequest<{ text?: string }>('clipboard', { action: 'readText' }))?.text ?? '',
|
|
270
324
|
},
|
package/src/index.ts
CHANGED
|
@@ -12,13 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export { run, type RunOptions, type Running } from './run';
|
|
15
|
-
export { Native, type NativeProps } from './native';
|
|
16
15
|
export { callNative, callNativeSync, nativeFunctions, hasNativeFunction, onNativeEvent } from './nativeFunctions';
|
|
17
16
|
export { installRuntime, type InstallOptions } from './runtime';
|
|
18
17
|
export { nativeBridge, launchArguments, type NativeBridge, type NativeFetchRequest, type NativeFetchResult, type WorkerEngine } from './bridge';
|
|
19
18
|
export { SimpleHeaders, SimpleRequest, SimpleResponse, SimpleAbortController, SimpleAbortSignal, createFetch } from './fetch';
|
|
20
19
|
export { SimpleURL, SimpleURLSearchParams } from './url';
|
|
21
|
-
export { SimpleBlob, SimpleFile, SimpleFormData, encodeMultipart, objectURL } from './blob';
|
|
20
|
+
export { LazyFile, SimpleBlob, SimpleFile, SimpleFormData, blobBytes, encodeMultipart, encodeMultipartAsync, objectURL, partsBytes, type BodyPart } from './blob';
|
|
22
21
|
export { NativeWebSocket, type NativeWebSocketEvent } from './webSocket';
|
|
23
22
|
export { createSubtleCrypto } from './subtle';
|
|
24
23
|
export { matchMedia, updateMediaEnvironment, type MediaEnvironment } from './matchMedia';
|
package/src/runtime.ts
CHANGED
|
@@ -190,7 +190,7 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
190
190
|
const carriesBytes = typeof native.hostRequestBytes === 'function';
|
|
191
191
|
// UTF-8 on the host past a few dozen characters (encoding.ts).
|
|
192
192
|
installNativeUTF8(native.decodeUTF8 && native.encodeUTF8 ? { decode: native.decodeUTF8.bind(native), encode: native.encodeUTF8.bind(native) } : null);
|
|
193
|
-
const fetchGlobals = createFetch(native, { bytes: carriesBytes });
|
|
193
|
+
const fetchGlobals = createFetch(native, { bytes: carriesBytes, streams: carriesBytes && native.fetchStreams?.() === true });
|
|
194
194
|
define('fetch', fetchGlobals.fetch);
|
|
195
195
|
define('Headers', fetchGlobals.Headers);
|
|
196
196
|
define('Request', fetchGlobals.Request);
|
package/src/webSocket.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `binaryType` (`blob` by default, or `arraybuffer`).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { SimpleBlob } from './blob';
|
|
12
|
+
import { blobBytes, LazyFile, SimpleBlob } from './blob';
|
|
13
13
|
import type { NativeBridge } from './bridge';
|
|
14
14
|
import { base64Encode, bridgedBytes } from './encoding';
|
|
15
15
|
|
|
@@ -92,6 +92,15 @@ export class NativeWebSocket {
|
|
|
92
92
|
bridge?.wsSend(this.id, JSON.stringify({ text: data }));
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
|
+
if (data instanceof LazyFile) {
|
|
96
|
+
// A picked file on disk: read it, then send (the frame goes out when the read lands; order among lazy sends is the reads').
|
|
97
|
+
void blobBytes(data).then((bytes) => {
|
|
98
|
+
if (this.readyState !== NativeWebSocket.OPEN) return;
|
|
99
|
+
if (carriesBytes) bridge?.wsSend(this.id, JSON.stringify({ binary: { $bytes: 0 } }), bytes);
|
|
100
|
+
else bridge?.wsSend(this.id, JSON.stringify({ binary: base64Encode(bytes) }));
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
95
104
|
const bytes =
|
|
96
105
|
data instanceof SimpleBlob
|
|
97
106
|
? data.bytes()
|
|
@@ -18,9 +18,11 @@ import SwiftBrowserProtocol
|
|
|
18
18
|
/// ```
|
|
19
19
|
@MainActor
|
|
20
20
|
public enum SwiftBrowserDSP {
|
|
21
|
-
/// Registers the `program` kind with SwiftBrowserNative's audio engine
|
|
21
|
+
/// Registers the `program` kind with SwiftBrowserNative's audio engine, and `ScopeView`'s SwiftUI view; call at
|
|
22
|
+
/// launch, before the bundle loads.
|
|
22
23
|
public static func install() {
|
|
23
24
|
AudioEngineNodes.register("program") { context in ProgramNode(format: context.format) }
|
|
25
|
+
ViewRegistry.register("SwiftBrowserDSP.scope") { context in DSPScopeView(props: context.props) }
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -37,6 +39,8 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
37
39
|
private var program: DSPProgram?
|
|
38
40
|
/// Every parameter the app set, by name: a program loaded later takes the values it has names for.
|
|
39
41
|
private var values: [String: Float] = [:]
|
|
42
|
+
/// The transport's tempo as last set, for a program loaded later.
|
|
43
|
+
private var tempo: Double = 120
|
|
40
44
|
|
|
41
45
|
init(format: AVAudioFormat) {
|
|
42
46
|
sampleRate = format.sampleRate
|
|
@@ -50,13 +54,15 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
50
54
|
let buffers = UnsafeMutableAudioBufferListPointer(list)
|
|
51
55
|
guard let first = buffers.first?.mData?.assumingMemoryBound(to: Float.self) else { return noErr }
|
|
52
56
|
let count = Int(frames)
|
|
53
|
-
|
|
57
|
+
// Two channels or more: the left side, then the right; one: their average.
|
|
58
|
+
let second = buffers.count > 1 ? buffers[1].mData?.assumingMemoryBound(to: Float.self) : nil
|
|
59
|
+
if !renderer.render(into: first, right: second, frames: count) {
|
|
54
60
|
silence.pointee = true
|
|
55
61
|
for buffer in buffers { buffer.mData?.initializeMemory(as: Float.self, repeating: 0, count: count) }
|
|
56
62
|
return noErr
|
|
57
63
|
}
|
|
58
|
-
for buffer in buffers.dropFirst() {
|
|
59
|
-
buffer.mData?.
|
|
64
|
+
for buffer in buffers.dropFirst(2) {
|
|
65
|
+
buffer.mData?.initializeMemory(as: Float.self, repeating: 0, count: count)
|
|
60
66
|
}
|
|
61
67
|
return noErr
|
|
62
68
|
}
|
|
@@ -64,6 +70,12 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
64
70
|
|
|
65
71
|
func set(_ key: String, _ value: JSONValue) throws -> Bool {
|
|
66
72
|
defer { renderer.collect() }
|
|
73
|
+
if key == "view" {
|
|
74
|
+
// What a view finds the node's frames by.
|
|
75
|
+
guard let token = value.string else { throw ProgramNodeError("unsupported: view takes a string") }
|
|
76
|
+
ScopeTaps.register(token, renderer.tap)
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
67
79
|
if key == "program" {
|
|
68
80
|
guard let json = value.string else { throw DSPProgramError("a program crosses as JSON") }
|
|
69
81
|
let program = try DSPProgram(json: json)
|
|
@@ -72,10 +84,21 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
72
84
|
for (name, value) in values {
|
|
73
85
|
if let index = machine.parameterIndex(name) { machine.setParameter(at: index, value) }
|
|
74
86
|
}
|
|
87
|
+
machine.setTempo(tempo)
|
|
75
88
|
self.program = program
|
|
76
89
|
renderer.replace(machine)
|
|
77
90
|
return true
|
|
78
91
|
}
|
|
92
|
+
if key == "tempo" || key == "beat" {
|
|
93
|
+
guard let number = value.double else { throw ProgramNodeError("unsupported: \(key) takes a number") }
|
|
94
|
+
if key == "tempo" {
|
|
95
|
+
tempo = number
|
|
96
|
+
renderer.send(.tempo(number))
|
|
97
|
+
} else {
|
|
98
|
+
renderer.send(.seek(number))
|
|
99
|
+
}
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
79
102
|
if key.hasPrefix("param.") {
|
|
80
103
|
guard let number = value.double else { throw ProgramNodeError("unsupported: \(key) takes a number") }
|
|
81
104
|
let name = String(key.dropFirst(6))
|
|
@@ -91,11 +114,13 @@ final class ProgramNode: AudioEngineCustomNode {
|
|
|
91
114
|
func note(_ action: String, _ params: JSONObject) throws -> Bool {
|
|
92
115
|
defer { renderer.collect() }
|
|
93
116
|
let int = { (key: String) in Int32(truncatingIfNeeded: params.int(key) ?? 0) }
|
|
117
|
+
// When on the program's transport, for a command that says.
|
|
118
|
+
let beat = params.double("beat")
|
|
94
119
|
switch action {
|
|
95
|
-
case "startNote": renderer.send(.noteOn(int("note"), int("velocity")))
|
|
96
|
-
case "stopNote": renderer.send(.noteOff(int("note")))
|
|
97
|
-
case "controller": renderer.send(.controller(int("controller"), int("value")))
|
|
98
|
-
case "pitchBend": renderer.send(.pitchBend(Int32(truncatingIfNeeded: params.int("value") ?? 8192)))
|
|
120
|
+
case "startNote": renderer.send(.noteOn(int("note"), int("velocity"), beat))
|
|
121
|
+
case "stopNote": renderer.send(.noteOff(int("note"), beat))
|
|
122
|
+
case "controller": renderer.send(.controller(int("controller"), int("value"), beat))
|
|
123
|
+
case "pitchBend": renderer.send(.pitchBend(Int32(truncatingIfNeeded: params.int("value") ?? 8192), beat))
|
|
99
124
|
default: return false
|
|
100
125
|
}
|
|
101
126
|
return true
|
|
@@ -115,11 +140,13 @@ struct ProgramNodeError: Error, CustomStringConvertible {
|
|
|
115
140
|
|
|
116
141
|
/// What the main actor sends the audio thread.
|
|
117
142
|
enum ProgramEvent: Sendable {
|
|
118
|
-
case noteOn(Int32, Int32)
|
|
119
|
-
case noteOff(Int32)
|
|
120
|
-
case controller(Int32, Int32)
|
|
121
|
-
case pitchBend(Int32)
|
|
143
|
+
case noteOn(Int32, Int32, Double?)
|
|
144
|
+
case noteOff(Int32, Double?)
|
|
145
|
+
case controller(Int32, Int32, Double?)
|
|
146
|
+
case pitchBend(Int32, Double?)
|
|
122
147
|
case parameter(Int32, Float)
|
|
148
|
+
case tempo(Double)
|
|
149
|
+
case seek(Double)
|
|
123
150
|
case reset
|
|
124
151
|
}
|
|
125
152
|
|
|
@@ -137,6 +164,8 @@ final class Renderer: @unchecked Sendable {
|
|
|
137
164
|
}
|
|
138
165
|
|
|
139
166
|
private let lock = OSAllocatedUnfairLock(initialState: Shared())
|
|
167
|
+
/// The frames rendered last, for views.
|
|
168
|
+
let tap = ScopeTap()
|
|
140
169
|
// The audio thread's own.
|
|
141
170
|
private var machine: DSPMachine?
|
|
142
171
|
private var inbox: [ProgramEvent] = []
|
|
@@ -171,10 +200,12 @@ final class Renderer: @unchecked Sendable {
|
|
|
171
200
|
_ = retired
|
|
172
201
|
}
|
|
173
202
|
|
|
174
|
-
/// The audio thread: false when there is no program yet (silence).
|
|
175
|
-
func render(into out: UnsafeMutablePointer<Float>, frames: Int) -> Bool {
|
|
203
|
+
/// The audio thread: false when there is no program yet (silence). With `right`, the left side into `out`.
|
|
204
|
+
func render(into out: UnsafeMutablePointer<Float>, right: UnsafeMutablePointer<Float>? = nil, frames: Int) -> Bool {
|
|
176
205
|
_ = lock.withLockIfAvailableUnchecked { shared in
|
|
177
206
|
if let next = shared.machine, shared.retired == nil {
|
|
207
|
+
// The transport carries on through a new program.
|
|
208
|
+
if let machine { next.seek(toBeat: machine.position) }
|
|
178
209
|
shared.retired = machine
|
|
179
210
|
machine = next
|
|
180
211
|
shared.machine = nil
|
|
@@ -187,17 +218,89 @@ final class Renderer: @unchecked Sendable {
|
|
|
187
218
|
}
|
|
188
219
|
for event in inbox {
|
|
189
220
|
switch event {
|
|
190
|
-
case let .noteOn(note, velocity): machine.noteOn(note, velocity: velocity)
|
|
191
|
-
case let .noteOff(note): machine.noteOff(note)
|
|
192
|
-
case let .controller(number, value): machine.controller(number, value: value)
|
|
193
|
-
case let .pitchBend(value): machine.pitchBend(value)
|
|
221
|
+
case let .noteOn(note, velocity, beat): machine.noteOn(note, velocity: velocity, atBeat: beat)
|
|
222
|
+
case let .noteOff(note, beat): machine.noteOff(note, atBeat: beat)
|
|
223
|
+
case let .controller(number, value, beat): machine.controller(number, value: value, atBeat: beat)
|
|
224
|
+
case let .pitchBend(value, beat): machine.pitchBend(value, atBeat: beat)
|
|
194
225
|
case let .parameter(index, value): machine.setParameter(at: Int(index), value)
|
|
226
|
+
case let .tempo(bpm): machine.setTempo(bpm)
|
|
227
|
+
case let .seek(beat): machine.seek(toBeat: beat)
|
|
195
228
|
case .reset: machine.reset()
|
|
196
229
|
}
|
|
197
230
|
}
|
|
198
231
|
inbox.removeAll(keepingCapacity: true)
|
|
199
|
-
|
|
232
|
+
if let right {
|
|
233
|
+
machine.render(left: out, right: right, frames: frames)
|
|
234
|
+
} else {
|
|
235
|
+
machine.render(into: out, frames: frames)
|
|
236
|
+
}
|
|
237
|
+
tap.write(out, right, frames: frames)
|
|
200
238
|
return true
|
|
201
239
|
}
|
|
202
240
|
}
|
|
241
|
+
|
|
242
|
+
/// A node's last 2048 frames, its two sides mixed, for views (`DSPScopeView`): the audio thread writes them when the
|
|
243
|
+
/// lock is free (never waiting, and a frame a view misses is only a frame), a view copies the newest out.
|
|
244
|
+
final class ScopeTap: @unchecked Sendable {
|
|
245
|
+
static let capacity = 2048
|
|
246
|
+
/// Frames written so far; the samples are only touched under it.
|
|
247
|
+
private let lock = OSAllocatedUnfairLock(initialState: 0)
|
|
248
|
+
private let samples: UnsafeMutablePointer<Float>
|
|
249
|
+
|
|
250
|
+
init() {
|
|
251
|
+
samples = .allocate(capacity: Self.capacity)
|
|
252
|
+
samples.initialize(repeating: 0, count: Self.capacity)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
deinit {
|
|
256
|
+
samples.deallocate()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// The audio thread.
|
|
260
|
+
func write(_ left: UnsafePointer<Float>, _ right: UnsafePointer<Float>?, frames: Int) {
|
|
261
|
+
let samples = samples
|
|
262
|
+
_ = lock.withLockIfAvailableUnchecked { written in
|
|
263
|
+
let mask = Self.capacity - 1
|
|
264
|
+
if let right {
|
|
265
|
+
for i in 0..<frames { samples[(written + i) & mask] = (left[i] + right[i]) * 0.5 }
|
|
266
|
+
} else {
|
|
267
|
+
for i in 0..<frames { samples[(written + i) & mask] = left[i] }
|
|
268
|
+
}
|
|
269
|
+
written += frames
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// The newest `buffer.count` frames (at most 2048), oldest first.
|
|
274
|
+
func read(into buffer: inout [Float]) {
|
|
275
|
+
let count = Swift.min(buffer.count, Self.capacity)
|
|
276
|
+
let samples = samples
|
|
277
|
+
buffer.withUnsafeMutableBufferPointer { out in
|
|
278
|
+
lock.withLockUnchecked { written in
|
|
279
|
+
let mask = Self.capacity - 1
|
|
280
|
+
for i in 0..<count { out[i] = samples[(written - count + i) & mask] }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// The program nodes' taps by view token, for views made before or after the node.
|
|
287
|
+
enum ScopeTaps {
|
|
288
|
+
private struct Weak {
|
|
289
|
+
weak var tap: ScopeTap?
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private static let lock = OSAllocatedUnfairLock(initialState: [String: Weak]())
|
|
293
|
+
|
|
294
|
+
static func register(_ token: String, _ tap: ScopeTap) {
|
|
295
|
+
lock.withLockUnchecked { taps in
|
|
296
|
+
taps = taps.filter { $0.value.tap != nil }
|
|
297
|
+
taps[token] = Weak(tap: tap)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
static func tap(_ token: String) -> ScopeTap? {
|
|
302
|
+
lock.withLockUnchecked { $0[token]?.tap }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
203
305
|
#endif
|
|
306
|
+
|