@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 +1 -1
- package/src/appFiles.ts +2 -0
- package/src/appleSignIn.ts +140 -0
- package/src/files.ts +53 -9
- package/src/hostRequests.ts +126 -2
- package/src/main.ts +1 -8
- package/src/protocol.ts +61 -3
- package/src/renderer.ts +7 -0
- package/src/scenePhase.ts +53 -0
- package/src/styles.css +17 -6
- package/src/symbols.ts +13 -0
- package/src/touches.ts +199 -0
package/package.json
CHANGED
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|
package/src/hostRequests.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
+
}
|