@multiplatform.one/platform 7.10.0 → 7.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +67 -0
  2. package/dist/cjs/config/devOrigin.cjs +42 -0
  3. package/dist/cjs/config/devOriginSource.cjs +32 -0
  4. package/dist/cjs/config/index.cjs +1 -0
  5. package/dist/cjs/file/index.cjs +30 -0
  6. package/dist/cjs/file/saveFile.cjs +47 -0
  7. package/dist/cjs/file/types.cjs +56 -0
  8. package/dist/cjs/utils/downloadFile.cjs +10 -4
  9. package/dist/esm/config/devOrigin.mjs +16 -0
  10. package/dist/esm/config/devOrigin.mjs.map +1 -0
  11. package/dist/esm/config/devOriginSource.mjs +7 -0
  12. package/dist/esm/config/devOriginSource.mjs.map +1 -0
  13. package/dist/esm/config/index.mjs +1 -0
  14. package/dist/esm/config/index.mjs.map +1 -1
  15. package/dist/esm/file/index.mjs +4 -0
  16. package/dist/esm/file/index.mjs.map +1 -0
  17. package/dist/esm/file/saveFile.mjs +22 -0
  18. package/dist/esm/file/saveFile.mjs.map +1 -0
  19. package/dist/esm/file/types.mjs +30 -0
  20. package/dist/esm/file/types.mjs.map +1 -0
  21. package/dist/esm/utils/downloadFile.mjs +10 -4
  22. package/dist/esm/utils/downloadFile.mjs.map +1 -1
  23. package/file.ts +1 -0
  24. package/package.json +18 -4
  25. package/src/config/config.native.ts +2 -1
  26. package/src/config/devOrigin.spec.ts +79 -0
  27. package/src/config/devOrigin.ts +45 -0
  28. package/src/config/devOriginSource.native.ts +9 -0
  29. package/src/config/devOriginSource.ts +5 -0
  30. package/src/config/index.ts +1 -0
  31. package/src/file/index.ts +3 -0
  32. package/src/file/saveFile.native.spec.ts +245 -0
  33. package/src/file/saveFile.native.ts +83 -0
  34. package/src/file/saveFile.spec.ts +98 -0
  35. package/src/file/saveFile.ts +25 -0
  36. package/src/file/saveFile.unavailable.native.spec.ts +24 -0
  37. package/src/file/types.ts +74 -0
  38. package/src/utils/downloadFile.spec.ts +54 -0
  39. package/src/utils/downloadFile.ts +11 -4
  40. package/types/config/config.native.d.ts.map +1 -1
  41. package/types/config/devOrigin.d.ts +28 -0
  42. package/types/config/devOrigin.d.ts.map +1 -0
  43. package/types/config/devOriginSource.d.ts +3 -0
  44. package/types/config/devOriginSource.d.ts.map +1 -0
  45. package/types/config/devOriginSource.native.d.ts +3 -0
  46. package/types/config/devOriginSource.native.d.ts.map +1 -0
  47. package/types/config/index.d.ts +1 -0
  48. package/types/config/index.d.ts.map +1 -1
  49. package/types/file/index.d.ts +4 -0
  50. package/types/file/index.d.ts.map +1 -0
  51. package/types/file/saveFile.d.ts +3 -0
  52. package/types/file/saveFile.d.ts.map +1 -0
  53. package/types/file/saveFile.native.d.ts +3 -0
  54. package/types/file/saveFile.native.d.ts.map +1 -0
  55. package/types/file/types.d.ts +34 -0
  56. package/types/file/types.d.ts.map +1 -0
  57. package/types/utils/downloadFile.d.ts.map +1 -1
@@ -0,0 +1,45 @@
1
+ import { devOriginSource } from "./devOriginSource";
2
+
3
+ /**
4
+ * Where the running app learns the dev machine's address from.
5
+ *
6
+ * On native, `hostUri` is Expo's `Constants.expoConfig.hostUri`: the
7
+ * `host:port` the device fetched its manifest from, which @expo/cli fills in
8
+ * from the request's Host header. It is therefore the one address the device
9
+ * is already proven to reach, whatever the machine's LAN IP happens to be,
10
+ * and it is absent in a release build, where nothing points at localhost.
11
+ */
12
+ export interface DevOriginSource {
13
+ isWeb: boolean;
14
+ hostUri?: string;
15
+ }
16
+
17
+ const loopbackOrigin = /^([a-z][a-z0-9+.-]*:\/\/)(localhost|127\.0\.0\.1)(?=[:/?#]|$)/i;
18
+
19
+ /** The host part of a `host:port`, `scheme://host:port/path` or `[v6]:port` value. */
20
+ export function devHostFromHostUri(hostUri: string | undefined): string | undefined {
21
+ if (!hostUri) return undefined;
22
+ const authority = hostUri.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[/?#]/, 1)[0];
23
+ const host = authority.startsWith("[")
24
+ ? authority.slice(0, authority.indexOf("]") + 1)
25
+ : authority.split(":", 1)[0];
26
+ return host || undefined;
27
+ }
28
+
29
+ /**
30
+ * Point a loopback-configured origin at the dev machine on native.
31
+ *
32
+ * `http://localhost:8000` in .env means "the machine running the dev server"
33
+ * on every platform. In a browser that is the page host; on a simulator, an
34
+ * emulator or a phone it is the host the bundle came from, so the same .env
35
+ * serves all of them. Web, and anything without a hostUri, is returned as is.
36
+ *
37
+ * String-based on purpose: React Native's URL polyfill has no `hostname`
38
+ * setter, so a new-URL-and-assign rewrite silently no-ops on device.
39
+ */
40
+ export function resolveDevOrigin(url: string, source: DevOriginSource = devOriginSource()): string {
41
+ if (source.isWeb) return url;
42
+ const host = devHostFromHostUri(source.hostUri);
43
+ if (!host) return url;
44
+ return url.replace(loopbackOrigin, `$1${host}`);
45
+ }
@@ -0,0 +1,9 @@
1
+ import constants from "expo-constants";
2
+ import type { DevOriginSource } from "./devOrigin";
3
+
4
+ export function devOriginSource(): DevOriginSource {
5
+ return {
6
+ isWeb: false,
7
+ hostUri: constants.expoConfig?.hostUri ?? constants.manifest2?.extra?.expoGo?.debuggerHost,
8
+ };
9
+ }
@@ -0,0 +1,5 @@
1
+ import type { DevOriginSource } from "./devOrigin";
2
+
3
+ export function devOriginSource(): DevOriginSource {
4
+ return { isWeb: true };
5
+ }
@@ -4,4 +4,5 @@ const config = new Config();
4
4
 
5
5
  export { config };
6
6
  export * from "./config";
7
+ export * from "./devOrigin";
7
8
  export * from "./runtimeConfig";
@@ -0,0 +1,3 @@
1
+ export { saveFile } from "./saveFile";
2
+ export { FileSaveError } from "./types";
3
+ export type { FileSaveOptions, FileSaveResult, FileSaveErrorCode, WrittenFile } from "./types";
@@ -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
+ });