@octane-xplat/files 0.9.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 +73 -20
- package/package.json +11 -4
- package/src/FileInput.linux.tsrx +1 -0
- package/src/FileInput.macos.tsrx +167 -0
- package/src/FileInput.tsrx +168 -0
- package/src/FileInput.web.test.tsx +99 -0
- package/src/FileInput.web.tsrx +240 -0
- package/src/file-bytes.test.ts +53 -0
- package/src/file-bytes.ts +68 -0
- package/src/file-input-utils.test.ts +22 -0
- package/src/file-input-utils.ts +113 -0
- package/src/files.bridge.test.ts +342 -0
- package/src/files.macos.ts +69 -7
- package/src/files.ts +350 -19
- package/src/files.web.test.ts +117 -0
- package/src/files.web.ts +156 -15
- package/src/index.macos.ts +9 -0
- package/src/index.ts +9 -0
- package/src/props.ts +52 -0
- package/src/types.ts +65 -1
- package/types/index.d.ts +22 -0
- package/types/index.macos.d.ts +21 -0
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
|
|
@@ -86,40 +102,355 @@ async function readContentUri(uri: string): Promise<string> {
|
|
|
86
102
|
}
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
105
|
+
async function pickFiles(
|
|
106
|
+
accept: string,
|
|
107
|
+
multiple: boolean,
|
|
108
|
+
opts?: { startingFolder?: string },
|
|
109
|
+
): Promise<FileRef[]> {
|
|
110
|
+
const { extensions, mimeTypes } = pickerTypes(accept)
|
|
111
|
+
const result = await openFilePicker({
|
|
112
|
+
extensions,
|
|
113
|
+
mimeTypes,
|
|
114
|
+
multipleSelection: multiple,
|
|
115
|
+
permissions: { read: true, persistable: true },
|
|
116
|
+
startingFolder: opts?.startingFolder,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return (result.files ?? []).map((uri, index) => ({
|
|
120
|
+
name: androidDisplayName(uri, index === 0 ? result.android : undefined) ?? fallbackName(uri),
|
|
121
|
+
uri,
|
|
122
|
+
}))
|
|
123
|
+
}
|
|
124
|
+
|
|
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
|
+
}
|
|
100
292
|
|
|
101
|
-
|
|
293
|
+
IosExportDelegate = class extends NSObject {
|
|
294
|
+
static ObjCProtocols = [UIDocumentPickerDelegate]
|
|
295
|
+
finish: ((saved: boolean) => void) | null = null
|
|
102
296
|
|
|
103
|
-
|
|
104
|
-
|
|
297
|
+
documentPickerDidPickDocumentAtURL(controller: any, _url: any): void {
|
|
298
|
+
this.settle(controller, true)
|
|
105
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
|
+
)
|
|
106
341
|
|
|
107
|
-
|
|
108
|
-
|
|
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 = {
|
|
386
|
+
async pickMultiple(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef[]> {
|
|
387
|
+
return pickFiles(accept, true, opts)
|
|
388
|
+
},
|
|
389
|
+
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
390
|
+
return (await pickFiles(accept, false, opts))[0] ?? null
|
|
109
391
|
},
|
|
110
392
|
async readText(ref: FileRef): Promise<string> {
|
|
111
393
|
if (ref.uri.startsWith('content://')) {
|
|
112
394
|
return readContentUri(ref.uri)
|
|
113
395
|
}
|
|
114
396
|
|
|
115
|
-
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)
|
|
116
402
|
},
|
|
117
403
|
async writeText(name: string, text: string): Promise<FileRef> {
|
|
404
|
+
assertSafeFileName(name)
|
|
118
405
|
const p = path.join(docs().path, name)
|
|
119
406
|
const f = File.fromPath(p)
|
|
120
407
|
f.writeTextSync(text)
|
|
121
408
|
return { name, uri: p }
|
|
122
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
|
+
},
|
|
123
454
|
release(ref: FileRef): void {
|
|
124
455
|
const cachePrefix = knownFolders.temp().path + '/'
|
|
125
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
|
+
})
|