@octane-xplat/files 0.10.0 → 0.11.1

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 CHANGED
@@ -46,3 +46,40 @@ if (file) {
46
46
  }
47
47
  }
48
48
  ```
49
+
50
+ ## Binary reads, writes, and export
51
+
52
+ `files.readBytes(ref, { maxBytes?, signal? })` reads the full contents as a
53
+ `Uint8Array`. It works on `content://` picks (read through Android's
54
+ `ContentResolver`), iOS picker copies, sandbox files, and web object URLs.
55
+ `maxBytes` rejects oversized files mid-read instead of loading them, and
56
+ `signal` aborts the read with an `AbortError`.
57
+
58
+ ```ts
59
+ const bytes = await files.readBytes(file, { maxBytes: 10 * 1024 * 1024 })
60
+ ```
61
+
62
+ `files.writeBytes(name, bytes)` mirrors `writeText`: on native it writes into
63
+ the app sandbox (Documents) and returns a `FileRef`; on web it starts a
64
+ browser download. `name` must be a bare file name — paths like `../x` or
65
+ `a/b` are rejected, and `writeText` enforces the same rule.
66
+
67
+ `files.export(name, bytes, { mimeType? })` asks the user where to save the
68
+ data and resolves `saved`, `cancelled`, or `unavailable`. `saved` means the
69
+ platform committed the bytes — Android flushes them through
70
+ `ContentResolver` to the picked document, iOS completes the export-as-copy
71
+ flow, and web closes a File System Access writable. Browsers without
72
+ `showSaveFilePicker` and the AppKit leaf report `unavailable`; a share-sheet
73
+ presentation never counts as `saved`. Only one export can run at a time — a
74
+ concurrent call rejects with an `ExportBusyError` rather than stealing the
75
+ open picker's result.
76
+
77
+ ```ts
78
+ const result = await files.export('report.pdf', pdfBytes, { mimeType: 'application/pdf' })
79
+ if (result === 'cancelled') console.log('user dismissed')
80
+ ```
81
+
82
+ Picked refs are only guaranteed readable within the picking session. Android
83
+ `content://` grants are not persisted by the picker, and iOS import-mode
84
+ copies live in a temp folder — copy what you need with `writeBytes`, and
85
+ call `files.release(ref)` when done.
@@ -0,0 +1,2 @@
1
+ import { FileInput } from "./FileInput.web.js";
2
+ export { FileInput };
@@ -0,0 +1,263 @@
1
+ import { cx, formatFileSize, normalizePicked, validateFiles } from "./file-input-utils.js";
2
+ import { Fragment, __s, bag5, clone, createElement, createScopedElement, createScopedValue, delegateEvents, evt1, hookSlots, positionalChildren, setEventHandler, setStyle, template, useId, useImperativeHandle, useRef, useState, withSlot } from "octane";
3
+ import { bindSignalAttribute, bindSignalChild, createElementFromConfig, enableSignalBindings } from "octane/internal/client";
4
+ import { Field, Icon, Spinner, Tooltip, useFieldControl } from "@octane-xplat/ui";
5
+ //#region src/FileInput.web.tsrx
6
+ enableSignalBindings(1, true);
7
+ delegateEvents([
8
+ "dragenter",
9
+ "dragleave",
10
+ "dragover",
11
+ "drop"
12
+ ]);
13
+ var _t$0 = /* @__PURE__ */ template("<div></div>");
14
+ var _hs$ = /* @__PURE__ */ hookSlots(9);
15
+ var _h$0 = /* @__PURE__ */ Symbol(_hs$);
16
+ var _h$7 = /* @__PURE__ */ Symbol(_hs$ + 7);
17
+ var STATUS_GLYPHS = {
18
+ error: "⚠",
19
+ warning: "⚠",
20
+ success: "✓"
21
+ };
22
+ var FileInputControl = /* @__PURE__ */ __s(function FileInputControl(inputProps, __s, __extra) {
23
+ const { props } = withSlot(_h$0, useFieldControl, inputProps);
24
+ const generatedId = useId(_hs$ + 1);
25
+ const inputId = props.id ?? generatedId;
26
+ const fileInput = useRef(null, _hs$ + 2);
27
+ const [dragging, setDragging] = useState(false, _hs$ + 3);
28
+ const [busy, setBusy] = useState(false, _hs$ + 4);
29
+ const [validationErrors, setValidationErrors] = useState([], _hs$ + 5);
30
+ const files = props.value == null ? [] : Array.isArray(props.value) ? props.value : [props.value];
31
+ const isDisabled = !!props.isDisabled || !!props.isReadOnly;
32
+ const isBusy = !!props.isLoading || busy;
33
+ const canClear = files.length > 0 && !isDisabled && !isBusy;
34
+ const showsDisabledReason = isDisabled && !!props.disabledMessage;
35
+ const statusType = validationErrors.length ? "error" : props.status?.type;
36
+ const statusMessage = validationErrors.length ? validationErrors.join(" ") : props.status?.message;
37
+ const emitValue = (next) => props.isMultiple ? next : next[0] ?? null;
38
+ const fireChange = (next) => {
39
+ const emitted = emitValue(next);
40
+ props.onChange?.(emitted);
41
+ if (props.changeAction) {
42
+ setBusy(true);
43
+ Promise.resolve(props.changeAction(emitted)).finally(() => setBusy(false));
44
+ }
45
+ };
46
+ const acceptRefs = (picked) => {
47
+ if (isDisabled || isBusy || !picked.length) return;
48
+ const merged = props.isMultiple ? [...files, ...picked] : picked;
49
+ const { valid, errors } = validateFiles(merged, {
50
+ accept: props.accept,
51
+ maxSize: props.maxSize,
52
+ maxFiles: props.maxFiles
53
+ });
54
+ setValidationErrors(errors);
55
+ const next = !props.isMultiple && errors.length ? files : valid;
56
+ if (next.length !== files.length || next.some((file, index) => file.uri !== files[index]?.uri)) fireChange(next);
57
+ };
58
+ const removeAt = (index) => {
59
+ if (isDisabled || isBusy) return;
60
+ setValidationErrors([]);
61
+ fireChange(files.filter((_, i) => i !== index));
62
+ };
63
+ const pick = () => {
64
+ if (isDisabled || isBusy) return;
65
+ if (props.pick) {
66
+ Promise.resolve(props.pick({
67
+ accept: props.accept,
68
+ multiple: props.isMultiple
69
+ })).then((r) => acceptRefs(normalizePicked(r))).catch((error) => setValidationErrors([String(error?.message ?? error)]));
70
+ return;
71
+ }
72
+ fileInput.current?.click();
73
+ };
74
+ const handleFileSelect = (e) => {
75
+ const list = e.target.files;
76
+ const picked = [];
77
+ if (list) for (let i = 0; i < list.length; i++) {
78
+ const f = list[i];
79
+ picked.push({
80
+ name: f.name,
81
+ uri: URL.createObjectURL(f),
82
+ size: f.size,
83
+ mimeType: f.type,
84
+ file: f
85
+ });
86
+ }
87
+ e.target.value = "";
88
+ acceptRefs(picked);
89
+ };
90
+ const handleDrop = (e) => {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ setDragging(false);
94
+ if (isDisabled || isBusy) return;
95
+ const list = e.dataTransfer?.files;
96
+ if (!list?.length) return;
97
+ const picked = [];
98
+ for (let i = 0; i < list.length; i++) {
99
+ const f = list[i];
100
+ picked.push({
101
+ name: f.name,
102
+ uri: URL.createObjectURL(f),
103
+ size: f.size,
104
+ mimeType: f.type,
105
+ file: f
106
+ });
107
+ }
108
+ acceptRefs(picked);
109
+ };
110
+ const dropzone = props.mode === "dropzone";
111
+ const itemContent = createScopedElement(Fragment, {}, () => positionalChildren([
112
+ files.length ? createScopedElement("ul", { className: "vx-fileinput-list" }, () => files.map((f, i) => createScopedElement("li", {
113
+ key: `${f.uri}-${i}`,
114
+ className: "vx-fileinput-item"
115
+ }, () => positionalChildren([
116
+ createElementFromConfig("c:b0d78e7b", Icon, {
117
+ name: "xplat-upload",
118
+ size: 14
119
+ }),
120
+ createScopedElement("span", { className: "vx-fileinput-itemname" }, () => f.name),
121
+ f.size != null ? createScopedElement("span", { className: "vx-fileinput-itemsize" }, () => formatFileSize(f.size)) : null,
122
+ !isDisabled && !isBusy ? createScopedValue(() => createElement("button", {
123
+ type: "button",
124
+ className: "vx-fileinput-itemremove",
125
+ "aria-label": `Remove ${f.name}`,
126
+ onClick: () => removeAt(i)
127
+ }, "×")) : null
128
+ ])))) : null,
129
+ dropzone ? createScopedElement("div", { className: "vx-fileinput-dropzone-hint" }, () => positionalChildren([
130
+ createElementFromConfig("c:928c8dae", Icon, {
131
+ name: "xplat-upload",
132
+ size: 24
133
+ }),
134
+ createScopedElement("span", {}, () => isDisabled ? "Upload disabled" : "Drag & drop files here or click to browse"),
135
+ props.accept ? createScopedElement("span", { className: "vx-fileinput-accept" }, () => props.accept) : null
136
+ ])) : createScopedValue(() => createScopedElement("button", {
137
+ type: "button",
138
+ id: inputId,
139
+ className: "vx-fileinput-trigger",
140
+ disabled: isDisabled && !showsDisabledReason || void 0,
141
+ "aria-disabled": showsDisabledReason || void 0,
142
+ "aria-describedby": props.ariaDescribedBy,
143
+ "aria-required": props.isRequired && !props.isOptional || void 0,
144
+ "aria-invalid": statusType === "error" || void 0,
145
+ "aria-busy": isBusy || void 0,
146
+ onClick: pick
147
+ }, () => positionalChildren([
148
+ createElementFromConfig("c:a0fe8d9f", Icon, {
149
+ name: "xplat-upload",
150
+ size: 14
151
+ }),
152
+ " ",
153
+ files.length ? "Choose more files" : "Choose files"
154
+ ]))),
155
+ dropzone ? createScopedValue(() => createElement("button", {
156
+ type: "button",
157
+ id: inputId,
158
+ className: "vx-fileinput-trigger vx-fileinput-trigger--hidden",
159
+ disabled: isDisabled && !showsDisabledReason || void 0,
160
+ "aria-disabled": showsDisabledReason || void 0,
161
+ "aria-describedby": props.ariaDescribedBy,
162
+ "aria-required": props.isRequired && !props.isOptional || void 0,
163
+ "aria-invalid": statusType === "error" || void 0,
164
+ "aria-busy": isBusy || void 0,
165
+ onClick: pick
166
+ }, "Browse files")) : null,
167
+ canClear ? createScopedValue(() => createElement("button", {
168
+ type: "button",
169
+ className: "vx-fileinput-clear",
170
+ "aria-label": `Clear ${props.fieldLabel ?? props.label ?? "files"}`,
171
+ onClick: () => {
172
+ setValidationErrors([]);
173
+ fireChange([]);
174
+ }
175
+ }, "×")) : null,
176
+ isBusy ? createElementFromConfig("c:47d00c47", Spinner, {
177
+ busy: true,
178
+ className: "vx-fileinput-spinner"
179
+ }) : null,
180
+ statusType ? props.statusVariant === "tooltip" && statusMessage ? createScopedValue(() => createElementFromConfig("c:86665b9b", Tooltip, {
181
+ trigger: createScopedValue(() => createScopedElement("span", { className: cx("vx-field-icon", `vx-field-icon--${statusType}`) }, () => STATUS_GLYPHS[statusType])),
182
+ content: statusMessage
183
+ })) : createScopedValue(() => createScopedElement("span", { className: cx("vx-field-icon", `vx-field-icon--${statusType}`) }, () => STATUS_GLYPHS[statusType])) : null,
184
+ statusMessage && props.statusVariant !== "tooltip" ? createScopedValue(() => createScopedElement("span", {
185
+ className: cx("vx-field-status", `vx-field-status--${statusType}`),
186
+ role: statusType === "error" ? "alert" : "status"
187
+ }, () => statusMessage)) : null,
188
+ createScopedValue(() => createElement("input", {
189
+ ref: (el) => {
190
+ fileInput.current = el;
191
+ },
192
+ type: "file",
193
+ className: "vx-fileinput-nativeinput",
194
+ accept: props.accept,
195
+ multiple: props.isMultiple || void 0,
196
+ disabled: isDisabled || void 0,
197
+ "aria-hidden": "true",
198
+ tabIndex: -1,
199
+ onChange: handleFileSelect
200
+ }))
201
+ ]));
202
+ useImperativeHandle(props.ref, () => ({
203
+ open: pick,
204
+ native: fileInput.current
205
+ }), void 0, _hs$ + 6);
206
+ if (__s.block.pending && !__s.block.crossRenderUpdate) return;
207
+ let _b = __s.slots[0];
208
+ if (_b === void 0) {
209
+ let _m1;
210
+ const _root = clone(_t$0);
211
+ _m1 = _root;
212
+ setEventHandler(_root, "$$dragover", (e) => {
213
+ e.preventDefault();
214
+ if (!isDisabled && !isBusy) setDragging(true);
215
+ });
216
+ setEventHandler(_root, "$$dragenter", (e) => {
217
+ e.preventDefault();
218
+ });
219
+ evt1(_root, "$$dragleave", setDragging, false);
220
+ setEventHandler(_root, "$$drop", handleDrop);
221
+ _b = bag5(__s, _root, void 0, _m1, void 0, null, void 0);
222
+ } else {
223
+ setEventHandler(_b.b, "$$dragover", (e) => {
224
+ e.preventDefault();
225
+ if (!isDisabled && !isBusy) setDragging(true);
226
+ });
227
+ setEventHandler(_b.b, "$$drop", handleDrop);
228
+ }
229
+ _b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", cx("vx-fileinput", dropzone && "vx-fileinput--dropzone", isDisabled && "vx-fileinput--disabled", isBusy && "vx-fileinput--loading", dragging && "vx-fileinput--dragging", statusType && `vx-fileinput--status-${statusType}`, props.size && `vx-fileinput--size-${props.size}`, props.className), "b:e1094c29", "class");
230
+ {
231
+ const _v = props.style;
232
+ if (_b.c !== _v) {
233
+ setStyle(_b.b, _v, _b.c);
234
+ _b.c = _v;
235
+ }
236
+ }
237
+ _b.f = bindSignalChild(__s, _b.f, 1, _b.b, itemContent, "b:11d99fad", null, void 0, void 0, void 0, true);
238
+ });
239
+ var FileInput = function FileInput(props, __s, __extra) {
240
+ const { inField } = withSlot(_h$7, useFieldControl, props);
241
+ const cid = useId(_hs$ + 8);
242
+ const control = createScopedValue(() => createElementFromConfig("c:afa4c501", FileInputControl, { ...props }));
243
+ if (inField) return control ?? null;
244
+ return createScopedValue(() => createScopedElement(Field, {
245
+ inputID: props.id ?? cid,
246
+ label: props.label,
247
+ isLabelHidden: props.isLabelHidden,
248
+ description: props.description,
249
+ isDisabled: props.isDisabled,
250
+ disabledMessage: props.disabledMessage,
251
+ isReadOnly: props.isReadOnly,
252
+ isLoading: props.isLoading,
253
+ isRequired: props.isRequired,
254
+ isOptional: props.isOptional,
255
+ size: props.size,
256
+ status: props.status,
257
+ statusVariant: props.statusVariant,
258
+ labelTooltip: props.labelTooltip,
259
+ width: props.width
260
+ }, () => control, "c:3f29a6d4"));
261
+ };
262
+ //#endregion
263
+ export { FileInput };
@@ -0,0 +1,45 @@
1
+ //#region src/file-bytes.ts
2
+ /**
3
+ * Shared helpers for the binary file primitives (readBytes/writeBytes/export).
4
+ * DOM-free so the native leaf can use it too.
5
+ */
6
+ /** File names used for sandbox writes and export temp files must be bare
7
+ * names, not paths — a `/`, `\`, or `..` segment escapes the sandbox. */
8
+ function assertSafeFileName(name) {
9
+ if (typeof name !== "string" || name.length === 0 || name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.includes("\0")) throw new Error(`Unsafe file name: ${JSON.stringify(name)}`);
10
+ }
11
+ function fileTooLarge(name, maxBytes) {
12
+ return /* @__PURE__ */ new Error(`${name}: file exceeds the ${maxBytes}-byte limit`);
13
+ }
14
+ /** Named rejection for a second `files.export` while one is in flight. */
15
+ function exportBusy() {
16
+ const error = /* @__PURE__ */ new Error("files.export: an export is already in progress");
17
+ error.name = "ExportBusyError";
18
+ return error;
19
+ }
20
+ function throwIfAborted(signal) {
21
+ if (!signal?.aborted) return;
22
+ const thrower = signal.throwIfAborted;
23
+ if (typeof thrower === "function") thrower.call(signal);
24
+ const error = /* @__PURE__ */ new Error("The operation was aborted");
25
+ error.name = "AbortError";
26
+ throw error;
27
+ }
28
+ /** Concatenate Uint8Array chunks into one exact-length array. */
29
+ function concatBytes(chunks, total) {
30
+ const out = new Uint8Array(total);
31
+ let offset = 0;
32
+ for (const chunk of chunks) {
33
+ out.set(chunk, offset);
34
+ offset += chunk.byteLength;
35
+ }
36
+ return out;
37
+ }
38
+ /** Return an ArrayBuffer covering exactly this view's byte range — native
39
+ * marshallers treat the argument as the whole buffer. */
40
+ function exactBuffer(bytes) {
41
+ if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) return bytes.buffer;
42
+ return bytes.slice().buffer;
43
+ }
44
+ //#endregion
45
+ export { assertSafeFileName, concatBytes, exactBuffer, exportBusy, fileTooLarge, throwIfAborted };
@@ -0,0 +1,52 @@
1
+ //#region src/file-input-utils.ts
2
+ function cx(...values) {
3
+ return values.filter(Boolean).join(" ");
4
+ }
5
+ function normalizePicked(result) {
6
+ if (result == null) return [];
7
+ return Array.isArray(result) ? result : [result];
8
+ }
9
+ function fileAccepts(file, accept) {
10
+ if (!accept) return true;
11
+ const tokens = accept.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
12
+ if (!tokens.length) return true;
13
+ const name = file.name.toLowerCase();
14
+ const mime = (file.mimeType ?? "").toLowerCase();
15
+ return tokens.some((token) => {
16
+ if (token === "*/*") return true;
17
+ if (token.startsWith(".")) return name.endsWith(token);
18
+ if (token.endsWith("/*")) return mime.startsWith(token.slice(0, -1));
19
+ return mime === token;
20
+ });
21
+ }
22
+ function formatFileSize(bytes) {
23
+ if (bytes < 1024) return `${bytes} B`;
24
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
25
+ if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
26
+ return `${(bytes / 1073741824).toFixed(1)} GB`;
27
+ }
28
+ function validateFiles(files, opts) {
29
+ const errors = [];
30
+ const valid = [];
31
+ for (const file of files) {
32
+ if (!fileAccepts(file, opts.accept)) {
33
+ errors.push(`${file.name}: file type not accepted`);
34
+ continue;
35
+ }
36
+ if (opts.maxSize != null && file.size != null && file.size > opts.maxSize) {
37
+ errors.push(`${file.name}: file exceeds ${formatFileSize(opts.maxSize)}`);
38
+ continue;
39
+ }
40
+ valid.push(file);
41
+ }
42
+ if (opts.maxFiles != null && valid.length > opts.maxFiles) {
43
+ const extra = valid.splice(opts.maxFiles);
44
+ for (const file of extra) errors.push(`${file.name}: exceeds the ${opts.maxFiles}-file limit`);
45
+ }
46
+ return {
47
+ valid,
48
+ errors
49
+ };
50
+ }
51
+ //#endregion
52
+ export { cx, fileAccepts, formatFileSize, normalizePicked, validateFiles };
@@ -0,0 +1,2 @@
1
+ import { files } from "./files.web.js";
2
+ export { files };
@@ -0,0 +1,144 @@
1
+ import { assertSafeFileName, concatBytes, exactBuffer, fileTooLarge, throwIfAborted } from "./file-bytes.js";
2
+ import { desktopHost } from "@octane-xplat/platform/host/web";
3
+ //#region src/files.web.ts
4
+ var objectBlobs = /* @__PURE__ */ new Map();
5
+ function mintObjectUrl(blob) {
6
+ const uri = URL.createObjectURL(blob);
7
+ objectBlobs.set(uri, blob);
8
+ return uri;
9
+ }
10
+ function chooseFiles(accept, multiple) {
11
+ return new Promise((resolve) => {
12
+ const input = document.createElement("input");
13
+ input.type = "file";
14
+ input.accept = accept;
15
+ input.multiple = multiple;
16
+ input.onchange = () => {
17
+ const refs = [];
18
+ for (const file of Array.from(input.files ?? [])) refs.push({
19
+ name: file.name,
20
+ uri: mintObjectUrl(file)
21
+ });
22
+ resolve(refs);
23
+ };
24
+ input.oncancel = () => resolve([]);
25
+ input.click();
26
+ });
27
+ }
28
+ async function fetchBytes(uri, opts) {
29
+ const response = await fetch(uri, { signal: opts?.signal });
30
+ if (!response.ok) throw new Error(`Unable to read file: ${uri}`);
31
+ if (opts?.maxBytes == null) return new Uint8Array(await response.arrayBuffer());
32
+ const reader = response.body?.getReader();
33
+ if (!reader) {
34
+ const bytes = new Uint8Array(await response.arrayBuffer());
35
+ if (bytes.byteLength > opts.maxBytes) throw fileTooLarge(uri, opts.maxBytes);
36
+ return bytes;
37
+ }
38
+ const chunks = [];
39
+ let total = 0;
40
+ try {
41
+ while (true) {
42
+ const { done, value } = await reader.read();
43
+ if (done) break;
44
+ total += value.byteLength;
45
+ if (total > opts.maxBytes) {
46
+ await reader.cancel().catch(() => {});
47
+ throw fileTooLarge(uri, opts.maxBytes);
48
+ }
49
+ chunks.push(value);
50
+ }
51
+ } finally {
52
+ reader.releaseLock();
53
+ }
54
+ return concatBytes(chunks, total);
55
+ }
56
+ var files = {
57
+ async pickMultiple(accept = "*/*", _opts) {
58
+ return chooseFiles(accept, true);
59
+ },
60
+ async pick(accept = "*/*", opts) {
61
+ const host = desktopHost();
62
+ if (host && await host.supports("files", "pick")) return host.files.pick(accept, { startingFolder: opts?.startingFolder });
63
+ return (await chooseFiles(accept, false))[0] ?? null;
64
+ },
65
+ async readText(ref) {
66
+ const host = desktopHost();
67
+ if (host && await host.supports("files", "readText")) {
68
+ const text = await host.files.readText(ref.uri);
69
+ if (text === null) throw new Error(`Unable to read host file: ${ref.uri}`);
70
+ return text;
71
+ }
72
+ return (await fetch(ref.uri)).text();
73
+ },
74
+ async readBytes(ref, opts) {
75
+ throwIfAborted(opts?.signal);
76
+ const blob = objectBlobs.get(ref.uri);
77
+ if (blob) {
78
+ if (opts?.maxBytes != null && blob.size > opts.maxBytes) throw fileTooLarge(ref.uri, opts.maxBytes);
79
+ const bytes = new Uint8Array(await blob.arrayBuffer());
80
+ throwIfAborted(opts?.signal);
81
+ return bytes;
82
+ }
83
+ return fetchBytes(ref.uri, opts);
84
+ },
85
+ /** Desktop hosts write the selected path; plain web starts a download. */
86
+ async writeText(name, text) {
87
+ assertSafeFileName(name);
88
+ const host = desktopHost();
89
+ if (host && await host.supports("files", "writeText")) {
90
+ const file = await host.files.writeText(name, text);
91
+ if (!file) throw new Error("Host file save was cancelled");
92
+ return file;
93
+ }
94
+ const uri = mintObjectUrl(new Blob([text], { type: "text/plain" }));
95
+ const a = document.createElement("a");
96
+ a.href = uri;
97
+ a.download = name;
98
+ a.click();
99
+ return {
100
+ name,
101
+ uri
102
+ };
103
+ },
104
+ /** The desktop host contract is text-only, so bytes always download. */
105
+ async writeBytes(name, bytes) {
106
+ assertSafeFileName(name);
107
+ const uri = mintObjectUrl(new Blob([exactBuffer(bytes)], { type: "application/octet-stream" }));
108
+ const a = document.createElement("a");
109
+ a.href = uri;
110
+ a.download = name;
111
+ a.click();
112
+ return {
113
+ name,
114
+ uri
115
+ };
116
+ },
117
+ async export(name, bytes, opts) {
118
+ assertSafeFileName(name);
119
+ const picker = window.showSaveFilePicker;
120
+ if (typeof picker !== "function") return "unavailable";
121
+ const extension = name.includes(".") ? `.${name.split(".").pop()}` : null;
122
+ const types = opts?.mimeType && extension ? [{ accept: { [opts.mimeType]: [extension] } }] : void 0;
123
+ try {
124
+ const writable = await (await picker.call(window, {
125
+ suggestedName: name,
126
+ types
127
+ })).createWritable();
128
+ await writable.write(bytes);
129
+ await writable.close();
130
+ return "saved";
131
+ } catch (error) {
132
+ if (error?.name === "AbortError") return "cancelled";
133
+ throw error;
134
+ }
135
+ },
136
+ release(ref) {
137
+ if (ref.uri.startsWith("blob:")) {
138
+ objectBlobs.delete(ref.uri);
139
+ URL.revokeObjectURL(ref.uri);
140
+ }
141
+ }
142
+ };
143
+ //#endregion
144
+ export { files };
@@ -0,0 +1,5 @@
1
+ import { FileInput } from "./FileInput.web.js";
2
+ import "./FileInput.js";
3
+ import { files } from "./files.web.js";
4
+ import "./files.js";
5
+ export { FileInput, files };
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "buildId": "190c966d-ea82-4510-9cd2-4581c6451685",
4
+ "mode": "production",
5
+ "capabilities": {
6
+ "independentHydration": false
7
+ }
8
+ }
File without changes
File without changes