@swiftbrowser/web 0.18.1 → 0.18.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
package/src/appFiles.ts CHANGED
@@ -132,6 +132,8 @@ export interface EntryRow {
132
132
  directory?: boolean;
133
133
  /** Milliseconds since the epoch; rows the module wrote (and older rows) have none. */
134
134
  modified?: number;
135
+ /** When it was made, in milliseconds since the epoch, where the writer said. */
136
+ created?: number;
135
137
  }
136
138
 
137
139
  export const ENTRIES = 'entries';
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Sign in with Apple on a page (docs/ops-protocol.md, "Sign in with Apple"):
3
+ * Apple's JS (`appleid.auth.js`) in a popup, for an app that configured its
4
+ * Services ID and return URL with `configureSignInWithApple` (both registered
5
+ * with Apple for the page's domain). `signIn` loads the script once, asks,
6
+ * and shapes the answer as `ASAuthorizationAppleIDCredential` does: the
7
+ * `user` is the identity token's subject, the name and email come only the
8
+ * first time the viewer agrees. A page cannot ask Apple what a credential is
9
+ * now, so `credentialState` is `unknown`. Natively the host answers instead
10
+ * (Host/AppleSignInBridge.swift).
11
+ */
12
+
13
+ import { base64Decode } from './base64';
14
+ import type { AppleCredentialState, AppleIDCredential, AppleSignInHost } from './hostRequests';
15
+
16
+ export interface AppleSignInConfiguration {
17
+ /** The Services ID registered with Apple for the page's domain (`com.example.app.web`). */
18
+ clientId: string;
19
+ /** The return URL registered with it, on this domain (`https://example.com/app/`). */
20
+ redirectURI: string;
21
+ /** Apple's script, where a page serves its own copy. */
22
+ scriptURL?: string;
23
+ }
24
+
25
+ /** What Apple's script puts on the page. */
26
+ interface AppleIDAuth {
27
+ init(options: { clientId: string; scope: string; redirectURI: string; state?: string; nonce?: string; usePopup: boolean }): void;
28
+ signIn(): Promise<AppleSignInResponse>;
29
+ }
30
+
31
+ interface AppleSignInResponse {
32
+ authorization: { code: string; id_token: string; state?: string };
33
+ user?: { email?: string; name?: { firstName?: string; lastName?: string } };
34
+ }
35
+
36
+ const SCRIPT_URL = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js';
37
+
38
+ let configuration: AppleSignInConfiguration | null = null;
39
+
40
+ /** Names the page's Services ID and return URL; before it, a sign-in fails `unsupported: …`. */
41
+ export function configureSignInWithApple(config: AppleSignInConfiguration | null): void {
42
+ configuration = config;
43
+ }
44
+
45
+ export function signInWithAppleConfiguration(): AppleSignInConfiguration | null {
46
+ return configuration;
47
+ }
48
+
49
+ /** The payload of a JWT, or null when it is not one. */
50
+ export function jwtPayload(token: string): Record<string, unknown> | null {
51
+ const parts = token.split('.');
52
+ if (parts.length < 2) return null;
53
+ try {
54
+ const base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/');
55
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
56
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(base64Decode(padded)));
57
+ return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** Apple's answer as the credential the app reads. */
64
+ export function credentialOf(response: AppleSignInResponse): AppleIDCredential {
65
+ const token = response.authorization?.id_token ?? null;
66
+ const claims = token ? jwtPayload(token) : null;
67
+ const user = typeof claims?.['sub'] === 'string' ? (claims['sub'] as string) : '';
68
+ const email = response.user?.email ?? (typeof claims?.['email'] === 'string' ? (claims['email'] as string) : null);
69
+ const name = response.user?.name;
70
+ return {
71
+ user,
72
+ email,
73
+ fullName: name
74
+ ? { givenName: name.firstName ?? null, familyName: name.lastName ?? null, middleName: null, namePrefix: null, nameSuffix: null, nickname: null }
75
+ : null,
76
+ identityToken: token,
77
+ authorizationCode: response.authorization?.code ?? null,
78
+ realUserStatus: 'unsupported',
79
+ state: response.authorization?.state ?? null,
80
+ };
81
+ }
82
+
83
+ let loading: Promise<AppleIDAuth> | null = null;
84
+
85
+ /** Apple's script, loaded once. */
86
+ function appleIDAuth(scriptURL: string): Promise<AppleIDAuth> {
87
+ const existing = (globalThis as { AppleID?: { auth: AppleIDAuth } }).AppleID?.auth;
88
+ if (existing) return Promise.resolve(existing);
89
+ return (loading ??= new Promise<AppleIDAuth>((resolve, reject) => {
90
+ const script = document.createElement('script');
91
+ script.src = scriptURL;
92
+ script.async = true;
93
+ script.onload = () => {
94
+ const auth = (globalThis as { AppleID?: { auth: AppleIDAuth } }).AppleID?.auth;
95
+ if (auth) resolve(auth);
96
+ else reject(new Error('appleSignIn: the script loaded without AppleID.auth'));
97
+ };
98
+ script.onerror = () => {
99
+ loading = null;
100
+ reject(new Error('network: could not load Sign in with Apple'));
101
+ };
102
+ document.head.append(script);
103
+ }));
104
+ }
105
+
106
+ /** Whether Apple's script reported the viewer closing the popup or declining. */
107
+ function isCancellation(error: unknown): boolean {
108
+ const code = (error as { error?: string } | null)?.error;
109
+ return code === 'popup_closed_by_user' || code === 'user_cancelled_authorize' || code === 'popup_blocked_by_browser';
110
+ }
111
+
112
+ export function browserAppleSignIn(): AppleSignInHost {
113
+ return {
114
+ async signIn(options) {
115
+ const config = configuration;
116
+ if (!config) throw new Error('unsupported: Sign in with Apple is not configured here (configureSignInWithApple on a page)');
117
+ const auth = await appleIDAuth(config.scriptURL ?? SCRIPT_URL);
118
+ const scope = options.scopes.map((item) => (item === 'fullName' ? 'name' : 'email')).join(' ');
119
+ auth.init({
120
+ clientId: config.clientId,
121
+ scope,
122
+ redirectURI: config.redirectURI,
123
+ ...(options.state ? { state: options.state } : {}),
124
+ ...(options.nonce ? { nonce: options.nonce } : {}),
125
+ usePopup: true,
126
+ });
127
+ let response: AppleSignInResponse;
128
+ try {
129
+ response = await auth.signIn();
130
+ } catch (error) {
131
+ if (isCancellation(error)) throw new Error('cancelled: the viewer closed Sign in with Apple');
132
+ throw new Error(`appleSignIn: ${(error as { error?: string } | null)?.error ?? String(error)}`);
133
+ }
134
+ return credentialOf(response);
135
+ },
136
+ async credentialState(): Promise<AppleCredentialState> {
137
+ return 'unknown';
138
+ },
139
+ };
140
+ }
package/src/files.ts CHANGED
@@ -21,7 +21,11 @@ export interface FileAttributes {
21
21
  size: number;
22
22
  /** When it was last written, in milliseconds since the epoch, where the host knows. */
23
23
  modified: number | null;
24
+ /** When it was made, in milliseconds since the epoch, where the host knows. */
25
+ created: number | null;
24
26
  directory: boolean;
27
+ /** The MIME type its extension names (`text/plain`); empty for a directory or an unknown extension. */
28
+ type: string;
25
29
  }
26
30
 
27
31
  /** The app's standard directories, as `file:` URLs. */
@@ -58,8 +62,19 @@ export interface FilesHost {
58
62
  move(from: string, to: string): Promise<void>;
59
63
  /** Copies a file or directory to a URL nothing is at, in a directory that exists. */
60
64
  copy(from: string, to: string): Promise<void>;
61
- /** A URL the page can load the file at: an object URL of its bytes on a page, the URL itself natively. */
65
+ /** A URL the page can load the file at: an object URL of its bytes on a page, the URL itself natively (downloaded from iCloud Drive first). */
62
66
  loadURL(url: string): Promise<string>;
67
+ /**
68
+ * The app's iCloud Drive `Documents`, where the app has the entitlement and
69
+ * the viewer iCloud Drive (natively); null elsewhere, and on a page.
70
+ */
71
+ iCloud?(): Promise<string | null>;
72
+ /**
73
+ * Calls `listener` with the URLs that changed: what this page wrote (so
74
+ * two readers of a file agree), and natively what another device wrote to
75
+ * iCloud Drive. Returns a function that stops listening.
76
+ */
77
+ watch?(listener: (urls: string[]) => void): () => void;
63
78
  }
64
79
 
65
80
  // ---------------------------------------------------------------------------
@@ -121,10 +136,11 @@ function parentOf(path: string): string {
121
136
  // Rows: a home's files by path, in IndexedDB or memory
122
137
  // ---------------------------------------------------------------------------
123
138
 
124
- /** A file's bytes, or null for a directory, and when it was written. */
139
+ /** A file's bytes, or null for a directory, and when it was written and made. */
125
140
  export interface FileRow {
126
141
  data: Uint8Array | null;
127
142
  modified: number | null;
143
+ created: number | null;
128
144
  }
129
145
 
130
146
  /** The rows of one home, by path relative to it (`Documents/notes.txt`). */
@@ -141,7 +157,7 @@ export function memoryRows(): FileRows {
141
157
  const rows = new Map<string, FileRow>();
142
158
  return {
143
159
  get: async (path) => rows.get(path),
144
- put: async (path, row) => void rows.set(path, { data: row.data === null ? null : row.data.slice(), modified: row.modified }),
160
+ put: async (path, row) => void rows.set(path, { data: row.data === null ? null : row.data.slice(), modified: row.modified, created: row.created }),
145
161
  remove: async (paths) => paths.forEach((path) => rows.delete(path)),
146
162
  keys: async () => [...rows.keys()],
147
163
  };
@@ -156,12 +172,17 @@ export function indexedDBRows(app: string, factory: IDBFactory = indexedDB): Fil
156
172
  async get(path) {
157
173
  const row = (await request((await store('readonly')).get(path))) as EntryRow | undefined;
158
174
  if (!row) return undefined;
159
- return { data: row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()), modified: typeof row.modified === 'number' ? row.modified : null };
175
+ return {
176
+ data: row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()),
177
+ modified: typeof row.modified === 'number' ? row.modified : null,
178
+ created: typeof row.created === 'number' ? row.created : null,
179
+ };
160
180
  },
161
181
  async put(path, row) {
162
182
  const entries = await store('readwrite');
163
183
  const stored: EntryRow = row.data === null ? { path, directory: true } : { path, data: row.data.slice() };
164
184
  if (row.modified !== null) stored.modified = row.modified;
185
+ if (row.created !== null) stored.created = row.created;
165
186
  entries.put(stored);
166
187
  await done(entries.transaction);
167
188
  },
@@ -232,6 +253,8 @@ export class BrowserFiles implements FilesHost {
232
253
  private readonly now: () => number;
233
254
  /** Object URLs handed out, by mount URL and path, revoked when the file changes. */
234
255
  private readonly loaded = new Map<string, string>();
256
+ /** Who hears about changes (`watch`). */
257
+ private readonly watchers = new Set<(urls: string[]) => void>();
235
258
 
236
259
  /** Whether the home's rows are the database named by `app` (not rows an option gave). */
237
260
  private readonly defaultRows: boolean;
@@ -271,6 +294,21 @@ export class BrowserFiles implements FilesHost {
271
294
  { url: TMP_URL, prefix: TMP.split('/').filter(Boolean), rows: () => this.temporaryRows, standard: [] },
272
295
  ];
273
296
 
297
+ /** No iCloud Drive on a page. */
298
+ async iCloud(): Promise<string | null> {
299
+ return null;
300
+ }
301
+
302
+ watch(listener: (urls: string[]) => void): () => void {
303
+ this.watchers.add(listener);
304
+ return () => void this.watchers.delete(listener);
305
+ }
306
+
307
+ /** Tells the watchers, after the change landed. */
308
+ private changed(...urls: string[]): void {
309
+ for (const listener of [...this.watchers]) listener(urls);
310
+ }
311
+
274
312
  /** The mount a URL falls in and its path there, or a failure for a URL outside the app's files. */
275
313
  private locate(url: string): { mount: Mount; path: string } {
276
314
  const segments = fileSegments(url);
@@ -295,7 +333,7 @@ export class BrowserFiles implements FilesHost {
295
333
  private async rowAt(mount: Mount, path: string): Promise<FileRow | undefined> {
296
334
  const row = await mount.rows().get(path);
297
335
  if (row) return row;
298
- return (await this.impliedDirectory(mount, path)) ? { data: null, modified: null } : undefined;
336
+ return (await this.impliedDirectory(mount, path)) ? { data: null, modified: null, created: null } : undefined;
299
337
  }
300
338
 
301
339
  private async requireDirectory(mount: Mount, path: string, url: string): Promise<void> {
@@ -326,8 +364,10 @@ export class BrowserFiles implements FilesHost {
326
364
  await this.requireDirectory(mount, parentOf(path), url);
327
365
  const existing = await this.rowAt(mount, path);
328
366
  if (existing && existing.data === null) throw new FilesFailure('isDirectory', `${url} is a directory`);
329
- await mount.rows().put(path, { data, modified: this.now() });
367
+ const now = this.now();
368
+ await mount.rows().put(path, { data, modified: now, created: existing?.created ?? now });
330
369
  this.forget(mount, path);
370
+ this.changed(url);
331
371
  }
332
372
 
333
373
  async remove(url: string): Promise<void> {
@@ -339,6 +379,7 @@ export class BrowserFiles implements FilesHost {
339
379
  const below = (await mount.rows().keys()).filter((key) => key === path || key.startsWith(prefix));
340
380
  await mount.rows().remove(below);
341
381
  for (const key of below) this.forget(mount, key);
382
+ this.changed(url);
342
383
  }
343
384
 
344
385
  async createDirectory(url: string): Promise<void> {
@@ -348,8 +389,9 @@ export class BrowserFiles implements FilesHost {
348
389
  const at = parts.slice(0, depth).join('/');
349
390
  const row = await mount.rows().get(at);
350
391
  if (row?.data) throw new FilesFailure('exists', `${fileURL(mount.url, at)} is a file`);
351
- if (!row && !mount.standard.includes(at)) await mount.rows().put(at, { data: null, modified: this.now() });
392
+ if (!row && !mount.standard.includes(at)) await mount.rows().put(at, { data: null, modified: this.now(), created: this.now() });
352
393
  }
394
+ this.changed(url);
353
395
  }
354
396
 
355
397
  async list(url: string): Promise<string[]> {
@@ -370,7 +412,8 @@ export class BrowserFiles implements FilesHost {
370
412
  const { mount, path } = this.locate(url);
371
413
  const row = await this.rowAt(mount, path);
372
414
  if (!row) return null;
373
- return { size: row.data?.byteLength ?? 0, modified: row.modified, directory: row.data === null };
415
+ const directory = row.data === null;
416
+ return { size: row.data?.byteLength ?? 0, modified: row.modified, created: row.created, directory, type: directory ? '' : mediaType(path) };
374
417
  }
375
418
 
376
419
  /** Every row at or below `path`, by path. */
@@ -399,12 +442,13 @@ export class BrowserFiles implements FilesHost {
399
442
  const now = this.now();
400
443
  for (const [path, row] of rows) {
401
444
  const moved = target.path + path.slice(source.path.length);
402
- await target.mount.rows().put(moved, { data: row.data, modified: removeSource ? row.modified : now });
445
+ await target.mount.rows().put(moved, { data: row.data, modified: removeSource ? row.modified : now, created: removeSource ? row.created : now });
403
446
  }
404
447
  if (removeSource) {
405
448
  await source.mount.rows().remove([...rows.keys()].filter((key) => key !== source.path || !source.mount.standard.includes(key)));
406
449
  for (const key of rows.keys()) this.forget(source.mount, key);
407
450
  }
451
+ this.changed(from, to);
408
452
  }
409
453
 
410
454
  move(from: string, to: string): Promise<void> {
@@ -2,7 +2,8 @@
2
2
  * Phase 13: what the Wasm app asks the page to do on its behalf. The app has
3
3
  * no network of its own; `URLSession` sends a `request` op of kind `fetch`
4
4
  * and `AsyncImage` one of kind `image` (see docs/ops-protocol.md, "Phase
5
- * 13"); `ShareLink` sends `share` and `PhotosPicker` `pickFiles`. The
5
+ * 13"); `ShareLink` sends `share`, `PhotosPicker` `pickFiles` and `fileExporter`
6
+ * `exportFiles`. The
6
7
  * loader (wasm.ts) hands each op to `performHostRequest` and delivers the
7
8
  * result as a `response` event with the same id.
8
9
  *
@@ -17,6 +18,7 @@
17
18
  * `UIPasteboard` `clipboard` (clipboard.ts).
18
19
  */
19
20
 
21
+ import { browserAppleSignIn } from './appleSignIn';
20
22
  import { base64Decode, base64Encode } from './base64';
21
23
  import { browserNotificationHost, browserPushHost, performNotification, type NotificationHost, type NotificationResult } from './notifications';
22
24
  import type {
@@ -25,6 +27,8 @@ import type {
25
27
  ImageRequestParams,
26
28
  NotificationRequestParams,
27
29
  PickFilesRequestParams,
30
+ ExportFilesRequestParams,
31
+ AppleSignInRequestParams,
28
32
  RequestOp,
29
33
  ScannerRequestParams,
30
34
  ShareRequestParams,
@@ -76,6 +80,45 @@ export interface ShareResult {
76
80
  outcome: 'shared' | 'downloaded' | 'cancelled';
77
81
  }
78
82
 
83
+ /** What Sign in with Apple answered (`ASAuthorizationAppleIDCredential`): the stable `user` identifier and, the first time, the name and email the viewer shared. */
84
+ export interface AppleIDCredential {
85
+ user: string;
86
+ email: string | null;
87
+ fullName: AppleIDName | null;
88
+ /** The identity token, a JWT (`sub` is `user`), for the app's server. */
89
+ identityToken: string | null;
90
+ /** The authorization code, short-lived, for the app's server. */
91
+ authorizationCode: string | null;
92
+ realUserStatus: 'likelyReal' | 'unknown' | 'unsupported';
93
+ state: string | null;
94
+ }
95
+
96
+ export interface AppleIDName {
97
+ givenName: string | null;
98
+ familyName: string | null;
99
+ middleName: string | null;
100
+ namePrefix: string | null;
101
+ nameSuffix: string | null;
102
+ nickname: string | null;
103
+ }
104
+
105
+ export type AppleCredentialState = 'authorized' | 'revoked' | 'notFound' | 'transferred' | 'unknown';
106
+
107
+ /** Sign in with Apple, as the host has it: Apple's JS on a page the app configured, `ASAuthorizationController` natively. */
108
+ export interface AppleSignInHost {
109
+ /** Asks the viewer; rejects `cancelled: …` when they dismiss it. It must open before it returns, as a chooser. */
110
+ signIn(options: { scopes: ('fullName' | 'email')[]; nonce?: string; state?: string }): Promise<AppleIDCredential>;
111
+ /** What the credential for `user` is now; `unknown` where the host cannot ask (a page). */
112
+ credentialState(user: string): Promise<AppleCredentialState>;
113
+ }
114
+
115
+ /** How an `exportFiles` request ended: saved where the viewer chose, saved as downloads, or dismissed by the viewer. */
116
+ export interface ExportFilesResult {
117
+ outcome: 'saved' | 'downloaded' | 'cancelled';
118
+ /** Where the files went, where the host knows (the document picker's `file:` URLs natively). */
119
+ urls?: string[];
120
+ }
121
+
79
122
  /** One file the viewer picked (`pickFiles`), with its bytes. */
80
123
  export interface PickedFile {
81
124
  name: string;
@@ -121,6 +164,12 @@ export interface HostRequestEnvironment {
121
164
  * only show one during the tap that asked (user activation).
122
165
  */
123
166
  pickFiles?: (options: { accept: string; multiple: boolean }) => Promise<File[]>;
167
+ /**
168
+ * Lets the viewer choose where files go (a save dialog, the Files app) and
169
+ * resolves with where they went, or `cancelled`. Absent, `exportFiles`
170
+ * downloads them instead. It must open before it returns, as `pickFiles`.
171
+ */
172
+ exportFiles?: (files: File[]) => Promise<ExportFilesResult>;
124
173
  /** A URL the page can show a picked file at (`URL.createObjectURL`). */
125
174
  objectURL?: (file: File) => string;
126
175
  /** A `UIImage` bitmap drawn into a PNG file (src/bitmapStore.ts), to share it as a file. */
@@ -135,6 +184,8 @@ export interface HostRequestEnvironment {
135
184
  openURL?: (url: string) => void;
136
185
  /** Sign-in windows (`webAuth`, webAuth.ts). */
137
186
  webAuth?: WebAuthSessions;
187
+ /** Sign in with Apple (`appleSignIn`, appleSignIn.ts on a page once configured; `ASAuthorizationController` natively). */
188
+ appleSignIn?: AppleSignInHost;
138
189
  /** AVFoundation's media files and probes, QuickLook's previews (`media`, media.ts). */
139
190
  media?: MediaHost;
140
191
  /** MediaPlayer's now-playing info and remote commands (src/mediaSession.ts); absent without the Media Session API. */
@@ -195,6 +246,38 @@ function downloadBlob(blob: Blob, filename: string): void {
195
246
  setTimeout(() => URL.revokeObjectURL(url), 60_000);
196
247
  }
197
248
 
249
+ /** The File System Access API's save dialog, where the browser has it (Chromium). */
250
+ interface SaveFilePickerWindow {
251
+ showSaveFilePicker(options: { suggestedName?: string; types?: { description?: string; accept: Record<string, string[]> }[] }): Promise<{
252
+ createWritable(): Promise<{ write(data: Blob): Promise<void>; close(): Promise<void> }>;
253
+ }>;
254
+ }
255
+
256
+ /** Saves each file where the viewer chooses; a dismissed dialog ends the export as `cancelled`. */
257
+ async function saveFiles(files: File[]): Promise<ExportFilesResult> {
258
+ const picker = window as unknown as SaveFilePickerWindow;
259
+ const urls: string[] = [];
260
+ for (const file of files) {
261
+ const dot = file.name.lastIndexOf('.');
262
+ const extension = dot > 0 ? file.name.slice(dot) : '';
263
+ let handle: Awaited<ReturnType<SaveFilePickerWindow['showSaveFilePicker']>>;
264
+ try {
265
+ handle = await picker.showSaveFilePicker({
266
+ suggestedName: file.name,
267
+ ...(extension && file.type && !file.type.includes(';') ? { types: [{ accept: { [file.type]: [extension] } }] } : {}),
268
+ });
269
+ } catch (error) {
270
+ if (error instanceof Error && error.name === 'AbortError') return { outcome: 'cancelled' };
271
+ throw error;
272
+ }
273
+ const writable = await handle.createWritable();
274
+ await writable.write(file);
275
+ await writable.close();
276
+ urls.push(`file:///${encodeURIComponent(file.name)}`);
277
+ }
278
+ return { outcome: 'saved', urls };
279
+ }
280
+
198
281
  const notificationHost = browserNotificationHost();
199
282
 
200
283
  /**
@@ -235,6 +318,8 @@ export const browserEnvironment: HostRequestEnvironment = {
235
318
  ? { canShare: (data: ShareData) => navigator.canShare(data) }
236
319
  : {}),
237
320
  ...(typeof document !== 'undefined' ? { download: downloadBlob, pickFiles: chooseFiles } : {}),
321
+ ...(typeof window !== 'undefined' && 'showSaveFilePicker' in window ? { exportFiles: saveFiles } : {}),
322
+ ...(typeof document !== 'undefined' ? { appleSignIn: browserAppleSignIn() } : {}),
238
323
  ...(typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
239
324
  ? { objectURL: (file: File) => URL.createObjectURL(file) }
240
325
  : {}),
@@ -278,7 +363,7 @@ export function loadableURL(url: string): string | Promise<string> {
278
363
  export async function performHostRequest(
279
364
  op: RequestOp,
280
365
  env: HostRequestEnvironment = browserEnvironment,
281
- ): Promise<FetchResult | ImageResult | ShareResult | PickFilesResult | NotificationResult | ScannerResult | WebAuthResult | Record<string, unknown>> {
366
+ ): Promise<FetchResult | ImageResult | ShareResult | PickFilesResult | ExportFilesResult | NotificationResult | ScannerResult | WebAuthResult | AppleIDCredential | Record<string, unknown>> {
282
367
  switch (op.kind) {
283
368
  case 'fetch':
284
369
  return performFetch(op.params as FetchRequestParams, env);
@@ -290,6 +375,8 @@ export async function performHostRequest(
290
375
  // Not awaited before the chooser opens: `performPickFiles` calls
291
376
  // `env.pickFiles` synchronously, inside the tap that sent the request.
292
377
  return performPickFiles(op.params as PickFilesRequestParams, env);
378
+ case 'exportFiles':
379
+ return performExportFiles(op.params as ExportFilesRequestParams, env);
293
380
  case 'notification':
294
381
  return performNotification(op.params as NotificationRequestParams, env.notifications);
295
382
  case 'scanner':
@@ -309,6 +396,9 @@ export async function performHostRequest(
309
396
  // synchronously, while the tap's activation may still count.
310
397
  if (!env.webAuth) throw new Error('unsupported: this page cannot open a sign-in window');
311
398
  return env.webAuth.perform(op.params as WebAuthRequestParams);
399
+ case 'appleSignIn':
400
+ // Not awaited before the sheet opens: Apple's JS opens its popup within the tap.
401
+ return performAppleSignIn(op.params as AppleSignInRequestParams, env.appleSignIn);
312
402
  case 'media':
313
403
  // `open` is called before any await, while the tap's activation may still count.
314
404
  return performMedia(op.params as MediaRequestParams, env.media);
@@ -434,6 +524,40 @@ async function collectFiles(
434
524
  return files;
435
525
  }
436
526
 
527
+ /**
528
+ * Sign in with Apple: `signIn` answers the credential, `credentialState`
529
+ * `{ state }`. Without a host (a page the app did not configure) the request
530
+ * fails `unsupported: …`, which the app shows as the button doing nothing it
531
+ * can do here.
532
+ */
533
+ function performAppleSignIn(params: AppleSignInRequestParams, host: AppleSignInHost | undefined): Promise<AppleIDCredential | { state: AppleCredentialState }> {
534
+ if (!host) return Promise.reject(new Error('unsupported: Sign in with Apple is not configured here (configureSignInWithApple on a page)'));
535
+ if (params.action === 'credentialState') {
536
+ if (typeof params.user !== 'string') return Promise.reject(new Error('appleSignIn: credentialState needs a user'));
537
+ return host.credentialState(params.user).then((state) => ({ state }));
538
+ }
539
+ const scopes = (params.scopes ?? []).filter((scope): scope is 'fullName' | 'email' => scope === 'fullName' || scope === 'email');
540
+ return host.signIn({ scopes, ...(params.nonce ? { nonce: params.nonce } : {}), ...(params.state ? { state: params.state } : {}) });
541
+ }
542
+
543
+ /**
544
+ * `exportFiles`: the files go where the viewer chooses (`env.exportFiles`:
545
+ * a save dialog, the Files app) or, where the page has no dialog, to the
546
+ * downloads, as a download link saves them. `fileExporter` in
547
+ * @swiftbrowser/react calls it with the document's file.
548
+ */
549
+ export async function exportFiles(files: File[], env: HostRequestEnvironment = browserEnvironment): Promise<ExportFilesResult> {
550
+ if (!files.length) throw new Error('unsupported: nothing to export');
551
+ if (env.exportFiles) return env.exportFiles(files);
552
+ if (!env.download) throw new Error('unsupported: this page can neither save nor download files');
553
+ for (const file of files) env.download(file, file.name);
554
+ return { outcome: 'downloaded' };
555
+ }
556
+
557
+ async function performExportFiles(params: ExportFilesRequestParams, env: HostRequestEnvironment): Promise<ExportFilesResult> {
558
+ return exportFiles(await collectFiles(params, env, 'Document'), env);
559
+ }
560
+
437
561
  /**
438
562
  * Saving to the photo library (Photos, UIKit): every file the request
439
563
  * carries is downloaded, as the browser saves any download.
package/src/main.ts CHANGED
@@ -20,6 +20,7 @@ import { withBlockedHosts } from './blockedHosts';
20
20
  import { withCORSProxy } from './corsProxy';
21
21
  import { browserEnvironment, installPushService } from './hostRequests';
22
22
  import { locateBundleFiles } from './media';
23
+ import { currentScenePhase } from './scenePhase';
23
24
  import { relayUndo } from './undo';
24
25
  import { callbackPageURL } from './webAuth';
25
26
  import { bitmapStore } from './bitmaps';
@@ -265,14 +266,6 @@ if (deviceMode) {
265
266
  // Phase 8: scene phase
266
267
  // ---------------------------------------------------------------------------
267
268
 
268
- /**
269
- * `ScenePhase` from the document: `background` while hidden, else `active`
270
- * when the window has focus and `inactive` when it does not.
271
- */
272
- function currentScenePhase(): ScenePhase {
273
- if (document.visibilityState === 'hidden') return 'background';
274
- return document.hasFocus() ? 'active' : 'inactive';
275
- }
276
269
  /** The scene phase last sent in an `environment` event (null before the first). */
277
270
  let sentScenePhase: ScenePhase | null = null;
278
271
 
package/src/protocol.ts CHANGED
@@ -1107,17 +1107,19 @@ export interface DefaultsOp {
1107
1107
  export interface RequestOp {
1108
1108
  op: 'request';
1109
1109
  id: number;
1110
- kind: 'fetch' | 'image' | 'share' | 'pickFiles' | 'notification' | 'scanner' | 'websocket' | 'openURL' | 'webAuth' | 'media' | 'download' | 'clipboard' | 'translate' | 'pickColor' | 'mediaSession' | 'undo';
1110
+ kind: 'fetch' | 'image' | 'share' | 'pickFiles' | 'exportFiles' | 'notification' | 'scanner' | 'websocket' | 'openURL' | 'webAuth' | 'appleSignIn' | 'media' | 'download' | 'clipboard' | 'translate' | 'pickColor' | 'mediaSession' | 'undo';
1111
1111
  params:
1112
1112
  | FetchRequestParams
1113
1113
  | ImageRequestParams
1114
1114
  | ShareRequestParams
1115
1115
  | PickFilesRequestParams
1116
+ | ExportFilesRequestParams
1116
1117
  | NotificationRequestParams
1117
1118
  | ScannerRequestParams
1118
1119
  | WebSocketRequestParams
1119
1120
  | OpenURLRequestParams
1120
1121
  | WebAuthRequestParams
1122
+ | AppleSignInRequestParams
1121
1123
  | MediaRequestParams
1122
1124
  | DownloadRequestParams
1123
1125
  | ClipboardRequestParams
@@ -1178,6 +1180,23 @@ export interface WebAuthRequestParams {
1178
1180
  callbackPage?: string;
1179
1181
  ephemeral?: boolean;
1180
1182
  }
1183
+ /**
1184
+ * Sign in with Apple (docs/ops-protocol.md, "Sign in with Apple"): `signIn`
1185
+ * asks for the viewer's Apple ID with `scopes` (`fullName`, `email`), a
1186
+ * `nonce` for the identity token and a `state` echoed back; the response
1187
+ * value is an `AppleIDCredential`, or the request fails `cancelled: …`.
1188
+ * `credentialState` asks what a `user` identifier's credential is now; the
1189
+ * response value is `{ state }` (`authorized`, `revoked`, `notFound`,
1190
+ * `transferred`, or `unknown` where the host cannot tell).
1191
+ */
1192
+ export interface AppleSignInRequestParams {
1193
+ action: 'signIn' | 'credentialState';
1194
+ scopes?: ('fullName' | 'email')[];
1195
+ nonce?: string;
1196
+ state?: string;
1197
+ user?: string;
1198
+ }
1199
+
1181
1200
  export interface OpenURLRequestParams {
1182
1201
  url: string;
1183
1202
  }
@@ -1266,6 +1285,16 @@ export interface PickFilesRequestParams {
1266
1285
  multiple: boolean;
1267
1286
  }
1268
1287
 
1288
+ /**
1289
+ * `fileExporter`, saving to Files: let the viewer choose where the app's
1290
+ * files go. The page shows a save dialog where the browser has one (the
1291
+ * File System Access API) and downloads the files elsewhere; the native app
1292
+ * shows the document picker. The response value is an `ExportFilesResult`.
1293
+ */
1294
+ export interface ExportFilesRequestParams {
1295
+ files: SharedFile[];
1296
+ }
1297
+
1269
1298
  /**
1270
1299
  * UserNotifications (docs/ops-protocol.md, "Notifications"): `status` reads
1271
1300
  * `Notification.permission`, `request` asks for it, `show` presents a
@@ -1940,8 +1969,18 @@ export interface DropDestinationProps {
1940
1969
  accepts?: string[];
1941
1970
  }
1942
1971
 
1972
+ /**
1973
+ * `.onTouches` (a SwiftBrowser addition: SwiftUI has no multi-touch gesture):
1974
+ * exactly one child, the child's size. The element owns every pointer that
1975
+ * goes down on it and receives `touches` events with all of them, so an app
1976
+ * can hear chords and glissandi (a piano, a drum pad, a multi-finger
1977
+ * drawing). No props.
1978
+ */
1979
+ export type TouchesProps = Record<string, never>;
1980
+
1943
1981
  export interface ElementPropsByKind {
1944
1982
  gesture: GestureProps;
1983
+ touches: TouchesProps;
1945
1984
  swipeactions: SwipeActionsProps;
1946
1985
  contextmenu: ContextMenuProps;
1947
1986
  listrow: ListRowProps;
@@ -1949,7 +1988,7 @@ export interface ElementPropsByKind {
1949
1988
  dropdestination: DropDestinationProps;
1950
1989
  }
1951
1990
 
1952
- export const INTERACTION_KINDS: readonly ElementKind[] = ['gesture', 'swipeactions', 'contextmenu', 'listrow', 'draggable', 'dropdestination'];
1991
+ export const INTERACTION_KINDS: readonly ElementKind[] = ['gesture', 'touches', 'swipeactions', 'contextmenu', 'listrow', 'draggable', 'dropdestination'];
1953
1992
 
1954
1993
  export interface ListProps {
1955
1994
  /** `EditButton` / `editMode`: a selectable or deletable `listrow` gains a 44 pt leading control and its content shifts right 40; a movable one a trailing grip. */
@@ -2117,6 +2156,25 @@ export interface LongPressEvent {
2117
2156
  type: 'longpress';
2118
2157
  id: ElementId;
2119
2158
  }
2159
+ /** One pointer on a `touches` element: a stable `id` while it is down, and its point in the element's coordinates. */
2160
+ export interface TouchPoint {
2161
+ id: number;
2162
+ x: number;
2163
+ y: number;
2164
+ }
2165
+ /**
2166
+ * A `touches` element's pointers changed: `began` when one came down,
2167
+ * `changed` when they moved (coalesced to one per animation frame), `ended`
2168
+ * when one lifted, `cancelled` when the renderer lost them all. `touches` is
2169
+ * every pointer still down after the change, so an app reads the state from
2170
+ * each event (the keys under the touches) instead of tracking ups and downs.
2171
+ */
2172
+ export interface TouchesEvent {
2173
+ type: 'touches';
2174
+ id: ElementId;
2175
+ phase: DragPhase;
2176
+ touches: TouchPoint[];
2177
+ }
2120
2178
  /** A `listrow` was swiped to delete or its edit-mode Delete button tapped. */
2121
2179
  export interface DeleteEvent {
2122
2180
  type: 'delete';
@@ -2191,7 +2249,7 @@ export interface DropEvent {
2191
2249
  }
2192
2250
 
2193
2251
  /** The Phase 8 events (merged into `RendererEvent` above). */
2194
- export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | ScrollVisibilityEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
2252
+ export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | TouchesEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | ScrollVisibilityEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
2195
2253
  // Phase 8: alerts, controls, effects
2196
2254
  // (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
2197
2255
  // context menus, list rows and refresh live in their own block)
package/src/renderer.ts CHANGED
@@ -162,6 +162,7 @@ import { applyPathShape, hasPathFrames, playPathFrames } from './pathShape';
162
162
  import { VideoEvents, applyVideo, stopVideos } from './video';
163
163
  import { loadableURL } from './hostRequests';
164
164
  import { PinchController } from './pinch';
165
+ import { TouchesController } from './touches';
165
166
  import { applyCanvas, createCanvasElement } from './canvasDrawing';
166
167
  import { scrollToTarget } from './scrollTo';
167
168
  import { POSITION_SETTLE_MS, POSITION_SNAP_WAITS, anchorPoint, positionTarget, restsOnSnapPoint, type PositionCandidate } from './scrollPosition';
@@ -433,6 +434,7 @@ export class Renderer {
433
434
  /** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
434
435
  private readonly externalDrops: ExternalDrops;
435
436
  private readonly pinches: PinchController;
437
+ private readonly touches: TouchesController;
436
438
  private readonly touchPaging: TouchPaging;
437
439
  /** `video` elements' media events, as `video` events (src/video.ts). */
438
440
  private readonly videoEvents: VideoEvents;
@@ -1012,6 +1014,8 @@ export class Renderer {
1012
1014
  this.externalDrops = new ExternalDrops(host);
1013
1015
  // Two-finger gestures: MagnifyGesture and RotateGesture (src/pinch.ts).
1014
1016
  this.pinches = new PinchController(host);
1017
+ // Multi-touch: every pointer on a `touches` element (src/touches.ts).
1018
+ this.touches = new TouchesController(host);
1015
1019
  // A finger on a paging scroll view pages it as iOS does (src/touchPaging.ts);
1016
1020
  // the view it drives reports its position once the drive rests it on a page.
1017
1021
  this.touchPaging = new TouchPaging(root, (el) => {
@@ -1956,6 +1960,7 @@ export class Renderer {
1956
1960
  this.pressEffects.dispose();
1957
1961
  this.externalDrops.dispose();
1958
1962
  this.pinches.dispose();
1963
+ this.touches.dispose();
1959
1964
  this.touchPaging.dispose();
1960
1965
  this.videoEvents.dispose();
1961
1966
  this.resizeObserver?.disconnect();
@@ -3131,6 +3136,7 @@ function createShell(kind: ElementKind): HTMLElement {
3131
3136
  return document.createElement('div');
3132
3137
  // Phase 8: gestures, rows, navigation; drag and drop
3133
3138
  case 'gesture':
3139
+ case 'touches':
3134
3140
  case 'contextmenu':
3135
3141
  case 'draggable':
3136
3142
  case 'dropdestination':
@@ -3960,6 +3966,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3960
3966
  el.dataset['sbLeading'] = String(Math.max(0, Math.floor(Number(swipe.leading) || 0)));
3961
3967
  break;
3962
3968
  }
3969
+ case 'touches':
3963
3970
  case 'contextmenu':
3964
3971
  break;
3965
3972
  case 'draggable':
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `ScenePhase` from the document, as a page reads it for the Swift
3
+ * environment (main.ts) and `mount` reads it for a React root's
4
+ * `useScenePhase`: `background` while the document is hidden, else `active`
5
+ * when the window has focus and `inactive` when it does not. The browser
6
+ * signals every change through `visibilitychange`, `focus` and `blur`.
7
+ */
8
+
9
+ import type { ScenePhase } from './protocol';
10
+
11
+ /** What `currentScenePhase` reads: a `Document`, or a stand-in in tests. */
12
+ export interface ScenePhaseDocument {
13
+ visibilityState: string;
14
+ hasFocus(): boolean;
15
+ addEventListener(type: 'visibilitychange', listener: () => void): void;
16
+ removeEventListener(type: 'visibilitychange', listener: () => void): void;
17
+ }
18
+
19
+ /** Where the focus events arrive: a `Window`, or a stand-in in tests. */
20
+ export interface ScenePhaseWindow {
21
+ addEventListener(type: 'focus' | 'blur', listener: () => void): void;
22
+ removeEventListener(type: 'focus' | 'blur', listener: () => void): void;
23
+ }
24
+
25
+ export function currentScenePhase(doc: ScenePhaseDocument = document): ScenePhase {
26
+ if (doc.visibilityState === 'hidden') return 'background';
27
+ return doc.hasFocus() ? 'active' : 'inactive';
28
+ }
29
+
30
+ /**
31
+ * Calls `onChange` with the new phase each time it changes (not with the
32
+ * current one). Returns a function that stops watching.
33
+ */
34
+ export function watchScenePhase(
35
+ onChange: (phase: ScenePhase) => void,
36
+ { doc = document, win = window }: { doc?: ScenePhaseDocument; win?: ScenePhaseWindow } = {},
37
+ ): () => void {
38
+ let last = currentScenePhase(doc);
39
+ const check = (): void => {
40
+ const next = currentScenePhase(doc);
41
+ if (next === last) return;
42
+ last = next;
43
+ onChange(next);
44
+ };
45
+ doc.addEventListener('visibilitychange', check);
46
+ win.addEventListener('focus', check);
47
+ win.addEventListener('blur', check);
48
+ return () => {
49
+ doc.removeEventListener('visibilitychange', check);
50
+ win.removeEventListener('focus', check);
51
+ win.removeEventListener('blur', check);
52
+ };
53
+ }
package/src/styles.css CHANGED
@@ -152,7 +152,8 @@
152
152
  --sb-alert-scrim: rgba(0, 0, 0, 0.46);
153
153
  --sb-alert-card: rgba(22, 22, 23, 0.9);
154
154
  --sb-color-field-border: #3a3a3c;
155
- --sb-color-field-background: #1c1c1e;
155
+ /* A rounded-border field is black in the dark, in a sheet too (UIKit's rounded-rect text field is not elevated). */
156
+ --sb-color-field-background: #000000;
156
157
  --sb-color-placeholder: rgba(235, 235, 245, 0.3);
157
158
  --sb-glass-background: rgba(30, 30, 30, 0.84);
158
159
  --sb-glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
@@ -1665,14 +1666,16 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1665
1666
  box-shadow: 0 10px 50px rgba(0, 0, 0, 0.25);
1666
1667
  }
1667
1668
 
1668
- /* In dark mode what a form sheet holds takes iOS's elevated colors, as the
1669
- card does: the base ones are black and would paint it black. */
1670
- .device[data-theme="dark"] .sb-sheet[data-sb-form] > .sb-sheet-card {
1669
+ /* In dark mode what a sheet holds takes iOS's elevated colors, as the card
1670
+ does (UIKit raises the interface level of anything presented as a sheet,
1671
+ on a phone as on an iPad's form sheet): the base ones are black and would
1672
+ paint it black. A full-screen cover is not a sheet to UIKit and keeps the
1673
+ base colors. */
1674
+ .device[data-theme="dark"] .sb-sheet:not([data-sb-detent="full"]) > .sb-sheet-card {
1671
1675
  --sb-color-system-background: #1c1c1e;
1672
1676
  --sb-color-secondary-system-background: #2c2c2e;
1673
1677
  --sb-color-grouped-background: #1c1c1e;
1674
1678
  --sb-color-grouped-cell: #2c2c2e;
1675
- --sb-color-field-background: #2c2c2e;
1676
1679
  }
1677
1680
 
1678
1681
  .sb-sheet-grabber {
@@ -3091,10 +3094,18 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
3091
3094
  }
3092
3095
 
3093
3096
  .sb-gesture[data-sb-drag],
3094
- .sb-gesture[data-sb-pinch] {
3097
+ .sb-gesture[data-sb-pinch],
3098
+ .sb-touches {
3095
3099
  touch-action: none;
3096
3100
  }
3097
3101
 
3102
+ /* `touches`: the element owns its pointers (src/touches.ts), so no selection, callout or scroll from them. */
3103
+ .sb-touches {
3104
+ user-select: none;
3105
+ -webkit-user-select: none;
3106
+ -webkit-touch-callout: none;
3107
+ }
3108
+
3098
3109
  /* `contextmenu` / `swipeactions`: only the content (the first child) shows
3099
3110
  inline; the items are listed in the popover / drawn as action buttons
3100
3111
  (they are in `hidden` without frames). */
package/src/symbols.ts CHANGED
@@ -505,6 +505,17 @@ const BOOKS_VERTICAL_FILL: IconNode = [
505
505
  ['rect', { x: '17.8', y: '4.5', width: '4', height: '16', rx: '1.2', stroke: 'none', transform: 'rotate(-14 19.8 12.5)' }],
506
506
  ];
507
507
 
508
+ /** `apple.logo`: the Apple logo (the simple-icons path, CC0), as one solid shape, for a Sign in with Apple button. */
509
+ const APPLE_LOGO: IconNode = [
510
+ [
511
+ 'path',
512
+ {
513
+ d: 'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701',
514
+ stroke: 'none',
515
+ },
516
+ ],
517
+ ];
518
+
508
519
  /** Marks a `knockout` glyph's inner shape that is filled, not only stroked (`enclosed`). */
509
520
  const SOLID_MARK = 'data-sb-knockout-solid';
510
521
 
@@ -1226,6 +1237,8 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
1226
1237
  bird: outline(Bird),
1227
1238
  // The Swift logo: a bird in flight.
1228
1239
  swift: outline(Bird),
1240
+ 'apple.logo': solid(APPLE_LOGO),
1241
+ applelogo: solid(APPLE_LOGO),
1229
1242
  cat: outline(Cat),
1230
1243
  dog: outline(Dog),
1231
1244
  carrot: outline(Carrot),
package/src/touches.ts ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Multi-touch: the `touches` element (`.onTouches` in the shim, `onTouches`
3
+ * in @swiftbrowser/react; docs/ops-protocol.md, "Multi-touch"). The
4
+ * single-pointer gestures of src/interaction.ts own one pointer at a time,
5
+ * which a piano or a drum pad cannot live with; this controller owns every
6
+ * pointer that goes down inside a `touches` element and sends the element
7
+ * `touches` events with all of them: `began` as one comes down, `changed` as
8
+ * they move (one event per animation frame), `ended` as one lifts and
9
+ * `cancelled` when the browser takes them away. Each event carries every
10
+ * pointer still down, in the element's coordinates, so the app reads the
11
+ * state from the event (the keys under the fingers) rather than pairing ups
12
+ * with downs.
13
+ *
14
+ * A pointer down inside a `touches` element stops there: the interaction
15
+ * controller never sees it, so no scroll view, swipe row or `gesture` above
16
+ * the element competes for the finger, and the stylesheet's `touch-action:
17
+ * none` keeps the browser from scrolling or zooming with it. Clicks still
18
+ * reach the controls inside (a click is not a pointer event).
19
+ *
20
+ * `TouchSet` is the pure part: which pointers are down and what each change
21
+ * reports, testable without a DOM.
22
+ */
23
+
24
+ import type { DragPhase, ElementId, TouchPoint, TouchesEvent } from './protocol';
25
+
26
+ export interface TouchesHost {
27
+ readonly root: HTMLElement;
28
+ send(event: TouchesEvent): void;
29
+ }
30
+
31
+ const round = (n: number): number => Math.round(n * 100) / 100;
32
+
33
+ /** The pointers down on one `touches` element, by pointer id, in order of arrival. */
34
+ export class TouchSet {
35
+ private readonly points = new Map<number, { x: number; y: number }>();
36
+
37
+ get size(): number {
38
+ return this.points.size;
39
+ }
40
+
41
+ has(id: number): boolean {
42
+ return this.points.has(id);
43
+ }
44
+
45
+ /** A pointer came down: the set with it, as the event lists it. */
46
+ down(id: number, x: number, y: number): TouchPoint[] {
47
+ this.points.set(id, { x, y });
48
+ return this.list();
49
+ }
50
+
51
+ /** A pointer moved; false when it is not one of ours or did not move. */
52
+ move(id: number, x: number, y: number): boolean {
53
+ const point = this.points.get(id);
54
+ if (!point || (point.x === x && point.y === y)) return false;
55
+ point.x = x;
56
+ point.y = y;
57
+ return true;
58
+ }
59
+
60
+ /** A pointer lifted: the set without it, or null when it was not one of ours. */
61
+ up(id: number): TouchPoint[] | null {
62
+ if (!this.points.delete(id)) return null;
63
+ return this.list();
64
+ }
65
+
66
+ clear(): void {
67
+ this.points.clear();
68
+ }
69
+
70
+ /** Every pointer down, rounded to hundredths of a point. */
71
+ list(): TouchPoint[] {
72
+ return [...this.points.entries()].map(([id, point]) => ({ id, x: round(point.x), y: round(point.y) }));
73
+ }
74
+ }
75
+
76
+ /** One `touches` element with pointers down on it. */
77
+ interface Session {
78
+ el: HTMLElement;
79
+ id: ElementId;
80
+ set: TouchSet;
81
+ /** The element's box in the viewport, measured as each pointer comes down. */
82
+ rect: DOMRect;
83
+ /** A `changed` waiting for the next frame. */
84
+ raf: number | null;
85
+ }
86
+
87
+ export class TouchesController {
88
+ /** The elements with pointers down, and which element each pointer is on. */
89
+ private readonly sessions = new Map<HTMLElement, Session>();
90
+ private readonly owners = new Map<number, Session>();
91
+ private listening = false;
92
+
93
+ constructor(private readonly host: TouchesHost) {
94
+ host.root.addEventListener('pointerdown', this.onPointerDown, true);
95
+ }
96
+
97
+ dispose(): void {
98
+ this.host.root.removeEventListener('pointerdown', this.onPointerDown, true);
99
+ this.detachWindow();
100
+ for (const session of this.sessions.values()) {
101
+ if (session.raf !== null) cancelAnimationFrame(session.raf);
102
+ }
103
+ this.sessions.clear();
104
+ this.owners.clear();
105
+ }
106
+
107
+ /** The innermost `touches` element at or above `target`, or null. */
108
+ private elementAt(target: EventTarget | null): { el: HTMLElement; id: ElementId } | null {
109
+ const start = target instanceof Element ? target : null;
110
+ const el = start?.closest<HTMLElement>('.sb-touches') ?? null;
111
+ if (!el || !this.host.root.contains(el)) return null;
112
+ // Pointer downs in a text field select text (as src/interaction.ts leaves them).
113
+ if (start?.closest('input, textarea, [contenteditable]')) return null;
114
+ const id = Number(el.dataset['sbId']);
115
+ return Number.isInteger(id) ? { el, id } : null;
116
+ }
117
+
118
+ private readonly onPointerDown = (event: PointerEvent): void => {
119
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
120
+ const found = this.elementAt(event.target);
121
+ if (!found) return;
122
+ // Ours alone: no gesture, scroll or swipe above competes for the pointer.
123
+ event.stopPropagation();
124
+ let session = this.sessions.get(found.el);
125
+ if (!session) {
126
+ session = { el: found.el, id: found.id, set: new TouchSet(), rect: found.el.getBoundingClientRect(), raf: null };
127
+ this.sessions.set(found.el, session);
128
+ } else {
129
+ session.rect = found.el.getBoundingClientRect();
130
+ }
131
+ this.owners.set(event.pointerId, session);
132
+ try {
133
+ found.el.setPointerCapture(event.pointerId);
134
+ } catch {
135
+ // Not every environment allows capture; the window listeners still hear the pointer.
136
+ }
137
+ this.attachWindow();
138
+ const touches = session.set.down(event.pointerId, event.clientX - session.rect.left, event.clientY - session.rect.top);
139
+ this.flush(session);
140
+ this.host.send({ type: 'touches', id: session.id, phase: 'began', touches });
141
+ };
142
+
143
+ private readonly onPointerMove = (event: PointerEvent): void => {
144
+ const session = this.owners.get(event.pointerId);
145
+ if (!session) return;
146
+ if (!session.set.move(event.pointerId, event.clientX - session.rect.left, event.clientY - session.rect.top)) return;
147
+ if (session.raf !== null) return;
148
+ session.raf = requestAnimationFrame(() => {
149
+ session.raf = null;
150
+ if (!this.sessions.has(session.el)) return;
151
+ this.host.send({ type: 'touches', id: session.id, phase: 'changed', touches: session.set.list() });
152
+ });
153
+ };
154
+
155
+ private readonly onPointerUp = (event: PointerEvent): void => {
156
+ this.lift(event.pointerId, 'ended');
157
+ };
158
+
159
+ private readonly onPointerCancel = (event: PointerEvent): void => {
160
+ this.lift(event.pointerId, 'cancelled');
161
+ };
162
+
163
+ /** A pointer is gone: the element hears the set without it; the last one ends the session. */
164
+ private lift(pointerId: number, phase: DragPhase): void {
165
+ const session = this.owners.get(pointerId);
166
+ if (!session) return;
167
+ this.owners.delete(pointerId);
168
+ const touches = session.set.up(pointerId);
169
+ if (touches === null) return;
170
+ this.flush(session);
171
+ if (touches.length === 0) this.sessions.delete(session.el);
172
+ if (this.owners.size === 0) this.detachWindow();
173
+ this.host.send({ type: 'touches', id: session.id, phase, touches });
174
+ }
175
+
176
+ /** A `changed` waiting for the next frame goes out now, before a down or an up, so the order holds. */
177
+ private flush(session: Session): void {
178
+ if (session.raf === null) return;
179
+ cancelAnimationFrame(session.raf);
180
+ session.raf = null;
181
+ this.host.send({ type: 'touches', id: session.id, phase: 'changed', touches: session.set.list() });
182
+ }
183
+
184
+ private attachWindow(): void {
185
+ if (this.listening) return;
186
+ this.listening = true;
187
+ window.addEventListener('pointermove', this.onPointerMove);
188
+ window.addEventListener('pointerup', this.onPointerUp);
189
+ window.addEventListener('pointercancel', this.onPointerCancel);
190
+ }
191
+
192
+ private detachWindow(): void {
193
+ if (!this.listening) return;
194
+ this.listening = false;
195
+ window.removeEventListener('pointermove', this.onPointerMove);
196
+ window.removeEventListener('pointerup', this.onPointerUp);
197
+ window.removeEventListener('pointercancel', this.onPointerCancel);
198
+ }
199
+ }