@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/src/files.web.ts CHANGED
@@ -1,27 +1,109 @@
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'
5
11
 
6
- export const files = {
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
+ }
29
+
30
+ function chooseFiles(accept: string, multiple: boolean): Promise<FileRef[]> {
31
+ return new Promise((resolve) => {
32
+ const input = document.createElement('input')
33
+ input.type = 'file'
34
+ input.accept = accept
35
+ input.multiple = multiple
36
+ input.onchange = () => {
37
+ const refs: FileRef[] = []
38
+ for (const file of Array.from(input.files ?? [])) {
39
+ refs.push({ name: file.name, uri: mintObjectUrl(file) })
40
+ }
41
+
42
+ resolve(refs)
43
+ }
44
+
45
+ input.oncancel = () => resolve([])
46
+ input.click()
47
+ })
48
+ }
49
+
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 = {
97
+ async pickMultiple(accept = '*/*', _opts?: { startingFolder?: string }): Promise<FileRef[]> {
98
+ return chooseFiles(accept, true)
99
+ },
7
100
  async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
8
101
  const host = desktopHost()
9
102
  if (host && (await host.supports('files', 'pick'))) {
10
103
  return host.files.pick(accept, { startingFolder: opts?.startingFolder })
11
104
  }
12
105
 
13
- return new Promise((resolve) => {
14
- const input = document.createElement('input')
15
- input.type = 'file'
16
- input.accept = accept
17
- input.onchange = () => {
18
- const f = input.files?.[0]
19
- resolve(f ? { name: f.name, uri: URL.createObjectURL(f) } : null)
20
- }
21
-
22
- input.oncancel = () => resolve(null)
23
- input.click()
24
- })
106
+ return (await chooseFiles(accept, false))[0] ?? null
25
107
  },
26
108
  async readText(ref: FileRef): Promise<string> {
27
109
  const host = desktopHost()
@@ -36,8 +118,26 @@ export const files = {
36
118
 
37
119
  return (await fetch(ref.uri)).text()
38
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
+ },
39
138
  /** Desktop hosts write the selected path; plain web starts a download. */
40
139
  async writeText(name: string, text: string): Promise<FileRef> {
140
+ assertSafeFileName(name)
41
141
  const host = desktopHost()
42
142
  if (host && (await host.supports('files', 'writeText'))) {
43
143
  const file = await host.files.writeText(name, text)
@@ -48,15 +148,56 @@ export const files = {
48
148
  return file
49
149
  }
50
150
 
51
- 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' }))
52
162
  const a = document.createElement('a')
53
163
  a.href = uri
54
164
  a.download = name
55
165
  a.click()
56
166
  return { name, uri }
57
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
+ },
58
198
  release(ref: FileRef): void {
59
199
  if (ref.uri.startsWith('blob:')) {
200
+ objectBlobs.delete(ref.uri)
60
201
  URL.revokeObjectURL(ref.uri)
61
202
  }
62
203
  },
@@ -1,2 +1,11 @@
1
1
  export * from './types'
2
2
  export { files } from './files.macos'
3
+ export { FileInput } from './FileInput.macos.tsrx'
4
+ export type {
5
+ FileInputFile,
6
+ FileInputHandle,
7
+ FileInputPick,
8
+ FileInputProps,
9
+ FileInputStatus,
10
+ FileInputStatusType,
11
+ } from './props'
package/src/index.ts CHANGED
@@ -1,2 +1,11 @@
1
1
  export * from './types'
2
2
  export { files } from './files'
3
+ export { FileInput } from './FileInput'
4
+ export type {
5
+ FileInputFile,
6
+ FileInputHandle,
7
+ FileInputPick,
8
+ FileInputProps,
9
+ FileInputStatus,
10
+ FileInputStatusType,
11
+ } from './props'
package/src/props.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { Octane } from 'octane/jsx-runtime'
2
+
3
+ import type { FieldControlProps, FieldStatus, FieldStatusType } from '@octane-xplat/ui'
4
+ import type { FileRef } from './types'
5
+
6
+ /** A portable selected file. The browser File is only present on web. */
7
+ export interface FileInputFile extends FileRef {
8
+ size?: number
9
+ mimeType?: string
10
+ file?: any
11
+ }
12
+
13
+ export type FileInputStatus = FieldStatus
14
+ export type FileInputStatusType = FieldStatusType
15
+
16
+ export type FileInputPick = (options: {
17
+ accept?: string
18
+ multiple?: boolean
19
+ }) => Promise<FileInputFile[] | FileInputFile | null>
20
+
21
+ export interface FileInputHandle {
22
+ open(): void
23
+ native: any
24
+ }
25
+
26
+ export interface FileInputProps extends FieldControlProps {
27
+ className?: any
28
+ style?: any
29
+ id?: string
30
+ accessibilityLabel?: string
31
+ accessibilityHint?: string
32
+ /** `input` is a compact field row; `dropzone` a larger target that
33
+ * accepts drag/drop on pointer platforms. @default 'input' */
34
+ mode?: 'input' | 'dropzone'
35
+ value: FileInputFile | FileInputFile[] | null
36
+ onChange: (value: FileInputFile | FileInputFile[] | null) => void
37
+ changeAction?: (value: FileInputFile | FileInputFile[] | null) => void | Promise<void>
38
+ /** `accept`-style filter: `.ext`, `type/subtype`, wildcard subtype, any. */
39
+ accept?: string
40
+ /** When true, `value` and `onChange` use file arrays. */
41
+ isMultiple?: boolean
42
+ /** Max bytes per file; skipped for refs without a `size`. */
43
+ maxSize?: number
44
+ maxFiles?: number
45
+ placeholder?: string
46
+ /** Optional per-instance picker override for cloud or app-specific sources. */
47
+ pick?: FileInputPick
48
+ ref?: Octane.Ref<FileInputHandle>
49
+ ios?: any
50
+ android?: any
51
+ web?: any
52
+ }
package/src/types.ts CHANGED
@@ -2,7 +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()` downloads on web and writes a file on native.
5
+ * `files.writeText()`/`files.writeBytes()` download on web and write the
6
+ * app sandbox on native. Picked Android files may use `content://` URIs.
6
7
  */
7
8
  uri: string
8
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
+ }
@@ -0,0 +1,22 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { FileInputProps } from '../src/props.js'
3
+
4
+ export declare const FileInput: UniversalComponent<FileInputProps>
5
+ export type {
6
+ FileInputFile,
7
+ FileInputHandle,
8
+ FileInputPick,
9
+ FileInputProps,
10
+ FileInputStatus,
11
+ FileInputStatusType,
12
+ } from '../src/props.js'
13
+
14
+ export { files } from '../src/files.js'
15
+ export type {
16
+ FileExportOptions,
17
+ FileExportResult,
18
+ FilePickOptions,
19
+ FileReadBytesOptions,
20
+ FileRef,
21
+ Files,
22
+ } from '../src/types.js'
@@ -0,0 +1,21 @@
1
+ import type { FileInputProps } from '../src/props.js'
2
+
3
+ export declare function FileInput(props: FileInputProps): unknown
4
+ export type {
5
+ FileInputFile,
6
+ FileInputHandle,
7
+ FileInputPick,
8
+ FileInputProps,
9
+ FileInputStatus,
10
+ FileInputStatusType,
11
+ } from '../src/props.js'
12
+
13
+ export { files } from '../src/files.macos.js'
14
+ export type {
15
+ FileExportOptions,
16
+ FileExportResult,
17
+ FilePickOptions,
18
+ FileReadBytesOptions,
19
+ FileRef,
20
+ Files,
21
+ } from '../src/types.js'