@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.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
|
|
4
|
+
import {
|
|
5
|
+
assertSafeFileName,
|
|
6
|
+
concatBytes,
|
|
7
|
+
exactBuffer,
|
|
8
|
+
fileTooLarge,
|
|
9
|
+
throwIfAborted,
|
|
10
|
+
} from './file-bytes'
|
|
5
11
|
|
|
6
|
-
|
|
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
|
|
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 =
|
|
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
|
},
|
package/src/index.macos.ts
CHANGED
|
@@ -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
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()`
|
|
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
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -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'
|