@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,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
|
}
|