@octane-xplat/files 0.0.1 → 0.9.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/LICENSE +21 -0
- package/README.md +31 -2
- package/package.json +61 -10
- package/src/files.linux.ts +2 -0
- package/src/files.macos.ts +18 -0
- package/src/files.ts +133 -0
- package/src/files.web.ts +63 -0
- package/src/index.macos.ts +2 -0
- package/src/index.ts +2 -0
- package/src/types.ts +8 -0
- package/index.d.ts +0 -1
- package/index.js +0 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alec Larson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@octane-xplat/files`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
File pick, read, and write for Octane xplat apps. iOS/Android run
|
|
4
|
+
`@nativescript-community/ui-document-picker` plus `@nativescript/core`
|
|
5
|
+
filesystem APIs (including `content://` URIs); web uses an `<input
|
|
6
|
+
type="file">` pick and downloads for writes; Linux goes through the desktop
|
|
7
|
+
host bridge (`Gtk.FileDialog`) and falls back to the web flow in a plain
|
|
8
|
+
browser; the macOS AppKit dev host is not wired and throws `unsupported`.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm add @octane-xplat/files
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { files } from '@octane-xplat/files'
|
|
16
|
+
|
|
17
|
+
const ref = await files.pick('image/*')
|
|
18
|
+
if (ref) {
|
|
19
|
+
const text = await files.readText(ref)
|
|
20
|
+
files.release(ref)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// web: triggers a download; native: writes into the app documents folder
|
|
24
|
+
const out = await files.writeText('packing-list.txt', text)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`FileRef.uri` is opaque — a blob/object URL on web, a filesystem path or
|
|
28
|
+
`content://` URI on native. Pass it back to `files.readText`; do not parse
|
|
29
|
+
it. `release(ref)` cleans up native temp picks and is a no-op elsewhere.
|
|
30
|
+
|
|
31
|
+
Guide: [Using device features](../../docs/platform-services.md);
|
|
32
|
+
per-target availability: [platform notes](../../docs/platform-notes.md).
|
package/package.json
CHANGED
|
@@ -1,19 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/files",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Native + web file pick and read/write for Octane xplat — matching web + NativeScript implementations",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"android",
|
|
7
|
+
"cross-platform",
|
|
8
|
+
"document-picker",
|
|
9
|
+
"file-picker",
|
|
10
|
+
"files",
|
|
11
|
+
"ios",
|
|
12
|
+
"nativescript",
|
|
13
|
+
"octane",
|
|
14
|
+
"web",
|
|
15
|
+
"xplat"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/files",
|
|
5
18
|
"license": "MIT",
|
|
6
19
|
"repository": {
|
|
7
20
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/octane-xplat/octane-xplat
|
|
21
|
+
"url": "https://github.com/octane-xplat/octane-xplat",
|
|
9
22
|
"directory": "packages/files"
|
|
10
23
|
},
|
|
11
|
-
"homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/files",
|
|
12
|
-
"main": "./index.js",
|
|
13
|
-
"types": "./index.d.ts",
|
|
14
24
|
"files": [
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"macos": "./src/index.macos.ts",
|
|
31
|
+
"default": "./src/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"./*": "./src/*"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@nativescript-community/ui-document-picker": "1.1.29",
|
|
40
|
+
"@octane-xplat/platform": "0.9.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@nativescript-community/octane": "0.2.4",
|
|
44
|
+
"@nativescript/core": "9.1.2",
|
|
45
|
+
"@nativescript/types": "9.0.0",
|
|
46
|
+
"octane": "0.6.3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
50
|
+
"@nativescript/core": ">=9.1.0 <10",
|
|
51
|
+
"octane": ">=0.6.3 <1"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@nativescript-community/octane": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@nativescript/core": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"nativescript": {
|
|
62
|
+
"platforms": {
|
|
63
|
+
"android": "9.1.0",
|
|
64
|
+
"ios": "9.1.0"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test:packed": "node tests/packed-consumer.mjs"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Files — AppKit host leaf. The dev host has no picker or save panel wired.
|
|
2
|
+
import type { FileRef } from './types'
|
|
3
|
+
|
|
4
|
+
export const files = {
|
|
5
|
+
async pick(): Promise<FileRef | null> {
|
|
6
|
+
throw new Error('unsupported: the AppKit host does not provide a file picker')
|
|
7
|
+
},
|
|
8
|
+
async pickMultiple(): Promise<FileRef[]> {
|
|
9
|
+
throw new Error('unsupported: the AppKit host does not provide a file picker')
|
|
10
|
+
},
|
|
11
|
+
async readText(_file: FileRef): Promise<string> {
|
|
12
|
+
throw new Error('unsupported: AppKit file access is not wired')
|
|
13
|
+
},
|
|
14
|
+
async writeText(_name: string, _text: string): Promise<FileRef> {
|
|
15
|
+
throw new Error('unsupported: the AppKit host does not provide a save panel')
|
|
16
|
+
},
|
|
17
|
+
release(_file: FileRef): void {},
|
|
18
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Files — native leaf. FileRef wraps an opaque native URI. App-document paths
|
|
2
|
+
// are still used for writes; picked Android SAF references may be content://
|
|
3
|
+
// URIs and must be read through ContentResolver.
|
|
4
|
+
import { openFilePicker } from '@nativescript-community/ui-document-picker'
|
|
5
|
+
import { Application, File, knownFolders, path } from '@nativescript/core'
|
|
6
|
+
import type { FileRef } from './types'
|
|
7
|
+
|
|
8
|
+
const docs = () => knownFolders.documents()
|
|
9
|
+
|
|
10
|
+
function pickerTypes(accept: string): { extensions: string[]; mimeTypes: string[] } {
|
|
11
|
+
const values = accept
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((value) => value.trim())
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
extensions: values.filter((value) => value.startsWith('.')).map((value) => value.slice(1)),
|
|
18
|
+
// `*/*` is the default, not a useful iOS UTType MIME value. Leaving it
|
|
19
|
+
// out lets the upstream picker use its public.data fallback.
|
|
20
|
+
mimeTypes: values.filter((value) => value.includes('/') && value !== '*/*'),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function androidDisplayName(uri: string, nativeUri: any): string | undefined {
|
|
25
|
+
if (!Application.android || !uri.startsWith('content://')) {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity
|
|
31
|
+
// Android accepts null for projection/filter args; its generated
|
|
32
|
+
// declarations omit that nullable bridge contract.
|
|
33
|
+
const noColumns = null as unknown as string[]
|
|
34
|
+
const noFilter = null as unknown as string
|
|
35
|
+
const cursor = activity
|
|
36
|
+
?.getContentResolver()
|
|
37
|
+
?.query(nativeUri ?? android.net.Uri.parse(uri), noColumns, noFilter, noColumns, noFilter)
|
|
38
|
+
|
|
39
|
+
if (!cursor) {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (!cursor.moveToFirst()) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const index = cursor.getColumnIndex('display_name')
|
|
49
|
+
return index >= 0 ? cursor.getString(index) : undefined
|
|
50
|
+
} finally {
|
|
51
|
+
cursor.close()
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fallbackName(uri: string): string {
|
|
59
|
+
const segment = uri.split('/').pop() ?? ''
|
|
60
|
+
try {
|
|
61
|
+
return decodeURIComponent(segment) || 'document'
|
|
62
|
+
} catch {
|
|
63
|
+
return segment || 'document'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readContentUri(uri: string): Promise<string> {
|
|
68
|
+
const activity = Application.android?.foregroundActivity ?? Application.android?.startActivity
|
|
69
|
+
const input = activity?.getContentResolver()?.openInputStream(android.net.Uri.parse(uri))
|
|
70
|
+
|
|
71
|
+
if (!input) {
|
|
72
|
+
throw new Error(`Unable to open picked file: ${uri}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reader = new java.io.BufferedReader(new java.io.InputStreamReader(input))
|
|
76
|
+
const lines: string[] = []
|
|
77
|
+
try {
|
|
78
|
+
let line: string | null
|
|
79
|
+
while ((line = reader.readLine()) !== null) {
|
|
80
|
+
lines.push(line)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return lines.join('\n')
|
|
84
|
+
} finally {
|
|
85
|
+
reader.close()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const files = {
|
|
90
|
+
/** Opens the platform document picker and returns its opaque native URI. */
|
|
91
|
+
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
92
|
+
const { extensions, mimeTypes } = pickerTypes(accept)
|
|
93
|
+
const result = await openFilePicker({
|
|
94
|
+
extensions,
|
|
95
|
+
mimeTypes,
|
|
96
|
+
multipleSelection: false,
|
|
97
|
+
permissions: { read: true, persistable: true },
|
|
98
|
+
startingFolder: opts?.startingFolder,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const uri = result.files?.[0]
|
|
102
|
+
|
|
103
|
+
if (!uri) {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const name = androidDisplayName(uri, result.android) ?? fallbackName(uri)
|
|
108
|
+
return { name, uri }
|
|
109
|
+
},
|
|
110
|
+
async readText(ref: FileRef): Promise<string> {
|
|
111
|
+
if (ref.uri.startsWith('content://')) {
|
|
112
|
+
return readContentUri(ref.uri)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return File.fromPath(ref.uri).readText()
|
|
116
|
+
},
|
|
117
|
+
async writeText(name: string, text: string): Promise<FileRef> {
|
|
118
|
+
const p = path.join(docs().path, name)
|
|
119
|
+
const f = File.fromPath(p)
|
|
120
|
+
f.writeTextSync(text)
|
|
121
|
+
return { name, uri: p }
|
|
122
|
+
},
|
|
123
|
+
release(ref: FileRef): void {
|
|
124
|
+
const cachePrefix = knownFolders.temp().path + '/'
|
|
125
|
+
if (!ref.uri.startsWith(cachePrefix)) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
File.fromPath(ref.uri).removeSync()
|
|
131
|
+
} catch {}
|
|
132
|
+
},
|
|
133
|
+
}
|
package/src/files.web.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Files — web leaf. Desktop webview hosts own real pickers and file:// refs;
|
|
2
|
+
// outside a host this uses opaque object URLs and browser downloads.
|
|
3
|
+
import { desktopHost } from '@octane-xplat/platform/host/web'
|
|
4
|
+
import type { FileRef } from './types'
|
|
5
|
+
|
|
6
|
+
export const files = {
|
|
7
|
+
async pick(accept = '*/*', opts?: { startingFolder?: string }): Promise<FileRef | null> {
|
|
8
|
+
const host = desktopHost()
|
|
9
|
+
if (host && (await host.supports('files', 'pick'))) {
|
|
10
|
+
return host.files.pick(accept, { startingFolder: opts?.startingFolder })
|
|
11
|
+
}
|
|
12
|
+
|
|
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
|
+
})
|
|
25
|
+
},
|
|
26
|
+
async readText(ref: FileRef): Promise<string> {
|
|
27
|
+
const host = desktopHost()
|
|
28
|
+
if (host && (await host.supports('files', 'readText'))) {
|
|
29
|
+
const text = await host.files.readText(ref.uri)
|
|
30
|
+
if (text === null) {
|
|
31
|
+
throw new Error(`Unable to read host file: ${ref.uri}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return text
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (await fetch(ref.uri)).text()
|
|
38
|
+
},
|
|
39
|
+
/** Desktop hosts write the selected path; plain web starts a download. */
|
|
40
|
+
async writeText(name: string, text: string): Promise<FileRef> {
|
|
41
|
+
const host = desktopHost()
|
|
42
|
+
if (host && (await host.supports('files', 'writeText'))) {
|
|
43
|
+
const file = await host.files.writeText(name, text)
|
|
44
|
+
if (!file) {
|
|
45
|
+
throw new Error('Host file save was cancelled')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return file
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const uri = URL.createObjectURL(new Blob([text], { type: 'text/plain' }))
|
|
52
|
+
const a = document.createElement('a')
|
|
53
|
+
a.href = uri
|
|
54
|
+
a.download = name
|
|
55
|
+
a.click()
|
|
56
|
+
return { name, uri }
|
|
57
|
+
},
|
|
58
|
+
release(ref: FileRef): void {
|
|
59
|
+
if (ref.uri.startsWith('blob:')) {
|
|
60
|
+
URL.revokeObjectURL(ref.uri)
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
package/index.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {}
|
package/index.js
DELETED