@multiplatform.one/platform 7.10.0 → 7.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/dist/cjs/file/index.cjs +30 -0
- package/dist/cjs/file/saveFile.cjs +47 -0
- package/dist/cjs/file/types.cjs +56 -0
- package/dist/cjs/utils/downloadFile.cjs +10 -4
- package/dist/esm/file/index.mjs +4 -0
- package/dist/esm/file/index.mjs.map +1 -0
- package/dist/esm/file/saveFile.mjs +22 -0
- package/dist/esm/file/saveFile.mjs.map +1 -0
- package/dist/esm/file/types.mjs +30 -0
- package/dist/esm/file/types.mjs.map +1 -0
- package/dist/esm/utils/downloadFile.mjs +10 -4
- package/dist/esm/utils/downloadFile.mjs.map +1 -1
- package/file.ts +1 -0
- package/package.json +17 -3
- package/src/file/index.ts +3 -0
- package/src/file/saveFile.native.spec.ts +245 -0
- package/src/file/saveFile.native.ts +83 -0
- package/src/file/saveFile.spec.ts +98 -0
- package/src/file/saveFile.ts +25 -0
- package/src/file/saveFile.unavailable.native.spec.ts +24 -0
- package/src/file/types.ts +74 -0
- package/src/utils/downloadFile.spec.ts +54 -0
- package/src/utils/downloadFile.ts +11 -4
- package/types/file/index.d.ts +4 -0
- package/types/file/index.d.ts.map +1 -0
- package/types/file/saveFile.d.ts +3 -0
- package/types/file/saveFile.d.ts.map +1 -0
- package/types/file/saveFile.native.d.ts +3 -0
- package/types/file/saveFile.native.d.ts.map +1 -0
- package/types/file/types.d.ts +34 -0
- package/types/file/types.d.ts.map +1 -0
- package/types/utils/downloadFile.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,73 @@ export function saveFile(contents: string) {
|
|
|
52
52
|
Flags are evaluated once at module load and are safe to use in render logic
|
|
53
53
|
and module scope.
|
|
54
54
|
|
|
55
|
+
## Save bytes to a file
|
|
56
|
+
|
|
57
|
+
Import the opt-in subpath from a user action:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { FileSaveError, saveFile } from "@multiplatform.one/platform/file";
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const result = await saveFile({
|
|
64
|
+
bytes: new TextEncoder().encode("café\n"),
|
|
65
|
+
filename: "example.txt",
|
|
66
|
+
mimeType: "text/plain; charset=utf-8",
|
|
67
|
+
});
|
|
68
|
+
// Render the returned status, not a blanket success message.
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof FileSaveError) {
|
|
71
|
+
console.error(error.code, error.cause, error.cleanupError);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Result | What it establishes |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `written` | Native byte writing completed at `uri`, with `byteLength` and `requestedFilename`. The actual `filename` is optional. Cloud synchronization is not confirmed. |
|
|
79
|
+
| `download-requested` | A browser download was activated. Browser completion or cancellation is unknown. |
|
|
80
|
+
| `not-saved`, reason `cancelled-or-refused` | The native picker reported cancellation or refused directory access. No file was created. iOS reports these with the same code. |
|
|
81
|
+
|
|
82
|
+
`WrittenFile.requestedFilename` always records the supplied filename. The optional
|
|
83
|
+
`filename` reports the actual name only for an iOS file URI. Android content URIs
|
|
84
|
+
omit it: Expo 55.0.26 derives `File.name` from the URI, which can contain an opaque
|
|
85
|
+
document ID instead of a provider display name. Consumers must distinguish the
|
|
86
|
+
requested name from an actual name and use `uri` for the saved destination.
|
|
87
|
+
|
|
88
|
+
Errors reject with `FileSaveError`: `invalid-input`, `unavailable`, `busy`,
|
|
89
|
+
`picker-failed` or `write-failed`. The original failure is in `cause`.
|
|
90
|
+
`cleanupError` records a second failure removing a partially written new file.
|
|
91
|
+
Only `ERR_PICKER_CANCELLED` and `ERR_FILE_PICKING_CANCELLED` are classified as
|
|
92
|
+
`not-saved`; other picker errors remain observable.
|
|
93
|
+
|
|
94
|
+
Native iOS/Android requires `expo-file-system` (`~55.0.26`, tested against
|
|
95
|
+
55.0.26) in the app and its native binary. Install it explicitly in the native
|
|
96
|
+
host. JavaScript or an OTA update cannot add a missing native module. The module
|
|
97
|
+
loads only when `saveFile` is invoked, and isn't exported through the platform
|
|
98
|
+
root. Other hosts require browser download support or return `unavailable`.
|
|
99
|
+
|
|
100
|
+
Native opens the system directory picker and creates a new child through the
|
|
101
|
+
selected provider. It doesn't request overwrite. A provider can refuse an
|
|
102
|
+
existing name or choose a suffix. `requestedFilename` doesn't confirm which name
|
|
103
|
+
the provider chose; `filename` is omitted when that metadata isn't established.
|
|
104
|
+
Successful files belong to the user and are **never automatically deleted**.
|
|
105
|
+
On write failure, cleanup attempts to delete only the newly created child.
|
|
106
|
+
No shared cache file or share sheet is involved.
|
|
107
|
+
|
|
108
|
+
Pass a `Uint8Array`, a nonempty single filename without separators, dot/dot-dot
|
|
109
|
+
or control characters, and a MIME type. Unicode filenames and empty files are
|
|
110
|
+
valid. Bytes are copied before awaiting the directory picker, including the
|
|
111
|
+
exact range of a sliced view. Native creation uses the MIME type without
|
|
112
|
+
parameters; the browser Blob retains the supplied type.
|
|
113
|
+
|
|
114
|
+
The existing root `downloadFile` and `downloadBlob` remain synchronous `void`
|
|
115
|
+
helpers. Blob URLs are released after 60 seconds; this delay manages resources
|
|
116
|
+
and does not indicate that a download completed.
|
|
117
|
+
|
|
118
|
+
`Platform/FileSave` is a generic Storybook specimen for parent browser/native
|
|
119
|
+
checks. Unit tests model native boundaries. Packed ESM/CJS and Metro checks
|
|
120
|
+
are package preflight, not real-device save evidence or registry publication.
|
|
121
|
+
|
|
55
122
|
## License
|
|
56
123
|
|
|
57
124
|
Apache-2.0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all) __defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
14
|
+
get: () => from[key],
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = mod => __copyProps(__defProp({}, "__esModule", {
|
|
21
|
+
value: true
|
|
22
|
+
}), mod);
|
|
23
|
+
var file_exports = {};
|
|
24
|
+
__export(file_exports, {
|
|
25
|
+
FileSaveError: () => import_types.FileSaveError,
|
|
26
|
+
saveFile: () => import_saveFile.saveFile
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(file_exports);
|
|
29
|
+
var import_saveFile = require("./saveFile.cjs");
|
|
30
|
+
var import_types = require("./types.cjs");
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all) __defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
14
|
+
get: () => from[key],
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = mod => __copyProps(__defProp({}, "__esModule", {
|
|
21
|
+
value: true
|
|
22
|
+
}), mod);
|
|
23
|
+
var saveFile_exports = {};
|
|
24
|
+
__export(saveFile_exports, {
|
|
25
|
+
saveFile: () => saveFile
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(saveFile_exports);
|
|
28
|
+
var import_downloadFile = require("../utils/downloadFile.cjs");
|
|
29
|
+
var import_types = require("./types.cjs");
|
|
30
|
+
async function saveFile(options) {
|
|
31
|
+
const input = (0, import_types.prepareFileSave)(options);
|
|
32
|
+
if (typeof document === "undefined" || !document.body || typeof Blob === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function" || typeof URL.revokeObjectURL !== "function" || !("download" in document.createElement("a"))) {
|
|
33
|
+
throw new import_types.FileSaveError("unavailable", "Browser file downloads are unavailable.");
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
(0, import_downloadFile.downloadBlob)(new Blob([input.bytes], {
|
|
37
|
+
type: input.mimeType
|
|
38
|
+
}), input.filename);
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
throw new import_types.FileSaveError("write-failed", "The browser download could not be requested.", {
|
|
41
|
+
cause
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
status: "download-requested"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all) __defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
14
|
+
get: () => from[key],
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = mod => __copyProps(__defProp({}, "__esModule", {
|
|
21
|
+
value: true
|
|
22
|
+
}), mod);
|
|
23
|
+
var types_exports = {};
|
|
24
|
+
__export(types_exports, {
|
|
25
|
+
FileSaveError: () => FileSaveError,
|
|
26
|
+
prepareFileSave: () => prepareFileSave
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(types_exports);
|
|
29
|
+
class FileSaveError extends Error {
|
|
30
|
+
code;
|
|
31
|
+
cleanupError;
|
|
32
|
+
constructor(code, message, options) {
|
|
33
|
+
super(message, options);
|
|
34
|
+
this.name = "FileSaveError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.cleanupError = options?.cleanupError;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function hasControlCharacters(value) {
|
|
40
|
+
return Array.from(value).some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
41
|
+
}
|
|
42
|
+
function prepareFileSave(options) {
|
|
43
|
+
if (!options || !(options.bytes instanceof Uint8Array) || typeof options.filename !== "string" || !options.filename.trim() || options.filename === "." || options.filename === ".." || /[/\\]/.test(options.filename) || hasControlCharacters(options.filename) || typeof options.mimeType !== "string" || hasControlCharacters(options.mimeType)) {
|
|
44
|
+
throw new FileSaveError("invalid-input", "Provide bytes, a single filename and a MIME type.");
|
|
45
|
+
}
|
|
46
|
+
const nativeMimeType = options.mimeType.split(";", 1)[0].trim();
|
|
47
|
+
if (!/^[\w!#$%&'*+.^`|~-]+\/[\w!#$%&'*+.^`|~-]+$/.test(nativeMimeType)) {
|
|
48
|
+
throw new FileSaveError("invalid-input", "Provide a MIME type with a type and subtype.");
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
bytes: Uint8Array.from(options.bytes),
|
|
52
|
+
filename: options.filename,
|
|
53
|
+
mimeType: options.mimeType,
|
|
54
|
+
nativeMimeType
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -31,11 +31,17 @@ function downloadFile(url, filename) {
|
|
|
31
31
|
a.href = url;
|
|
32
32
|
a.download = filename;
|
|
33
33
|
document.body.appendChild(a);
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
try {
|
|
35
|
+
a.click();
|
|
36
|
+
} finally {
|
|
37
|
+
a.remove();
|
|
38
|
+
}
|
|
36
39
|
}
|
|
37
40
|
function downloadBlob(blob, filename) {
|
|
38
41
|
const url = URL.createObjectURL(blob);
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
try {
|
|
43
|
+
downloadFile(url, filename);
|
|
44
|
+
} finally {
|
|
45
|
+
setTimeout(() => URL.revokeObjectURL(url), 6e4);
|
|
46
|
+
}
|
|
41
47
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["saveFile","FileSaveError"],"sources":["../../../src/file/index.ts"],"sourcesContent":[null],"mappings":"AAAA,SAASA,QAAA,QAAgB;AACzB,SAASC,aAAA,QAAqB","ignoreList":[]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { downloadBlob } from "../utils/downloadFile.mjs";
|
|
2
|
+
import { FileSaveError, prepareFileSave } from "./types.mjs";
|
|
3
|
+
async function saveFile(options) {
|
|
4
|
+
const input = prepareFileSave(options);
|
|
5
|
+
if (typeof document === "undefined" || !document.body || typeof Blob === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function" || typeof URL.revokeObjectURL !== "function" || !("download" in document.createElement("a"))) {
|
|
6
|
+
throw new FileSaveError("unavailable", "Browser file downloads are unavailable.");
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
downloadBlob(new Blob([input.bytes], {
|
|
10
|
+
type: input.mimeType
|
|
11
|
+
}), input.filename);
|
|
12
|
+
} catch (cause) {
|
|
13
|
+
throw new FileSaveError("write-failed", "The browser download could not be requested.", {
|
|
14
|
+
cause
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
status: "download-requested"
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export { saveFile };
|
|
22
|
+
//# sourceMappingURL=saveFile.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["downloadBlob","FileSaveError","prepareFileSave","saveFile","options","input","document","body","Blob","URL","createObjectURL","revokeObjectURL","createElement","bytes","type","mimeType","filename","cause","status"],"sources":["../../../src/file/saveFile.ts"],"sourcesContent":[null],"mappings":"AAAA,SAASA,YAAA,QAAoB;AAC7B,SAASC,aAAA,EAA0DC,eAAA,QAAuB;AAE1F,eAAsBC,SAASC,OAAA,EAAmD;EAChF,MAAMC,KAAA,GAAQH,eAAA,CAAgBE,OAAO;EACrC,IACE,OAAOE,QAAA,KAAa,eACpB,CAACA,QAAA,CAASC,IAAA,IACV,OAAOC,IAAA,KAAS,eAChB,OAAOC,GAAA,KAAQ,eACf,OAAOA,GAAA,CAAIC,eAAA,KAAoB,cAC/B,OAAOD,GAAA,CAAIE,eAAA,KAAoB,cAC/B,EAAE,cAAcL,QAAA,CAASM,aAAA,CAAc,GAAG,IAC1C;IACA,MAAM,IAAIX,aAAA,CAAc,eAAe,yCAAyC;EAClF;EACA,IAAI;IACFD,YAAA,CAAa,IAAIQ,IAAA,CAAK,CAACH,KAAA,CAAMQ,KAAK,GAAG;MAAEC,IAAA,EAAMT,KAAA,CAAMU;IAAS,CAAC,GAAGV,KAAA,CAAMW,QAAQ;EAChF,SAASC,KAAA,EAAO;IACd,MAAM,IAAIhB,aAAA,CAAc,gBAAgB,gDAAgD;MACtFgB;IACF,CAAC;EACH;EACA,OAAO;IAAEC,MAAA,EAAQ;EAAqB;AACxC","ignoreList":[]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class FileSaveError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
cleanupError;
|
|
4
|
+
constructor(code, message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = "FileSaveError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.cleanupError = options?.cleanupError;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function hasControlCharacters(value) {
|
|
12
|
+
return Array.from(value).some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
13
|
+
}
|
|
14
|
+
function prepareFileSave(options) {
|
|
15
|
+
if (!options || !(options.bytes instanceof Uint8Array) || typeof options.filename !== "string" || !options.filename.trim() || options.filename === "." || options.filename === ".." || /[/\\]/.test(options.filename) || hasControlCharacters(options.filename) || typeof options.mimeType !== "string" || hasControlCharacters(options.mimeType)) {
|
|
16
|
+
throw new FileSaveError("invalid-input", "Provide bytes, a single filename and a MIME type.");
|
|
17
|
+
}
|
|
18
|
+
const nativeMimeType = options.mimeType.split(";", 1)[0].trim();
|
|
19
|
+
if (!/^[\w!#$%&'*+.^`|~-]+\/[\w!#$%&'*+.^`|~-]+$/.test(nativeMimeType)) {
|
|
20
|
+
throw new FileSaveError("invalid-input", "Provide a MIME type with a type and subtype.");
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
bytes: Uint8Array.from(options.bytes),
|
|
24
|
+
filename: options.filename,
|
|
25
|
+
mimeType: options.mimeType,
|
|
26
|
+
nativeMimeType
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export { FileSaveError, prepareFileSave };
|
|
30
|
+
//# sourceMappingURL=types.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["FileSaveError","Error","code","cleanupError","constructor","message","options","name","hasControlCharacters","value","Array","from","some","character","charCodeAt","prepareFileSave","bytes","Uint8Array","filename","trim","test","mimeType","nativeMimeType","split"],"sources":["../../../src/file/types.ts"],"sourcesContent":[null],"mappings":"AA0BO,MAAMA,aAAA,SAAsBC,KAAA,CAAM;EAC9BC,IAAA;EACAC,YAAA;EAETC,YACEF,IAAA,EACAG,OAAA,EACAC,OAAA,EACA;IACA,MAAMD,OAAA,EAASC,OAAO;IACtB,KAAKC,IAAA,GAAO;IACZ,KAAKL,IAAA,GAAOA,IAAA;IACZ,KAAKC,YAAA,GAAeG,OAAA,EAASH,YAAA;EAC/B;AACF;AAEA,SAASK,qBAAqBC,KAAA,EAAe;EAC3C,OAAOC,KAAA,CAAMC,IAAA,CAAKF,KAAK,EAAEG,IAAA,CACtBC,SAAA,IAAcA,SAAA,CAAUC,UAAA,CAAW,CAAC,IAAI,MAAMD,SAAA,CAAUC,UAAA,CAAW,CAAC,MAAM,GAC7E;AACF;AAEO,SAASC,gBAAgBT,OAAA,EAA0B;EACxD,IACE,CAACA,OAAA,IACD,EAAEA,OAAA,CAAQU,KAAA,YAAiBC,UAAA,KAC3B,OAAOX,OAAA,CAAQY,QAAA,KAAa,YAC5B,CAACZ,OAAA,CAAQY,QAAA,CAASC,IAAA,CAAK,KACvBb,OAAA,CAAQY,QAAA,KAAa,OACrBZ,OAAA,CAAQY,QAAA,KAAa,QACrB,QAAQE,IAAA,CAAKd,OAAA,CAAQY,QAAQ,KAC7BV,oBAAA,CAAqBF,OAAA,CAAQY,QAAQ,KACrC,OAAOZ,OAAA,CAAQe,QAAA,KAAa,YAC5Bb,oBAAA,CAAqBF,OAAA,CAAQe,QAAQ,GACrC;IACA,MAAM,IAAIrB,aAAA,CAAc,iBAAiB,mDAAmD;EAC9F;EACA,MAAMsB,cAAA,GAAiBhB,OAAA,CAAQe,QAAA,CAASE,KAAA,CAAM,KAAK,CAAC,EAAE,CAAC,EAAEJ,IAAA,CAAK;EAC9D,IAAI,CAAC,6CAA6CC,IAAA,CAAKE,cAAc,GAAG;IACtE,MAAM,IAAItB,aAAA,CAAc,iBAAiB,8CAA8C;EACzF;EACA,OAAO;IACLgB,KAAA,EAAOC,UAAA,CAAWN,IAAA,CAAKL,OAAA,CAAQU,KAAK;IACpCE,QAAA,EAAUZ,OAAA,CAAQY,QAAA;IAClBG,QAAA,EAAUf,OAAA,CAAQe,QAAA;IAClBC;EACF;AACF","ignoreList":[]}
|
|
@@ -3,13 +3,19 @@ function downloadFile(url, filename) {
|
|
|
3
3
|
a.href = url;
|
|
4
4
|
a.download = filename;
|
|
5
5
|
document.body.appendChild(a);
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
try {
|
|
7
|
+
a.click();
|
|
8
|
+
} finally {
|
|
9
|
+
a.remove();
|
|
10
|
+
}
|
|
8
11
|
}
|
|
9
12
|
function downloadBlob(blob, filename) {
|
|
10
13
|
const url = URL.createObjectURL(blob);
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
try {
|
|
15
|
+
downloadFile(url, filename);
|
|
16
|
+
} finally {
|
|
17
|
+
setTimeout(() => URL.revokeObjectURL(url), 6e4);
|
|
18
|
+
}
|
|
13
19
|
}
|
|
14
20
|
export { downloadBlob, downloadFile };
|
|
15
21
|
//# sourceMappingURL=downloadFile.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["downloadFile","url","filename","a","document","createElement","href","download","body","appendChild","click","
|
|
1
|
+
{"version":3,"names":["downloadFile","url","filename","a","document","createElement","href","download","body","appendChild","click","remove","downloadBlob","blob","URL","createObjectURL","setTimeout","revokeObjectURL"],"sources":["../../../src/utils/downloadFile.ts"],"sourcesContent":[null],"mappings":"AAAO,SAASA,aAAaC,GAAA,EAAaC,QAAA,EAAwB;EAChE,MAAMC,CAAA,GAAIC,QAAA,CAASC,aAAA,CAAc,GAAG;EACpCF,CAAA,CAAEG,IAAA,GAAOL,GAAA;EACTE,CAAA,CAAEI,QAAA,GAAWL,QAAA;EACbE,QAAA,CAASI,IAAA,CAAKC,WAAA,CAAYN,CAAC;EAC3B,IAAI;IACFA,CAAA,CAAEO,KAAA,CAAM;EACV,UAAE;IACAP,CAAA,CAAEQ,MAAA,CAAO;EACX;AACF;AAEO,SAASC,aAAaC,IAAA,EAAYX,QAAA,EAAwB;EAC/D,MAAMD,GAAA,GAAMa,GAAA,CAAIC,eAAA,CAAgBF,IAAI;EACpC,IAAI;IACFb,YAAA,CAAaC,GAAA,EAAKC,QAAQ;EAC5B,UAAE;IAEAc,UAAA,CAAW,MAAMF,GAAA,CAAIG,eAAA,CAAgBhB,GAAG,GAAG,GAAM;EACnD;AACF","ignoreList":[]}
|
package/file.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/file/index";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/platform",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.11.0",
|
|
4
4
|
"description": "Platform detection flags and utilities for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"detection",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"source": "src/index.ts",
|
|
19
19
|
"files": [
|
|
20
|
+
"file.ts",
|
|
20
21
|
"dist",
|
|
21
22
|
"src",
|
|
22
23
|
"types",
|
|
@@ -31,6 +32,14 @@
|
|
|
31
32
|
"./rnw-overrides": {
|
|
32
33
|
"types": "./src/rnw_overrides.d.ts"
|
|
33
34
|
},
|
|
35
|
+
"./file": {
|
|
36
|
+
"source": "./src/file/index.ts",
|
|
37
|
+
"react-native": "./src/file/index.ts",
|
|
38
|
+
"types": "./types/file/index.d.ts",
|
|
39
|
+
"import": "./dist/esm/file/index.mjs",
|
|
40
|
+
"require": "./dist/cjs/file/index.cjs",
|
|
41
|
+
"default": "./dist/cjs/file/index.cjs"
|
|
42
|
+
},
|
|
34
43
|
".": {
|
|
35
44
|
"source": "./src/index.ts",
|
|
36
45
|
"react-native": "./src/index.ts",
|
|
@@ -47,24 +56,29 @@
|
|
|
47
56
|
"@tamagui/constants": "2.7.6"
|
|
48
57
|
},
|
|
49
58
|
"devDependencies": {
|
|
59
|
+
"@multiplatform.one/config": "7.11.0",
|
|
50
60
|
"@tamagui/build": "2.7.6",
|
|
51
61
|
"@types/chrome": "^0.1.40",
|
|
52
62
|
"@types/firefox-webext-browser": "^143.0.0",
|
|
53
63
|
"@vitest/coverage-v8": "^4.1.5",
|
|
64
|
+
"expo-file-system": "55.0.26",
|
|
54
65
|
"happy-dom": "^20.9.0",
|
|
55
66
|
"react": "19.2.5",
|
|
56
67
|
"typescript": "~5.9.3",
|
|
57
|
-
"vitest": "^4.1.5"
|
|
58
|
-
"@multiplatform.one/config": "7.10.0"
|
|
68
|
+
"vitest": "^4.1.5"
|
|
59
69
|
},
|
|
60
70
|
"peerDependencies": {
|
|
61
71
|
"expo-constants": "~55.0.15",
|
|
72
|
+
"expo-file-system": "~55.0.26",
|
|
62
73
|
"react": "^19.1.0"
|
|
63
74
|
},
|
|
64
75
|
"peerDependenciesMeta": {
|
|
65
76
|
"expo-constants": {
|
|
66
77
|
"optional": true
|
|
67
78
|
},
|
|
79
|
+
"expo-file-system": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
68
82
|
"react": {
|
|
69
83
|
"optional": true
|
|
70
84
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const native = vi.hoisted(() => ({
|
|
4
|
+
platform: { isIos: false, isAndroid: true },
|
|
5
|
+
pick: vi.fn(),
|
|
6
|
+
loaded: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("../platform", () => ({ platform: native.platform }));
|
|
9
|
+
|
|
10
|
+
describe("native byte save (native boundary modeled)", () => {
|
|
11
|
+
const options = () => ({
|
|
12
|
+
bytes: new Uint8Array([0, 255, 13, 10]),
|
|
13
|
+
filename: "café.bin",
|
|
14
|
+
mimeType: "application/octet-stream; charset=binary",
|
|
15
|
+
});
|
|
16
|
+
let file: {
|
|
17
|
+
uri: string;
|
|
18
|
+
name: string;
|
|
19
|
+
write: ReturnType<typeof vi.fn>;
|
|
20
|
+
delete: ReturnType<typeof vi.fn>;
|
|
21
|
+
};
|
|
22
|
+
let directory: {
|
|
23
|
+
uri: string;
|
|
24
|
+
createFile: ReturnType<typeof vi.fn>;
|
|
25
|
+
delete: ReturnType<typeof vi.fn>;
|
|
26
|
+
};
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.resetModules();
|
|
29
|
+
vi.doMock("expo-file-system", () => {
|
|
30
|
+
native.loaded();
|
|
31
|
+
return { Directory: { pickDirectoryAsync: native.pick } };
|
|
32
|
+
});
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
native.platform.isIos = false;
|
|
35
|
+
native.platform.isAndroid = true;
|
|
36
|
+
file = {
|
|
37
|
+
uri: "content://provider/child-42",
|
|
38
|
+
name: "café (1).bin",
|
|
39
|
+
write: vi.fn(),
|
|
40
|
+
delete: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
directory = {
|
|
43
|
+
uri: "content://provider/tree/selected",
|
|
44
|
+
createFile: vi.fn(() => file),
|
|
45
|
+
delete: vi.fn(),
|
|
46
|
+
};
|
|
47
|
+
native.pick.mockReset().mockResolvedValue(directory);
|
|
48
|
+
});
|
|
49
|
+
const api = () => import("./saveFile.native");
|
|
50
|
+
|
|
51
|
+
it("loads FileSystem only on invocation and writes the exact snapshot to the provider child", async () => {
|
|
52
|
+
const { saveFile } = await api();
|
|
53
|
+
expect(native.loaded).not.toHaveBeenCalled();
|
|
54
|
+
let choose!: (value: typeof directory) => void;
|
|
55
|
+
native.pick.mockReturnValue(
|
|
56
|
+
new Promise((resolve) => {
|
|
57
|
+
choose = resolve;
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
const input = options();
|
|
61
|
+
input.bytes = new Uint8Array([99, 0, 255, 13, 10, 88]).subarray(1, 5);
|
|
62
|
+
const pending = saveFile(input);
|
|
63
|
+
input.bytes.fill(8);
|
|
64
|
+
input.filename = "changed-after-request.bin";
|
|
65
|
+
await vi.waitFor(() => expect(native.pick).toHaveBeenCalledOnce());
|
|
66
|
+
choose(directory);
|
|
67
|
+
await expect(pending).resolves.toEqual({
|
|
68
|
+
status: "written",
|
|
69
|
+
uri: file.uri,
|
|
70
|
+
requestedFilename: "café.bin",
|
|
71
|
+
byteLength: 4,
|
|
72
|
+
});
|
|
73
|
+
expect(directory.createFile).toHaveBeenCalledExactlyOnceWith(
|
|
74
|
+
"café.bin",
|
|
75
|
+
"application/octet-stream",
|
|
76
|
+
);
|
|
77
|
+
expect(Array.from(file.write.mock.calls[0][0])).toEqual([0, 255, 13, 10]);
|
|
78
|
+
expect(file.delete).not.toHaveBeenCalled();
|
|
79
|
+
expect(directory.delete).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reports the iOS file-URI name separately from the requested name, including empty files", async () => {
|
|
83
|
+
native.platform.isIos = true;
|
|
84
|
+
native.platform.isAndroid = false;
|
|
85
|
+
directory.uri = "file:///selected/";
|
|
86
|
+
file.uri = "file:///selected/caf%C3%A9%20(1).bin";
|
|
87
|
+
const { saveFile } = await api();
|
|
88
|
+
await expect(saveFile({ ...options(), bytes: new Uint8Array() })).resolves.toEqual({
|
|
89
|
+
status: "written",
|
|
90
|
+
uri: file.uri,
|
|
91
|
+
requestedFilename: "café.bin",
|
|
92
|
+
filename: "café (1).bin",
|
|
93
|
+
byteLength: 0,
|
|
94
|
+
});
|
|
95
|
+
expect(file.write).toHaveBeenCalledWith(new Uint8Array());
|
|
96
|
+
expect(file.delete).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("omits an opaque Android document ID without reading it as a filename", async () => {
|
|
100
|
+
file.uri = "content://example.documents/tree/root/document/42";
|
|
101
|
+
const readName = vi.fn(() => "42");
|
|
102
|
+
Object.defineProperty(file, "name", { get: readName });
|
|
103
|
+
const input = { ...options(), filename: "fixture.bin" };
|
|
104
|
+
const { saveFile } = await api();
|
|
105
|
+
await expect(saveFile(input)).resolves.toEqual({
|
|
106
|
+
status: "written",
|
|
107
|
+
uri: file.uri,
|
|
108
|
+
requestedFilename: "fixture.bin",
|
|
109
|
+
byteLength: 4,
|
|
110
|
+
});
|
|
111
|
+
expect(readName).not.toHaveBeenCalled();
|
|
112
|
+
expect(directory.createFile).toHaveBeenCalledExactlyOnceWith(
|
|
113
|
+
"fixture.bin",
|
|
114
|
+
"application/octet-stream",
|
|
115
|
+
);
|
|
116
|
+
expect(file.write).toHaveBeenCalledExactlyOnceWith(new Uint8Array([0, 255, 13, 10]));
|
|
117
|
+
expect(file.delete).not.toHaveBeenCalled();
|
|
118
|
+
expect(directory.delete).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("only reports an iOS filename when the created file has a file URI", async () => {
|
|
122
|
+
native.platform.isIos = true;
|
|
123
|
+
native.platform.isAndroid = false;
|
|
124
|
+
directory.uri = "file:///selected/";
|
|
125
|
+
const readName = vi.fn(() => "child-42");
|
|
126
|
+
Object.defineProperty(file, "name", { get: readName });
|
|
127
|
+
const { saveFile } = await api();
|
|
128
|
+
await expect(saveFile(options())).resolves.toEqual({
|
|
129
|
+
status: "written",
|
|
130
|
+
uri: file.uri,
|
|
131
|
+
requestedFilename: "café.bin",
|
|
132
|
+
byteLength: 4,
|
|
133
|
+
});
|
|
134
|
+
expect(readName).not.toHaveBeenCalled();
|
|
135
|
+
expect(file.write).toHaveBeenCalledOnce();
|
|
136
|
+
expect(file.delete).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each(["ERR_PICKER_CANCELLED", "ERR_FILE_PICKING_CANCELLED"])(
|
|
140
|
+
"reports %s as not saved without inventing cancel/refusal distinction",
|
|
141
|
+
async (code) => {
|
|
142
|
+
const { saveFile } = await api();
|
|
143
|
+
native.pick.mockRejectedValueOnce({ code });
|
|
144
|
+
await expect(saveFile(options())).resolves.toEqual({
|
|
145
|
+
status: "not-saved",
|
|
146
|
+
reason: "cancelled-or-refused",
|
|
147
|
+
});
|
|
148
|
+
expect(directory.createFile).not.toHaveBeenCalled();
|
|
149
|
+
expect(file.write).not.toHaveBeenCalled();
|
|
150
|
+
await expect(saveFile(options())).resolves.toMatchObject({ status: "written" });
|
|
151
|
+
expect(file.write).toHaveBeenCalledOnce();
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
it.each([
|
|
156
|
+
{ code: "ERR_ACCESS_DENIED" },
|
|
157
|
+
new Error("User cancelled"),
|
|
158
|
+
{ code: "ERR_PICKER_CANCELLED_EXTRA" },
|
|
159
|
+
])("preserves non-cancellation picker failures: %j", async (cause) => {
|
|
160
|
+
native.pick.mockRejectedValue(cause);
|
|
161
|
+
const { saveFile } = await api();
|
|
162
|
+
await expect(saveFile(options())).rejects.toMatchObject({ code: "picker-failed", cause });
|
|
163
|
+
expect(directory.createFile).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects overlapping requests and accepts a retry once the picker settles", async () => {
|
|
167
|
+
let choose!: (value: typeof directory) => void;
|
|
168
|
+
native.pick.mockReturnValueOnce(
|
|
169
|
+
new Promise((resolve) => {
|
|
170
|
+
choose = resolve;
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
const { saveFile } = await api();
|
|
174
|
+
const first = saveFile(options());
|
|
175
|
+
await expect(saveFile(options())).rejects.toMatchObject({ code: "busy" });
|
|
176
|
+
await vi.waitFor(() => expect(native.pick).toHaveBeenCalledOnce());
|
|
177
|
+
choose(directory);
|
|
178
|
+
await expect(first).resolves.toMatchObject({ status: "written" });
|
|
179
|
+
await expect(saveFile(options())).resolves.toMatchObject({ status: "written" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("rejects invalid inputs before loading native code or opening the picker", async () => {
|
|
183
|
+
const { saveFile } = await api();
|
|
184
|
+
await expect(saveFile({ ...options(), filename: "../escape" })).rejects.toMatchObject({
|
|
185
|
+
code: "invalid-input",
|
|
186
|
+
});
|
|
187
|
+
expect(native.loaded).not.toHaveBeenCalled();
|
|
188
|
+
expect(native.pick).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it.each(["file:///private/not-saf", "https://example.test", ""])(
|
|
192
|
+
"refuses unexpected Android directory URI %j",
|
|
193
|
+
async (uri) => {
|
|
194
|
+
directory.uri = uri;
|
|
195
|
+
const { saveFile } = await api();
|
|
196
|
+
await expect(saveFile(options())).rejects.toMatchObject({ code: "picker-failed" });
|
|
197
|
+
expect(directory.createFile).not.toHaveBeenCalled();
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
it("refuses unsupported native hosts before loading FileSystem", async () => {
|
|
202
|
+
native.platform.isAndroid = false;
|
|
203
|
+
const { saveFile } = await api();
|
|
204
|
+
await expect(saveFile(options())).rejects.toMatchObject({ code: "unavailable" });
|
|
205
|
+
expect(native.loaded).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not delete anything when creation or a duplicate name is refused", async () => {
|
|
209
|
+
const cause = new Error("already exists");
|
|
210
|
+
directory.createFile.mockImplementation(() => {
|
|
211
|
+
throw cause;
|
|
212
|
+
});
|
|
213
|
+
const { saveFile } = await api();
|
|
214
|
+
await expect(saveFile(options())).rejects.toMatchObject({ code: "write-failed", cause });
|
|
215
|
+
expect(file.write).not.toHaveBeenCalled();
|
|
216
|
+
expect(file.delete).not.toHaveBeenCalled();
|
|
217
|
+
expect(directory.delete).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it.each([false, true])(
|
|
221
|
+
"deletes only its new child on write failure and preserves cleanup failure (%s)",
|
|
222
|
+
async (cleanupFails) => {
|
|
223
|
+
const cause = new Error("storage full");
|
|
224
|
+
const cleanupError = new Error("delete refused");
|
|
225
|
+
file.write.mockImplementationOnce(() => {
|
|
226
|
+
throw cause;
|
|
227
|
+
});
|
|
228
|
+
if (cleanupFails)
|
|
229
|
+
file.delete.mockImplementation(() => {
|
|
230
|
+
throw cleanupError;
|
|
231
|
+
});
|
|
232
|
+
const { saveFile } = await api();
|
|
233
|
+
await expect(saveFile(options())).rejects.toMatchObject({
|
|
234
|
+
code: "write-failed",
|
|
235
|
+
cause,
|
|
236
|
+
cleanupError: cleanupFails ? cleanupError : undefined,
|
|
237
|
+
});
|
|
238
|
+
expect(file.write).toHaveBeenCalledOnce();
|
|
239
|
+
expect(file.delete).toHaveBeenCalledOnce();
|
|
240
|
+
expect(directory.delete).not.toHaveBeenCalled();
|
|
241
|
+
await expect(saveFile(options())).resolves.toMatchObject({ status: "written" });
|
|
242
|
+
expect(file.delete).toHaveBeenCalledOnce();
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Directory, File } from "expo-file-system";
|
|
2
|
+
import { platform } from "../platform";
|
|
3
|
+
import { FileSaveError, type FileSaveOptions, type FileSaveResult, prepareFileSave } from "./types";
|
|
4
|
+
|
|
5
|
+
let busy = false;
|
|
6
|
+
|
|
7
|
+
export async function saveFile(options: FileSaveOptions): Promise<FileSaveResult> {
|
|
8
|
+
const input = prepareFileSave(options);
|
|
9
|
+
if (!platform.isIos && !platform.isAndroid) {
|
|
10
|
+
throw new FileSaveError(
|
|
11
|
+
"unavailable",
|
|
12
|
+
"Directory file saving is unavailable on this platform.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
if (busy) throw new FileSaveError("busy", "Another file save is in progress.");
|
|
16
|
+
busy = true;
|
|
17
|
+
try {
|
|
18
|
+
let fileSystem: typeof import("expo-file-system");
|
|
19
|
+
try {
|
|
20
|
+
fileSystem = await import("expo-file-system");
|
|
21
|
+
if (typeof fileSystem.Directory?.pickDirectoryAsync !== "function") {
|
|
22
|
+
throw new Error("The native FileSystem directory picker is unavailable.");
|
|
23
|
+
}
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
throw new FileSaveError(
|
|
26
|
+
"unavailable",
|
|
27
|
+
"This native build cannot save files to a directory.",
|
|
28
|
+
{ cause },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let directory: Directory;
|
|
33
|
+
try {
|
|
34
|
+
directory = await fileSystem.Directory.pickDirectoryAsync();
|
|
35
|
+
const scheme = platform.isAndroid ? "content://" : "file://";
|
|
36
|
+
if (!directory.uri.startsWith(scheme) || directory.uri.length <= scheme.length) {
|
|
37
|
+
throw new Error("The picker returned an unsupported directory URI.");
|
|
38
|
+
}
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
if (
|
|
41
|
+
typeof cause === "object" &&
|
|
42
|
+
cause !== null &&
|
|
43
|
+
"code" in cause &&
|
|
44
|
+
(cause.code === "ERR_PICKER_CANCELLED" || cause.code === "ERR_FILE_PICKING_CANCELLED")
|
|
45
|
+
) {
|
|
46
|
+
// iOS also uses this code when security-scoped directory access is refused.
|
|
47
|
+
return { status: "not-saved", reason: "cancelled-or-refused" };
|
|
48
|
+
}
|
|
49
|
+
throw new FileSaveError("picker-failed", "A writable directory could not be selected.", {
|
|
50
|
+
cause,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let file: File | undefined;
|
|
55
|
+
try {
|
|
56
|
+
file = directory.createFile(input.filename, input.nativeMimeType);
|
|
57
|
+
const uri = file.uri;
|
|
58
|
+
// Android content URIs can contain opaque IDs instead of provider display names.
|
|
59
|
+
const filename = platform.isIos && uri.startsWith("file://") ? { filename: file.name } : {};
|
|
60
|
+
file.write(input.bytes);
|
|
61
|
+
return {
|
|
62
|
+
status: "written",
|
|
63
|
+
uri,
|
|
64
|
+
requestedFilename: input.filename,
|
|
65
|
+
...filename,
|
|
66
|
+
byteLength: input.bytes.byteLength,
|
|
67
|
+
};
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
let cleanupError: unknown;
|
|
70
|
+
try {
|
|
71
|
+
file?.delete();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
cleanupError = error;
|
|
74
|
+
}
|
|
75
|
+
throw new FileSaveError("write-failed", "The file could not be written.", {
|
|
76
|
+
cause,
|
|
77
|
+
cleanupError,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
busy = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { FileSaveError, saveFile } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("web byte save", () => {
|
|
5
|
+
const options = () => ({
|
|
6
|
+
bytes: new Uint8Array([0, 255, 13, 10]),
|
|
7
|
+
filename: "café.bin",
|
|
8
|
+
mimeType: "application/octet-stream",
|
|
9
|
+
});
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.useFakeTimers();
|
|
12
|
+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
|
13
|
+
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fixture");
|
|
14
|
+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.runAllTimers();
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
vi.unstubAllGlobals();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("requests a download with the exact byte view and filename", async () => {
|
|
24
|
+
const input = options();
|
|
25
|
+
input.bytes = new Uint8Array([99, 0, 255, 13, 10, 88]).subarray(1, 5);
|
|
26
|
+
vi.mocked(HTMLAnchorElement.prototype.click).mockImplementation(
|
|
27
|
+
function (this: HTMLAnchorElement) {
|
|
28
|
+
expect(this.download).toBe("café.bin");
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
const result = saveFile(input);
|
|
32
|
+
input.bytes.fill(7);
|
|
33
|
+
await expect(result).resolves.toEqual({ status: "download-requested" });
|
|
34
|
+
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob;
|
|
35
|
+
expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([0, 255, 13, 10]);
|
|
36
|
+
expect(blob.type).toBe("application/octet-stream");
|
|
37
|
+
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preserves UTF-8 bytes and MIME parameters, including an empty file", async () => {
|
|
41
|
+
const bytes = new TextEncoder().encode("café ☕\n");
|
|
42
|
+
await saveFile({ ...options(), bytes, mimeType: "text/plain; charset=utf-8" });
|
|
43
|
+
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob;
|
|
44
|
+
expect(await blob.text()).toBe("café ☕\n");
|
|
45
|
+
expect(blob.type).toBe("text/plain; charset=utf-8");
|
|
46
|
+
await saveFile({ ...options(), bytes: new Uint8Array() });
|
|
47
|
+
expect((vi.mocked(URL.createObjectURL).mock.calls[1][0] as Blob).size).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each(["", " ", ".", "..", "a/b", "a\\b", "a\u0000b", "a\nb", "a\u007fb"])(
|
|
51
|
+
"rejects filename %j before allocating resources",
|
|
52
|
+
async (filename) => {
|
|
53
|
+
await expect(saveFile({ ...options(), filename })).rejects.toMatchObject({
|
|
54
|
+
code: "invalid-input",
|
|
55
|
+
});
|
|
56
|
+
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
|
57
|
+
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled();
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it.each(["", "text", "text/", "text /plain", "text/plain\n"])(
|
|
62
|
+
"rejects invalid MIME %j",
|
|
63
|
+
async (mimeType) => {
|
|
64
|
+
await expect(saveFile({ ...options(), mimeType })).rejects.toMatchObject({
|
|
65
|
+
code: "invalid-input",
|
|
66
|
+
});
|
|
67
|
+
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
it.each([null, {}, { ...options(), bytes: [1, 2] }])(
|
|
72
|
+
"rejects malformed input %j",
|
|
73
|
+
async (input) => {
|
|
74
|
+
await expect(saveFile(input as never)).rejects.toBeInstanceOf(FileSaveError);
|
|
75
|
+
await expect(saveFile(input as never)).rejects.toMatchObject({ code: "invalid-input" });
|
|
76
|
+
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
it.each(["document", "Blob", "URL"])(
|
|
81
|
+
"reports an unavailable %s capability",
|
|
82
|
+
async (capability) => {
|
|
83
|
+
vi.stubGlobal(capability, undefined);
|
|
84
|
+
await expect(saveFile(options())).rejects.toMatchObject({ code: "unavailable" });
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
it("reports activation failure with its original cause and cleans resources", async () => {
|
|
89
|
+
const cause = new Error("download refused");
|
|
90
|
+
vi.mocked(HTMLAnchorElement.prototype.click).mockImplementation(() => {
|
|
91
|
+
throw cause;
|
|
92
|
+
});
|
|
93
|
+
await expect(saveFile(options())).rejects.toMatchObject({ code: "write-failed", cause });
|
|
94
|
+
expect(document.querySelector("a")).toBeNull();
|
|
95
|
+
vi.advanceTimersByTime(60_000);
|
|
96
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fixture");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { downloadBlob } from "../utils/downloadFile";
|
|
2
|
+
import { FileSaveError, type FileSaveOptions, type FileSaveResult, prepareFileSave } from "./types";
|
|
3
|
+
|
|
4
|
+
export async function saveFile(options: FileSaveOptions): Promise<FileSaveResult> {
|
|
5
|
+
const input = prepareFileSave(options);
|
|
6
|
+
if (
|
|
7
|
+
typeof document === "undefined" ||
|
|
8
|
+
!document.body ||
|
|
9
|
+
typeof Blob === "undefined" ||
|
|
10
|
+
typeof URL === "undefined" ||
|
|
11
|
+
typeof URL.createObjectURL !== "function" ||
|
|
12
|
+
typeof URL.revokeObjectURL !== "function" ||
|
|
13
|
+
!("download" in document.createElement("a"))
|
|
14
|
+
) {
|
|
15
|
+
throw new FileSaveError("unavailable", "Browser file downloads are unavailable.");
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
downloadBlob(new Blob([input.bytes], { type: input.mimeType }), input.filename);
|
|
19
|
+
} catch (cause) {
|
|
20
|
+
throw new FileSaveError("write-failed", "The browser download could not be requested.", {
|
|
21
|
+
cause,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return { status: "download-requested" };
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const boundary = vi.hoisted(() => ({
|
|
4
|
+
loaded: vi.fn(),
|
|
5
|
+
cause: new Error("Native FileSystem is absent"),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("../platform", () => ({ platform: { isAndroid: true, isIos: false } }));
|
|
8
|
+
vi.mock("expo-file-system", () => {
|
|
9
|
+
boundary.loaded();
|
|
10
|
+
throw boundary.cause;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("keeps a missing native module out of import-time execution and rejects invocation", async () => {
|
|
14
|
+
const { saveFile } = await import("./saveFile.native");
|
|
15
|
+
expect(boundary.loaded).not.toHaveBeenCalled();
|
|
16
|
+
const input = {
|
|
17
|
+
bytes: new Uint8Array([42]),
|
|
18
|
+
filename: "fixture.bin",
|
|
19
|
+
mimeType: "application/octet-stream",
|
|
20
|
+
};
|
|
21
|
+
await expect(saveFile(input)).rejects.toMatchObject({ code: "unavailable" });
|
|
22
|
+
expect(boundary.loaded).toHaveBeenCalledOnce();
|
|
23
|
+
await expect(saveFile(input)).rejects.toMatchObject({ code: "unavailable" });
|
|
24
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface FileSaveOptions {
|
|
2
|
+
bytes: Uint8Array;
|
|
3
|
+
filename: string;
|
|
4
|
+
mimeType: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type WrittenFile = {
|
|
8
|
+
status: "written";
|
|
9
|
+
uri: string;
|
|
10
|
+
requestedFilename: string;
|
|
11
|
+
filename?: string;
|
|
12
|
+
byteLength: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type FileSaveResult =
|
|
16
|
+
| WrittenFile
|
|
17
|
+
| { status: "download-requested" }
|
|
18
|
+
| { status: "not-saved"; reason: "cancelled-or-refused" };
|
|
19
|
+
|
|
20
|
+
export type FileSaveErrorCode =
|
|
21
|
+
| "invalid-input"
|
|
22
|
+
| "unavailable"
|
|
23
|
+
| "busy"
|
|
24
|
+
| "picker-failed"
|
|
25
|
+
| "write-failed";
|
|
26
|
+
|
|
27
|
+
export class FileSaveError extends Error {
|
|
28
|
+
readonly code: FileSaveErrorCode;
|
|
29
|
+
readonly cleanupError?: unknown;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
code: FileSaveErrorCode,
|
|
33
|
+
message: string,
|
|
34
|
+
options?: { cause?: unknown; cleanupError?: unknown },
|
|
35
|
+
) {
|
|
36
|
+
super(message, options);
|
|
37
|
+
this.name = "FileSaveError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.cleanupError = options?.cleanupError;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hasControlCharacters(value: string) {
|
|
44
|
+
return Array.from(value).some(
|
|
45
|
+
(character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function prepareFileSave(options: FileSaveOptions) {
|
|
50
|
+
if (
|
|
51
|
+
!options ||
|
|
52
|
+
!(options.bytes instanceof Uint8Array) ||
|
|
53
|
+
typeof options.filename !== "string" ||
|
|
54
|
+
!options.filename.trim() ||
|
|
55
|
+
options.filename === "." ||
|
|
56
|
+
options.filename === ".." ||
|
|
57
|
+
/[/\\]/.test(options.filename) ||
|
|
58
|
+
hasControlCharacters(options.filename) ||
|
|
59
|
+
typeof options.mimeType !== "string" ||
|
|
60
|
+
hasControlCharacters(options.mimeType)
|
|
61
|
+
) {
|
|
62
|
+
throw new FileSaveError("invalid-input", "Provide bytes, a single filename and a MIME type.");
|
|
63
|
+
}
|
|
64
|
+
const nativeMimeType = options.mimeType.split(";", 1)[0].trim();
|
|
65
|
+
if (!/^[\w!#$%&'*+.^`|~-]+\/[\w!#$%&'*+.^`|~-]+$/.test(nativeMimeType)) {
|
|
66
|
+
throw new FileSaveError("invalid-input", "Provide a MIME type with a type and subtype.");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
bytes: Uint8Array.from(options.bytes),
|
|
70
|
+
filename: options.filename,
|
|
71
|
+
mimeType: options.mimeType,
|
|
72
|
+
nativeMimeType,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { downloadBlob, downloadFile } from "./downloadFile";
|
|
3
|
+
|
|
4
|
+
describe("synchronous download helpers", () => {
|
|
5
|
+
beforeEach(() => vi.useFakeTimers());
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.runAllTimers();
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
document.body.replaceChildren();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("activates the named URL and returns void synchronously", () => {
|
|
14
|
+
const click = vi
|
|
15
|
+
.spyOn(HTMLAnchorElement.prototype, "click")
|
|
16
|
+
.mockImplementation(function (this: HTMLAnchorElement) {
|
|
17
|
+
expect(this.isConnected).toBe(true);
|
|
18
|
+
expect(this.href).toBe("https://example.test/file");
|
|
19
|
+
expect(this.download).toBe("café.txt");
|
|
20
|
+
});
|
|
21
|
+
expect(downloadFile("https://example.test/file", "café.txt")).toBeUndefined();
|
|
22
|
+
expect(click).toHaveBeenCalledOnce();
|
|
23
|
+
expect(document.querySelector("a")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("removes the anchor when activation throws", () => {
|
|
27
|
+
const failure = new Error("activation refused");
|
|
28
|
+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {
|
|
29
|
+
throw failure;
|
|
30
|
+
});
|
|
31
|
+
expect(() => downloadFile("https://example.test/file", "file.txt")).toThrow(failure);
|
|
32
|
+
expect(document.querySelector("a")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([false, true])(
|
|
36
|
+
"releases a Blob URL after activation, even if it throws (%s)",
|
|
37
|
+
(throws) => {
|
|
38
|
+
const blob = new Blob(["fixture"]);
|
|
39
|
+
const create = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fixture");
|
|
40
|
+
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
41
|
+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {
|
|
42
|
+
expect(revoke).not.toHaveBeenCalled();
|
|
43
|
+
if (throws) throw new Error("activation refused");
|
|
44
|
+
});
|
|
45
|
+
if (throws) expect(() => downloadBlob(blob, "fixture.txt")).toThrow("activation refused");
|
|
46
|
+
else expect(downloadBlob(blob, "fixture.txt")).toBeUndefined();
|
|
47
|
+
expect(create).toHaveBeenCalledWith(blob);
|
|
48
|
+
expect(document.querySelector("a")).toBeNull();
|
|
49
|
+
expect(revoke).not.toHaveBeenCalled();
|
|
50
|
+
vi.advanceTimersByTime(60_000);
|
|
51
|
+
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:fixture");
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
});
|
|
@@ -3,12 +3,19 @@ export function downloadFile(url: string, filename: string): void {
|
|
|
3
3
|
a.href = url;
|
|
4
4
|
a.download = filename;
|
|
5
5
|
document.body.appendChild(a);
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
try {
|
|
7
|
+
a.click();
|
|
8
|
+
} finally {
|
|
9
|
+
a.remove();
|
|
10
|
+
}
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export function downloadBlob(blob: Blob, filename: string): void {
|
|
11
14
|
const url = URL.createObjectURL(blob);
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
try {
|
|
16
|
+
downloadFile(url, filename);
|
|
17
|
+
} finally {
|
|
18
|
+
// Activation has no completion receipt; keep the URL alive for the browser to read.
|
|
19
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
20
|
+
}
|
|
14
21
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/file/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACxC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saveFile.d.ts","sourceRoot":"","sources":["../../src/file/saveFile.ts"],"names":[],"mappings":"AACA,OAAO,EAAiB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAmB,MAAM,SAAS,CAAC;AAEpG,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAqBhF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saveFile.native.d.ts","sourceRoot":"","sources":["../../src/file/saveFile.native.ts"],"names":[],"mappings":"AAEA,OAAO,EAAiB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAmB,MAAM,SAAS,CAAC;AAIpG,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CA4EhF"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface FileSaveOptions {
|
|
2
|
+
bytes: Uint8Array;
|
|
3
|
+
filename: string;
|
|
4
|
+
mimeType: string;
|
|
5
|
+
}
|
|
6
|
+
export type WrittenFile = {
|
|
7
|
+
status: "written";
|
|
8
|
+
uri: string;
|
|
9
|
+
requestedFilename: string;
|
|
10
|
+
filename?: string;
|
|
11
|
+
byteLength: number;
|
|
12
|
+
};
|
|
13
|
+
export type FileSaveResult = WrittenFile | {
|
|
14
|
+
status: "download-requested";
|
|
15
|
+
} | {
|
|
16
|
+
status: "not-saved";
|
|
17
|
+
reason: "cancelled-or-refused";
|
|
18
|
+
};
|
|
19
|
+
export type FileSaveErrorCode = "invalid-input" | "unavailable" | "busy" | "picker-failed" | "write-failed";
|
|
20
|
+
export declare class FileSaveError extends Error {
|
|
21
|
+
readonly code: FileSaveErrorCode;
|
|
22
|
+
readonly cleanupError?: unknown;
|
|
23
|
+
constructor(code: FileSaveErrorCode, message: string, options?: {
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
cleanupError?: unknown;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export declare function prepareFileSave(options: FileSaveOptions): {
|
|
29
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
30
|
+
filename: string;
|
|
31
|
+
mimeType: string;
|
|
32
|
+
nativeMimeType: string;
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/file/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,SAAS,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,iBAAiB,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,cAAc,GACtB,WAAW,GACX;IAAE,MAAM,EAAE,oBAAoB,CAAA;CAAE,GAChC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,sBAAsB,CAAA;CAAE,CAAC;AAE5D,MAAM,MAAM,iBAAiB,GACzB,eAAe,GACf,aAAa,GACb,MAAM,GACN,eAAe,GACf,cAAc,CAAC;AAEnB,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;gBAG9B,IAAI,EAAE,iBAAiB,EACvB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE;CAOxD;AAQD,wBAAgB,eAAe,CAAC,OAAO,EAAE,eAAe;;;;;EAyBvD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"downloadFile.d.ts","sourceRoot":"","sources":["../../src/utils/downloadFile.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"downloadFile.d.ts","sourceRoot":"","sources":["../../src/utils/downloadFile.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAUhE;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQ/D"}
|