@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 +37 -0
- package/dist/linux/FileInput.js +2 -0
- package/dist/linux/FileInput.web.js +263 -0
- package/dist/linux/file-bytes.js +45 -0
- package/dist/linux/file-input-utils.js +52 -0
- package/dist/linux/files.js +2 -0
- package/dist/linux/files.web.js +144 -0
- package/dist/linux/index.js +5 -0
- package/dist/linux/octane-client-build.json +8 -0
- package/dist/linux/props.js +0 -0
- package/dist/linux/types.js +0 -0
- package/dist/native/FileInput.js +434 -0
- package/dist/native/file-bytes.js +45 -0
- package/dist/native/file-input-utils.js +52 -0
- package/dist/native/files.js +283 -0
- package/dist/native/index.js +3 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/props.js +0 -0
- package/dist/native/types.js +0 -0
- package/dist/web/FileInput.js +263 -0
- package/dist/web/file-bytes.js +45 -0
- package/dist/web/file-input-utils.js +52 -0
- package/dist/web/files.js +144 -0
- package/dist/web/index.js +3 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/props.js +0 -0
- package/dist/web/types.js +0 -0
- package/package.json +50 -8
- package/src/file-bytes.test.ts +53 -0
- package/src/file-bytes.ts +68 -0
- package/src/files.bridge.test.ts +342 -0
- package/src/files.macos.ts +23 -2
- package/src/files.ts +329 -6
- package/src/files.web.test.ts +117 -0
- package/src/files.web.ts +133 -4
- package/src/types.ts +65 -2
- package/types/index.d.ts +8 -1
- package/types/index.macos.d.ts +8 -1
|
@@ -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 };
|
|
File without changes
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/files",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Native + web file pick and read/write for Octane xplat — matching web + NativeScript implementations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"directory": "packages/files"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
|
+
"dist",
|
|
25
26
|
"src",
|
|
26
27
|
"types"
|
|
27
28
|
],
|
|
@@ -32,24 +33,64 @@
|
|
|
32
33
|
"types": "./types/index.macos.d.ts",
|
|
33
34
|
"default": "./src/index.macos.ts"
|
|
34
35
|
},
|
|
36
|
+
"linux": "./dist/linux/index.js",
|
|
35
37
|
"types": "./types/index.d.ts",
|
|
36
|
-
"
|
|
38
|
+
"web": "./dist/web/index.js",
|
|
39
|
+
"native": "./dist/native/index.js",
|
|
40
|
+
"default": "./dist/native/index.js"
|
|
37
41
|
},
|
|
38
|
-
"./*":
|
|
42
|
+
"./*": {
|
|
43
|
+
"linux": "./dist/linux/*.js",
|
|
44
|
+
"types": "./src/*.ts",
|
|
45
|
+
"web": "./dist/web/*.js",
|
|
46
|
+
"native": "./dist/native/*.js",
|
|
47
|
+
"default": "./dist/native/*.js"
|
|
48
|
+
},
|
|
49
|
+
"./*.ts": {
|
|
50
|
+
"types": "./src/*.ts",
|
|
51
|
+
"default": "./dist/native/*.js"
|
|
52
|
+
},
|
|
53
|
+
"./*.web.ts": {
|
|
54
|
+
"types": "./src/*.web.ts",
|
|
55
|
+
"default": "./dist/web/*.js"
|
|
56
|
+
},
|
|
57
|
+
"./*.macos.ts": "./src/*.macos.ts",
|
|
58
|
+
"./*.linux.ts": {
|
|
59
|
+
"types": "./src/*.linux.ts",
|
|
60
|
+
"default": "./dist/linux/*.js"
|
|
61
|
+
},
|
|
62
|
+
"./*.tsrx": {
|
|
63
|
+
"types": "./src/*.tsrx",
|
|
64
|
+
"default": "./dist/native/*.js"
|
|
65
|
+
},
|
|
66
|
+
"./*.web.tsrx": {
|
|
67
|
+
"types": "./src/*.web.tsrx",
|
|
68
|
+
"default": "./dist/web/*.js"
|
|
69
|
+
},
|
|
70
|
+
"./*.macos.tsrx": "./src/*.macos.tsrx",
|
|
71
|
+
"./*.linux.tsrx": {
|
|
72
|
+
"types": "./src/*.linux.tsrx",
|
|
73
|
+
"default": "./dist/linux/*.js"
|
|
74
|
+
},
|
|
75
|
+
"./*.web": "./dist/web/*.js",
|
|
76
|
+
"./*.macos": "./src/*.macos.ts",
|
|
77
|
+
"./*.linux": "./dist/linux/*.js"
|
|
39
78
|
},
|
|
40
79
|
"publishConfig": {
|
|
41
80
|
"access": "public"
|
|
42
81
|
},
|
|
43
82
|
"dependencies": {
|
|
44
83
|
"@nativescript-community/ui-document-picker": "1.1.29",
|
|
45
|
-
"@octane-xplat/platform": "0.
|
|
84
|
+
"@octane-xplat/platform": "0.11.1"
|
|
46
85
|
},
|
|
47
86
|
"devDependencies": {
|
|
48
87
|
"@nativescript-community/octane": "0.2.4",
|
|
49
88
|
"@nativescript/core": "9.1.2",
|
|
50
|
-
"@nativescript/types": "9.
|
|
51
|
-
"@octane-xplat/ui": "0.
|
|
52
|
-
"octane": "0.6.3"
|
|
89
|
+
"@nativescript/types": "9.1.4",
|
|
90
|
+
"@octane-xplat/ui": "0.11.1",
|
|
91
|
+
"octane": "0.6.3",
|
|
92
|
+
"@octanejs/vite-plugin": "0.1.61",
|
|
93
|
+
"vite": "8.3.0"
|
|
53
94
|
},
|
|
54
95
|
"peerDependencies": {
|
|
55
96
|
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
@@ -72,6 +113,7 @@
|
|
|
72
113
|
}
|
|
73
114
|
},
|
|
74
115
|
"scripts": {
|
|
75
|
-
"test:packed": "node tests/packed-consumer.mjs"
|
|
116
|
+
"test:packed": "node tests/packed-consumer.mjs",
|
|
117
|
+
"build": "vite build && vite build --mode native && vite build --mode linux && node ../../scripts/check-native-dist.mjs"
|
|
76
118
|
}
|
|
77
119
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { assertSafeFileName, concatBytes, exactBuffer, throwIfAborted } from './file-bytes'
|
|
3
|
+
|
|
4
|
+
describe('assertSafeFileName', () => {
|
|
5
|
+
it('accepts plain file names', () => {
|
|
6
|
+
for (const name of ['a.txt', 'report 1.pdf', 'x.y.z.bin', 'résumé.md']) {
|
|
7
|
+
expect(() => assertSafeFileName(name)).not.toThrow()
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('rejects empty, dot, and traversal names', () => {
|
|
12
|
+
for (const name of ['', '.', '..', '../x', 'a/b', 'a\\b', '..\\x', 'a\0b']) {
|
|
13
|
+
expect(() => assertSafeFileName(name)).toThrow(/Unsafe file name/)
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('throwIfAborted', () => {
|
|
19
|
+
it('passes for live or absent signals', () => {
|
|
20
|
+
expect(() => throwIfAborted(undefined)).not.toThrow()
|
|
21
|
+
const controller = new AbortController()
|
|
22
|
+
expect(() => throwIfAborted(controller.signal)).not.toThrow()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('throws AbortError for aborted signals', () => {
|
|
26
|
+
const controller = new AbortController()
|
|
27
|
+
controller.abort()
|
|
28
|
+
expect(() => throwIfAborted(controller.signal)).toThrowError(
|
|
29
|
+
expect.objectContaining({ name: 'AbortError' }),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('concatBytes / exactBuffer', () => {
|
|
35
|
+
it('concatenates chunks in order', () => {
|
|
36
|
+
const joined = concatBytes(
|
|
37
|
+
[new Uint8Array([1, 2]), new Uint8Array([3]), new Uint8Array([4, 5])],
|
|
38
|
+
5,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect([...joined]).toEqual([1, 2, 3, 4, 5])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns the buffer only when the view covers it', () => {
|
|
45
|
+
const buffer = new Uint8Array([9, 8, 7, 6]).buffer
|
|
46
|
+
expect(exactBuffer(new Uint8Array(buffer))).toBe(buffer)
|
|
47
|
+
|
|
48
|
+
const windowed = new Uint8Array(buffer, 1, 2)
|
|
49
|
+
const exact = exactBuffer(windowed)
|
|
50
|
+
expect(exact.byteLength).toBe(2)
|
|
51
|
+
expect([...new Uint8Array(exact)]).toEqual([8, 7])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the binary file primitives (readBytes/writeBytes/export).
|
|
3
|
+
* DOM-free so the native leaf can use it too.
|
|
4
|
+
*/
|
|
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
|
+
export function assertSafeFileName(name: string): void {
|
|
9
|
+
if (
|
|
10
|
+
typeof name !== 'string' ||
|
|
11
|
+
name.length === 0 ||
|
|
12
|
+
name === '.' ||
|
|
13
|
+
name === '..' ||
|
|
14
|
+
name.includes('/') ||
|
|
15
|
+
name.includes('\\') ||
|
|
16
|
+
name.includes('\0')
|
|
17
|
+
) {
|
|
18
|
+
throw new Error(`Unsafe file name: ${JSON.stringify(name)}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function fileTooLarge(name: string, maxBytes: number): Error {
|
|
23
|
+
return new Error(`${name}: file exceeds the ${maxBytes}-byte limit`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Named rejection for a second `files.export` while one is in flight. */
|
|
27
|
+
export function exportBusy(): Error {
|
|
28
|
+
const error = new Error('files.export: an export is already in progress')
|
|
29
|
+
error.name = 'ExportBusyError'
|
|
30
|
+
return error
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
34
|
+
if (!signal?.aborted) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const thrower = (signal as { throwIfAborted?: () => void }).throwIfAborted
|
|
39
|
+
if (typeof thrower === 'function') {
|
|
40
|
+
thrower.call(signal)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const error = new Error('The operation was aborted')
|
|
44
|
+
error.name = 'AbortError'
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Concatenate Uint8Array chunks into one exact-length array. */
|
|
49
|
+
export function concatBytes(chunks: Uint8Array[], total: number): Uint8Array {
|
|
50
|
+
const out = new Uint8Array(total)
|
|
51
|
+
let offset = 0
|
|
52
|
+
for (const chunk of chunks) {
|
|
53
|
+
out.set(chunk, offset)
|
|
54
|
+
offset += chunk.byteLength
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Return an ArrayBuffer covering exactly this view's byte range — native
|
|
61
|
+
* marshallers treat the argument as the whole buffer. */
|
|
62
|
+
export function exactBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
63
|
+
if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
|
|
64
|
+
return bytes.buffer as ArrayBuffer
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return bytes.slice().buffer as ArrayBuffer
|
|
68
|
+
}
|