geometryview 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +37 -2
  2. package/bin/geometryview.mjs +65 -0
  3. package/package.json +42 -4
  4. package/src/bridge.mjs +77 -0
  5. package/src/mcp.mjs +73 -0
  6. package/src/native.mjs +176 -0
  7. package/src/page.mjs +147 -0
  8. package/src/tools.mjs +265 -0
  9. package/src/web.mjs +173 -0
  10. package/src/websocket.mjs +104 -0
  11. package/web/LICENSE +21 -0
  12. package/web/NOTICES.md +49 -0
  13. package/web/bridge.js +29 -0
  14. package/web/build/geometryview-web.mjs +2 -0
  15. package/web/build/geometryview-web.wasm +0 -0
  16. package/web/chrome.js +541 -0
  17. package/web/engine.js +453 -0
  18. package/web/fonts/IBMPlexMono-Medium.ttf +0 -0
  19. package/web/fonts/IBMPlexMono-Regular.ttf +0 -0
  20. package/web/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  21. package/web/fonts/LICENSE.txt +93 -0
  22. package/web/icons/icon-180.png +0 -0
  23. package/web/icons/icon-192.png +0 -0
  24. package/web/icons/icon-32.png +0 -0
  25. package/web/icons/icon-512.png +0 -0
  26. package/web/icons.js +50 -0
  27. package/web/import-worker.js +329 -0
  28. package/web/index.html +17 -0
  29. package/web/input.js +255 -0
  30. package/web/isolation-worker.js +21 -0
  31. package/web/library-rows.js +120 -0
  32. package/web/library-store.js +368 -0
  33. package/web/library.js +306 -0
  34. package/web/manifest.webmanifest +14 -0
  35. package/web/occt/geometryview-occt-web.mjs +2 -0
  36. package/web/occt/geometryview-occt-web.wasm +0 -0
  37. package/web/occt/geometryview-step-web.mjs +2 -0
  38. package/web/occt/geometryview-step-web.wasm +0 -0
  39. package/web/occt-threads/geometryview-step-web.mjs +2 -0
  40. package/web/occt-threads/geometryview-step-web.wasm +0 -0
  41. package/web/occt-worker.js +45 -0
  42. package/web/structure.js +435 -0
  43. package/web/toolbar-fold.js +49 -0
  44. package/web/ui.js +134 -0
  45. package/web/viewer.css +558 -0
  46. package/web/viewer.js +1163 -0
@@ -0,0 +1,120 @@
1
+ // What the Library lists, as rows: the connected folders, each a tree read a level at a time as it
2
+ // is opened, then the files opened recently. Pure: the store gives it the folders, their listings
3
+ // and the recent files, and the card draws its rows. The Mac app's LibraryRows.swift does the
4
+ // same, and Launcher/test/library-rows.test.mjs checks this one.
5
+
6
+ export const kRecentLimit = 12;
7
+
8
+ export const RowKinds = Object.freeze({
9
+ Section: "section", // "Folders" or "Recent"
10
+ Folder: "folder", // a connected folder (depth 1) or one inside it
11
+ File: "file", // a STEP or STL file in a folder
12
+ Recent: "recent", // a file opened before
13
+ Message: "message", // a line of text where rows would be
14
+ });
15
+
16
+ export const Sections = Object.freeze({ Folders: "folders", Recent: "recent" });
17
+
18
+ const kModel = /\.(step|stp|stl)$/i;
19
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
20
+
21
+ // What a folder lists: its folders, then its STEP and STL files, each in the order people read
22
+ // numbers in names; hidden names left out. `entries` are { name, kind: "folder" | "file" }.
23
+ export function listed(entries) {
24
+ const shown = entries.filter((e) => !e.name.startsWith(".") &&
25
+ (e.kind === "folder" || kModel.test(e.name)));
26
+ return shown.sort((a, b) => (a.kind === b.kind ? collator.compare(a.name, b.name)
27
+ : a.kind === "folder" ? -1 : 1));
28
+ }
29
+
30
+ // The key of a folder's listing, or a file's, by its connected folder and the path inside it.
31
+ export const keyOf = (root, path) => `${root}/${path}`;
32
+
33
+ // The rows, top to bottom. `state` is:
34
+ // folders: [{ id, name, state: "ready" | "reconnect" | "missing" | "visit" }]
35
+ // listings: Map of keyOf(folder, path) to { state: "reading" | "listed" | "failed", entries }
36
+ // expanded: Set of keyOf(folder, path) for the folders shown open
37
+ // collapsed: Set of the Sections hidden
38
+ // recent: [{ key, name, where, openedAt }], newest first
39
+ // open: Set of the keys of the files shown now
40
+ // A row is { kind, depth, key, name, ... }.
41
+ export function rowsOf({ folders, listings, expanded, collapsed, recent, open = new Set() }) {
42
+ const rows = [];
43
+ const folding = !collapsed.has(Sections.Folders);
44
+ rows.push({ kind: RowKinds.Section, depth: 0, key: Sections.Folders, name: "Folders",
45
+ count: folders.length, expanded: folding });
46
+ if (folding) {
47
+ if (folders.length === 0) {
48
+ rows.push({ kind: RowKinds.Message, depth: 1, key: "folders-empty",
49
+ text: "Connect a folder to browse it." });
50
+ }
51
+ for (const folder of folders) {
52
+ const key = keyOf(folder.id, "");
53
+ const usable = folder.state === "ready" || folder.state === "visit";
54
+ const isOpen = usable && expanded.has(key);
55
+ rows.push({ kind: RowKinds.Folder, depth: 1, key, root: folder.id, path: "", name: folder.name,
56
+ state: folder.state, expanded: isOpen, top: true });
57
+ if (isOpen) addListing(rows, folder.id, "", 2, listings, expanded, open);
58
+ }
59
+ }
60
+ const recentShown = !collapsed.has(Sections.Recent);
61
+ rows.push({ kind: RowKinds.Section, depth: 0, key: Sections.Recent, name: "Recent",
62
+ count: recent.length, expanded: recentShown });
63
+ if (recentShown) {
64
+ if (recent.length === 0) {
65
+ rows.push({ kind: RowKinds.Message, depth: 1, key: "recent-empty", text: "Files you open appear here." });
66
+ }
67
+ for (const file of recent) {
68
+ rows.push({ kind: RowKinds.Recent, depth: 1, key: `recent:${file.key}`, recentKey: file.key,
69
+ name: file.name, where: file.where, openedAt: file.openedAt, missing: file.missing,
70
+ open: open.has(file.key) });
71
+ }
72
+ }
73
+ return rows;
74
+ }
75
+
76
+ function addListing(rows, root, path, depth, listings, expanded, open) {
77
+ const listing = listings.get(keyOf(root, path));
78
+ if (!listing || listing.state === "reading") {
79
+ if (listing?.slow) rows.push({ kind: RowKinds.Message, depth, key: `${keyOf(root, path)}:reading`, text: "Reading…" });
80
+ return;
81
+ }
82
+ if (listing.state === "failed") {
83
+ rows.push({ kind: RowKinds.Message, depth, key: `${keyOf(root, path)}:failed`, text: "This folder could not be read." });
84
+ return;
85
+ }
86
+ const entries = listed(listing.entries);
87
+ if (entries.length === 0) {
88
+ rows.push({ kind: RowKinds.Message, depth, key: `${keyOf(root, path)}:empty`, text: "No STEP or STL files here." });
89
+ }
90
+ for (const entry of entries) {
91
+ const inner = path ? `${path}/${entry.name}` : entry.name;
92
+ const key = keyOf(root, inner);
93
+ if (entry.kind === "folder") {
94
+ const isOpen = expanded.has(key);
95
+ rows.push({ kind: RowKinds.Folder, depth, key, root, path: inner, name: entry.name, state: "ready",
96
+ expanded: isOpen, top: false });
97
+ if (isOpen) addListing(rows, root, inner, depth + 1, listings, expanded, open);
98
+ } else {
99
+ rows.push({ kind: RowKinds.File, depth, key, root, path: inner, name: entry.name, open: open.has(key) });
100
+ }
101
+ }
102
+ }
103
+
104
+ // The recent files with `file` first: an entry with the same key moves up rather than repeating,
105
+ // and the oldest beyond the limit fall off. Returns { recent, dropped }.
106
+ export function remember(recent, file, limit = kRecentLimit) {
107
+ const rest = recent.filter((r) => r.key !== file.key);
108
+ const next = [file, ...rest];
109
+ return { recent: next.slice(0, limit), dropped: next.slice(limit) };
110
+ }
111
+
112
+ // How long ago, in a few characters: "now", "5 min", "3 h", "2 d".
113
+ export function ageText(openedAt, now = Date.now()) {
114
+ const minutes = Math.floor(Math.max(0, now - openedAt) / 60000);
115
+ if (minutes < 1) return "now";
116
+ if (minutes < 60) return `${minutes} min`;
117
+ const hours = Math.floor(minutes / 60);
118
+ if (hours < 24) return `${hours} h`;
119
+ return `${Math.floor(hours / 24)} d`;
120
+ }
@@ -0,0 +1,368 @@
1
+ // Where the Library's folders and recent files come from, and how a file in one is read: one
2
+ // interface over the three ways a page can reach folders.
3
+ //
4
+ // shell The iPhone shell picks folders and files natively, keeps them, and serves them.
5
+ // access The File System Access API (Chrome, Edge): folders are handles, kept in IndexedDB from
6
+ // one visit to the next; the browser may ask again for permission on a later visit.
7
+ // visit Elsewhere (Safari, Firefox), a folder chosen with a folder input or dropped lasts
8
+ // for this visit only.
9
+ //
10
+ // Nothing is read until it is shown: a folder is listed when it is opened, by names alone, and a
11
+ // file is read when it is opened. The recent files are kept in IndexedDB everywhere.
12
+
13
+ import { keptFile } from "./library.js";
14
+ import { remember as rememberIn } from "./library-rows.js";
15
+
16
+ const kDatabase = "geometryview";
17
+
18
+ function database() {
19
+ return new Promise((resolve, reject) => {
20
+ const request = indexedDB.open(kDatabase, 1);
21
+ request.onupgradeneeded = () => {
22
+ request.result.createObjectStore("folders", { keyPath: "id" });
23
+ request.result.createObjectStore("recent", { keyPath: "key" });
24
+ };
25
+ request.onsuccess = () => resolve(request.result);
26
+ request.onerror = () => reject(request.error);
27
+ });
28
+ }
29
+
30
+ let opened = null;
31
+ async function store(name, mode, work) {
32
+ opened ??= database();
33
+ const db = await opened;
34
+ return new Promise((resolve, reject) => {
35
+ const transaction = db.transaction(name, mode);
36
+ const result = work(transaction.objectStore(name));
37
+ transaction.oncomplete = () => resolve(result?.result);
38
+ transaction.onerror = () => reject(transaction.error);
39
+ });
40
+ }
41
+ const all = (name) => store(name, "readonly", (s) => s.getAll()).catch(() => []);
42
+ const put = (name, value) => store(name, "readwrite", (s) => s.put(value)).catch(() => {});
43
+ const remove = (name, key) => store(name, "readwrite", (s) => s.delete(key)).catch(() => {});
44
+
45
+ const kModelTypes = [{ description: "STEP, STL or cache files",
46
+ accept: { "application/octet-stream": [".step", ".stp", ".stl", ".geoblob"] } }];
47
+
48
+ // ---- File System Access -----------------------------------------------------------------------
49
+
50
+ class AccessFolders {
51
+ kind = "access";
52
+ canConnect = true;
53
+ #folders = null; // [{ id, name, handle }]
54
+
55
+ async #all() {
56
+ this.#folders ??= await all("folders");
57
+ return this.#folders;
58
+ }
59
+
60
+ async folders() {
61
+ const folders = await this.#all();
62
+ return Promise.all(folders.map(async ({ id, name, handle }) => {
63
+ const permission = await handle.queryPermission({ mode: "read" }).catch(() => "denied");
64
+ return { id, name, state: permission === "granted" ? "ready" : "reconnect" };
65
+ }));
66
+ }
67
+
68
+ // Asks for a folder; null if the person turns the picker away.
69
+ async connect() {
70
+ let handle;
71
+ try {
72
+ handle = await showDirectoryPicker({ id: "geometryview-library", mode: "read" });
73
+ } catch (error) {
74
+ if (error.name === "AbortError") return null;
75
+ throw error;
76
+ }
77
+ return this.adoptHandle(handle);
78
+ }
79
+
80
+ async adoptHandle(handle) {
81
+ const folders = await this.#all();
82
+ for (const folder of folders) {
83
+ if (await folder.handle.isSameEntry(handle).catch(() => false)) return folder.id;
84
+ }
85
+ const folder = { id: crypto.randomUUID(), name: handle.name, handle };
86
+ folders.push(folder);
87
+ await put("folders", folder);
88
+ return folder.id;
89
+ }
90
+
91
+ // The tap is the gesture the browser needs to ask again.
92
+ async reconnect(id) {
93
+ const folder = (await this.#all()).find((f) => f.id === id);
94
+ return (await folder?.handle.requestPermission({ mode: "read" })) === "granted";
95
+ }
96
+
97
+ async disconnect(id) {
98
+ this.#folders = (await this.#all()).filter((f) => f.id !== id);
99
+ await remove("folders", id);
100
+ }
101
+
102
+ async #directory(id, path) {
103
+ let directory = (await this.#all()).find((f) => f.id === id)?.handle;
104
+ if (!directory) throw new Error("That folder is no longer connected.");
105
+ for (const part of path.split("/").filter(Boolean)) directory = await directory.getDirectoryHandle(part);
106
+ return directory;
107
+ }
108
+
109
+ async list(id, path) {
110
+ const entries = [];
111
+ for await (const [name, handle] of (await this.#directory(id, path)).entries()) {
112
+ entries.push({ name, kind: handle.kind === "directory" ? "folder" : "file" });
113
+ }
114
+ return entries;
115
+ }
116
+
117
+ async fileHandle(id, path) {
118
+ const parts = path.split("/");
119
+ const name = parts.pop();
120
+ return (await this.#directory(id, parts.join("/"))).getFileHandle(name);
121
+ }
122
+
123
+ async file(id, path) {
124
+ return (await this.fileHandle(id, path)).getFile();
125
+ }
126
+
127
+ // Files to open, each with its handle so Recent can read it again; null if turned away.
128
+ async pickFiles() {
129
+ try {
130
+ const handles = await showOpenFilePicker({ id: "geometryview-open", multiple: true, types: kModelTypes });
131
+ return Promise.all(handles.map(async (handle) => ({ file: await handle.getFile(), handle })));
132
+ } catch (error) {
133
+ if (error.name === "AbortError") return null;
134
+ throw error;
135
+ }
136
+ }
137
+ }
138
+
139
+ // ---- A folder for this visit ------------------------------------------------------------------
140
+
141
+ class VisitFolders {
142
+ kind = "visit";
143
+ canConnect = "webkitdirectory" in HTMLInputElement.prototype;
144
+ #folders = []; // [{ id, name, files?: Map(path, File), entry?: DirectoryEntry }]
145
+
146
+ async folders() {
147
+ return this.#folders.map(({ id, name }) => ({ id, name, state: "visit" }));
148
+ }
149
+
150
+ // A folder input: every file in the folder arrives at once, with its path inside it.
151
+ connect() {
152
+ return new Promise((resolve) => {
153
+ const input = Object.assign(document.createElement("input"), { type: "file", webkitdirectory: true });
154
+ input.addEventListener("change", () => {
155
+ const files = [...input.files];
156
+ if (files.length === 0) return resolve(null);
157
+ const name = files[0].webkitRelativePath.split("/")[0];
158
+ const byPath = new Map(files.map((f) => [f.webkitRelativePath.split("/").slice(1).join("/"), f]));
159
+ resolve(this.adoptFiles(name, byPath));
160
+ });
161
+ input.addEventListener("cancel", () => resolve(null));
162
+ input.click();
163
+ });
164
+ }
165
+
166
+ adoptEntry(entry) {
167
+ return this.#add({ name: entry.name, entry });
168
+ }
169
+
170
+ // A folder as a Map of the paths inside it to its files.
171
+ adoptFiles(name, files) {
172
+ return this.#add({ name, files });
173
+ }
174
+
175
+ #add(folder) {
176
+ const id = crypto.randomUUID();
177
+ this.#folders.push({ id, ...folder });
178
+ return id;
179
+ }
180
+
181
+ async reconnect() { return true; }
182
+
183
+ async disconnect(id) {
184
+ this.#folders = this.#folders.filter((f) => f.id !== id);
185
+ }
186
+
187
+ async list(id, path) {
188
+ const folder = this.#folders.find((f) => f.id === id);
189
+ if (!folder) throw new Error("That folder is no longer connected.");
190
+ if (folder.files) {
191
+ const prefix = path ? `${path}/` : "";
192
+ const seen = new Map();
193
+ for (const inner of folder.files.keys()) {
194
+ if (!inner.startsWith(prefix)) continue;
195
+ const [name, ...rest] = inner.slice(prefix.length).split("/");
196
+ if (!seen.has(name)) seen.set(name, rest.length > 0 ? "folder" : "file");
197
+ }
198
+ return [...seen].map(([name, kind]) => ({ name, kind }));
199
+ }
200
+ const directory = await this.#entry(folder.entry, path);
201
+ const entries = [];
202
+ const reader = directory.createReader();
203
+ for (;;) {
204
+ const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
205
+ if (batch.length === 0) break;
206
+ for (const e of batch) entries.push({ name: e.name, kind: e.isDirectory ? "folder" : "file" });
207
+ }
208
+ return entries;
209
+ }
210
+
211
+ async #entry(directory, path) {
212
+ for (const part of path.split("/").filter(Boolean)) {
213
+ directory = await new Promise((resolve, reject) => directory.getDirectory(part, {}, resolve, reject));
214
+ }
215
+ return directory;
216
+ }
217
+
218
+ async file(id, path) {
219
+ const folder = this.#folders.find((f) => f.id === id);
220
+ if (!folder) throw new Error("That folder is no longer connected.");
221
+ if (folder.files) return folder.files.get(path);
222
+ const parts = path.split("/");
223
+ const name = parts.pop();
224
+ const directory = await this.#entry(folder.entry, parts.join("/"));
225
+ const entry = await new Promise((resolve, reject) => directory.getFile(name, {}, resolve, reject));
226
+ return new Promise((resolve, reject) => entry.file(resolve, reject));
227
+ }
228
+
229
+ pickFiles() { return Promise.resolve(undefined); } // the page's own file input instead
230
+ }
231
+
232
+ // ---- The iPhone shell ------------------------------------------------------------------------
233
+
234
+ const encodePath = (path) => path.split("/").map(encodeURIComponent).join("/");
235
+
236
+ class ShellFolders {
237
+ kind = "shell";
238
+ canConnect = true;
239
+
240
+ constructor() {
241
+ // The shell serves the page from the same address each launch, so what it keeps lasts: asked
242
+ // not to be cleared under storage pressure.
243
+ navigator.storage?.persist?.().catch(() => {});
244
+ }
245
+
246
+ #ask(op, extra = {}) {
247
+ return window.webkit.messageHandlers.library.postMessage({ op, ...extra });
248
+ }
249
+
250
+ folders() { return this.#ask("folders"); }
251
+ async connect() { return (await this.#ask("connectFolder")) ?? null; }
252
+ async reconnect() { return true; }
253
+ async disconnect(id) { await this.#ask("disconnect", { id }); }
254
+
255
+ async list(id, path) {
256
+ const response = await fetch(`/list/${id}/${encodePath(path)}`);
257
+ if (!response.ok) throw new Error("This folder could not be read.");
258
+ return response.json();
259
+ }
260
+
261
+ async file(id, path) {
262
+ return this.#fetchFile(`/open/${id}/${encodePath(path)}`, path.split("/").pop());
263
+ }
264
+
265
+ async #fetchFile(url, name) {
266
+ const response = await fetch(url);
267
+ if (!response.ok) throw new Error(`${name} could not be read.`);
268
+ const lastModified = Number(response.headers.get("GeometryView-Modified")) || Date.now();
269
+ return new File([await response.blob()], name, { lastModified });
270
+ }
271
+
272
+ // The system's picker, opening files where they are.
273
+ async pickFiles() {
274
+ const picked = await this.#ask("pickFiles");
275
+ if (!picked) return null;
276
+ return Promise.all(picked.map(async (p) => ({ file: await this.#fetchFile(p.path, p.name), handle: null })));
277
+ }
278
+ }
279
+
280
+ // ---- The store --------------------------------------------------------------------------------
281
+
282
+ export class LibraryStore {
283
+ folders;
284
+ #recent = null;
285
+
286
+ // `backend` "visit" uses a folder for this visit even where the browser offers more, for checks.
287
+ constructor({ backend } = {}) {
288
+ const shell = window.webkit?.messageHandlers?.library !== undefined;
289
+ const access = typeof showDirectoryPicker === "function" && backend !== "visit";
290
+ this.folders = shell ? new ShellFolders() : access ? new AccessFolders() : new VisitFolders();
291
+ }
292
+
293
+ get kind() { return this.folders.kind; }
294
+ get canConnect() { return this.folders.canConnect; }
295
+
296
+ // Dropped items, read in the drop event itself, as browsers require: folders are connected and
297
+ // files returned to open, each with its handle where the browser gives one. Returns
298
+ // { files: [{ file, handle }], connected: [ids] }.
299
+ async adopt(dataTransfer) {
300
+ const items = [...dataTransfer.items].filter((item) => item.kind === "file");
301
+ // Every handle asked for before any await, while the drop's data can still be read.
302
+ const handles = items.map((item) => item.getAsFileSystemHandle?.() ?? null);
303
+ const entries = items.map((item) => item.webkitGetAsEntry?.() ?? null);
304
+ const plain = items.map((item) => item.getAsFile());
305
+ const files = [];
306
+ const connected = [];
307
+ for (let i = 0; i < items.length; ++i) {
308
+ const handle = await handles[i]?.catch(() => null);
309
+ if (handle?.kind === "directory" && this.folders.adoptHandle) {
310
+ connected.push(await this.folders.adoptHandle(handle));
311
+ } else if (entries[i]?.isDirectory && this.folders.adoptEntry) {
312
+ connected.push(this.folders.adoptEntry(entries[i]));
313
+ } else if (plain[i]) {
314
+ files.push({ file: plain[i], handle: handle?.kind === "file" ? handle : null });
315
+ }
316
+ }
317
+ return { files, connected };
318
+ }
319
+
320
+ // ---- Recent files ----
321
+
322
+ // The recent files, newest first: { key, name, where, openedAt, handle?, root?, path? }. On a
323
+ // first visit, the imports this browser kept before Recent existed.
324
+ async recent(kept) {
325
+ if (this.#recent) return this.#recent;
326
+ const saved = await all("recent");
327
+ if (saved.length === 0 && kept?.length) {
328
+ this.#recent = kept.slice(0, 12).map((k) => ({ key: k.name, name: k.name, where: "Kept in this browser",
329
+ openedAt: k.modified }));
330
+ for (const r of this.#recent) await put("recent", r);
331
+ } else {
332
+ this.#recent = saved.sort((a, b) => b.openedAt - a.openedAt);
333
+ }
334
+ return this.#recent;
335
+ }
336
+
337
+ // Puts `entry` first; returns the entries that fell off.
338
+ async remember(entry) {
339
+ const { recent, dropped } = rememberIn(await this.recent(), entry);
340
+ this.#recent = recent;
341
+ await put("recent", entry);
342
+ for (const old of dropped) await remove("recent", old.key);
343
+ return dropped;
344
+ }
345
+
346
+ async forget(key) {
347
+ this.#recent = (await this.recent()).filter((r) => r.key !== key);
348
+ await remove("recent", key);
349
+ }
350
+
351
+ // What reopening a recent file gives: { file } to import (its kept import is used if it is
352
+ // unchanged), { cache } to open as it was kept, or null if it can't be had. May ask for
353
+ // permission, so call it from the click.
354
+ async reopen(entry, readyFolders) {
355
+ if (entry.root && readyFolders.has(entry.root)) {
356
+ return { file: await this.folders.file(entry.root, entry.path) };
357
+ }
358
+ const permission = await entry.handle?.queryPermission?.({ mode: "read" }).catch(() => "denied");
359
+ if (permission === "granted") return { file: await entry.handle.getFile() };
360
+ const cache = await keptFile(entry.name).catch(() => null);
361
+ if (cache) return { cache };
362
+ if (entry.handle && (await entry.handle.requestPermission({ mode: "read" }).catch(() => "denied")) === "granted") {
363
+ return { file: await entry.handle.getFile() };
364
+ }
365
+ if (entry.url) return { url: entry.url };
366
+ return null;
367
+ }
368
+ }