@banou/ponyfill 0.0.6 → 0.0.7
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/README.md +53 -0
- package/build/file-system.cjs +240 -3
- package/build/file-system.d.ts +108 -25
- package/build/file-system.js +239 -4
- package/build/index.cjs +2 -0
- package/build/index.d.ts +1 -1
- package/build/index.js +2 -2
- package/build/storage.cjs +47 -14
- package/build/storage.d.ts +10 -6
- package/build/storage.js +47 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,59 @@ Cache is the thing that can be fetched again; a caller that never reclaims becau
|
|
|
71
71
|
pressure is the failure this replaces. The ceiling still follows the platform DOWNWARD, and is never
|
|
72
72
|
reported below the bytes already held, so `quota - usage` reaches zero and never goes negative.
|
|
73
73
|
|
|
74
|
+
The ceiling is held per realm and in memory, and it **lets go when the origin's persistence changes**.
|
|
75
|
+
A granted `persist()` moved the reported quota from 12 GB to 3.97 TB on Firefox (measured
|
|
76
|
+
2026-09-01), so the figure learned before that is worse than useless afterwards. The release is keyed
|
|
77
|
+
on the state rather than on the call, because `persist()` only exists on the main thread: a worker
|
|
78
|
+
that latched the old ceiling and could never call it would go on deciding what to delete from a
|
|
79
|
+
number that no longer exists.
|
|
80
|
+
|
|
81
|
+
### `permissions`
|
|
82
|
+
|
|
83
|
+
`query()` has four ways of behaving and only one of them is an answer: the engine may have no
|
|
84
|
+
Permissions API, may reject a name it does not implement, may **throw synchronously** for one (which
|
|
85
|
+
a `.catch` on the returned promise never sees), or may answer a real state.
|
|
86
|
+
|
|
87
|
+
All three non-answers collapse to `'prompt'`. Not `'denied'`, because an engine that cannot be asked
|
|
88
|
+
has not refused anything, and treating silence as refusal costs the person a control that might have
|
|
89
|
+
worked.
|
|
90
|
+
|
|
91
|
+
### `showOpenFilePicker`, `showDirectoryPicker`, `showSaveFilePicker`
|
|
92
|
+
|
|
93
|
+
Measured 2026-09-03 across Chromium 149, Firefox 151 and WebKit 26:
|
|
94
|
+
|
|
95
|
+
| | Chromium | Firefox | WebKit |
|
|
96
|
+
| --- | --- | --- | --- |
|
|
97
|
+
| the three pickers | present | **absent** | **absent** |
|
|
98
|
+
| `FileSystemHandle` and friends as globals | present | present | **absent** |
|
|
99
|
+
| `<input>.webkitdirectory` and its `cancel` event | yes | yes | yes |
|
|
100
|
+
| an object holding its methods as own properties, cloned | `DataCloneError` | `DataCloneError` | `DataCloneError` |
|
|
101
|
+
| the same object with its methods on a prototype | clones, silently | clones, silently | clones, silently |
|
|
102
|
+
| `<input webkitdirectory>` file order, same tree | `a`, `sub/b` | `sub/b`, `a` | `sub/b`, `a` |
|
|
103
|
+
|
|
104
|
+
**Reading always works, and always answers handles.** Where the platform has a picker it is used and
|
|
105
|
+
its handles come back untouched. Where it does not, an `<input type="file">` is opened and what comes
|
|
106
|
+
back is wrapped in the same shape, so a caller has one call and one return type instead of a
|
|
107
|
+
`FileSystemFileHandle | File` union threaded through everything downstream.
|
|
108
|
+
|
|
109
|
+
**Writing is refused rather than faked.** `showSaveFilePicker` has no fallback, `showDirectoryPicker`
|
|
110
|
+
refuses `mode: 'readwrite'` where there is no native picker, and a wrapped handle's
|
|
111
|
+
`createWritable()` throws. There is no way to write to a chosen location without the platform's
|
|
112
|
+
picker.
|
|
113
|
+
|
|
114
|
+
**Refusals happen before the gesture is spent**, and are named `NotAllowedError` so a caller can tell
|
|
115
|
+
them from the `AbortError` the platform throws when the person cancels. A caller has one transient
|
|
116
|
+
activation, and a picker that rejects at call time has already spent part of it.
|
|
117
|
+
|
|
118
|
+
**A wrapped handle cannot be persisted, and says so loudly.** This is the one difference that cannot
|
|
119
|
+
be absorbed, so what is picked is how it fails. A native handle survives `structuredClone` and comes
|
|
120
|
+
back out of IndexedDB still usable; a wrapper around a `File` cannot, because a snapshot is not an
|
|
121
|
+
entry on a disk. Left as an ordinary object it would clone *successfully* and come back with its
|
|
122
|
+
prototype gone and every method with it, failing after a reload with nothing pointing at the pick. So
|
|
123
|
+
every wrapped handle carries its methods as own properties, which makes the store throw
|
|
124
|
+
`DataCloneError` at the moment of the mistake: one `.catch` where it matters instead of a capability
|
|
125
|
+
probe at every call site.
|
|
126
|
+
|
|
74
127
|
## Adding to it
|
|
75
128
|
|
|
76
129
|
If you hit something that behaves differently between engines, or differently from its own
|
package/build/file-system.cjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
//#region src/file-system.ts
|
|
3
|
+
var NO_SAVE_PICKER = "this browser has no file save picker";
|
|
4
|
+
var NO_FRAMED_PICKER = "a cross origin frame cannot show a file save picker";
|
|
5
|
+
var NO_DOCUMENT = "there is no document here to open a file picker from";
|
|
6
|
+
var NO_WRITE = "this file was opened from a picker that cannot write, so there is nothing to write to";
|
|
7
|
+
var NO_WRITE_FOLDER = "this browser cannot grant write access to a chosen folder";
|
|
8
|
+
var refuse = (message) => new DOMException(message, "NotAllowedError");
|
|
3
9
|
/**
|
|
4
10
|
* Whether this document is framed by another origin.
|
|
5
11
|
*
|
|
6
|
-
* Chromium exposes the
|
|
12
|
+
* Chromium exposes the pickers either way and refuses them at call time, so a property probe says
|
|
7
13
|
* nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
|
|
8
14
|
* an opaque origin, which is the case a sandboxed frame presents.
|
|
9
15
|
*/
|
|
@@ -18,11 +24,242 @@ var framedByAnotherOrigin = () => {
|
|
|
18
24
|
return true;
|
|
19
25
|
}
|
|
20
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* The `accept` attribute for an `<input>`, from the picker's own `types`.
|
|
29
|
+
*
|
|
30
|
+
* Both halves of each entry go in. The attribute takes MIME types and extensions in one comma
|
|
31
|
+
* separated list, and an engine that does not recognise one of them ignores that one rather than the
|
|
32
|
+
* whole attribute, so listing both is strictly better than choosing. `*` patterns are dropped: they
|
|
33
|
+
* are what `excludeAcceptAllOption: false` already means, and an `accept` of `*` filters nothing
|
|
34
|
+
* while making the dialog claim it does.
|
|
35
|
+
*/
|
|
36
|
+
var acceptFrom = (types) => {
|
|
37
|
+
const out = /* @__PURE__ */ new Set();
|
|
38
|
+
for (const type of types ?? []) for (const [mime, extensions] of Object.entries(type.accept ?? {})) {
|
|
39
|
+
if (mime && !mime.includes("*")) out.add(mime);
|
|
40
|
+
for (const extension of extensions) out.add(extension);
|
|
41
|
+
}
|
|
42
|
+
return [...out].join(",");
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* One pick through a detached `<input type="file">`, resolving the files or rejecting like a cancel.
|
|
46
|
+
*
|
|
47
|
+
* The `cancel` event is what makes this a promise that always settles, and it is why this fallback is
|
|
48
|
+
* worth having at all rather than being a hazard: measured present on all three engines above. An
|
|
49
|
+
* engine without it would leave a picker that was dismissed pending forever, which is worse than not
|
|
50
|
+
* offering one.
|
|
51
|
+
*
|
|
52
|
+
* NEEDS THE CALLER'S TRANSIENT ACTIVATION, exactly as the platform picker does. `click()` on a file
|
|
53
|
+
* input opens nothing without a gesture, so this has to be reached synchronously from the handler,
|
|
54
|
+
* and an `await` before it loses that. The same rule the native picker has, for the same reason.
|
|
55
|
+
*
|
|
56
|
+
* The input is attached and removed rather than left detached: a detached input's `click()` is
|
|
57
|
+
* ignored by some engines, and leaving it in the document would leave one element per pick behind.
|
|
58
|
+
*/
|
|
59
|
+
var pickThroughInput = (setup) => {
|
|
60
|
+
const document = globalThis.document;
|
|
61
|
+
if (!document?.body) return Promise.reject(refuse(NO_DOCUMENT));
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const input = document.createElement("input");
|
|
64
|
+
input.type = "file";
|
|
65
|
+
input.style.display = "none";
|
|
66
|
+
input.setAttribute("aria-hidden", "true");
|
|
67
|
+
setup(input);
|
|
68
|
+
const settle = (finish) => {
|
|
69
|
+
input.remove();
|
|
70
|
+
finish();
|
|
71
|
+
};
|
|
72
|
+
input.addEventListener("change", () => settle(() => resolve([...input.files ?? []])), { once: true });
|
|
73
|
+
input.addEventListener("cancel", () => settle(() => reject(new DOMException("the file picker was closed", "AbortError"))), { once: true });
|
|
74
|
+
document.body.append(input);
|
|
75
|
+
input.click();
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
var handles = /* @__PURE__ */ new WeakMap();
|
|
79
|
+
/**
|
|
80
|
+
* Entries in a fixed order, which the platform does not promise and the engines do not agree on.
|
|
81
|
+
*
|
|
82
|
+
* Measured above: the same tree comes out of `<input webkitdirectory>` in one order on Chromium and
|
|
83
|
+
* the opposite on Firefox and WebKit. Sorted by code unit rather than `localeCompare`, because a
|
|
84
|
+
* locale sensitive sort is one more thing that differs between two machines running the same code.
|
|
85
|
+
*/
|
|
86
|
+
var ordered = (children) => [...children].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
87
|
+
var fileHandleFor = (node) => {
|
|
88
|
+
return {
|
|
89
|
+
kind: "file",
|
|
90
|
+
name: node.name,
|
|
91
|
+
/**
|
|
92
|
+
* The same `File` every time, where the platform hands back a fresh one.
|
|
93
|
+
*
|
|
94
|
+
* A `File` from an input is a snapshot taken when it was picked. It cannot be re-read from disk,
|
|
95
|
+
* so `lastModified` never moves and a staleness check across it can never fire. What it does do
|
|
96
|
+
* is throw on READ once the file underneath has changed, so a pass over the bytes still refuses
|
|
97
|
+
* to produce a mixture of two versions; it just reports it as a failed read rather than a
|
|
98
|
+
* changed file.
|
|
99
|
+
*/
|
|
100
|
+
getFile: async () => node.file,
|
|
101
|
+
createWritable: async () => {
|
|
102
|
+
throw refuse(NO_WRITE);
|
|
103
|
+
},
|
|
104
|
+
isSameEntry: async (other) => other === handles.get(node)
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
var directoryHandleFor = (node) => {
|
|
108
|
+
const child = (name) => node.children.get(name);
|
|
109
|
+
const entries = async function* () {
|
|
110
|
+
for (const [name, entry] of ordered(node.children)) yield [name, handleFor(entry)];
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
kind: "directory",
|
|
114
|
+
name: node.name,
|
|
115
|
+
entries,
|
|
116
|
+
keys: async function* () {
|
|
117
|
+
for (const [name] of ordered(node.children)) yield name;
|
|
118
|
+
},
|
|
119
|
+
values: async function* () {
|
|
120
|
+
for (const [, entry] of ordered(node.children)) yield handleFor(entry);
|
|
121
|
+
},
|
|
122
|
+
[Symbol.asyncIterator]: entries,
|
|
123
|
+
getFileHandle: async (name, options) => {
|
|
124
|
+
if (options?.create) throw refuse(NO_WRITE);
|
|
125
|
+
const found = child(name);
|
|
126
|
+
if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
|
|
127
|
+
if (found.kind !== "file") throw new DOMException(`${name} is a directory`, "TypeMismatchError");
|
|
128
|
+
return handleFor(found);
|
|
129
|
+
},
|
|
130
|
+
getDirectoryHandle: async (name, options) => {
|
|
131
|
+
if (options?.create) throw refuse(NO_WRITE);
|
|
132
|
+
const found = child(name);
|
|
133
|
+
if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
|
|
134
|
+
if (found.kind !== "directory") throw new DOMException(`${name} is a file`, "TypeMismatchError");
|
|
135
|
+
return handleFor(found);
|
|
136
|
+
},
|
|
137
|
+
removeEntry: async () => {
|
|
138
|
+
throw refuse(NO_WRITE);
|
|
139
|
+
},
|
|
140
|
+
/** The path from here down to a handle in this tree, or null where it is not in it. */
|
|
141
|
+
resolve: async (descendant) => {
|
|
142
|
+
const search = (from, path) => {
|
|
143
|
+
for (const [name, entry] of ordered(from.children)) {
|
|
144
|
+
if (handles.get(entry) === descendant) return [...path, name];
|
|
145
|
+
if (entry.kind === "directory") {
|
|
146
|
+
const deeper = search(entry, [...path, name]);
|
|
147
|
+
if (deeper) return deeper;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
return handles.get(node) === descendant ? [] : search(node, []);
|
|
153
|
+
},
|
|
154
|
+
isSameEntry: async (other) => other === handles.get(node)
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
var handleFor = (node) => {
|
|
158
|
+
const made = handles.get(node);
|
|
159
|
+
if (made) return made;
|
|
160
|
+
const fresh = node.kind === "file" ? fileHandleFor(node) : directoryHandleFor(node);
|
|
161
|
+
handles.set(node, fresh);
|
|
162
|
+
return fresh;
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* The picked folder, rebuilt from the flat list an input hands over.
|
|
166
|
+
*
|
|
167
|
+
* `webkitRelativePath` is the whole tree already flattened: `Pack/Subs/E01.ass` for a folder called
|
|
168
|
+
* `Pack`. The first segment is the folder's own name, so it becomes the root's name and is dropped
|
|
169
|
+
* from every path under it, which is what makes this the same shape `showDirectoryPicker` returns.
|
|
170
|
+
*
|
|
171
|
+
* AN EMPTY FOLDER LOSES ITS NAME, and nothing can be done about that. The name is only ever learned
|
|
172
|
+
* from a file's path, so a folder with nothing in it comes back named `''`. It is a real pick rather
|
|
173
|
+
* than a cancel, since `change` fired, and reporting it as an empty directory is the closest true
|
|
174
|
+
* answer available.
|
|
175
|
+
*
|
|
176
|
+
* A name used twice, once as a file and once as a folder, resolves to the folder, because a path
|
|
177
|
+
* continuing through it proves it is one. Two files with the same path keep the last, which is what
|
|
178
|
+
* a map does and what re-picking the same tree would do anyway.
|
|
179
|
+
*/
|
|
180
|
+
var treeFrom = (files) => {
|
|
181
|
+
const root = {
|
|
182
|
+
kind: "directory",
|
|
183
|
+
name: "",
|
|
184
|
+
children: /* @__PURE__ */ new Map()
|
|
185
|
+
};
|
|
186
|
+
for (const file of files) {
|
|
187
|
+
const segments = (file.webkitRelativePath || file.name).split("/").filter(Boolean);
|
|
188
|
+
if (!segments.length) continue;
|
|
189
|
+
const rooted = Boolean(file.webkitRelativePath) && segments.length > 1;
|
|
190
|
+
if (rooted && !root.name) root.name = segments[0];
|
|
191
|
+
const path = rooted ? segments.slice(1) : segments;
|
|
192
|
+
let level = root.children;
|
|
193
|
+
for (const name of path.slice(0, -1)) {
|
|
194
|
+
const existing = level.get(name);
|
|
195
|
+
const directory = existing?.kind === "directory" ? existing : {
|
|
196
|
+
kind: "directory",
|
|
197
|
+
name,
|
|
198
|
+
children: /* @__PURE__ */ new Map()
|
|
199
|
+
};
|
|
200
|
+
if (existing !== directory) level.set(name, directory);
|
|
201
|
+
level = directory.children;
|
|
202
|
+
}
|
|
203
|
+
const leaf = path[path.length - 1];
|
|
204
|
+
level.set(leaf, {
|
|
205
|
+
kind: "file",
|
|
206
|
+
name: leaf,
|
|
207
|
+
file
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return root;
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* Same name and signature, refusing before the gesture is spent and never faking a save.
|
|
214
|
+
*
|
|
215
|
+
* The one picker with no fallback. Writing to a location somebody chose needs the platform's picker,
|
|
216
|
+
* and what an app does instead is a product decision rather than a shim: see the header.
|
|
217
|
+
*/
|
|
21
218
|
var showSaveFilePicker = async (options) => {
|
|
22
219
|
const picker = globalThis.showSaveFilePicker;
|
|
23
|
-
if (!picker) throw
|
|
24
|
-
if (framedByAnotherOrigin()) throw
|
|
220
|
+
if (!picker) throw refuse(NO_SAVE_PICKER);
|
|
221
|
+
if (framedByAnotherOrigin()) throw refuse(NO_FRAMED_PICKER);
|
|
25
222
|
return picker(options);
|
|
26
223
|
};
|
|
224
|
+
/**
|
|
225
|
+
* Same name and signature, answering file handles on every engine.
|
|
226
|
+
*
|
|
227
|
+
* The native picker is preferred wherever it can actually be shown, and its handles come back
|
|
228
|
+
* UNTOUCHED: they are structured cloneable, they can be re-granted after a reload, and wrapping them
|
|
229
|
+
* would take exactly that away. A cross origin frame is skipped rather than tried, since the platform
|
|
230
|
+
* refuses there and refusing costs part of the click the fallback still needs.
|
|
231
|
+
*/
|
|
232
|
+
var showOpenFilePicker = async (options = {}) => {
|
|
233
|
+
const picker = globalThis.showOpenFilePicker;
|
|
234
|
+
if (picker && !framedByAnotherOrigin()) return picker(options);
|
|
235
|
+
const accept = acceptFrom(options.types);
|
|
236
|
+
return (await pickThroughInput((input) => {
|
|
237
|
+
input.multiple = options.multiple === true;
|
|
238
|
+
if (accept) input.accept = accept;
|
|
239
|
+
})).map((file) => handleFor({
|
|
240
|
+
kind: "file",
|
|
241
|
+
name: file.name,
|
|
242
|
+
file
|
|
243
|
+
}));
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Same name and signature, answering a directory handle wherever reading one is possible.
|
|
247
|
+
*
|
|
248
|
+
* `mode: 'readwrite'` is refused where there is no native picker, and that refusal is the honest
|
|
249
|
+
* answer rather than a gap: an `<input>` hands over copies of bytes and there is no route from one
|
|
250
|
+
* back to the folder it came from. Refusing at the ask beats handing back a handle whose every write
|
|
251
|
+
* fails later.
|
|
252
|
+
*/
|
|
253
|
+
var showDirectoryPicker = async (options = {}) => {
|
|
254
|
+
const picker = globalThis.showDirectoryPicker;
|
|
255
|
+
if (picker && !framedByAnotherOrigin()) return picker(options);
|
|
256
|
+
if (options.mode === "readwrite") throw refuse(NO_WRITE_FOLDER);
|
|
257
|
+
return handleFor(treeFrom(await pickThroughInput((input) => {
|
|
258
|
+
input.webkitdirectory = true;
|
|
259
|
+
input.multiple = true;
|
|
260
|
+
})));
|
|
261
|
+
};
|
|
27
262
|
//#endregion
|
|
263
|
+
exports.showDirectoryPicker = showDirectoryPicker;
|
|
264
|
+
exports.showOpenFilePicker = showOpenFilePicker;
|
|
28
265
|
exports.showSaveFilePicker = showSaveFilePicker;
|
package/build/file-system.d.ts
CHANGED
|
@@ -1,46 +1,129 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The File System Access pickers, with the same names and one behaviour picked.
|
|
2
|
+
* The File System Access pickers, with the same names and one behaviour picked for each.
|
|
3
|
+
*
|
|
4
|
+
* ## What was measured, on what, and when
|
|
5
|
+
*
|
|
6
|
+
* 2026-09-03, one machine, three engines driven headless through Playwright, same probe in each:
|
|
7
|
+
*
|
|
8
|
+
* | | Chromium 149 | Firefox 151 | WebKit 26 |
|
|
9
|
+
* | --- | --- | --- | --- |
|
|
10
|
+
* | `showOpenFilePicker` / `showDirectoryPicker` / `showSaveFilePicker` | all present | all absent | all absent |
|
|
11
|
+
* | `FileSystemHandle` and friends as globals | present | present | ABSENT |
|
|
12
|
+
* | `queryPermission` on a native handle | function | undefined | no handles to ask |
|
|
13
|
+
* | `<input>.webkitdirectory` | yes | yes | yes |
|
|
14
|
+
* | the input's `cancel` event | yes | yes | yes |
|
|
15
|
+
* | an object holding its methods as OWN properties, cloned | DataCloneError | DataCloneError | DataCloneError |
|
|
16
|
+
* | the same object with its methods on a PROTOTYPE, cloned | clones, silently | clones, silently | clones, silently |
|
|
17
|
+
* | `<input webkitdirectory>` file order, same tree | `a.bin`, `sub/b.bin` | `sub/b.bin`, `a.bin` | `sub/b.bin`, `a.bin` |
|
|
18
|
+
*
|
|
19
|
+
* Four of those rows decide something below, and each is noted where it does.
|
|
3
20
|
*
|
|
4
21
|
* ## The divergence
|
|
5
22
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
23
|
+
* Two engines of three have NO picker at all, so an app that wants files from a person carries a
|
|
24
|
+
* second route for them: an `<input type="file">`, a `File` where the others have a handle, and a
|
|
25
|
+
* union type threaded through everything downstream. That union is the cost, and it is paid in every
|
|
26
|
+
* file the bytes pass through rather than at the boundary where the difference actually is.
|
|
27
|
+
*
|
|
28
|
+
* `showSaveFilePicker` diverges a second way, which is why it is the one entry here with no
|
|
29
|
+
* fallback. Chromium exposes it whether or not it can be used and refuses at CALL time in two cases a
|
|
30
|
+
* property probe cannot see: a cross origin ancestor frame ("Cross origin sub frames aren't allowed
|
|
31
|
+
* to show a file picker") and no transient activation.
|
|
32
|
+
*
|
|
33
|
+
* ## The picks
|
|
8
34
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
35
|
+
* READING ALWAYS WORKS, and always answers handles. `showOpenFilePicker` and `showDirectoryPicker`
|
|
36
|
+
* open the platform's picker where there is one and hand back its handles untouched; where there is
|
|
37
|
+
* none they open an `<input type="file">` and wrap what comes back in the same shape. One call, one
|
|
38
|
+
* return type, no branch in the caller.
|
|
12
39
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
40
|
+
* WRITING IS REFUSED RATHER THAN FAKED. `showSaveFilePicker` has no fallback, `showDirectoryPicker`
|
|
41
|
+
* refuses `mode: 'readwrite'` where it has no native picker, and a wrapped handle's
|
|
42
|
+
* `createWritable()` throws. There is no way to write to a chosen location without the platform's
|
|
43
|
+
* picker, and a download that appeared in someone's downloads folder instead would be a different
|
|
44
|
+
* thing wearing the same name.
|
|
15
45
|
*
|
|
16
|
-
*
|
|
46
|
+
* REFUSALS HAPPEN BEFORE THE GESTURE IS SPENT, and are named so a caller can tell them from a
|
|
47
|
+
* cancel. A caller with a fallback chain has one transient activation, and a picker that rejects at
|
|
48
|
+
* call time has already consumed part of it, so the fallback reached for next can fail too.
|
|
49
|
+
* `NotAllowedError` for every refusal and never `AbortError`, because `AbortError` is what the
|
|
50
|
+
* platform throws when the PERSON cancels: a caller that cannot tell those apart reports a failure
|
|
51
|
+
* for something somebody chose to do.
|
|
17
52
|
*
|
|
18
|
-
*
|
|
53
|
+
* A WRAPPED HANDLE CANNOT BE PERSISTED, AND SAYS SO LOUDLY. This is the one difference that cannot be
|
|
54
|
+
* absorbed, so what is picked is how it FAILS. A native handle survives `structuredClone` and comes
|
|
55
|
+
* back out of IndexedDB still usable, which is the whole reason handles are worth having; a wrapper
|
|
56
|
+
* around a `File` cannot, because the thing it refers to is a snapshot rather than an entry on a
|
|
57
|
+
* disk. Left as an ordinary object it would clone SUCCESSFULLY and come back with its prototype gone
|
|
58
|
+
* and every method with it, so the app would store it, reload, and fail somewhere else entirely with
|
|
59
|
+
* nothing pointing back here. Measured on all three engines above, both halves of that.
|
|
19
60
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
61
|
+
* So every wrapped handle carries its methods as OWN properties, which makes `structuredClone` and
|
|
62
|
+
* `IDBObjectStore.put` throw `DataCloneError` at the moment of the mistake. `await set(key, handle)`
|
|
63
|
+
* rejecting where the browser cannot remember it is the honest shape, and it is one `.catch` at the
|
|
64
|
+
* one place that cares rather than a capability probe at every call site.
|
|
23
65
|
*
|
|
24
|
-
*
|
|
25
|
-
* platform throws when the PERSON cancels, and a caller that cannot tell those apart shows an error
|
|
26
|
-
* for something the person chose to do. Ripple's `isSaveCancelled` matches on `AbortError`, so this
|
|
27
|
-
* distinction is load bearing rather than tidy.
|
|
66
|
+
* WHAT IS NOT ABSORBED, deliberately:
|
|
28
67
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
68
|
+
* - the fallback chain for saving. An anchor download, a service worker sink, or holding bytes in
|
|
69
|
+
* memory are not platform names, and choosing between them is a product decision about what a page
|
|
70
|
+
* does when it cannot save.
|
|
71
|
+
* - `queryPermission` and `requestPermission` on a handle. They are Chromium's alone, absent from
|
|
72
|
+
* Firefox even on its own native handles, and they are methods on an object the caller already
|
|
73
|
+
* holds rather than a name this package could export without inventing one.
|
|
74
|
+
* - re-opening. Nothing here makes a snapshot durable. An app that needs the bytes after a reload
|
|
75
|
+
* has to copy them somewhere it owns, which spends the origin's quota and is therefore its
|
|
76
|
+
* decision to make rather than this package's to make silently.
|
|
33
77
|
*/
|
|
78
|
+
type FilePickerAcceptType = {
|
|
79
|
+
description?: string;
|
|
80
|
+
accept: Record<string, string[]>;
|
|
81
|
+
};
|
|
34
82
|
type SaveFilePickerOptions = {
|
|
35
83
|
suggestedName?: string;
|
|
36
|
-
types?:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
84
|
+
types?: FilePickerAcceptType[];
|
|
85
|
+
excludeAcceptAllOption?: boolean;
|
|
86
|
+
id?: string;
|
|
87
|
+
startIn?: unknown;
|
|
88
|
+
};
|
|
89
|
+
type OpenFilePickerOptions = {
|
|
90
|
+
multiple?: boolean;
|
|
91
|
+
types?: FilePickerAcceptType[];
|
|
40
92
|
excludeAcceptAllOption?: boolean;
|
|
41
93
|
id?: string;
|
|
42
94
|
startIn?: unknown;
|
|
43
95
|
};
|
|
96
|
+
type DirectoryPickerOptions = {
|
|
97
|
+
id?: string;
|
|
98
|
+
mode?: 'read' | 'readwrite';
|
|
99
|
+
startIn?: unknown;
|
|
100
|
+
};
|
|
44
101
|
type SaveFilePicker = (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
|
|
102
|
+
type OpenFilePicker = (options?: OpenFilePickerOptions) => Promise<FileSystemFileHandle[]>;
|
|
103
|
+
type DirectoryPicker = (options?: DirectoryPickerOptions) => Promise<FileSystemDirectoryHandle>;
|
|
104
|
+
/**
|
|
105
|
+
* Same name and signature, refusing before the gesture is spent and never faking a save.
|
|
106
|
+
*
|
|
107
|
+
* The one picker with no fallback. Writing to a location somebody chose needs the platform's picker,
|
|
108
|
+
* and what an app does instead is a product decision rather than a shim: see the header.
|
|
109
|
+
*/
|
|
45
110
|
export declare const showSaveFilePicker: SaveFilePicker;
|
|
111
|
+
/**
|
|
112
|
+
* Same name and signature, answering file handles on every engine.
|
|
113
|
+
*
|
|
114
|
+
* The native picker is preferred wherever it can actually be shown, and its handles come back
|
|
115
|
+
* UNTOUCHED: they are structured cloneable, they can be re-granted after a reload, and wrapping them
|
|
116
|
+
* would take exactly that away. A cross origin frame is skipped rather than tried, since the platform
|
|
117
|
+
* refuses there and refusing costs part of the click the fallback still needs.
|
|
118
|
+
*/
|
|
119
|
+
export declare const showOpenFilePicker: OpenFilePicker;
|
|
120
|
+
/**
|
|
121
|
+
* Same name and signature, answering a directory handle wherever reading one is possible.
|
|
122
|
+
*
|
|
123
|
+
* `mode: 'readwrite'` is refused where there is no native picker, and that refusal is the honest
|
|
124
|
+
* answer rather than a gap: an `<input>` hands over copies of bytes and there is no route from one
|
|
125
|
+
* back to the folder it came from. Refusing at the ask beats handing back a handle whose every write
|
|
126
|
+
* fails later.
|
|
127
|
+
*/
|
|
128
|
+
export declare const showDirectoryPicker: DirectoryPicker;
|
|
46
129
|
export {};
|
package/build/file-system.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
//#region src/file-system.ts
|
|
2
|
+
var NO_SAVE_PICKER = "this browser has no file save picker";
|
|
3
|
+
var NO_FRAMED_PICKER = "a cross origin frame cannot show a file save picker";
|
|
4
|
+
var NO_DOCUMENT = "there is no document here to open a file picker from";
|
|
5
|
+
var NO_WRITE = "this file was opened from a picker that cannot write, so there is nothing to write to";
|
|
6
|
+
var NO_WRITE_FOLDER = "this browser cannot grant write access to a chosen folder";
|
|
7
|
+
var refuse = (message) => new DOMException(message, "NotAllowedError");
|
|
2
8
|
/**
|
|
3
9
|
* Whether this document is framed by another origin.
|
|
4
10
|
*
|
|
5
|
-
* Chromium exposes the
|
|
11
|
+
* Chromium exposes the pickers either way and refuses them at call time, so a property probe says
|
|
6
12
|
* nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
|
|
7
13
|
* an opaque origin, which is the case a sandboxed frame presents.
|
|
8
14
|
*/
|
|
@@ -17,11 +23,240 @@ var framedByAnotherOrigin = () => {
|
|
|
17
23
|
return true;
|
|
18
24
|
}
|
|
19
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* The `accept` attribute for an `<input>`, from the picker's own `types`.
|
|
28
|
+
*
|
|
29
|
+
* Both halves of each entry go in. The attribute takes MIME types and extensions in one comma
|
|
30
|
+
* separated list, and an engine that does not recognise one of them ignores that one rather than the
|
|
31
|
+
* whole attribute, so listing both is strictly better than choosing. `*` patterns are dropped: they
|
|
32
|
+
* are what `excludeAcceptAllOption: false` already means, and an `accept` of `*` filters nothing
|
|
33
|
+
* while making the dialog claim it does.
|
|
34
|
+
*/
|
|
35
|
+
var acceptFrom = (types) => {
|
|
36
|
+
const out = /* @__PURE__ */ new Set();
|
|
37
|
+
for (const type of types ?? []) for (const [mime, extensions] of Object.entries(type.accept ?? {})) {
|
|
38
|
+
if (mime && !mime.includes("*")) out.add(mime);
|
|
39
|
+
for (const extension of extensions) out.add(extension);
|
|
40
|
+
}
|
|
41
|
+
return [...out].join(",");
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* One pick through a detached `<input type="file">`, resolving the files or rejecting like a cancel.
|
|
45
|
+
*
|
|
46
|
+
* The `cancel` event is what makes this a promise that always settles, and it is why this fallback is
|
|
47
|
+
* worth having at all rather than being a hazard: measured present on all three engines above. An
|
|
48
|
+
* engine without it would leave a picker that was dismissed pending forever, which is worse than not
|
|
49
|
+
* offering one.
|
|
50
|
+
*
|
|
51
|
+
* NEEDS THE CALLER'S TRANSIENT ACTIVATION, exactly as the platform picker does. `click()` on a file
|
|
52
|
+
* input opens nothing without a gesture, so this has to be reached synchronously from the handler,
|
|
53
|
+
* and an `await` before it loses that. The same rule the native picker has, for the same reason.
|
|
54
|
+
*
|
|
55
|
+
* The input is attached and removed rather than left detached: a detached input's `click()` is
|
|
56
|
+
* ignored by some engines, and leaving it in the document would leave one element per pick behind.
|
|
57
|
+
*/
|
|
58
|
+
var pickThroughInput = (setup) => {
|
|
59
|
+
const document = globalThis.document;
|
|
60
|
+
if (!document?.body) return Promise.reject(refuse(NO_DOCUMENT));
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const input = document.createElement("input");
|
|
63
|
+
input.type = "file";
|
|
64
|
+
input.style.display = "none";
|
|
65
|
+
input.setAttribute("aria-hidden", "true");
|
|
66
|
+
setup(input);
|
|
67
|
+
const settle = (finish) => {
|
|
68
|
+
input.remove();
|
|
69
|
+
finish();
|
|
70
|
+
};
|
|
71
|
+
input.addEventListener("change", () => settle(() => resolve([...input.files ?? []])), { once: true });
|
|
72
|
+
input.addEventListener("cancel", () => settle(() => reject(new DOMException("the file picker was closed", "AbortError"))), { once: true });
|
|
73
|
+
document.body.append(input);
|
|
74
|
+
input.click();
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
var handles = /* @__PURE__ */ new WeakMap();
|
|
78
|
+
/**
|
|
79
|
+
* Entries in a fixed order, which the platform does not promise and the engines do not agree on.
|
|
80
|
+
*
|
|
81
|
+
* Measured above: the same tree comes out of `<input webkitdirectory>` in one order on Chromium and
|
|
82
|
+
* the opposite on Firefox and WebKit. Sorted by code unit rather than `localeCompare`, because a
|
|
83
|
+
* locale sensitive sort is one more thing that differs between two machines running the same code.
|
|
84
|
+
*/
|
|
85
|
+
var ordered = (children) => [...children].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
86
|
+
var fileHandleFor = (node) => {
|
|
87
|
+
return {
|
|
88
|
+
kind: "file",
|
|
89
|
+
name: node.name,
|
|
90
|
+
/**
|
|
91
|
+
* The same `File` every time, where the platform hands back a fresh one.
|
|
92
|
+
*
|
|
93
|
+
* A `File` from an input is a snapshot taken when it was picked. It cannot be re-read from disk,
|
|
94
|
+
* so `lastModified` never moves and a staleness check across it can never fire. What it does do
|
|
95
|
+
* is throw on READ once the file underneath has changed, so a pass over the bytes still refuses
|
|
96
|
+
* to produce a mixture of two versions; it just reports it as a failed read rather than a
|
|
97
|
+
* changed file.
|
|
98
|
+
*/
|
|
99
|
+
getFile: async () => node.file,
|
|
100
|
+
createWritable: async () => {
|
|
101
|
+
throw refuse(NO_WRITE);
|
|
102
|
+
},
|
|
103
|
+
isSameEntry: async (other) => other === handles.get(node)
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
var directoryHandleFor = (node) => {
|
|
107
|
+
const child = (name) => node.children.get(name);
|
|
108
|
+
const entries = async function* () {
|
|
109
|
+
for (const [name, entry] of ordered(node.children)) yield [name, handleFor(entry)];
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
kind: "directory",
|
|
113
|
+
name: node.name,
|
|
114
|
+
entries,
|
|
115
|
+
keys: async function* () {
|
|
116
|
+
for (const [name] of ordered(node.children)) yield name;
|
|
117
|
+
},
|
|
118
|
+
values: async function* () {
|
|
119
|
+
for (const [, entry] of ordered(node.children)) yield handleFor(entry);
|
|
120
|
+
},
|
|
121
|
+
[Symbol.asyncIterator]: entries,
|
|
122
|
+
getFileHandle: async (name, options) => {
|
|
123
|
+
if (options?.create) throw refuse(NO_WRITE);
|
|
124
|
+
const found = child(name);
|
|
125
|
+
if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
|
|
126
|
+
if (found.kind !== "file") throw new DOMException(`${name} is a directory`, "TypeMismatchError");
|
|
127
|
+
return handleFor(found);
|
|
128
|
+
},
|
|
129
|
+
getDirectoryHandle: async (name, options) => {
|
|
130
|
+
if (options?.create) throw refuse(NO_WRITE);
|
|
131
|
+
const found = child(name);
|
|
132
|
+
if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
|
|
133
|
+
if (found.kind !== "directory") throw new DOMException(`${name} is a file`, "TypeMismatchError");
|
|
134
|
+
return handleFor(found);
|
|
135
|
+
},
|
|
136
|
+
removeEntry: async () => {
|
|
137
|
+
throw refuse(NO_WRITE);
|
|
138
|
+
},
|
|
139
|
+
/** The path from here down to a handle in this tree, or null where it is not in it. */
|
|
140
|
+
resolve: async (descendant) => {
|
|
141
|
+
const search = (from, path) => {
|
|
142
|
+
for (const [name, entry] of ordered(from.children)) {
|
|
143
|
+
if (handles.get(entry) === descendant) return [...path, name];
|
|
144
|
+
if (entry.kind === "directory") {
|
|
145
|
+
const deeper = search(entry, [...path, name]);
|
|
146
|
+
if (deeper) return deeper;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
};
|
|
151
|
+
return handles.get(node) === descendant ? [] : search(node, []);
|
|
152
|
+
},
|
|
153
|
+
isSameEntry: async (other) => other === handles.get(node)
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
var handleFor = (node) => {
|
|
157
|
+
const made = handles.get(node);
|
|
158
|
+
if (made) return made;
|
|
159
|
+
const fresh = node.kind === "file" ? fileHandleFor(node) : directoryHandleFor(node);
|
|
160
|
+
handles.set(node, fresh);
|
|
161
|
+
return fresh;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* The picked folder, rebuilt from the flat list an input hands over.
|
|
165
|
+
*
|
|
166
|
+
* `webkitRelativePath` is the whole tree already flattened: `Pack/Subs/E01.ass` for a folder called
|
|
167
|
+
* `Pack`. The first segment is the folder's own name, so it becomes the root's name and is dropped
|
|
168
|
+
* from every path under it, which is what makes this the same shape `showDirectoryPicker` returns.
|
|
169
|
+
*
|
|
170
|
+
* AN EMPTY FOLDER LOSES ITS NAME, and nothing can be done about that. The name is only ever learned
|
|
171
|
+
* from a file's path, so a folder with nothing in it comes back named `''`. It is a real pick rather
|
|
172
|
+
* than a cancel, since `change` fired, and reporting it as an empty directory is the closest true
|
|
173
|
+
* answer available.
|
|
174
|
+
*
|
|
175
|
+
* A name used twice, once as a file and once as a folder, resolves to the folder, because a path
|
|
176
|
+
* continuing through it proves it is one. Two files with the same path keep the last, which is what
|
|
177
|
+
* a map does and what re-picking the same tree would do anyway.
|
|
178
|
+
*/
|
|
179
|
+
var treeFrom = (files) => {
|
|
180
|
+
const root = {
|
|
181
|
+
kind: "directory",
|
|
182
|
+
name: "",
|
|
183
|
+
children: /* @__PURE__ */ new Map()
|
|
184
|
+
};
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const segments = (file.webkitRelativePath || file.name).split("/").filter(Boolean);
|
|
187
|
+
if (!segments.length) continue;
|
|
188
|
+
const rooted = Boolean(file.webkitRelativePath) && segments.length > 1;
|
|
189
|
+
if (rooted && !root.name) root.name = segments[0];
|
|
190
|
+
const path = rooted ? segments.slice(1) : segments;
|
|
191
|
+
let level = root.children;
|
|
192
|
+
for (const name of path.slice(0, -1)) {
|
|
193
|
+
const existing = level.get(name);
|
|
194
|
+
const directory = existing?.kind === "directory" ? existing : {
|
|
195
|
+
kind: "directory",
|
|
196
|
+
name,
|
|
197
|
+
children: /* @__PURE__ */ new Map()
|
|
198
|
+
};
|
|
199
|
+
if (existing !== directory) level.set(name, directory);
|
|
200
|
+
level = directory.children;
|
|
201
|
+
}
|
|
202
|
+
const leaf = path[path.length - 1];
|
|
203
|
+
level.set(leaf, {
|
|
204
|
+
kind: "file",
|
|
205
|
+
name: leaf,
|
|
206
|
+
file
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return root;
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* Same name and signature, refusing before the gesture is spent and never faking a save.
|
|
213
|
+
*
|
|
214
|
+
* The one picker with no fallback. Writing to a location somebody chose needs the platform's picker,
|
|
215
|
+
* and what an app does instead is a product decision rather than a shim: see the header.
|
|
216
|
+
*/
|
|
20
217
|
var showSaveFilePicker = async (options) => {
|
|
21
218
|
const picker = globalThis.showSaveFilePicker;
|
|
22
|
-
if (!picker) throw
|
|
23
|
-
if (framedByAnotherOrigin()) throw
|
|
219
|
+
if (!picker) throw refuse(NO_SAVE_PICKER);
|
|
220
|
+
if (framedByAnotherOrigin()) throw refuse(NO_FRAMED_PICKER);
|
|
24
221
|
return picker(options);
|
|
25
222
|
};
|
|
223
|
+
/**
|
|
224
|
+
* Same name and signature, answering file handles on every engine.
|
|
225
|
+
*
|
|
226
|
+
* The native picker is preferred wherever it can actually be shown, and its handles come back
|
|
227
|
+
* UNTOUCHED: they are structured cloneable, they can be re-granted after a reload, and wrapping them
|
|
228
|
+
* would take exactly that away. A cross origin frame is skipped rather than tried, since the platform
|
|
229
|
+
* refuses there and refusing costs part of the click the fallback still needs.
|
|
230
|
+
*/
|
|
231
|
+
var showOpenFilePicker = async (options = {}) => {
|
|
232
|
+
const picker = globalThis.showOpenFilePicker;
|
|
233
|
+
if (picker && !framedByAnotherOrigin()) return picker(options);
|
|
234
|
+
const accept = acceptFrom(options.types);
|
|
235
|
+
return (await pickThroughInput((input) => {
|
|
236
|
+
input.multiple = options.multiple === true;
|
|
237
|
+
if (accept) input.accept = accept;
|
|
238
|
+
})).map((file) => handleFor({
|
|
239
|
+
kind: "file",
|
|
240
|
+
name: file.name,
|
|
241
|
+
file
|
|
242
|
+
}));
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* Same name and signature, answering a directory handle wherever reading one is possible.
|
|
246
|
+
*
|
|
247
|
+
* `mode: 'readwrite'` is refused where there is no native picker, and that refusal is the honest
|
|
248
|
+
* answer rather than a gap: an `<input>` hands over copies of bytes and there is no route from one
|
|
249
|
+
* back to the folder it came from. Refusing at the ask beats handing back a handle whose every write
|
|
250
|
+
* fails later.
|
|
251
|
+
*/
|
|
252
|
+
var showDirectoryPicker = async (options = {}) => {
|
|
253
|
+
const picker = globalThis.showDirectoryPicker;
|
|
254
|
+
if (picker && !framedByAnotherOrigin()) return picker(options);
|
|
255
|
+
if (options.mode === "readwrite") throw refuse(NO_WRITE_FOLDER);
|
|
256
|
+
return handleFor(treeFrom(await pickThroughInput((input) => {
|
|
257
|
+
input.webkitdirectory = true;
|
|
258
|
+
input.multiple = true;
|
|
259
|
+
})));
|
|
260
|
+
};
|
|
26
261
|
//#endregion
|
|
27
|
-
export { showSaveFilePicker };
|
|
262
|
+
export { showDirectoryPicker, showOpenFilePicker, showSaveFilePicker };
|
package/build/index.cjs
CHANGED
|
@@ -3,5 +3,7 @@ const require_storage = require("./storage.cjs");
|
|
|
3
3
|
const require_permissions = require("./permissions.cjs");
|
|
4
4
|
const require_file_system = require("./file-system.cjs");
|
|
5
5
|
exports.permissions = require_permissions.permissions;
|
|
6
|
+
exports.showDirectoryPicker = require_file_system.showDirectoryPicker;
|
|
7
|
+
exports.showOpenFilePicker = require_file_system.showOpenFilePicker;
|
|
6
8
|
exports.showSaveFilePicker = require_file_system.showSaveFilePicker;
|
|
7
9
|
exports.storage = require_storage.storage;
|
package/build/index.d.ts
CHANGED
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
*/
|
|
35
35
|
export { storage } from './storage';
|
|
36
36
|
export { permissions } from './permissions';
|
|
37
|
-
export { showSaveFilePicker } from './file-system';
|
|
37
|
+
export { showDirectoryPicker, showOpenFilePicker, showSaveFilePicker } from './file-system';
|
|
38
38
|
export type { StorageEstimate } from './storage';
|
|
39
39
|
export type { PermissionStatus } from './permissions';
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { storage } from "./storage.js";
|
|
2
2
|
import { permissions } from "./permissions.js";
|
|
3
|
-
import { showSaveFilePicker } from "./file-system.js";
|
|
4
|
-
export { permissions, showSaveFilePicker, storage };
|
|
3
|
+
import { showDirectoryPicker, showOpenFilePicker, showSaveFilePicker } from "./file-system.js";
|
|
4
|
+
export { permissions, showDirectoryPicker, showOpenFilePicker, showSaveFilePicker, storage };
|
package/build/storage.cjs
CHANGED
|
@@ -91,15 +91,45 @@ var reconcile = (estimate, walked) => {
|
|
|
91
91
|
* slightly early. Cache is the thing that can be fetched again; a caller that never reclaims because
|
|
92
92
|
* it never sees pressure is the failure this replaces.
|
|
93
93
|
*
|
|
94
|
-
* In memory, and per
|
|
95
|
-
* re-anchors on a figure the platform stated at
|
|
94
|
+
* In memory, and per REALM rather than per page: a worker holds its own. Persisting it would mean
|
|
95
|
+
* owning storage to describe storage, and a reload re-anchors on a figure the platform stated at
|
|
96
|
+
* that moment, which is the same guarantee. What a realm cannot be allowed to do is hold a ceiling
|
|
97
|
+
* that has since moved, and the release below is how it lets go without any realm telling another.
|
|
96
98
|
*/
|
|
97
99
|
var narrowest;
|
|
98
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Whether the origin was persistent when the ceiling above was latched.
|
|
102
|
+
*
|
|
103
|
+
* THE LATCH HAS TO BE ABLE TO LET GO, AND IN A REALM THAT NEVER ASKED. A grant moves the ceiling by
|
|
104
|
+
* orders of magnitude (measured 2026-09-01 on Firefox: 12 GB to 3.97 TB), so the figure learned
|
|
105
|
+
* before it is worse than useless afterwards. `persist()` is a MAIN THREAD call, because a worker's
|
|
106
|
+
* StorageManager has none, so a worker that latched 12 GB would hold it for its whole life while the
|
|
107
|
+
* page moved on. Anything deciding what to delete from that figure would go on deleting against a
|
|
108
|
+
* ceiling that no longer exists.
|
|
109
|
+
*
|
|
110
|
+
* So the release is keyed on the STATE rather than on the call: every `estimate()` reads
|
|
111
|
+
* `persisted()` alongside the quota, and a change since the latch drops it. That works in every
|
|
112
|
+
* realm, including the ones that could not have made the call, and it needs no shared state between
|
|
113
|
+
* them. `undefined` means the engine would not say, and an engine that will not say has not reported
|
|
114
|
+
* a change, so the latch stands.
|
|
115
|
+
*/
|
|
116
|
+
var latchedUnder;
|
|
117
|
+
var ceiling = (reported, usage, persistent) => {
|
|
99
118
|
if (reported === void 0) return void 0;
|
|
119
|
+
if (persistent !== latchedUnder) narrowest = void 0;
|
|
120
|
+
latchedUnder = persistent;
|
|
100
121
|
narrowest = narrowest === void 0 ? reported : Math.min(narrowest, reported);
|
|
101
122
|
return Math.max(narrowest, usage ?? 0);
|
|
102
123
|
};
|
|
124
|
+
/**
|
|
125
|
+
* Whether this origin is persistent, or undefined where the engine has no way to be asked.
|
|
126
|
+
*
|
|
127
|
+
* A REJECTION IS NOT A CHANGE OF STATE, so it answers whatever the ceiling was latched under and the
|
|
128
|
+
* latch is left alone. Treating a failed read as a third value would make an engine that fails this
|
|
129
|
+
* call intermittently drop the ceiling every other estimate, which is the pick quietly turning
|
|
130
|
+
* itself off on exactly the engines least able to spare it.
|
|
131
|
+
*/
|
|
132
|
+
var persistence = async (native) => native.persisted ? native.persisted().then((value) => value === true).catch(() => latchedUnder) : void 0;
|
|
103
133
|
var storage = {
|
|
104
134
|
/**
|
|
105
135
|
* Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
|
|
@@ -112,11 +142,12 @@ var storage = {
|
|
|
112
142
|
const native = globalThis.navigator?.storage;
|
|
113
143
|
if (!native?.estimate) return {};
|
|
114
144
|
const estimate = await native.estimate();
|
|
115
|
-
const
|
|
145
|
+
const [walked, persistent] = await Promise.all([native.getDirectory ? native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null, persistence(native)]);
|
|
146
|
+
const usage = reconcile(estimate, walked);
|
|
116
147
|
return {
|
|
117
148
|
...estimate,
|
|
118
149
|
usage,
|
|
119
|
-
quota: ceiling(estimate.quota, usage)
|
|
150
|
+
quota: ceiling(estimate.quota, usage, persistent)
|
|
120
151
|
};
|
|
121
152
|
},
|
|
122
153
|
/**
|
|
@@ -131,12 +162,16 @@ var storage = {
|
|
|
131
162
|
* left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
|
|
132
163
|
* platform will not state the state.
|
|
133
164
|
*
|
|
134
|
-
* SECOND, the ceiling
|
|
135
|
-
* 2026-09-01 on Firefox, granting the "Store data
|
|
136
|
-
* reported quota from 12 GB to 3.97 TB on an 8.03 TB
|
|
137
|
-
* the narrowest quota it has seen, which is right
|
|
138
|
-
*
|
|
139
|
-
*
|
|
165
|
+
* SECOND, the ceiling, which this call does NOT release and deliberately so. A granted persist can
|
|
166
|
+
* move the quota by orders of magnitude: measured 2026-09-01 on Firefox, granting the "Store data
|
|
167
|
+
* in persistent storage" doorhanger moved the reported quota from 12 GB to 3.97 TB on an 8.03 TB
|
|
168
|
+
* device, about 330 times. `estimate()` latches the narrowest quota it has seen, which is right
|
|
169
|
+
* while nothing changes the ceiling and wrong the moment something does.
|
|
170
|
+
*
|
|
171
|
+
* Releasing it HERE would only release it in the realm that made the call, and this call can only
|
|
172
|
+
* be made from the main thread. So the release lives in `estimate()`, keyed on the persistence
|
|
173
|
+
* state rather than on the call, and every realm picks the change up on its next read. See
|
|
174
|
+
* `latchedUnder`.
|
|
140
175
|
*
|
|
141
176
|
* Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
|
|
142
177
|
* at any point, and the quota stays flat. That is not a failure to handle: it is the engine
|
|
@@ -146,9 +181,7 @@ var storage = {
|
|
|
146
181
|
const native = globalThis.navigator?.storage;
|
|
147
182
|
if (!native?.persist) return false;
|
|
148
183
|
const answered = await native.persist().catch(() => false);
|
|
149
|
-
|
|
150
|
-
if (persisted) narrowest = void 0;
|
|
151
|
-
return persisted;
|
|
184
|
+
return await persistence(native) ?? null ?? answered;
|
|
152
185
|
},
|
|
153
186
|
persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
|
|
154
187
|
getDirectory: () => {
|
package/build/storage.d.ts
CHANGED
|
@@ -93,12 +93,16 @@ export declare const storage: {
|
|
|
93
93
|
* left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
|
|
94
94
|
* platform will not state the state.
|
|
95
95
|
*
|
|
96
|
-
* SECOND, the ceiling
|
|
97
|
-
* 2026-09-01 on Firefox, granting the "Store data
|
|
98
|
-
* reported quota from 12 GB to 3.97 TB on an 8.03 TB
|
|
99
|
-
* the narrowest quota it has seen, which is right
|
|
100
|
-
*
|
|
101
|
-
*
|
|
96
|
+
* SECOND, the ceiling, which this call does NOT release and deliberately so. A granted persist can
|
|
97
|
+
* move the quota by orders of magnitude: measured 2026-09-01 on Firefox, granting the "Store data
|
|
98
|
+
* in persistent storage" doorhanger moved the reported quota from 12 GB to 3.97 TB on an 8.03 TB
|
|
99
|
+
* device, about 330 times. `estimate()` latches the narrowest quota it has seen, which is right
|
|
100
|
+
* while nothing changes the ceiling and wrong the moment something does.
|
|
101
|
+
*
|
|
102
|
+
* Releasing it HERE would only release it in the realm that made the call, and this call can only
|
|
103
|
+
* be made from the main thread. So the release lives in `estimate()`, keyed on the persistence
|
|
104
|
+
* state rather than on the call, and every realm picks the change up on its next read. See
|
|
105
|
+
* `latchedUnder`.
|
|
102
106
|
*
|
|
103
107
|
* Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
|
|
104
108
|
* at any point, and the quota stays flat. That is not a failure to handle: it is the engine
|
package/build/storage.js
CHANGED
|
@@ -90,15 +90,45 @@ var reconcile = (estimate, walked) => {
|
|
|
90
90
|
* slightly early. Cache is the thing that can be fetched again; a caller that never reclaims because
|
|
91
91
|
* it never sees pressure is the failure this replaces.
|
|
92
92
|
*
|
|
93
|
-
* In memory, and per
|
|
94
|
-
* re-anchors on a figure the platform stated at
|
|
93
|
+
* In memory, and per REALM rather than per page: a worker holds its own. Persisting it would mean
|
|
94
|
+
* owning storage to describe storage, and a reload re-anchors on a figure the platform stated at
|
|
95
|
+
* that moment, which is the same guarantee. What a realm cannot be allowed to do is hold a ceiling
|
|
96
|
+
* that has since moved, and the release below is how it lets go without any realm telling another.
|
|
95
97
|
*/
|
|
96
98
|
var narrowest;
|
|
97
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Whether the origin was persistent when the ceiling above was latched.
|
|
101
|
+
*
|
|
102
|
+
* THE LATCH HAS TO BE ABLE TO LET GO, AND IN A REALM THAT NEVER ASKED. A grant moves the ceiling by
|
|
103
|
+
* orders of magnitude (measured 2026-09-01 on Firefox: 12 GB to 3.97 TB), so the figure learned
|
|
104
|
+
* before it is worse than useless afterwards. `persist()` is a MAIN THREAD call, because a worker's
|
|
105
|
+
* StorageManager has none, so a worker that latched 12 GB would hold it for its whole life while the
|
|
106
|
+
* page moved on. Anything deciding what to delete from that figure would go on deleting against a
|
|
107
|
+
* ceiling that no longer exists.
|
|
108
|
+
*
|
|
109
|
+
* So the release is keyed on the STATE rather than on the call: every `estimate()` reads
|
|
110
|
+
* `persisted()` alongside the quota, and a change since the latch drops it. That works in every
|
|
111
|
+
* realm, including the ones that could not have made the call, and it needs no shared state between
|
|
112
|
+
* them. `undefined` means the engine would not say, and an engine that will not say has not reported
|
|
113
|
+
* a change, so the latch stands.
|
|
114
|
+
*/
|
|
115
|
+
var latchedUnder;
|
|
116
|
+
var ceiling = (reported, usage, persistent) => {
|
|
98
117
|
if (reported === void 0) return void 0;
|
|
118
|
+
if (persistent !== latchedUnder) narrowest = void 0;
|
|
119
|
+
latchedUnder = persistent;
|
|
99
120
|
narrowest = narrowest === void 0 ? reported : Math.min(narrowest, reported);
|
|
100
121
|
return Math.max(narrowest, usage ?? 0);
|
|
101
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* Whether this origin is persistent, or undefined where the engine has no way to be asked.
|
|
125
|
+
*
|
|
126
|
+
* A REJECTION IS NOT A CHANGE OF STATE, so it answers whatever the ceiling was latched under and the
|
|
127
|
+
* latch is left alone. Treating a failed read as a third value would make an engine that fails this
|
|
128
|
+
* call intermittently drop the ceiling every other estimate, which is the pick quietly turning
|
|
129
|
+
* itself off on exactly the engines least able to spare it.
|
|
130
|
+
*/
|
|
131
|
+
var persistence = async (native) => native.persisted ? native.persisted().then((value) => value === true).catch(() => latchedUnder) : void 0;
|
|
102
132
|
var storage = {
|
|
103
133
|
/**
|
|
104
134
|
* Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
|
|
@@ -111,11 +141,12 @@ var storage = {
|
|
|
111
141
|
const native = globalThis.navigator?.storage;
|
|
112
142
|
if (!native?.estimate) return {};
|
|
113
143
|
const estimate = await native.estimate();
|
|
114
|
-
const
|
|
144
|
+
const [walked, persistent] = await Promise.all([native.getDirectory ? native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null, persistence(native)]);
|
|
145
|
+
const usage = reconcile(estimate, walked);
|
|
115
146
|
return {
|
|
116
147
|
...estimate,
|
|
117
148
|
usage,
|
|
118
|
-
quota: ceiling(estimate.quota, usage)
|
|
149
|
+
quota: ceiling(estimate.quota, usage, persistent)
|
|
119
150
|
};
|
|
120
151
|
},
|
|
121
152
|
/**
|
|
@@ -130,12 +161,16 @@ var storage = {
|
|
|
130
161
|
* left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
|
|
131
162
|
* platform will not state the state.
|
|
132
163
|
*
|
|
133
|
-
* SECOND, the ceiling
|
|
134
|
-
* 2026-09-01 on Firefox, granting the "Store data
|
|
135
|
-
* reported quota from 12 GB to 3.97 TB on an 8.03 TB
|
|
136
|
-
* the narrowest quota it has seen, which is right
|
|
137
|
-
*
|
|
138
|
-
*
|
|
164
|
+
* SECOND, the ceiling, which this call does NOT release and deliberately so. A granted persist can
|
|
165
|
+
* move the quota by orders of magnitude: measured 2026-09-01 on Firefox, granting the "Store data
|
|
166
|
+
* in persistent storage" doorhanger moved the reported quota from 12 GB to 3.97 TB on an 8.03 TB
|
|
167
|
+
* device, about 330 times. `estimate()` latches the narrowest quota it has seen, which is right
|
|
168
|
+
* while nothing changes the ceiling and wrong the moment something does.
|
|
169
|
+
*
|
|
170
|
+
* Releasing it HERE would only release it in the realm that made the call, and this call can only
|
|
171
|
+
* be made from the main thread. So the release lives in `estimate()`, keyed on the persistence
|
|
172
|
+
* state rather than on the call, and every realm picks the change up on its next read. See
|
|
173
|
+
* `latchedUnder`.
|
|
139
174
|
*
|
|
140
175
|
* Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
|
|
141
176
|
* at any point, and the quota stays flat. That is not a failure to handle: it is the engine
|
|
@@ -145,9 +180,7 @@ var storage = {
|
|
|
145
180
|
const native = globalThis.navigator?.storage;
|
|
146
181
|
if (!native?.persist) return false;
|
|
147
182
|
const answered = await native.persist().catch(() => false);
|
|
148
|
-
|
|
149
|
-
if (persisted) narrowest = void 0;
|
|
150
|
-
return persisted;
|
|
183
|
+
return await persistence(native) ?? null ?? answered;
|
|
151
184
|
},
|
|
152
185
|
persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
|
|
153
186
|
getDirectory: () => {
|