@swiftbrowser/web 0.17.7 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node/dist/node/apps.d.ts +5 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +3 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +2 -0
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +2 -0
- package/node/dist/node/config.js.map +1 -1
- package/package.json +1 -1
- package/public/_headers +5 -0
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +50 -5
- package/src/interaction.ts +108 -27
- package/src/layout/engine.ts +295 -19
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -5
- package/src/main.ts +10 -1
- package/src/notifications.ts +134 -1
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +307 -14
- package/src/renderer.ts +354 -43
- package/src/richEditor.ts +330 -0
- package/src/scrollGeometry.ts +79 -0
- package/src/scrollTransition.ts +145 -0
- package/src/styles.css +142 -11
- package/src/symbols.ts +22 -0
- package/src/touchPaging.ts +23 -2
- package/src/undo.ts +126 -0
- package/src/video.ts +59 -8
- package/src/vite-env.d.ts +2 -0
package/src/files.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's files from JavaScript: `FileManager` in @swiftbrowser/react
|
|
3
|
+
* (packages/react/src/fileManager.ts) over the `files` field of the host
|
|
4
|
+
* request environment, a `FilesHost`. On a page the host is this module: the
|
|
5
|
+
* same iOS-like home a Swift app has (appFiles.ts, "Phase 19"), `/data` with
|
|
6
|
+
* `Documents`, `Library/Application Support` and `Library/Caches`, kept in
|
|
7
|
+
* the app's IndexedDB database (`sb-files:<App>`, the `entries` store, one
|
|
8
|
+
* row per file or directory), and `/tmp`, kept in memory for the session.
|
|
9
|
+
* Natively the host is the app's sandbox (packages/native/src/hostRequests.ts,
|
|
10
|
+
* Host/FilesBridge.swift). Paths are `file:` URLs on both sides, and
|
|
11
|
+
* `loadURL` turns one into a URL a media element or an image can load (an
|
|
12
|
+
* object URL of the bytes here, the URL itself natively), which the renderer
|
|
13
|
+
* does for a `file:` URL in a `video` or `image` element.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ENTRIES, HOME, HOME_DIRECTORIES, TMP, done, openFilesDatabase, request, type EntryRow } from './appFiles';
|
|
17
|
+
|
|
18
|
+
/** What `attributes` answers for a file or directory that exists. */
|
|
19
|
+
export interface FileAttributes {
|
|
20
|
+
/** The file's bytes; 0 for a directory. */
|
|
21
|
+
size: number;
|
|
22
|
+
/** When it was last written, in milliseconds since the epoch, where the host knows. */
|
|
23
|
+
modified: number | null;
|
|
24
|
+
directory: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The app's standard directories, as `file:` URLs. */
|
|
28
|
+
export interface FileDirectories {
|
|
29
|
+
documents: string;
|
|
30
|
+
applicationSupport: string;
|
|
31
|
+
caches: string;
|
|
32
|
+
temporary: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The host of the app's files. Every URL is a `file:` URL in the app's home;
|
|
37
|
+
* one outside it fails `unsupported: …`. Failures are errors whose message
|
|
38
|
+
* starts with a word and a colon: `notFound: …` (no such file or directory,
|
|
39
|
+
* or the destination's directory), `exists: …` (a destination, or a file
|
|
40
|
+
* where a directory is made), `isDirectory: …` (a directory read as a file),
|
|
41
|
+
* `notDirectory: …` (a file listed as a directory).
|
|
42
|
+
*/
|
|
43
|
+
export interface FilesHost {
|
|
44
|
+
readonly directories: FileDirectories;
|
|
45
|
+
/** The file's bytes. */
|
|
46
|
+
read(url: string): Promise<Uint8Array>;
|
|
47
|
+
/** Writes the bytes as the file, replacing one there; its directory must exist. */
|
|
48
|
+
write(url: string, data: Uint8Array): Promise<void>;
|
|
49
|
+
/** Removes a file, or a directory and everything in it. */
|
|
50
|
+
remove(url: string): Promise<void>;
|
|
51
|
+
/** Makes the directory and the directories above it; one already there is fine. */
|
|
52
|
+
createDirectory(url: string): Promise<void>;
|
|
53
|
+
/** The URLs of what the directory holds, by name. */
|
|
54
|
+
list(url: string): Promise<string[]>;
|
|
55
|
+
/** The attributes of what is at the URL, or null when nothing is. */
|
|
56
|
+
attributes(url: string): Promise<FileAttributes | null>;
|
|
57
|
+
/** Moves a file or directory to a URL nothing is at, in a directory that exists. */
|
|
58
|
+
move(from: string, to: string): Promise<void>;
|
|
59
|
+
/** Copies a file or directory to a URL nothing is at, in a directory that exists. */
|
|
60
|
+
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. */
|
|
62
|
+
loadURL(url: string): Promise<string>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Paths
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** The home on a page, as the wasm module sees it, and the session's temporary directory. */
|
|
70
|
+
export const HOME_URL = `file://${HOME}`;
|
|
71
|
+
export const TMP_URL = `file://${TMP}`;
|
|
72
|
+
|
|
73
|
+
export const BROWSER_DIRECTORIES: FileDirectories = {
|
|
74
|
+
documents: `${HOME_URL}/Documents`,
|
|
75
|
+
applicationSupport: `${HOME_URL}/Library/Application Support`,
|
|
76
|
+
caches: `${HOME_URL}/Library/Caches`,
|
|
77
|
+
temporary: TMP_URL,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The segments of a `file:` URL's path, `.` and `..` resolved; null for another scheme. */
|
|
81
|
+
export function fileSegments(url: string): string[] | null {
|
|
82
|
+
let parsed: URL;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(url);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (parsed.protocol !== 'file:') return null;
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
for (const raw of parsed.pathname.split('/')) {
|
|
91
|
+
let part = raw;
|
|
92
|
+
try {
|
|
93
|
+
part = decodeURIComponent(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
// Not percent-encoded: as written.
|
|
96
|
+
}
|
|
97
|
+
if (part === '' || part === '.') continue;
|
|
98
|
+
if (part === '..') out.pop();
|
|
99
|
+
else out.push(part);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A `file:` URL of `root` (a `file:` URL) and the path below it. */
|
|
105
|
+
export function fileURL(root: string, path: string): string {
|
|
106
|
+
const encoded = path
|
|
107
|
+
.split('/')
|
|
108
|
+
.filter((part) => part !== '')
|
|
109
|
+
.map((part) => encodeURIComponent(part))
|
|
110
|
+
.join('/');
|
|
111
|
+
return encoded === '' ? root : `${root.replace(/\/+$/, '')}/${encoded}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `path`'s directory ('' at the top). */
|
|
115
|
+
function parentOf(path: string): string {
|
|
116
|
+
const at = path.lastIndexOf('/');
|
|
117
|
+
return at < 0 ? '' : path.slice(0, at);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Rows: a home's files by path, in IndexedDB or memory
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** A file's bytes, or null for a directory, and when it was written. */
|
|
125
|
+
export interface FileRow {
|
|
126
|
+
data: Uint8Array | null;
|
|
127
|
+
modified: number | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The rows of one home, by path relative to it (`Documents/notes.txt`). */
|
|
131
|
+
export interface FileRows {
|
|
132
|
+
get(path: string): Promise<FileRow | undefined>;
|
|
133
|
+
put(path: string, row: FileRow): Promise<void>;
|
|
134
|
+
remove(paths: readonly string[]): Promise<void>;
|
|
135
|
+
/** Every path that has a row. */
|
|
136
|
+
keys(): Promise<string[]>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Rows in a Map: `/tmp`, a page without IndexedDB, and tests. */
|
|
140
|
+
export function memoryRows(): FileRows {
|
|
141
|
+
const rows = new Map<string, FileRow>();
|
|
142
|
+
return {
|
|
143
|
+
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 }),
|
|
145
|
+
remove: async (paths) => paths.forEach((path) => rows.delete(path)),
|
|
146
|
+
keys: async () => [...rows.keys()],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The app's rows in its IndexedDB database (the `entries` store the wasm loader reads). */
|
|
151
|
+
export function indexedDBRows(app: string, factory: IDBFactory = indexedDB): FileRows {
|
|
152
|
+
let opened: Promise<IDBDatabase> | null = null;
|
|
153
|
+
const database = (): Promise<IDBDatabase> => (opened ??= openFilesDatabase(app, factory));
|
|
154
|
+
const store = async (mode: IDBTransactionMode): Promise<IDBObjectStore> => (await database()).transaction(ENTRIES, mode).objectStore(ENTRIES);
|
|
155
|
+
return {
|
|
156
|
+
async get(path) {
|
|
157
|
+
const row = (await request((await store('readonly')).get(path))) as EntryRow | undefined;
|
|
158
|
+
if (!row) return undefined;
|
|
159
|
+
return { data: row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()), modified: typeof row.modified === 'number' ? row.modified : null };
|
|
160
|
+
},
|
|
161
|
+
async put(path, row) {
|
|
162
|
+
const entries = await store('readwrite');
|
|
163
|
+
const stored: EntryRow = row.data === null ? { path, directory: true } : { path, data: row.data.slice() };
|
|
164
|
+
if (row.modified !== null) stored.modified = row.modified;
|
|
165
|
+
entries.put(stored);
|
|
166
|
+
await done(entries.transaction);
|
|
167
|
+
},
|
|
168
|
+
async remove(paths) {
|
|
169
|
+
if (paths.length === 0) return;
|
|
170
|
+
const entries = await store('readwrite');
|
|
171
|
+
for (const path of paths) entries.delete(path);
|
|
172
|
+
await done(entries.transaction);
|
|
173
|
+
},
|
|
174
|
+
async keys() {
|
|
175
|
+
return (await request((await store('readonly')).getAllKeys())).map(String);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// The host
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export class FilesFailure extends Error {
|
|
185
|
+
constructor(kind: 'unsupported' | 'notFound' | 'exists' | 'isDirectory' | 'notDirectory', detail: string) {
|
|
186
|
+
super(`${kind}: ${detail}`);
|
|
187
|
+
this.name = 'FilesFailure';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A mounted root: its URL, where its rows are, and the directories it always has. */
|
|
192
|
+
interface Mount {
|
|
193
|
+
url: string;
|
|
194
|
+
prefix: string[];
|
|
195
|
+
rows: () => FileRows;
|
|
196
|
+
standard: readonly string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const MEDIA_TYPES: Record<string, string> = {
|
|
200
|
+
m4a: 'audio/mp4', mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/mp4', webm: 'video/webm', wav: 'audio/wav', mp3: 'audio/mpeg',
|
|
201
|
+
aac: 'audio/aac', ogg: 'audio/ogg', oga: 'audio/ogg', flac: 'audio/flac', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
202
|
+
webp: 'image/webp', svg: 'image/svg+xml', heic: 'image/heic', avif: 'image/avif', pdf: 'application/pdf', json: 'application/json', txt: 'text/plain',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** The media type a file's extension says, for the blob a media element loads. */
|
|
206
|
+
export function mediaType(path: string): string {
|
|
207
|
+
const dot = path.lastIndexOf('.');
|
|
208
|
+
return (dot >= 0 && MEDIA_TYPES[path.slice(dot + 1).toLowerCase()]) || 'application/octet-stream';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface BrowserFilesOptions {
|
|
212
|
+
/** The home's rows (the app's IndexedDB database by default). */
|
|
213
|
+
home?: FileRows;
|
|
214
|
+
/** `/tmp`'s rows (memory by default). */
|
|
215
|
+
temporary?: FileRows;
|
|
216
|
+
/** `URL.createObjectURL` and `URL.revokeObjectURL`, or null for `data:` URLs. */
|
|
217
|
+
objectURLs?: { create(blob: Blob): string; revoke(url: string): void } | null;
|
|
218
|
+
now?: () => number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The page's files host over rows (see the module comment). `app` names the
|
|
223
|
+
* database the home's rows are in, read when the home is first touched after
|
|
224
|
+
* `setApp` (mount's `app` option); `app` by default.
|
|
225
|
+
*/
|
|
226
|
+
export class BrowserFiles implements FilesHost {
|
|
227
|
+
readonly directories = BROWSER_DIRECTORIES;
|
|
228
|
+
private app = 'app';
|
|
229
|
+
private homeRows: FileRows | null;
|
|
230
|
+
private readonly temporaryRows: FileRows;
|
|
231
|
+
private readonly objectURLs: { create(blob: Blob): string; revoke(url: string): void } | null;
|
|
232
|
+
private readonly now: () => number;
|
|
233
|
+
/** Object URLs handed out, by mount URL and path, revoked when the file changes. */
|
|
234
|
+
private readonly loaded = new Map<string, string>();
|
|
235
|
+
|
|
236
|
+
/** Whether the home's rows are the database named by `app` (not rows an option gave). */
|
|
237
|
+
private readonly defaultRows: boolean;
|
|
238
|
+
|
|
239
|
+
constructor(options: BrowserFilesOptions = {}) {
|
|
240
|
+
this.homeRows = options.home ?? null;
|
|
241
|
+
this.defaultRows = options.home === undefined;
|
|
242
|
+
this.temporaryRows = options.temporary ?? memoryRows();
|
|
243
|
+
this.objectURLs =
|
|
244
|
+
options.objectURLs !== undefined
|
|
245
|
+
? options.objectURLs
|
|
246
|
+
: typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
|
|
247
|
+
? { create: (blob) => URL.createObjectURL(blob), revoke: (url) => URL.revokeObjectURL(url) }
|
|
248
|
+
: null;
|
|
249
|
+
this.now = options.now ?? (() => Date.now());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Names the app whose database holds the home (`mount`'s `app` option),
|
|
254
|
+
* for what the app does with its files from here on; name it before the
|
|
255
|
+
* app touches them, since what was written before stays in the database
|
|
256
|
+
* named then.
|
|
257
|
+
*/
|
|
258
|
+
setApp(app: string): void {
|
|
259
|
+
if (app === this.app) return;
|
|
260
|
+
this.app = app;
|
|
261
|
+
if (this.defaultRows) this.homeRows = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private readonly mounts: Mount[] = [
|
|
265
|
+
{
|
|
266
|
+
url: HOME_URL,
|
|
267
|
+
prefix: HOME.split('/').filter(Boolean),
|
|
268
|
+
rows: () => (this.homeRows ??= typeof indexedDB === 'undefined' ? memoryRows() : indexedDBRows(this.app)),
|
|
269
|
+
standard: HOME_DIRECTORIES,
|
|
270
|
+
},
|
|
271
|
+
{ url: TMP_URL, prefix: TMP.split('/').filter(Boolean), rows: () => this.temporaryRows, standard: [] },
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
/** The mount a URL falls in and its path there, or a failure for a URL outside the app's files. */
|
|
275
|
+
private locate(url: string): { mount: Mount; path: string } {
|
|
276
|
+
const segments = fileSegments(url);
|
|
277
|
+
if (segments) {
|
|
278
|
+
for (const mount of this.mounts) {
|
|
279
|
+
if (segments.length >= mount.prefix.length && mount.prefix.every((part, i) => segments[i] === part)) {
|
|
280
|
+
return { mount, path: segments.slice(mount.prefix.length).join('/') };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
throw new FilesFailure('unsupported', `${url} is not a file in the app's home (${HOME_URL}) or its temporary directory (${TMP_URL})`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Whether `path` is a directory the mount always has, or an ancestor of a row (a directory the rows imply). */
|
|
288
|
+
private async impliedDirectory(mount: Mount, path: string): Promise<boolean> {
|
|
289
|
+
if (path === '' || mount.standard.includes(path)) return true;
|
|
290
|
+
const prefix = `${path}/`;
|
|
291
|
+
return (await mount.rows().keys()).some((key) => key.startsWith(prefix));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The row at the path, a directory row for an implied directory, or undefined. */
|
|
295
|
+
private async rowAt(mount: Mount, path: string): Promise<FileRow | undefined> {
|
|
296
|
+
const row = await mount.rows().get(path);
|
|
297
|
+
if (row) return row;
|
|
298
|
+
return (await this.impliedDirectory(mount, path)) ? { data: null, modified: null } : undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async requireDirectory(mount: Mount, path: string, url: string): Promise<void> {
|
|
302
|
+
const row = await this.rowAt(mount, path);
|
|
303
|
+
if (!row) throw new FilesFailure('notFound', `no directory at ${fileURL(mount.url, path)} (for ${url})`);
|
|
304
|
+
if (row.data !== null) throw new FilesFailure('notDirectory', `${fileURL(mount.url, path)} is a file`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private forget(mount: Mount, path: string): void {
|
|
308
|
+
const key = `${mount.url}/${path}`;
|
|
309
|
+
const url = this.loaded.get(key);
|
|
310
|
+
if (url === undefined) return;
|
|
311
|
+
this.loaded.delete(key);
|
|
312
|
+
this.objectURLs?.revoke(url);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async read(url: string): Promise<Uint8Array> {
|
|
316
|
+
const { mount, path } = this.locate(url);
|
|
317
|
+
const row = await this.rowAt(mount, path);
|
|
318
|
+
if (!row) throw new FilesFailure('notFound', `no file at ${url}`);
|
|
319
|
+
if (row.data === null) throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
320
|
+
return row.data;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async write(url: string, data: Uint8Array): Promise<void> {
|
|
324
|
+
const { mount, path } = this.locate(url);
|
|
325
|
+
if (path === '') throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
326
|
+
await this.requireDirectory(mount, parentOf(path), url);
|
|
327
|
+
const existing = await this.rowAt(mount, path);
|
|
328
|
+
if (existing && existing.data === null) throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
329
|
+
await mount.rows().put(path, { data, modified: this.now() });
|
|
330
|
+
this.forget(mount, path);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async remove(url: string): Promise<void> {
|
|
334
|
+
const { mount, path } = this.locate(url);
|
|
335
|
+
if (path === '') throw new FilesFailure('unsupported', `${url} is the home itself`);
|
|
336
|
+
const row = await this.rowAt(mount, path);
|
|
337
|
+
if (!row) throw new FilesFailure('notFound', `nothing at ${url}`);
|
|
338
|
+
const prefix = `${path}/`;
|
|
339
|
+
const below = (await mount.rows().keys()).filter((key) => key === path || key.startsWith(prefix));
|
|
340
|
+
await mount.rows().remove(below);
|
|
341
|
+
for (const key of below) this.forget(mount, key);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async createDirectory(url: string): Promise<void> {
|
|
345
|
+
const { mount, path } = this.locate(url);
|
|
346
|
+
const parts = path.split('/').filter(Boolean);
|
|
347
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
348
|
+
const at = parts.slice(0, depth).join('/');
|
|
349
|
+
const row = await mount.rows().get(at);
|
|
350
|
+
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() });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async list(url: string): Promise<string[]> {
|
|
356
|
+
const { mount, path } = this.locate(url);
|
|
357
|
+
await this.requireDirectory(mount, path, url);
|
|
358
|
+
const prefix = path === '' ? '' : `${path}/`;
|
|
359
|
+
const names = new Set<string>();
|
|
360
|
+
for (const key of [...(await mount.rows().keys()), ...mount.standard]) {
|
|
361
|
+
if (!key.startsWith(prefix) || key === path) continue;
|
|
362
|
+
const rest = key.slice(prefix.length);
|
|
363
|
+
const slash = rest.indexOf('/');
|
|
364
|
+
names.add(slash < 0 ? rest : rest.slice(0, slash));
|
|
365
|
+
}
|
|
366
|
+
return [...names].sort((a, b) => a.localeCompare(b)).map((name) => fileURL(mount.url, prefix + name));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async attributes(url: string): Promise<FileAttributes | null> {
|
|
370
|
+
const { mount, path } = this.locate(url);
|
|
371
|
+
const row = await this.rowAt(mount, path);
|
|
372
|
+
if (!row) return null;
|
|
373
|
+
return { size: row.data?.byteLength ?? 0, modified: row.modified, directory: row.data === null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Every row at or below `path`, by path. */
|
|
377
|
+
private async subtree(mount: Mount, path: string): Promise<Map<string, FileRow>> {
|
|
378
|
+
const out = new Map<string, FileRow>();
|
|
379
|
+
const own = await this.rowAt(mount, path);
|
|
380
|
+
if (own) out.set(path, own);
|
|
381
|
+
const prefix = `${path}/`;
|
|
382
|
+
for (const key of await mount.rows().keys()) {
|
|
383
|
+
if (!key.startsWith(prefix)) continue;
|
|
384
|
+
const row = await mount.rows().get(key);
|
|
385
|
+
if (row) out.set(key, row);
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async transfer(from: string, to: string, removeSource: boolean): Promise<void> {
|
|
391
|
+
const source = this.locate(from);
|
|
392
|
+
const target = this.locate(to);
|
|
393
|
+
if (source.path === '' || target.path === '') throw new FilesFailure('unsupported', `the home itself cannot be ${removeSource ? 'moved' : 'copied'}`);
|
|
394
|
+
const rows = await this.subtree(source.mount, source.path);
|
|
395
|
+
if (rows.size === 0) throw new FilesFailure('notFound', `nothing at ${from}`);
|
|
396
|
+
if (await this.rowAt(target.mount, target.path)) throw new FilesFailure('exists', `something is at ${to} already`);
|
|
397
|
+
await this.requireDirectory(target.mount, parentOf(target.path), to);
|
|
398
|
+
if (target.path.startsWith(`${source.path}/`) && target.mount === source.mount) throw new FilesFailure('unsupported', `${to} is inside ${from}`);
|
|
399
|
+
const now = this.now();
|
|
400
|
+
for (const [path, row] of rows) {
|
|
401
|
+
const moved = target.path + path.slice(source.path.length);
|
|
402
|
+
await target.mount.rows().put(moved, { data: row.data, modified: removeSource ? row.modified : now });
|
|
403
|
+
}
|
|
404
|
+
if (removeSource) {
|
|
405
|
+
await source.mount.rows().remove([...rows.keys()].filter((key) => key !== source.path || !source.mount.standard.includes(key)));
|
|
406
|
+
for (const key of rows.keys()) this.forget(source.mount, key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
move(from: string, to: string): Promise<void> {
|
|
411
|
+
return this.transfer(from, to, true);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
copy(from: string, to: string): Promise<void> {
|
|
415
|
+
return this.transfer(from, to, false);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async loadURL(url: string): Promise<string> {
|
|
419
|
+
const { mount, path } = this.locate(url);
|
|
420
|
+
const key = `${mount.url}/${path}`;
|
|
421
|
+
const known = this.loaded.get(key);
|
|
422
|
+
if (known !== undefined) return known;
|
|
423
|
+
const data = await this.read(url);
|
|
424
|
+
const type = mediaType(path);
|
|
425
|
+
if (!this.objectURLs) return `data:${type};base64,${base64Of(data)}`;
|
|
426
|
+
const made = this.objectURLs.create(new Blob([data.slice()], { type }));
|
|
427
|
+
this.loaded.set(key, made);
|
|
428
|
+
return made;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function base64Of(bytes: Uint8Array): string {
|
|
433
|
+
let binary = '';
|
|
434
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
435
|
+
return btoa(binary);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The page's files host, where the page keeps files (IndexedDB) or can at least hold them for the session. */
|
|
439
|
+
export function browserFiles(): BrowserFiles | undefined {
|
|
440
|
+
if (typeof indexedDB === 'undefined' && typeof document === 'undefined') return undefined;
|
|
441
|
+
return new BrowserFiles();
|
|
442
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How gestures compose (docs/ops-protocol.md, "Gesture spaces and
|
|
3
|
+
* composition"): the order the wrappers on a pointer's way to the root are
|
|
4
|
+
* asked in, and the taps a `tapgesture` with `count` collects. Pure, so the
|
|
5
|
+
* rules are tested in Node; src/interaction.ts and src/renderer.ts apply them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GestureMode } from './protocol';
|
|
9
|
+
|
|
10
|
+
/** A behaviour that could own a pointer: a `gesture` element's carries its mode. */
|
|
11
|
+
export interface Wrapper {
|
|
12
|
+
kind: string;
|
|
13
|
+
mode?: GestureMode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The double-tap interval: a single tap beside a double tap waits this long. */
|
|
17
|
+
export const DOUBLE_TAP_MS = 300;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Puts a wrapper found on the way up into `out`, which lists the wrappers
|
|
21
|
+
* found so far innermost first, from `wrapperStart` (what comes before them
|
|
22
|
+
* is the system's: a row's grip, the back swipe). A `highPriority` gesture
|
|
23
|
+
* goes in front of the wrappers inside it, as `.highPriorityGesture` wins
|
|
24
|
+
* over the gestures of the views it wraps; an outer one found later goes in
|
|
25
|
+
* front of it in turn. Everything else goes at the end, innermost first.
|
|
26
|
+
*/
|
|
27
|
+
export function placeWrapper<T extends Wrapper>(out: T[], candidate: T, wrapperStart: number): void {
|
|
28
|
+
if (candidate.kind === 'gesture' && candidate.mode === 'highPriority') out.splice(wrapperStart, 0, candidate);
|
|
29
|
+
else out.push(candidate);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `.simultaneousGesture`: runs beside the pointer's owner, never claiming. */
|
|
33
|
+
export function isSimultaneous(candidate: Wrapper): boolean {
|
|
34
|
+
return candidate.kind === 'gesture' && candidate.mode === 'simultaneous';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The taps a click lands: `multi` is the innermost `tapgesture` with a
|
|
39
|
+
* `count` above one on the click's way up (null when none), `single` the
|
|
40
|
+
* innermost of one tap; `detail` is what the firing needs of the click (its
|
|
41
|
+
* point), the last click's when several make a tap. A click with no `multi`
|
|
42
|
+
* fires `single` at once. With one, the clicks are counted: the `count`th
|
|
43
|
+
* within the interval fires `multi`; should the interval pass first, `single`
|
|
44
|
+
* fires instead (as iOS waits for a double tap before a single tap lands). A
|
|
45
|
+
* click elsewhere ends the count, firing the pending single.
|
|
46
|
+
*/
|
|
47
|
+
export class TapCounter<T, D = void> {
|
|
48
|
+
private pending: { multi: T; single: T | null; detail: D; taps: number; timer: unknown } | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly fire: (target: T, detail: D) => void,
|
|
52
|
+
private readonly schedule: (run: () => void, ms: number) => unknown = (run, ms) => setTimeout(run, ms),
|
|
53
|
+
private readonly cancel: (timer: unknown) => void = (timer) => clearTimeout(timer as number),
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
click(multi: T | null, count: number, single: T | null, detail: D): void {
|
|
57
|
+
if (this.pending && this.pending.multi !== multi) this.flush();
|
|
58
|
+
if (!multi) {
|
|
59
|
+
if (single) this.fire(single, detail);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (this.pending) {
|
|
63
|
+
this.pending.taps += 1;
|
|
64
|
+
this.pending.detail = detail;
|
|
65
|
+
if (this.pending.taps < count) return;
|
|
66
|
+
this.cancel(this.pending.timer);
|
|
67
|
+
this.pending = null;
|
|
68
|
+
this.fire(multi, detail);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (count <= 1) {
|
|
72
|
+
this.fire(multi, detail);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pending = { multi, single, detail, taps: 1, timer: null as unknown };
|
|
76
|
+
pending.timer = this.schedule(() => {
|
|
77
|
+
if (this.pending !== pending) return;
|
|
78
|
+
this.pending = null;
|
|
79
|
+
if (single) this.fire(single, pending.detail);
|
|
80
|
+
}, DOUBLE_TAP_MS);
|
|
81
|
+
this.pending = pending;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ends a count early: the single tap it was holding fires (nothing when there was none). */
|
|
85
|
+
flush(): void {
|
|
86
|
+
const pending = this.pending;
|
|
87
|
+
if (!pending) return;
|
|
88
|
+
this.cancel(pending.timer);
|
|
89
|
+
this.pending = null;
|
|
90
|
+
if (pending.single) this.fire(pending.single, pending.detail);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/hostRequests.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { base64Decode, base64Encode } from './base64';
|
|
21
|
-
import { browserNotificationHost, performNotification, type NotificationHost, type NotificationResult } from './notifications';
|
|
21
|
+
import { browserNotificationHost, browserPushHost, performNotification, type NotificationHost, type NotificationResult } from './notifications';
|
|
22
22
|
import type {
|
|
23
23
|
BitmapRecipe,
|
|
24
24
|
FetchRequestParams,
|
|
@@ -37,14 +37,18 @@ import type {
|
|
|
37
37
|
ClipboardRequestParams,
|
|
38
38
|
TranslateRequestParams,
|
|
39
39
|
PickColorRequestParams,
|
|
40
|
+
UndoRequestParams,
|
|
40
41
|
SharedFile,
|
|
41
42
|
} from './protocol';
|
|
43
|
+
import { browserAudioRecorder, type AudioRecorderHost } from './audioRecorder';
|
|
44
|
+
import { browserFiles, type FilesHost } from './files';
|
|
42
45
|
import { browserClipboardHost, performClipboard, type ClipboardHost } from './clipboard';
|
|
43
46
|
import { browserMediaHost, performMedia, type MediaHost } from './media';
|
|
44
47
|
import { browserMediaSessionHost, MediaSessionBridge, performMediaSession } from './mediaSession';
|
|
45
48
|
import { browserScannerHost, performScanner, type ScannerHost, type ScannerResult } from './scanner';
|
|
46
49
|
import { browserTranslationHost, performTranslate, type TranslationHost } from './translation';
|
|
47
50
|
import { chooseColor, performPickColor, type ColorChooser } from './colorPicker';
|
|
51
|
+
import { browserUndoHost, performUndo, type UndoHost } from './undo';
|
|
48
52
|
import { WebSocketHost, type WebSocketLike } from './webSocket';
|
|
49
53
|
import { browserWebAuthHost, WebAuthSessions, type WebAuthResult } from './webAuth';
|
|
50
54
|
|
|
@@ -129,10 +133,16 @@ export interface HostRequestEnvironment {
|
|
|
129
133
|
mediaSession?: MediaSessionBridge;
|
|
130
134
|
/** The system clipboard (`clipboard`, clipboard.ts). */
|
|
131
135
|
clipboard?: ClipboardHost;
|
|
136
|
+
/** The microphone, recording as `AVAudioRecorder` does (audioRecorder.ts); `useAudioRecorder` calls it. */
|
|
137
|
+
audioRecorder?: AudioRecorderHost;
|
|
138
|
+
/** The app's files (files.ts: its IndexedDB home on a page, its sandbox natively); `FileManager` in @swiftbrowser/react calls it. */
|
|
139
|
+
files?: FilesHost;
|
|
132
140
|
/** The browser's built-in translator (`translate`, translation.ts), where it has one. */
|
|
133
141
|
translation?: TranslationHost;
|
|
134
142
|
/** The browser's color chooser (`pickColor`, colorPicker.ts); it must open before it returns. */
|
|
135
143
|
pickColor?: ColorChooser;
|
|
144
|
+
/** The system's undo for the app's undo manager (undo.ts): the keyboard here, the window's `UndoManager` natively; `useUndoManager` listens. */
|
|
145
|
+
undoManager?: UndoHost;
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
/**
|
|
@@ -178,7 +188,20 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
const notificationHost = browserNotificationHost();
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Gives the notifications Web Push through the service worker at `workerURL`
|
|
194
|
+
* with `scope` (the site's base, which the page knows: main.ts), where the
|
|
195
|
+
* browser has a push service.
|
|
196
|
+
*/
|
|
197
|
+
export function installPushService(workerURL: string, scope: string): void {
|
|
198
|
+
if (!notificationHost || notificationHost.push) return;
|
|
199
|
+
const push = browserPushHost(workerURL, scope);
|
|
200
|
+
if (push) notificationHost.push = push;
|
|
201
|
+
}
|
|
181
202
|
const scannerHost = browserScannerHost();
|
|
203
|
+
const audioRecorderHost = browserAudioRecorder(() => browserEnvironment.files);
|
|
204
|
+
const filesHost = browserFiles();
|
|
182
205
|
const webAuthHost = browserWebAuthHost();
|
|
183
206
|
const mediaHost = browserMediaHost(typeof document !== 'undefined' ? downloadBlob : undefined);
|
|
184
207
|
const mediaSessionHost = browserMediaSessionHost();
|
|
@@ -187,13 +210,16 @@ const translationHost = browserTranslationHost();
|
|
|
187
210
|
|
|
188
211
|
export const browserEnvironment: HostRequestEnvironment = {
|
|
189
212
|
fetch: (input, init) => fetch(input, init),
|
|
190
|
-
loadImage: (url) =>
|
|
191
|
-
|
|
213
|
+
loadImage: async (url) => {
|
|
214
|
+
// A `file:` URL of the app's files loads at its object URL (`loadableURL`).
|
|
215
|
+
const src = await loadableURL(url);
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
192
217
|
const img = new Image();
|
|
193
218
|
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
194
219
|
img.onerror = () => reject(new Error(`network: could not load ${url}`));
|
|
195
|
-
img.src =
|
|
196
|
-
})
|
|
220
|
+
img.src = src;
|
|
221
|
+
});
|
|
222
|
+
},
|
|
197
223
|
...(typeof navigator !== 'undefined' && typeof navigator.share === 'function'
|
|
198
224
|
? { share: (data: ShareData) => navigator.share(data) }
|
|
199
225
|
: {}),
|
|
@@ -216,10 +242,26 @@ export const browserEnvironment: HostRequestEnvironment = {
|
|
|
216
242
|
...(mediaHost ? { media: mediaHost } : {}),
|
|
217
243
|
...(mediaSessionHost ? { mediaSession: new MediaSessionBridge(mediaSessionHost) } : {}),
|
|
218
244
|
...(clipboardHost ? { clipboard: clipboardHost } : {}),
|
|
245
|
+
...(audioRecorderHost ? { audioRecorder: audioRecorderHost } : {}),
|
|
246
|
+
...(filesHost ? { files: filesHost } : {}),
|
|
219
247
|
...(translationHost ? { translation: translationHost } : {}),
|
|
220
248
|
...(typeof document !== 'undefined' ? { pickColor: chooseColor } : {}),
|
|
249
|
+
...(typeof document !== 'undefined' ? { undoManager: browserUndoHost(document) } : {}),
|
|
221
250
|
};
|
|
222
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The URL a media element or an image loads `url` at: a `file:` URL of the
|
|
254
|
+
* app's files becomes an object URL of its bytes through the files host (a
|
|
255
|
+
* promise), any other URL is itself. A file that cannot be loaded resolves to
|
|
256
|
+
* the `file:` URL as given, so the element fails to load it and says so.
|
|
257
|
+
*/
|
|
258
|
+
export function loadableURL(url: string): string | Promise<string> {
|
|
259
|
+
if (!url.startsWith('file:')) return url;
|
|
260
|
+
const files = browserEnvironment.files;
|
|
261
|
+
if (!files) return url;
|
|
262
|
+
return files.loadURL(url).catch(() => url);
|
|
263
|
+
}
|
|
264
|
+
|
|
223
265
|
/**
|
|
224
266
|
* Performs one request. Resolves with the result the app expects for the
|
|
225
267
|
* op's kind; rejects with an `Error` whose message starts with a short tag
|
|
@@ -276,6 +318,9 @@ export async function performHostRequest(
|
|
|
276
318
|
case 'pickColor':
|
|
277
319
|
// Not awaited before the chooser opens: it is clicked inside the tap that sent the request.
|
|
278
320
|
return performPickColor(op.params as PickColorRequestParams, env.pickColor);
|
|
321
|
+
case 'undo':
|
|
322
|
+
// The Swift app's undo manager, after a change: told to the host, which shows nothing on a page (undo.ts).
|
|
323
|
+
return performUndo(op.params as UndoRequestParams, env.undoManager);
|
|
279
324
|
default:
|
|
280
325
|
throw new Error(`unsupported: request kind ${String((op as { kind: unknown }).kind)}`);
|
|
281
326
|
}
|