@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.
Files changed (64) hide show
  1. package/README.md +34 -11
  2. package/dist/blob.d.ts +85 -11
  3. package/dist/blob.d.ts.map +1 -1
  4. package/dist/blob.js +184 -34
  5. package/dist/blob.js.map +1 -1
  6. package/dist/bridge.d.ts +33 -2
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +19 -11
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +105 -41
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +3 -1
  14. package/dist/hostRequests.d.ts.map +1 -1
  15. package/dist/hostRequests.js +47 -6
  16. package/dist/hostRequests.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/runtime.js +1 -1
  22. package/dist/runtime.js.map +1 -1
  23. package/dist/webSocket.d.ts.map +1 -1
  24. package/dist/webSocket.js +13 -1
  25. package/dist/webSocket.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/blob.ts +215 -34
  28. package/src/bridge.ts +27 -2
  29. package/src/fetch.ts +99 -41
  30. package/src/hostRequests.ts +62 -8
  31. package/src/index.ts +1 -2
  32. package/src/runtime.ts +1 -1
  33. package/src/webSocket.ts +10 -1
  34. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +122 -19
  35. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  36. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1247 -209
  37. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +314 -19
  38. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  39. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  40. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  41. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  42. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  43. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  44. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
  45. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
  46. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
  47. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  48. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  49. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +11 -7
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
  52. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
  53. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
  54. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  55. package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
  56. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  57. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  58. package/wasm/SwiftBrowserNative.wasm +2 -2
  59. package/dist/native.d.ts +0 -19
  60. package/dist/native.d.ts.map +0 -1
  61. package/dist/native.js +0 -17
  62. package/dist/native.js.map +0 -1
  63. package/src/native.ts +0 -35
  64. 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). Bodies cross
4
- * as base64; a `Response` reads them back as text, JSON, bytes or a `Blob`.
5
- * A `Blob` or `FormData` body (blob.ts) is sent whole, the form as
6
- * multipart; there are no streams.
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
- /** Bytes of a body, and the content type it implies when the caller set none. */
133
- function encodeBody(body: BodyInitLike): { bytes: Uint8Array | null; contentType: string | null } {
134
- if (body === null || body === undefined) return { bytes: null, contentType: null };
135
- if (typeof body === 'string') return { bytes: utf8Encode(body), contentType: 'text/plain;charset=UTF-8' };
136
- if (body instanceof SimpleBlob) return { bytes: body.bytes(), contentType: body.type || null };
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
- protected bytes: Uint8Array | null;
149
+ /** The body's parts: in memory, or on disk until read. */
150
+ protected parts: BodyPart[] | null;
148
151
  bodyUsed = false;
149
- constructor(bytes: Uint8Array | null) {
150
- this.bytes = bytes;
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.bytes ?? new Uint8Array(0);
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 { bytes, contentType } = encodeBody(init.body);
200
- super(bytes ?? base?.rawBytes() ?? null);
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.bytes;
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, { body: this.bytes });
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 { bytes, contentType } = encodeBody(body);
237
- super(bytes);
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
- return new SimpleResponse(this.bytes, { status: this.status, statusText: this.statusText, headers: this.headers, url: this.url });
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)) reject(request.signal.reason);
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
- if (options.bytes) {
299
- if (bytes) native.fetch(id, { ...head, body: { $bytes: 0 }, bytes: true }, bytes);
300
- else native.fetch(id, { ...head, body: null, bytes: true });
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
- native.fetch(id, { ...head, body: bytes ? base64Encode(bytes) : null });
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
  };
@@ -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
- data: string | Uint8Array;
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
- return { name: file.name ?? 'file', type: file.type, data: file.bytes() };
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?: BridgedFile[] } | undefined)?.files ?? [];
139
- return files.map((file) => new SimpleFile([bridgedBytes(file.data)], file.name, { type: file.type }));
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
- write: (url: string, data: Uint8Array): Promise<void> => files({ action: 'write', url, data }).then(() => undefined),
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
- objectURL: (file: SimpleBlob): string => objectURL(file),
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.bytes() }] });
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; call at launch, before the bundle loads.
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
- if !renderer.render(into: first, frames: count) {
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?.assumingMemoryBound(to: Float.self).update(from: first, count: count)
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
- machine.render(into: out, frames: frames)
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
+