@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/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 type { FileRef } from './types'
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
- export const files = {
110
- /** Opens the platform document picker and returns selected opaque URIs. */
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 type { FileRef } from './types'
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: URL.createObjectURL(file) })
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
- export const files = {
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 = URL.createObjectURL(new Blob([text], { type: 'text/plain' }))
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
  },