@octane-xplat/files 0.10.0 → 0.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 +37 -0
- package/package.json +3 -3
- 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
package/README.md
CHANGED
|
@@ -46,3 +46,40 @@ if (file) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
|
+
|
|
50
|
+
## Binary reads, writes, and export
|
|
51
|
+
|
|
52
|
+
`files.readBytes(ref, { maxBytes?, signal? })` reads the full contents as a
|
|
53
|
+
`Uint8Array`. It works on `content://` picks (read through Android's
|
|
54
|
+
`ContentResolver`), iOS picker copies, sandbox files, and web object URLs.
|
|
55
|
+
`maxBytes` rejects oversized files mid-read instead of loading them, and
|
|
56
|
+
`signal` aborts the read with an `AbortError`.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const bytes = await files.readBytes(file, { maxBytes: 10 * 1024 * 1024 })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`files.writeBytes(name, bytes)` mirrors `writeText`: on native it writes into
|
|
63
|
+
the app sandbox (Documents) and returns a `FileRef`; on web it starts a
|
|
64
|
+
browser download. `name` must be a bare file name — paths like `../x` or
|
|
65
|
+
`a/b` are rejected, and `writeText` enforces the same rule.
|
|
66
|
+
|
|
67
|
+
`files.export(name, bytes, { mimeType? })` asks the user where to save the
|
|
68
|
+
data and resolves `saved`, `cancelled`, or `unavailable`. `saved` means the
|
|
69
|
+
platform committed the bytes — Android flushes them through
|
|
70
|
+
`ContentResolver` to the picked document, iOS completes the export-as-copy
|
|
71
|
+
flow, and web closes a File System Access writable. Browsers without
|
|
72
|
+
`showSaveFilePicker` and the AppKit leaf report `unavailable`; a share-sheet
|
|
73
|
+
presentation never counts as `saved`. Only one export can run at a time — a
|
|
74
|
+
concurrent call rejects with an `ExportBusyError` rather than stealing the
|
|
75
|
+
open picker's result.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const result = await files.export('report.pdf', pdfBytes, { mimeType: 'application/pdf' })
|
|
79
|
+
if (result === 'cancelled') console.log('user dismissed')
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Picked refs are only guaranteed readable within the picking session. Android
|
|
83
|
+
`content://` grants are not persisted by the picker, and iOS import-mode
|
|
84
|
+
copies live in a temp folder — copy what you need with `writeBytes`, and
|
|
85
|
+
call `files.release(ref)` when done.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/files",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Native + web file pick and read/write for Octane xplat — matching web + NativeScript implementations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@nativescript-community/ui-document-picker": "1.1.29",
|
|
45
|
-
"@octane-xplat/platform": "0.
|
|
45
|
+
"@octane-xplat/platform": "0.11.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@nativescript-community/octane": "0.2.4",
|
|
49
49
|
"@nativescript/core": "9.1.2",
|
|
50
50
|
"@nativescript/types": "9.0.0",
|
|
51
|
-
"@octane-xplat/ui": "0.
|
|
51
|
+
"@octane-xplat/ui": "0.11.0",
|
|
52
52
|
"octane": "0.6.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Native-leaf bridge tests. The @nativescript/* module imports and the
|
|
2
|
+
// iOS/Android global APIs are replaced with faithful mock shapes (same call
|
|
3
|
+
// surfaces the runtime marshals) so the leaf's failure, concurrency, and
|
|
4
|
+
// cleanup paths are exercised in node — not a substitute for native smoke.
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
const bridge = vi.hoisted(() => ({
|
|
8
|
+
application: { android: undefined as any },
|
|
9
|
+
listeners: new Map<string, Set<(arg: any) => void>>(),
|
|
10
|
+
resolver: null as any,
|
|
11
|
+
activity: null as any,
|
|
12
|
+
visibleVC: null as any,
|
|
13
|
+
keyWindow: true,
|
|
14
|
+
writeAtomic: true,
|
|
15
|
+
removedPaths: [] as string[],
|
|
16
|
+
channelWrites: [] as any[],
|
|
17
|
+
presented: [] as any[],
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
vi.mock('@nativescript-community/ui-document-picker', () => ({
|
|
21
|
+
openFilePicker: vi.fn(async () => ({ files: [] })),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
vi.mock('@nativescript/core', () => ({
|
|
25
|
+
Application: bridge.application,
|
|
26
|
+
File: {
|
|
27
|
+
fromPath: (p: string) => ({
|
|
28
|
+
removeSync: () => {
|
|
29
|
+
bridge.removedPaths.push(p)
|
|
30
|
+
},
|
|
31
|
+
readText: async () => '',
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
knownFolders: {
|
|
35
|
+
documents: () => ({ path: '/docs' }),
|
|
36
|
+
temp: () => ({ path: '/tmp' }),
|
|
37
|
+
},
|
|
38
|
+
path: {
|
|
39
|
+
join: (...parts: string[]) => parts.join('/'),
|
|
40
|
+
},
|
|
41
|
+
Utils: {
|
|
42
|
+
ios: {
|
|
43
|
+
getVisibleViewController: (root: any) => (root ? bridge.visibleVC : null),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
// Explicit .ts specifier: the web resolve chain would otherwise pick
|
|
49
|
+
// files.web.ts; this suite covers the shared iOS/Android leaf.
|
|
50
|
+
import { files } from './files.ts'
|
|
51
|
+
import { files as macosFiles } from './files.macos'
|
|
52
|
+
|
|
53
|
+
function stubAndroid() {
|
|
54
|
+
bridge.resolver = { openOutputStream: vi.fn(() => ({ close: vi.fn() })) }
|
|
55
|
+
bridge.activity = {
|
|
56
|
+
getContentResolver: () => bridge.resolver,
|
|
57
|
+
startActivityForResult: vi.fn(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
bridge.application.android = {
|
|
61
|
+
activityResultEvent: 'activityResult',
|
|
62
|
+
foregroundActivity: bridge.activity,
|
|
63
|
+
startActivity: bridge.activity,
|
|
64
|
+
on(event: string, cb: (arg: any) => void) {
|
|
65
|
+
if (!bridge.listeners.has(event)) {
|
|
66
|
+
bridge.listeners.set(event, new Set())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
bridge.listeners.get(event)!.add(cb)
|
|
70
|
+
},
|
|
71
|
+
off(event: string, cb: (arg: any) => void) {
|
|
72
|
+
bridge.listeners.get(event)?.delete(cb)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function emitResult(arg: any) {
|
|
78
|
+
for (const cb of Array.from(bridge.listeners.get('activityResult') ?? [])) {
|
|
79
|
+
cb(arg)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function okResult(uri = 'content://dest/doc.bin') {
|
|
84
|
+
return {
|
|
85
|
+
requestCode: 1241,
|
|
86
|
+
resultCode: -1,
|
|
87
|
+
intent: { getData: () => ({ toString: () => uri }) },
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let lastPicker: any
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
bridge.listeners.clear()
|
|
95
|
+
bridge.removedPaths = []
|
|
96
|
+
bridge.channelWrites = []
|
|
97
|
+
bridge.presented = []
|
|
98
|
+
bridge.keyWindow = true
|
|
99
|
+
bridge.writeAtomic = true
|
|
100
|
+
bridge.resolver = null
|
|
101
|
+
bridge.activity = null
|
|
102
|
+
lastPicker = null
|
|
103
|
+
|
|
104
|
+
bridge.visibleVC = {
|
|
105
|
+
presentViewControllerAnimatedCompletion: vi.fn((controller: any) => {
|
|
106
|
+
bridge.presented.push(controller)
|
|
107
|
+
}),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
vi.stubGlobal('android', {
|
|
111
|
+
content: {
|
|
112
|
+
Intent: class {
|
|
113
|
+
static ACTION_CREATE_DOCUMENT = 'android.intent.action.CREATE_DOCUMENT'
|
|
114
|
+
static CATEGORY_OPENABLE = 'android.intent.category.OPENABLE'
|
|
115
|
+
static EXTRA_TITLE = 'android.intent.extra.TITLE'
|
|
116
|
+
static FLAG_GRANT_READ_URI_PERMISSION = 1
|
|
117
|
+
static FLAG_GRANT_WRITE_URI_PERMISSION = 2
|
|
118
|
+
action: any
|
|
119
|
+
extras = new Map()
|
|
120
|
+
constructor(action: any) {
|
|
121
|
+
this.action = action
|
|
122
|
+
}
|
|
123
|
+
addCategory() {}
|
|
124
|
+
setType() {}
|
|
125
|
+
putExtra(key: any, value: any) {
|
|
126
|
+
this.extras.set(key, value)
|
|
127
|
+
}
|
|
128
|
+
addFlags() {}
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
app: { Activity: { RESULT_OK: -1 } },
|
|
132
|
+
net: { Uri: { parse: (s: string) => ({ toString: () => s }) } },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
vi.stubGlobal('java', {
|
|
136
|
+
io: {
|
|
137
|
+
FileOutputStream: class {},
|
|
138
|
+
FileInputStream: class {},
|
|
139
|
+
ByteArrayOutputStream: class {
|
|
140
|
+
data: number[] = []
|
|
141
|
+
write(chunk: any, _off: number, count: number) {
|
|
142
|
+
for (let i = 0; i < count; i++) {
|
|
143
|
+
this.data.push(chunk[i])
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
size() {
|
|
147
|
+
return this.data.length
|
|
148
|
+
}
|
|
149
|
+
toByteArray() {
|
|
150
|
+
return this.data
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
BufferedReader: class {},
|
|
154
|
+
InputStreamReader: class {},
|
|
155
|
+
},
|
|
156
|
+
nio: {
|
|
157
|
+
channels: {
|
|
158
|
+
Channels: {
|
|
159
|
+
newChannel: (out: any) => ({
|
|
160
|
+
write: (buf: ArrayBuffer) => {
|
|
161
|
+
bridge.channelWrites.push({ out, buf })
|
|
162
|
+
return buf.byteLength
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
vi.stubGlobal('NSData', {
|
|
171
|
+
dataWithBytesLength: (view: Uint8Array, length: number) => ({
|
|
172
|
+
view,
|
|
173
|
+
length,
|
|
174
|
+
writeToFileAtomically: vi.fn(() => bridge.writeAtomic),
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
vi.stubGlobal('NSURL', { fileURLWithPath: (p: string) => ({ path: p }) })
|
|
179
|
+
vi.stubGlobal('UIApplication', {
|
|
180
|
+
sharedApplication: {
|
|
181
|
+
get keyWindow() {
|
|
182
|
+
return bridge.keyWindow ? { rootViewController: {} } : null
|
|
183
|
+
},
|
|
184
|
+
windows: { count: 0 },
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
vi.stubGlobal('UIDocumentPickerViewController', {
|
|
189
|
+
alloc: () => ({
|
|
190
|
+
initForExportingURLsAsCopy: (urls: any, asCopy: boolean) => {
|
|
191
|
+
lastPicker = { urls, asCopy, delegate: null, shouldShowFileExtensions: false }
|
|
192
|
+
return lastPicker
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
vi.stubGlobal('UIDocumentPickerDelegate', {})
|
|
198
|
+
vi.stubGlobal(
|
|
199
|
+
'NSObject',
|
|
200
|
+
class {
|
|
201
|
+
static new(this: any) {
|
|
202
|
+
return new this()
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
bridge.application.android = undefined
|
|
210
|
+
vi.unstubAllGlobals()
|
|
211
|
+
vi.restoreAllMocks()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('files.writeBytes (ios bridge)', () => {
|
|
215
|
+
it('rejects when the atomic write reports failure instead of returning a dead ref', async () => {
|
|
216
|
+
bridge.writeAtomic = false
|
|
217
|
+
await expect(files.writeBytes('a.bin', new Uint8Array([1]))).rejects.toThrow(
|
|
218
|
+
/Failed to write file/,
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('returns a sandbox ref after a confirmed write', async () => {
|
|
223
|
+
const ref = await files.writeBytes('a.bin', new Uint8Array([1, 2]))
|
|
224
|
+
expect(ref).toEqual({ name: 'a.bin', uri: '/docs/a.bin' })
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('files.export (android bridge)', () => {
|
|
229
|
+
beforeEach(stubAndroid)
|
|
230
|
+
|
|
231
|
+
it('flushes bytes to the picked URI and resolves saved', async () => {
|
|
232
|
+
const pending = files.export('out.bin', new Uint8Array([7, 8]))
|
|
233
|
+
expect(bridge.activity.startActivityForResult).toHaveBeenCalledTimes(1)
|
|
234
|
+
emitResult(okResult())
|
|
235
|
+
expect(await pending).toBe('saved')
|
|
236
|
+
expect(bridge.resolver.openOutputStream).toHaveBeenCalledTimes(1)
|
|
237
|
+
expect(bridge.channelWrites).toHaveLength(1)
|
|
238
|
+
expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('resolves cancelled on a non-OK result and drops the listener', async () => {
|
|
242
|
+
const pending = files.export('out.bin', new Uint8Array([1]))
|
|
243
|
+
emitResult({ requestCode: 1241, resultCode: 0, intent: null })
|
|
244
|
+
expect(await pending).toBe('cancelled')
|
|
245
|
+
expect(bridge.resolver.openOutputStream).not.toHaveBeenCalled()
|
|
246
|
+
expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('rejects a concurrent export with ExportBusyError and recovers after settle', async () => {
|
|
250
|
+
const first = files.export('a.bin', new Uint8Array([1]))
|
|
251
|
+
await expect(files.export('b.bin', new Uint8Array([2]))).rejects.toThrowError(
|
|
252
|
+
expect.objectContaining({ name: 'ExportBusyError' }),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
expect(bridge.activity.startActivityForResult).toHaveBeenCalledTimes(1)
|
|
256
|
+
|
|
257
|
+
emitResult(okResult())
|
|
258
|
+
expect(await first).toBe('saved')
|
|
259
|
+
|
|
260
|
+
const third = files.export('c.bin', new Uint8Array([3]))
|
|
261
|
+
emitResult(okResult())
|
|
262
|
+
expect(await third).toBe('saved')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('rejects when the destination stream cannot open and cleans up', async () => {
|
|
266
|
+
bridge.resolver.openOutputStream.mockReturnValue(null)
|
|
267
|
+
const pending = files.export('out.bin', new Uint8Array([1]))
|
|
268
|
+
emitResult(okResult())
|
|
269
|
+
await expect(pending).rejects.toThrow(/Unable to open destination/)
|
|
270
|
+
expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
|
|
271
|
+
|
|
272
|
+
// Guard released — a follow-up export is not stuck busy.
|
|
273
|
+
bridge.resolver.openOutputStream.mockReturnValue({ close: vi.fn() })
|
|
274
|
+
const next = files.export('next.bin', new Uint8Array([1]))
|
|
275
|
+
emitResult(okResult())
|
|
276
|
+
expect(await next).toBe('saved')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('rejects when the activity cannot be started and removes the listener', async () => {
|
|
280
|
+
bridge.activity.startActivityForResult.mockImplementation(() => {
|
|
281
|
+
throw new Error('no activity to handle intent')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
await expect(files.export('out.bin', new Uint8Array([1]))).rejects.toThrow(/no activity/)
|
|
285
|
+
expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('files.export (ios bridge)', () => {
|
|
290
|
+
it('stages a temp copy, presents export-as-copy, and resolves saved with cleanup', async () => {
|
|
291
|
+
const pending = files.export('out.bin', new Uint8Array([9]))
|
|
292
|
+
expect(bridge.presented).toHaveLength(1)
|
|
293
|
+
expect(lastPicker.asCopy).toBe(true)
|
|
294
|
+
expect(lastPicker.delegate).toBeTruthy()
|
|
295
|
+
|
|
296
|
+
lastPicker.delegate.documentPickerDidPickDocumentAtURL(lastPicker, { path: '/dest' })
|
|
297
|
+
expect(await pending).toBe('saved')
|
|
298
|
+
expect(bridge.removedPaths).toEqual(['/tmp/out.bin'])
|
|
299
|
+
expect(lastPicker.delegate).toBeNull()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('resolves cancelled on dismissal and removes the temp copy', async () => {
|
|
303
|
+
const pending = files.export('out.bin', new Uint8Array([9]))
|
|
304
|
+
lastPicker.delegate.documentPickerWasCancelled(lastPicker)
|
|
305
|
+
expect(await pending).toBe('cancelled')
|
|
306
|
+
expect(bridge.removedPaths).toEqual(['/tmp/out.bin'])
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('rejects when the temp write fails instead of hanging', async () => {
|
|
310
|
+
bridge.writeAtomic = false
|
|
311
|
+
await expect(files.export('out.bin', new Uint8Array([1]))).rejects.toThrow(
|
|
312
|
+
/Failed to stage export file/,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
expect(bridge.presented).toHaveLength(0)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('rejects when no view controller can present instead of hanging', async () => {
|
|
319
|
+
bridge.keyWindow = false
|
|
320
|
+
await expect(files.export('out.bin', new Uint8Array([1]))).rejects.toThrow(
|
|
321
|
+
/No visible view controller/,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
expect(bridge.removedPaths).toEqual(['/tmp/out.bin'])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('rejects a concurrent export with ExportBusyError', async () => {
|
|
328
|
+
const first = files.export('a.bin', new Uint8Array([1]))
|
|
329
|
+
await expect(files.export('b.bin', new Uint8Array([2]))).rejects.toThrowError(
|
|
330
|
+
expect.objectContaining({ name: 'ExportBusyError' }),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
lastPicker.delegate.documentPickerDidPickDocumentsAtURLs(lastPicker, { count: 1 })
|
|
334
|
+
expect(await first).toBe('saved')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe('files.export (macos leaf)', () => {
|
|
339
|
+
it('reports unavailable instead of throwing', async () => {
|
|
340
|
+
expect(await macosFiles.export('a.bin', new Uint8Array([1]))).toBe('unavailable')
|
|
341
|
+
})
|
|
342
|
+
})
|
package/src/files.macos.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// Files — AppKit host leaf.
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
FileExportOptions,
|
|
4
|
+
FileExportResult,
|
|
5
|
+
FileReadBytesOptions,
|
|
6
|
+
FileRef,
|
|
7
|
+
Files,
|
|
8
|
+
} from './types'
|
|
3
9
|
|
|
4
10
|
function openPanel(accept = '*/*', multiple = false): FileRef[] | null {
|
|
5
11
|
const appKit = globalThis as any
|
|
@@ -42,7 +48,7 @@ function openPanel(accept = '*/*', multiple = false): FileRef[] | null {
|
|
|
42
48
|
}))
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
export const files = {
|
|
51
|
+
export const files: Files = {
|
|
46
52
|
async pick(accept = '*/*'): Promise<FileRef | null> {
|
|
47
53
|
return openPanel(accept)?.[0] ?? null
|
|
48
54
|
},
|
|
@@ -52,8 +58,23 @@ export const files = {
|
|
|
52
58
|
async readText(_file: FileRef): Promise<string> {
|
|
53
59
|
throw new Error('unsupported: AppKit file access is not wired')
|
|
54
60
|
},
|
|
61
|
+
async readBytes(_file: FileRef, _opts?: FileReadBytesOptions): Promise<Uint8Array> {
|
|
62
|
+
throw new Error('unsupported: AppKit file access is not wired')
|
|
63
|
+
},
|
|
55
64
|
async writeText(_name: string, _text: string): Promise<FileRef> {
|
|
56
65
|
throw new Error('unsupported: the AppKit host does not provide a save panel')
|
|
57
66
|
},
|
|
67
|
+
async writeBytes(_name: string, _bytes: Uint8Array): Promise<FileRef> {
|
|
68
|
+
throw new Error('unsupported: the AppKit host does not provide a save panel')
|
|
69
|
+
},
|
|
70
|
+
// FileExportResult names this leaf as the 'unavailable' case — the AppKit
|
|
71
|
+
// host offers no save panel, so report it instead of throwing.
|
|
72
|
+
async export(
|
|
73
|
+
_name: string,
|
|
74
|
+
_bytes: Uint8Array,
|
|
75
|
+
_opts?: FileExportOptions,
|
|
76
|
+
): Promise<FileExportResult> {
|
|
77
|
+
return 'unavailable'
|
|
78
|
+
},
|
|
58
79
|
release(_file: FileRef): void {},
|
|
59
80
|
}
|
package/src/files.ts
CHANGED
|
@@ -2,10 +2,26 @@
|
|
|
2
2
|
// are still used for writes; picked Android SAF references may be content://
|
|
3
3
|
// URIs and must be read through ContentResolver.
|
|
4
4
|
import { openFilePicker } from '@nativescript-community/ui-document-picker'
|
|
5
|
-
import { Application, File, knownFolders, path } from '@nativescript/core'
|
|
6
|
-
import
|
|
5
|
+
import { Application, File, knownFolders, path, Utils } from '@nativescript/core'
|
|
6
|
+
import {
|
|
7
|
+
assertSafeFileName,
|
|
8
|
+
exactBuffer,
|
|
9
|
+
exportBusy,
|
|
10
|
+
fileTooLarge,
|
|
11
|
+
throwIfAborted,
|
|
12
|
+
} from './file-bytes'
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
FileExportOptions,
|
|
16
|
+
FileExportResult,
|
|
17
|
+
FileReadBytesOptions,
|
|
18
|
+
FileRef,
|
|
19
|
+
Files,
|
|
20
|
+
} from './types'
|
|
7
21
|
|
|
8
22
|
const docs = () => knownFolders.documents()
|
|
23
|
+
const READ_CHUNK = 64 * 1024
|
|
24
|
+
const ANDROID_EXPORT_REQUEST_CODE = 1241
|
|
9
25
|
|
|
10
26
|
function pickerTypes(accept: string): { extensions: string[]; mimeTypes: string[] } {
|
|
11
27
|
const values = accept
|
|
@@ -106,12 +122,270 @@ async function pickFiles(
|
|
|
106
122
|
}))
|
|
107
123
|
}
|
|
108
124
|
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
// openInputStream accepts content:// and file:// schemes; bare paths go
|
|
126
|
+
// through FileInputStream. Returns null when the resolver cannot open the URI.
|
|
127
|
+
function openAndroidInput(uri: string): any {
|
|
128
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity
|
|
129
|
+
if (uri.startsWith('content://') || uri.startsWith('file://')) {
|
|
130
|
+
return activity?.getContentResolver()?.openInputStream(android.net.Uri.parse(uri))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return new java.io.FileInputStream(uri)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readAndroidBytes(uri: string, opts?: FileReadBytesOptions): Uint8Array {
|
|
137
|
+
const input = openAndroidInput(uri)
|
|
138
|
+
if (!input) {
|
|
139
|
+
throw new Error(`Unable to open picked file: ${uri}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const out = new java.io.ByteArrayOutputStream()
|
|
143
|
+
const chunk = Array.create('byte', READ_CHUNK)
|
|
144
|
+
try {
|
|
145
|
+
let count: number
|
|
146
|
+
while ((count = input.read(chunk)) !== -1) {
|
|
147
|
+
throwIfAborted(opts?.signal)
|
|
148
|
+
out.write(chunk, 0, count)
|
|
149
|
+
if (opts?.maxBytes != null && out.size() > opts.maxBytes) {
|
|
150
|
+
throw fileTooLarge(uri, opts.maxBytes)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
input.close()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ArrayBuffer.from is an NS Android runtime builtin that takes a
|
|
158
|
+
// java.nio.ByteBuffer — wrap the byte[] rather than marshalling per byte.
|
|
159
|
+
return new Uint8Array((ArrayBuffer as any).from(java.nio.ByteBuffer.wrap(out.toByteArray())))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Picked iOS refs come from the picker's Import mode, so they are app-owned
|
|
163
|
+
// copies — no security-scoped access needed. Single-pick URIs keep their
|
|
164
|
+
// percent-encoding, so fall back to a decoded path when the literal misses.
|
|
165
|
+
function iosFilePath(uri: string): string {
|
|
166
|
+
let p = uri.startsWith('file://') ? uri.slice('file://'.length) : uri
|
|
167
|
+
if (NSFileManager.defaultManager.fileExistsAtPath(p)) {
|
|
168
|
+
return p
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const decoded = decodeURIComponent(p)
|
|
173
|
+
if (decoded !== p && NSFileManager.defaultManager.fileExistsAtPath(decoded)) {
|
|
174
|
+
return decoded
|
|
175
|
+
}
|
|
176
|
+
} catch {}
|
|
177
|
+
|
|
178
|
+
return p
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readIosBytes(uri: string, opts?: FileReadBytesOptions): Uint8Array {
|
|
182
|
+
const p = iosFilePath(uri)
|
|
183
|
+
const handle = NSFileHandle.fileHandleForReadingAtPath(p)
|
|
184
|
+
if (!handle) {
|
|
185
|
+
throw new Error(`Unable to open picked file: ${uri}`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const out = NSMutableData.new()
|
|
189
|
+
try {
|
|
190
|
+
while (true) {
|
|
191
|
+
throwIfAborted(opts?.signal)
|
|
192
|
+
const data = handle.readDataOfLength(READ_CHUNK)
|
|
193
|
+
if (!data || data.length === 0) {
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
out.appendData(data)
|
|
198
|
+
if (opts?.maxBytes != null && out.length > opts.maxBytes) {
|
|
199
|
+
throw fileTooLarge(uri, opts.maxBytes)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
handle.closeFile()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return new Uint8Array(interop.bufferFromData(out))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ContentResolver.openOutputStream accepts content:// and file:// URIs; bare
|
|
210
|
+
// paths go through FileOutputStream. The WritableByteChannel adapter writes
|
|
211
|
+
// the full buffer per the OutputStream contract — no chunked short writes.
|
|
212
|
+
function writeAndroidBytes(uri: string, bytes: Uint8Array): void {
|
|
213
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity
|
|
214
|
+
const out: java.io.OutputStream =
|
|
215
|
+
uri.startsWith('content://') || uri.startsWith('file://')
|
|
216
|
+
? activity?.getContentResolver()?.openOutputStream(android.net.Uri.parse(uri))
|
|
217
|
+
: new java.io.FileOutputStream(uri)
|
|
218
|
+
|
|
219
|
+
if (!out) {
|
|
220
|
+
throw new Error(`Unable to open destination: ${uri}`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
java.nio.channels.Channels.newChannel(out).write(exactBuffer(bytes) as any)
|
|
225
|
+
} finally {
|
|
226
|
+
out.close()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function androidExport(
|
|
231
|
+
name: string,
|
|
232
|
+
bytes: Uint8Array,
|
|
233
|
+
opts?: FileExportOptions,
|
|
234
|
+
): Promise<FileExportResult> {
|
|
235
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity
|
|
236
|
+
if (!activity) {
|
|
237
|
+
return Promise.resolve('unavailable')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const Intent = android.content.Intent
|
|
241
|
+
const intent = new Intent(Intent.ACTION_CREATE_DOCUMENT)
|
|
242
|
+
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
243
|
+
intent.setType(opts?.mimeType ?? '*/*')
|
|
244
|
+
intent.putExtra(Intent.EXTRA_TITLE, name)
|
|
245
|
+
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
246
|
+
|
|
247
|
+
return new Promise<FileExportResult>((resolve, reject) => {
|
|
248
|
+
const onEvent = (event: any) => {
|
|
249
|
+
if (event.requestCode !== ANDROID_EXPORT_REQUEST_CODE) {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
Application.android.off(Application.android.activityResultEvent, onEvent)
|
|
254
|
+
const uri = event.intent?.getData?.()
|
|
255
|
+
if (event.resultCode !== android.app.Activity.RESULT_OK || !uri) {
|
|
256
|
+
resolve('cancelled')
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
writeAndroidBytes(uri.toString(), bytes)
|
|
262
|
+
resolve('saved')
|
|
263
|
+
} catch (error) {
|
|
264
|
+
reject(error)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
Application.android.on(Application.android.activityResultEvent, onEvent)
|
|
269
|
+
try {
|
|
270
|
+
activity.startActivityForResult(intent, ANDROID_EXPORT_REQUEST_CODE)
|
|
271
|
+
} catch (error) {
|
|
272
|
+
Application.android.off(Application.android.activityResultEvent, onEvent)
|
|
273
|
+
reject(error)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// One export UI at a time: Android listeners share the activity-result event,
|
|
279
|
+
// and the iOS picker delegate below is kept alive in this slot (the
|
|
280
|
+
// controller's `delegate` property is weak). A second concurrent export would
|
|
281
|
+
// corrupt the first, so `files.export` fails fast instead.
|
|
282
|
+
let activeExport: { delegate?: any; controller?: any } | null = null
|
|
283
|
+
|
|
284
|
+
// Per-call delegate — defined lazily because NSObject and the picker symbols
|
|
285
|
+
// only exist on iOS, while this module also loads on Android.
|
|
286
|
+
let IosExportDelegate: any = null
|
|
287
|
+
|
|
288
|
+
function iosExportDelegateClass(): any {
|
|
289
|
+
if (IosExportDelegate) {
|
|
290
|
+
return IosExportDelegate
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
IosExportDelegate = class extends NSObject {
|
|
294
|
+
static ObjCProtocols = [UIDocumentPickerDelegate]
|
|
295
|
+
finish: ((saved: boolean) => void) | null = null
|
|
296
|
+
|
|
297
|
+
documentPickerDidPickDocumentAtURL(controller: any, _url: any): void {
|
|
298
|
+
this.settle(controller, true)
|
|
299
|
+
}
|
|
300
|
+
documentPickerDidPickDocumentsAtURLs(controller: any, urls: any): void {
|
|
301
|
+
this.settle(controller, urls.count > 0)
|
|
302
|
+
}
|
|
303
|
+
documentPickerWasCancelled(controller: any): void {
|
|
304
|
+
this.settle(controller, false)
|
|
305
|
+
}
|
|
306
|
+
private settle(controller: any, saved: boolean): void {
|
|
307
|
+
const finish = this.finish
|
|
308
|
+
this.finish = null
|
|
309
|
+
controller.delegate = null
|
|
310
|
+
finish?.(saved)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return IosExportDelegate
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function iosVisibleViewController(): any {
|
|
318
|
+
const app = UIApplication.sharedApplication
|
|
319
|
+
const keyWindow = app.keyWindow || (app.windows.count > 0 && app.windows[0])
|
|
320
|
+
// Utils.ios is only populated on iOS — typed as an empty bag elsewhere.
|
|
321
|
+
const getVisible = (Utils.ios as any)?.getVisibleViewController
|
|
322
|
+
return keyWindow && typeof getVisible === 'function'
|
|
323
|
+
? getVisible(keyWindow.rootViewController)
|
|
324
|
+
: null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Own export picker rather than the upstream plugin's saveFile: that helper
|
|
328
|
+
// runs an async promise executor (a failed temp write or a missing visible
|
|
329
|
+
// controller leaves the promise unsettled) and holds the picker delegate in
|
|
330
|
+
// one weak module slot, so overlapping calls drop the earlier request.
|
|
331
|
+
async function iosExport(
|
|
332
|
+
name: string,
|
|
333
|
+
bytes: Uint8Array,
|
|
334
|
+
slot: { delegate?: any; controller?: any },
|
|
335
|
+
): Promise<FileExportResult> {
|
|
336
|
+
const tempPath = path.join(knownFolders.temp().path, name)
|
|
337
|
+
const staged = NSData.dataWithBytesLength(bytes as any, bytes.byteLength).writeToFileAtomically(
|
|
338
|
+
tempPath,
|
|
339
|
+
true,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const removeTemp = () => {
|
|
343
|
+
try {
|
|
344
|
+
File.fromPath(tempPath).removeSync()
|
|
345
|
+
} catch {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!staged) {
|
|
349
|
+
throw new Error(`Failed to stage export file: ${name}`)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const visibleVC = iosVisibleViewController()
|
|
353
|
+
if (!visibleVC) {
|
|
354
|
+
removeTemp()
|
|
355
|
+
throw new Error('No visible view controller to present the export picker')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return new Promise<FileExportResult>((resolve, reject) => {
|
|
359
|
+
try {
|
|
360
|
+
const controller = UIDocumentPickerViewController.alloc().initForExportingURLsAsCopy(
|
|
361
|
+
[NSURL.fileURLWithPath(tempPath)] as any,
|
|
362
|
+
true,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
const delegate = iosExportDelegateClass().new()
|
|
366
|
+
// `saved` only after the system commits the export-as-copy to the
|
|
367
|
+
// chosen destination; dismissal settles as 'cancelled'.
|
|
368
|
+
delegate.finish = (saved: boolean) => {
|
|
369
|
+
removeTemp()
|
|
370
|
+
resolve(saved ? 'saved' : 'cancelled')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
slot.delegate = delegate
|
|
374
|
+
slot.controller = controller
|
|
375
|
+
controller.delegate = delegate
|
|
376
|
+
controller.shouldShowFileExtensions = true
|
|
377
|
+
visibleVC.presentViewControllerAnimatedCompletion(controller, true, null)
|
|
378
|
+
} catch (error) {
|
|
379
|
+
removeTemp()
|
|
380
|
+
reject(error)
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export const files: Files = {
|
|
111
386
|
async pickMultiple(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef[]> {
|
|
112
387
|
return pickFiles(accept, true, opts)
|
|
113
388
|
},
|
|
114
|
-
/** Opens the platform document picker and returns its first opaque URI. */
|
|
115
389
|
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
116
390
|
return (await pickFiles(accept, false, opts))[0] ?? null
|
|
117
391
|
},
|
|
@@ -120,14 +394,63 @@ export const files = {
|
|
|
120
394
|
return readContentUri(ref.uri)
|
|
121
395
|
}
|
|
122
396
|
|
|
123
|
-
return File.fromPath(ref.uri).readText()
|
|
397
|
+
return File.fromPath(Application.android ? ref.uri : iosFilePath(ref.uri)).readText()
|
|
398
|
+
},
|
|
399
|
+
async readBytes(ref: FileRef, opts?: FileReadBytesOptions): Promise<Uint8Array> {
|
|
400
|
+
throwIfAborted(opts?.signal)
|
|
401
|
+
return Application.android ? readAndroidBytes(ref.uri, opts) : readIosBytes(ref.uri, opts)
|
|
124
402
|
},
|
|
125
403
|
async writeText(name: string, text: string): Promise<FileRef> {
|
|
404
|
+
assertSafeFileName(name)
|
|
126
405
|
const p = path.join(docs().path, name)
|
|
127
406
|
const f = File.fromPath(p)
|
|
128
407
|
f.writeTextSync(text)
|
|
129
408
|
return { name, uri: p }
|
|
130
409
|
},
|
|
410
|
+
async writeBytes(name: string, bytes: Uint8Array): Promise<FileRef> {
|
|
411
|
+
assertSafeFileName(name)
|
|
412
|
+
const p = path.join(docs().path, name)
|
|
413
|
+
if (Application.android) {
|
|
414
|
+
writeAndroidBytes(p, bytes)
|
|
415
|
+
} else {
|
|
416
|
+
// FileSystemAccess.writeBufferSync drops the writeToFileAtomically
|
|
417
|
+
// result and swallows errors without an onError callback, so a
|
|
418
|
+
// failed write would resolve with a ref to a missing file. Check
|
|
419
|
+
// the BOOL — it is the platform's overwrite confirmation.
|
|
420
|
+
const written = NSData.dataWithBytesLength(
|
|
421
|
+
bytes as any,
|
|
422
|
+
bytes.byteLength,
|
|
423
|
+
).writeToFileAtomically(p, true)
|
|
424
|
+
|
|
425
|
+
if (!written) {
|
|
426
|
+
throw new Error(`Failed to write file: ${p}`)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { name, uri: p }
|
|
431
|
+
},
|
|
432
|
+
async export(
|
|
433
|
+
name: string,
|
|
434
|
+
bytes: Uint8Array,
|
|
435
|
+
opts?: FileExportOptions,
|
|
436
|
+
): Promise<FileExportResult> {
|
|
437
|
+
assertSafeFileName(name)
|
|
438
|
+
if (activeExport) {
|
|
439
|
+
throw exportBusy()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const slot: { delegate?: any; controller?: any } = {}
|
|
443
|
+
activeExport = slot
|
|
444
|
+
try {
|
|
445
|
+
return Application.android
|
|
446
|
+
? await androidExport(name, bytes, opts)
|
|
447
|
+
: await iosExport(name, bytes, slot)
|
|
448
|
+
} finally {
|
|
449
|
+
if (activeExport === slot) {
|
|
450
|
+
activeExport = null
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
},
|
|
131
454
|
release(ref: FileRef): void {
|
|
132
455
|
const cachePrefix = knownFolders.temp().path + '/'
|
|
133
456
|
if (!ref.uri.startsWith(cachePrefix)) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { files } from './files.web'
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
let counter = 0
|
|
7
|
+
vi.stubGlobal('URL', {
|
|
8
|
+
...URL,
|
|
9
|
+
createObjectURL: (_blob: Blob) => `blob:mock/${counter++}`,
|
|
10
|
+
revokeObjectURL: vi.fn(),
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.unstubAllGlobals()
|
|
16
|
+
vi.restoreAllMocks()
|
|
17
|
+
delete (window as any).showSaveFilePicker
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('files.writeBytes (web)', () => {
|
|
21
|
+
it('starts a download and returns a readable ref', async () => {
|
|
22
|
+
const ref = await files.writeBytes('report.bin', new Uint8Array([1, 2, 3, 255]))
|
|
23
|
+
expect(ref.name).toBe('report.bin')
|
|
24
|
+
expect(ref.uri).toMatch(/^blob:/)
|
|
25
|
+
|
|
26
|
+
const back = await files.readBytes(ref)
|
|
27
|
+
expect([...back]).toEqual([1, 2, 3, 255])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('rejects path-traversal names', async () => {
|
|
31
|
+
await expect(files.writeBytes('../escape.bin', new Uint8Array(1))).rejects.toThrow(
|
|
32
|
+
/Unsafe file name/,
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('files.readBytes (web)', () => {
|
|
38
|
+
it('enforces maxBytes without returning truncated data', async () => {
|
|
39
|
+
const ref = await files.writeBytes('big.bin', new Uint8Array(1024))
|
|
40
|
+
await expect(files.readBytes(ref, { maxBytes: 512 })).rejects.toThrow(/exceeds/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('rejects on an aborted signal', async () => {
|
|
44
|
+
const ref = await files.writeBytes('a.bin', new Uint8Array([1]))
|
|
45
|
+
const controller = new AbortController()
|
|
46
|
+
controller.abort()
|
|
47
|
+
await expect(files.readBytes(ref, { signal: controller.signal })).rejects.toThrowError(
|
|
48
|
+
expect.objectContaining({ name: 'AbortError' }),
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('rejects when the signal aborts during blob.arrayBuffer()', async () => {
|
|
53
|
+
const controller = new AbortController()
|
|
54
|
+
vi.spyOn(Blob.prototype, 'arrayBuffer').mockImplementation(async () => {
|
|
55
|
+
controller.abort()
|
|
56
|
+
return new Uint8Array([1]).buffer
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const ref = await files.writeBytes('a.bin', new Uint8Array([1]))
|
|
60
|
+
await expect(files.readBytes(ref, { signal: controller.signal })).rejects.toThrowError(
|
|
61
|
+
expect.objectContaining({ name: 'AbortError' }),
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('reads foreign URLs through fetch with the cap applied', async () => {
|
|
66
|
+
const body = new Uint8Array([1, 2, 3, 4])
|
|
67
|
+
vi.stubGlobal(
|
|
68
|
+
'fetch',
|
|
69
|
+
vi.fn(async () => new Response(body)),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const back = await files.readBytes({ name: 'remote.bin', uri: 'https://x/remote.bin' })
|
|
73
|
+
expect([...back]).toEqual([1, 2, 3, 4])
|
|
74
|
+
expect(fetch).toHaveBeenCalledWith('https://x/remote.bin', { signal: undefined })
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
files.readBytes({ name: 'remote.bin', uri: 'https://x/remote.bin' }, { maxBytes: 2 }),
|
|
78
|
+
).rejects.toThrow(/exceeds/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('revokes the object URL on release', async () => {
|
|
82
|
+
const ref = await files.writeBytes('gone.bin', new Uint8Array([1]))
|
|
83
|
+
files.release(ref)
|
|
84
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith(ref.uri)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('files.export (web)', () => {
|
|
89
|
+
it('returns unavailable without the File System Access API', async () => {
|
|
90
|
+
expect(await files.export('a.bin', new Uint8Array([1]))).toBe('unavailable')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('writes bytes through a save picker and reports saved', async () => {
|
|
94
|
+
const write = vi.fn()
|
|
95
|
+
const close = vi.fn(async () => {})
|
|
96
|
+
const showSaveFilePicker = vi.fn(async () => ({
|
|
97
|
+
createWritable: async () => ({ write, close }),
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
;(window as any).showSaveFilePicker = showSaveFilePicker
|
|
101
|
+
|
|
102
|
+
const result = await files.export('dump.bin', new Uint8Array([7, 8]))
|
|
103
|
+
expect(result).toBe('saved')
|
|
104
|
+
expect(write).toHaveBeenCalledWith(new Uint8Array([7, 8]))
|
|
105
|
+
expect(close).toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('maps picker dismissal to cancelled', async () => {
|
|
109
|
+
const showSaveFilePicker = vi.fn(async () => {
|
|
110
|
+
throw new DOMException('dismissed', 'AbortError')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
;(window as any).showSaveFilePicker = showSaveFilePicker
|
|
114
|
+
|
|
115
|
+
expect(await files.export('dump.bin', new Uint8Array([7]))).toBe('cancelled')
|
|
116
|
+
})
|
|
117
|
+
})
|
package/src/files.web.ts
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
// Files — web leaf. Desktop webview hosts own real pickers and file:// refs;
|
|
2
2
|
// outside a host this uses opaque object URLs and browser downloads.
|
|
3
3
|
import { desktopHost } from '@octane-xplat/platform/host/web'
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
assertSafeFileName,
|
|
6
|
+
concatBytes,
|
|
7
|
+
exactBuffer,
|
|
8
|
+
fileTooLarge,
|
|
9
|
+
throwIfAborted,
|
|
10
|
+
} from './file-bytes'
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
FileExportOptions,
|
|
14
|
+
FileExportResult,
|
|
15
|
+
FileReadBytesOptions,
|
|
16
|
+
FileRef,
|
|
17
|
+
Files,
|
|
18
|
+
} from './types'
|
|
19
|
+
|
|
20
|
+
// Object URLs this leaf minted, keyed by URL, so readBytes returns exact bytes
|
|
21
|
+
// without a fetch round-trip and release can drop the backing Blob.
|
|
22
|
+
const objectBlobs = new Map<string, Blob>()
|
|
23
|
+
|
|
24
|
+
function mintObjectUrl(blob: Blob): string {
|
|
25
|
+
const uri = URL.createObjectURL(blob)
|
|
26
|
+
objectBlobs.set(uri, blob)
|
|
27
|
+
return uri
|
|
28
|
+
}
|
|
5
29
|
|
|
6
30
|
function chooseFiles(accept: string, multiple: boolean): Promise<FileRef[]> {
|
|
7
31
|
return new Promise((resolve) => {
|
|
@@ -12,7 +36,7 @@ function chooseFiles(accept: string, multiple: boolean): Promise<FileRef[]> {
|
|
|
12
36
|
input.onchange = () => {
|
|
13
37
|
const refs: FileRef[] = []
|
|
14
38
|
for (const file of Array.from(input.files ?? [])) {
|
|
15
|
-
refs.push({ name: file.name, uri:
|
|
39
|
+
refs.push({ name: file.name, uri: mintObjectUrl(file) })
|
|
16
40
|
}
|
|
17
41
|
|
|
18
42
|
resolve(refs)
|
|
@@ -23,7 +47,53 @@ function chooseFiles(accept: string, multiple: boolean): Promise<FileRef[]> {
|
|
|
23
47
|
})
|
|
24
48
|
}
|
|
25
49
|
|
|
26
|
-
|
|
50
|
+
// Capped stream read: stop as soon as the cap is exceeded instead of
|
|
51
|
+
// materializing the whole response.
|
|
52
|
+
async function fetchBytes(uri: string, opts?: FileReadBytesOptions): Promise<Uint8Array> {
|
|
53
|
+
const response = await fetch(uri, { signal: opts?.signal })
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error(`Unable to read file: ${uri}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (opts?.maxBytes == null) {
|
|
59
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const reader = response.body?.getReader()
|
|
63
|
+
if (!reader) {
|
|
64
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
65
|
+
if (bytes.byteLength > opts.maxBytes) {
|
|
66
|
+
throw fileTooLarge(uri, opts.maxBytes)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return bytes
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const chunks: Uint8Array[] = []
|
|
73
|
+
let total = 0
|
|
74
|
+
try {
|
|
75
|
+
while (true) {
|
|
76
|
+
const { done, value } = await reader.read()
|
|
77
|
+
if (done) {
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
total += value.byteLength
|
|
82
|
+
if (total > opts.maxBytes) {
|
|
83
|
+
await reader.cancel().catch(() => {})
|
|
84
|
+
throw fileTooLarge(uri, opts.maxBytes)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
chunks.push(value)
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
reader.releaseLock()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return concatBytes(chunks, total)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const files: Files = {
|
|
27
97
|
async pickMultiple(accept = '*/*', _opts?: { startingFolder?: string }): Promise<FileRef[]> {
|
|
28
98
|
return chooseFiles(accept, true)
|
|
29
99
|
},
|
|
@@ -48,8 +118,26 @@ export const files = {
|
|
|
48
118
|
|
|
49
119
|
return (await fetch(ref.uri)).text()
|
|
50
120
|
},
|
|
121
|
+
async readBytes(ref: FileRef, opts?: FileReadBytesOptions): Promise<Uint8Array> {
|
|
122
|
+
throwIfAborted(opts?.signal)
|
|
123
|
+
const blob = objectBlobs.get(ref.uri)
|
|
124
|
+
if (blob) {
|
|
125
|
+
if (opts?.maxBytes != null && blob.size > opts.maxBytes) {
|
|
126
|
+
throw fileTooLarge(ref.uri, opts.maxBytes)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const bytes = new Uint8Array(await blob.arrayBuffer())
|
|
130
|
+
throwIfAborted(opts?.signal)
|
|
131
|
+
return bytes
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Host file:// refs need a host binary read, which the desktop service
|
|
135
|
+
// contract does not provide; other URLs go through fetch.
|
|
136
|
+
return fetchBytes(ref.uri, opts)
|
|
137
|
+
},
|
|
51
138
|
/** Desktop hosts write the selected path; plain web starts a download. */
|
|
52
139
|
async writeText(name: string, text: string): Promise<FileRef> {
|
|
140
|
+
assertSafeFileName(name)
|
|
53
141
|
const host = desktopHost()
|
|
54
142
|
if (host && (await host.supports('files', 'writeText'))) {
|
|
55
143
|
const file = await host.files.writeText(name, text)
|
|
@@ -60,15 +148,56 @@ export const files = {
|
|
|
60
148
|
return file
|
|
61
149
|
}
|
|
62
150
|
|
|
63
|
-
const uri =
|
|
151
|
+
const uri = mintObjectUrl(new Blob([text], { type: 'text/plain' }))
|
|
152
|
+
const a = document.createElement('a')
|
|
153
|
+
a.href = uri
|
|
154
|
+
a.download = name
|
|
155
|
+
a.click()
|
|
156
|
+
return { name, uri }
|
|
157
|
+
},
|
|
158
|
+
/** The desktop host contract is text-only, so bytes always download. */
|
|
159
|
+
async writeBytes(name: string, bytes: Uint8Array): Promise<FileRef> {
|
|
160
|
+
assertSafeFileName(name)
|
|
161
|
+
const uri = mintObjectUrl(new Blob([exactBuffer(bytes)], { type: 'application/octet-stream' }))
|
|
64
162
|
const a = document.createElement('a')
|
|
65
163
|
a.href = uri
|
|
66
164
|
a.download = name
|
|
67
165
|
a.click()
|
|
68
166
|
return { name, uri }
|
|
69
167
|
},
|
|
168
|
+
async export(
|
|
169
|
+
name: string,
|
|
170
|
+
bytes: Uint8Array,
|
|
171
|
+
opts?: FileExportOptions,
|
|
172
|
+
): Promise<FileExportResult> {
|
|
173
|
+
assertSafeFileName(name)
|
|
174
|
+
const picker = (window as any).showSaveFilePicker as ((opts: any) => Promise<any>) | undefined
|
|
175
|
+
|
|
176
|
+
if (typeof picker !== 'function') {
|
|
177
|
+
return 'unavailable'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const extension = name.includes('.') ? `.${name.split('.').pop()}` : null
|
|
181
|
+
const types =
|
|
182
|
+
opts?.mimeType && extension ? [{ accept: { [opts.mimeType]: [extension] } }] : undefined
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const handle = await picker.call(window, { suggestedName: name, types })
|
|
186
|
+
const writable = await handle.createWritable()
|
|
187
|
+
await writable.write(bytes)
|
|
188
|
+
await writable.close()
|
|
189
|
+
return 'saved'
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if ((error as DOMException)?.name === 'AbortError') {
|
|
192
|
+
return 'cancelled'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
197
|
+
},
|
|
70
198
|
release(ref: FileRef): void {
|
|
71
199
|
if (ref.uri.startsWith('blob:')) {
|
|
200
|
+
objectBlobs.delete(ref.uri)
|
|
72
201
|
URL.revokeObjectURL(ref.uri)
|
|
73
202
|
}
|
|
74
203
|
},
|
package/src/types.ts
CHANGED
|
@@ -2,8 +2,71 @@ export interface FileRef {
|
|
|
2
2
|
name: string
|
|
3
3
|
/**
|
|
4
4
|
* Opaque reference: blob/object URL on web, filesystem path on native.
|
|
5
|
-
* `files.writeText()`
|
|
6
|
-
* Picked
|
|
5
|
+
* `files.writeText()`/`files.writeBytes()` download on web and write the
|
|
6
|
+
* app sandbox on native. Picked Android files may use `content://` URIs.
|
|
7
7
|
*/
|
|
8
8
|
uri: string
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
export interface FilePickOptions {
|
|
12
|
+
startingFolder?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FileReadBytesOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Reject with a too-large error once the file exceeds this many bytes.
|
|
18
|
+
* Enforced while reading, so oversized sources are never fully loaded.
|
|
19
|
+
*/
|
|
20
|
+
maxBytes?: number
|
|
21
|
+
/** Abort an in-progress read. Rejects with an `AbortError`. */
|
|
22
|
+
signal?: AbortSignal
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FileExportOptions {
|
|
26
|
+
/**
|
|
27
|
+
* MIME type hint for the destination — the Android SAF action type and the
|
|
28
|
+
* web save picker's accept filter. Ignored on iOS.
|
|
29
|
+
*/
|
|
30
|
+
mimeType?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `saved`: the platform committed the bytes to the chosen destination —
|
|
35
|
+
* bytes flushed through `ContentResolver` on Android, the export-as-copy
|
|
36
|
+
* picker's destination commit on iOS, or a closed `FileSystemWritableFileStream`
|
|
37
|
+
* on web. `cancelled`: the user dismissed the destination UI. `unavailable`:
|
|
38
|
+
* the platform offers no qualified export surface (browser without the
|
|
39
|
+
* File System Access API, the AppKit leaf).
|
|
40
|
+
*/
|
|
41
|
+
export type FileExportResult = 'saved' | 'cancelled' | 'unavailable'
|
|
42
|
+
|
|
43
|
+
/** Shared contract every platform leaf implements. */
|
|
44
|
+
export interface Files {
|
|
45
|
+
/** Opens the platform document picker and returns its first opaque URI. */
|
|
46
|
+
pick(accept?: string, opts?: FilePickOptions): Promise<FileRef | null>
|
|
47
|
+
/** Opens the platform document picker and returns selected opaque URIs. */
|
|
48
|
+
pickMultiple(accept?: string, opts?: FilePickOptions): Promise<FileRef[]>
|
|
49
|
+
readText(ref: FileRef): Promise<string>
|
|
50
|
+
/**
|
|
51
|
+
* Reads the full binary content of a picked or sandbox-written ref.
|
|
52
|
+
* Android `content://` refs are read through `ContentResolver`; iOS picker
|
|
53
|
+
* refs are app-owned import copies; web reads the backing `Blob` directly
|
|
54
|
+
* or fetches foreign URLs.
|
|
55
|
+
*/
|
|
56
|
+
readBytes(ref: FileRef, opts?: FileReadBytesOptions): Promise<Uint8Array>
|
|
57
|
+
writeText(name: string, text: string): Promise<FileRef>
|
|
58
|
+
/**
|
|
59
|
+
* Writes bytes inside the app sandbox (native) or starts a browser
|
|
60
|
+
* download (web) and returns a ref suitable for `readBytes`/`release`.
|
|
61
|
+
*/
|
|
62
|
+
writeBytes(name: string, bytes: Uint8Array): Promise<FileRef>
|
|
63
|
+
/**
|
|
64
|
+
* User-directed export to a destination the user picks: the Android SAF
|
|
65
|
+
* create action, iOS export-as-copy picker, web `showSaveFilePicker`.
|
|
66
|
+
* Only one export can be in flight — a concurrent call rejects with an
|
|
67
|
+
* `ExportBusyError`.
|
|
68
|
+
*/
|
|
69
|
+
export(name: string, bytes: Uint8Array, opts?: FileExportOptions): Promise<FileExportResult>
|
|
70
|
+
/** Free the ref's backing resource (revoke object URLs, delete temp copies). */
|
|
71
|
+
release(ref: FileRef): void
|
|
72
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -12,4 +12,11 @@ export type {
|
|
|
12
12
|
} from '../src/props.js'
|
|
13
13
|
|
|
14
14
|
export { files } from '../src/files.js'
|
|
15
|
-
export type {
|
|
15
|
+
export type {
|
|
16
|
+
FileExportOptions,
|
|
17
|
+
FileExportResult,
|
|
18
|
+
FilePickOptions,
|
|
19
|
+
FileReadBytesOptions,
|
|
20
|
+
FileRef,
|
|
21
|
+
Files,
|
|
22
|
+
} from '../src/types.js'
|
package/types/index.macos.d.ts
CHANGED
|
@@ -11,4 +11,11 @@ export type {
|
|
|
11
11
|
} from '../src/props.js'
|
|
12
12
|
|
|
13
13
|
export { files } from '../src/files.macos.js'
|
|
14
|
-
export type {
|
|
14
|
+
export type {
|
|
15
|
+
FileExportOptions,
|
|
16
|
+
FileExportResult,
|
|
17
|
+
FilePickOptions,
|
|
18
|
+
FileReadBytesOptions,
|
|
19
|
+
FileRef,
|
|
20
|
+
Files,
|
|
21
|
+
} from '../src/types.js'
|