@octane-xplat/media 0.14.0 → 0.15.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 +56 -14
- package/package.json +31 -8
- package/src/media.macos.ts +119 -10
- package/src/media.ts +32 -11
- package/src/media.web.ts +40 -39
- package/src/photo-capture.macos.ts +113 -0
- package/src/types.ts +10 -10
- package/types/generated/.tsrx-typegen-manifest.json +12 -0
- package/types/generated/index.d.ts +2 -0
- package/types/generated/index.macos.d.ts +2 -0
- package/types/generated/media.d.ts +2 -0
- package/types/generated/media.macos.d.ts +2 -0
- package/types/generated/media.web.d.ts +2 -0
- package/types/generated/photo-capture.macos.d.ts +5 -0
- package/types/generated/types.d.ts +51 -0
package/README.md
CHANGED
|
@@ -1,55 +1,97 @@
|
|
|
1
1
|
# `@octane-xplat/media`
|
|
2
2
|
|
|
3
3
|
```sh
|
|
4
|
-
pnpm add @octane-xplat/media
|
|
4
|
+
pnpm add @octane-xplat/media @octane-xplat/files
|
|
5
5
|
```
|
|
6
6
|
|
|
7
7
|
Still-image picking and OS camera capture for Octane xplat apps. iOS runs
|
|
8
8
|
`@nativescript/imagepicker` + `@nativescript/camera`, Android the same
|
|
9
9
|
plugins over its photo picker/camera intent; web uses `<input
|
|
10
10
|
type="file">` (with the `capture` attribute for camera shots, which mobile
|
|
11
|
-
browsers route to the camera app)
|
|
12
|
-
|
|
11
|
+
browsers route to the camera app). macOS AppKit uses the normal file picker
|
|
12
|
+
for existing images and an app-owned Capture/Cancel sheet for still photos.
|
|
13
|
+
Photos-library browsing and gallery saving remain unsupported.
|
|
13
14
|
|
|
14
15
|
```ts
|
|
15
16
|
import { media } from '@octane-xplat/media'
|
|
16
17
|
|
|
17
|
-
await media.ensure('photos')
|
|
18
18
|
const image = await media.pickImage() // null when the user cancels
|
|
19
19
|
const shot = await media.capturePhoto()
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Every method returns a `PickedImage` — `FileRef` (`name`, opaque `uri`)
|
|
23
|
-
|
|
24
|
-
resolves `null` (or `[]` for `pickImages`), so
|
|
25
|
-
than catching. `ensure('camera' | 'photos')` resolves
|
|
22
|
+
Every method returns a `PickedImage` — a `FileRef` (`name`, opaque `uri`)
|
|
23
|
+
you can hand straight to `Image src`, or to `files.readBytes()` for an
|
|
24
|
+
upload body. Cancellation resolves `null` (or `[]` for `pickImages`), so
|
|
25
|
+
check the result rather than catching. `ensure('camera' | 'photos')` resolves
|
|
26
26
|
`'granted' | 'denied' | 'unsupported'`.
|
|
27
27
|
|
|
28
28
|
```tsx
|
|
29
29
|
import { useState } from 'octane'
|
|
30
30
|
import { Button, Image } from '@octane-xplat/ui'
|
|
31
31
|
import { media } from '@octane-xplat/media'
|
|
32
|
+
import type { PickedImage } from '@octane-xplat/media'
|
|
33
|
+
import { files } from '@octane-xplat/files'
|
|
32
34
|
|
|
33
35
|
export function PhotoPicker() {
|
|
34
|
-
const [preview, setPreview] = useState<
|
|
36
|
+
const [preview, setPreview] = useState<PickedImage | null>(null)
|
|
35
37
|
async function pickPhoto() {
|
|
36
|
-
if ((await media.ensure('photos')) !== 'granted') return
|
|
37
38
|
const photo = await media.pickImage()
|
|
38
|
-
if (photo)
|
|
39
|
+
if (!photo) return
|
|
40
|
+
// The ref is a file handle — keep it alive while it displays; release
|
|
41
|
+
// the previous pick once the new one is showing.
|
|
42
|
+
const previous = preview
|
|
43
|
+
setPreview(photo)
|
|
44
|
+
if (previous) files.release(previous)
|
|
39
45
|
}
|
|
40
46
|
return (
|
|
41
47
|
<>
|
|
42
|
-
<Button
|
|
43
|
-
{preview && <Image src={preview} alt="Selected photo" />}
|
|
48
|
+
<Button clickAction={pickPhoto}>Choose photo</Button>
|
|
49
|
+
{preview && <Image src={preview.uri} alt="Selected photo" />}
|
|
44
50
|
</>
|
|
45
51
|
)
|
|
46
52
|
}
|
|
47
53
|
```
|
|
48
54
|
|
|
55
|
+
On macOS, call the picker directly from a button. It reads only the selected
|
|
56
|
+
files, keeps their pixel dimensions and aspect ratio, and returns an app-owned
|
|
57
|
+
temporary JPEG ref. Install `@octane-xplat/files` and release that JPEG after
|
|
58
|
+
preview/upload use — the ref is dead once released.
|
|
59
|
+
Failed conversion or writing rejects and cleans outputs from that selection.
|
|
60
|
+
Real macOS panel selection is a separate runtime qualification gate.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { media } from '@octane-xplat/media'
|
|
64
|
+
import { files } from '@octane-xplat/files'
|
|
65
|
+
|
|
66
|
+
const images = await media.pickImages() // [] on cancel
|
|
67
|
+
try {
|
|
68
|
+
console.log(images.map((image) => image.uri))
|
|
69
|
+
} finally {
|
|
70
|
+
for (const image of images) files.release(image)
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
49
74
|
For a live preview surface instead of one-shot capture, use
|
|
50
75
|
[`@octane-xplat/camera`](../camera/README.md)'s `CameraView`.
|
|
51
76
|
|
|
52
77
|
Guide: [Using device features](../../docs/platform/platform-services.md);
|
|
53
|
-
per-target availability: [platform notes](
|
|
78
|
+
per-target availability: [platform notes](../../.agents/docs/notes/platform-notes.md).
|
|
54
79
|
Exercised by the harness `Services` screen and
|
|
55
80
|
[`CameraDemo`](../demos/src/CameraDemo.tsrx).
|
|
81
|
+
|
|
82
|
+
On AppKit, invoke capture from a user action. The first Capture press authorizes
|
|
83
|
+
and starts the camera preview; the next takes the photo. Cancel or failure keeps
|
|
84
|
+
a null result. Add `NSCameraUsageDescription` to your macOS package's Info.plist.
|
|
85
|
+
The still-photo flow requests no microphone permission and rejects gallery saving
|
|
86
|
+
before permission or UI. Actual camera capture on AppKit remains unqualified;
|
|
87
|
+
deterministic sheet and lifecycle checks use an injected camera backend.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { media } from '@octane-xplat/media'
|
|
91
|
+
|
|
92
|
+
export async function onCapturePress() {
|
|
93
|
+
return media.capturePhoto({ width: 800, height: 600, saveToGallery: false })
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
[AppKit setup and ownership example](../../docs/platform/platform-services.md#appkit-still-photos).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/media",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Image picking and OS camera capture for Octane xplat — matching web + NativeScript implementations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -23,28 +23,47 @@
|
|
|
23
23
|
"directory": "packages/media"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"src"
|
|
26
|
+
"src",
|
|
27
|
+
"types",
|
|
28
|
+
"!src/**/*.test.*",
|
|
29
|
+
"!src/**/*.spec.*"
|
|
27
30
|
],
|
|
28
31
|
"type": "module",
|
|
29
32
|
"exports": {
|
|
30
33
|
".": {
|
|
31
|
-
"macos":
|
|
34
|
+
"macos": {
|
|
35
|
+
"types": "./types/generated/index.macos.d.ts",
|
|
36
|
+
"default": "./src/index.macos.ts"
|
|
37
|
+
},
|
|
38
|
+
"types": "./types/generated/index.d.ts",
|
|
32
39
|
"default": "./src/index.ts"
|
|
33
40
|
},
|
|
34
|
-
"./*":
|
|
41
|
+
"./*": {
|
|
42
|
+
"types": "./types/generated/*.d.ts",
|
|
43
|
+
"default": "./src/*"
|
|
44
|
+
},
|
|
45
|
+
"./*.ts": {
|
|
46
|
+
"types": "./types/generated/*.d.ts",
|
|
47
|
+
"default": "./src/*.ts"
|
|
48
|
+
}
|
|
35
49
|
},
|
|
36
50
|
"publishConfig": {
|
|
37
51
|
"access": "public"
|
|
38
52
|
},
|
|
39
53
|
"dependencies": {
|
|
40
54
|
"@nativescript/camera": "7.0.0",
|
|
41
|
-
"@nativescript/imagepicker": "5.0.0"
|
|
55
|
+
"@nativescript/imagepicker": "5.0.0",
|
|
56
|
+
"@octane-xplat/camera": "0.15.0",
|
|
57
|
+
"@octane-xplat/platform": "0.15.0"
|
|
42
58
|
},
|
|
43
59
|
"devDependencies": {
|
|
44
|
-
"@nativescript-community/octane": "0.2.
|
|
60
|
+
"@nativescript-community/octane": "0.2.5",
|
|
45
61
|
"@nativescript/core": "9.1.3",
|
|
46
62
|
"@nativescript/types": "9.1.4",
|
|
47
|
-
"
|
|
63
|
+
"@tsrx/typescript-plugin": "0.4.11",
|
|
64
|
+
"octane": "0.11.0",
|
|
65
|
+
"tsrx-typegen": "0.3.0",
|
|
66
|
+
"typescript": "5.9.3"
|
|
48
67
|
},
|
|
49
68
|
"peerDependencies": {
|
|
50
69
|
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
@@ -66,6 +85,10 @@
|
|
|
66
85
|
}
|
|
67
86
|
},
|
|
68
87
|
"scripts": {
|
|
69
|
-
"test": "node --test tests/*.test.mjs"
|
|
88
|
+
"test": "node --test tests/*.test.mjs",
|
|
89
|
+
"test:packed": "node tests/packed-consumer.mjs",
|
|
90
|
+
"test:macos-runtime": "node tests/macos-runtime.mjs",
|
|
91
|
+
"typegen": "tsrx-typegen --project tsconfig.types.json",
|
|
92
|
+
"pack:check": "tsrx-typegen --pack-check"
|
|
70
93
|
}
|
|
71
94
|
}
|
package/src/media.macos.ts
CHANGED
|
@@ -1,18 +1,127 @@
|
|
|
1
|
-
// Media — AppKit
|
|
2
|
-
|
|
3
|
-
import
|
|
1
|
+
// Media — AppKit file selection. Photos-library browsing is not requested.
|
|
2
|
+
import type { MediaImpl, PickedImage } from './types'
|
|
3
|
+
import { capturePhoto, ensureCamera } from './photo-capture.macos'
|
|
4
|
+
|
|
5
|
+
// __xplatTemporaryFiles is a registry shared with @octane-xplat/files: app
|
|
6
|
+
// registers only the temp outputs it minted, and files.release deletes them.
|
|
7
|
+
function temporaryImages(): Set<string> {
|
|
8
|
+
return ((globalThis as any).__xplatTemporaryFiles ??= new Set<string>())
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function removeOutput(uri: string): void {
|
|
12
|
+
const manager = (globalThis as any).NSFileManager.defaultManager
|
|
13
|
+
if (manager.fileExistsAtPath(uri) && !manager.removeItemAtPathError(uri, null)) {
|
|
14
|
+
throw new Error(`Could not remove temporary image: ${uri}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
temporaryImages().delete(uri)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function persistSelection(url: any, outputs: string[]): PickedImage {
|
|
21
|
+
const host = globalThis as any
|
|
22
|
+
const scoped = url.startAccessingSecurityScopedResource()
|
|
23
|
+
try {
|
|
24
|
+
const data = host.NSData.dataWithContentsOfURL(url)
|
|
25
|
+
const bitmap = data && host.NSBitmapImageRep.imageRepWithData(data)
|
|
26
|
+
if (!bitmap || !(bitmap.pixelsWide > 0 && bitmap.pixelsHigh > 0)) {
|
|
27
|
+
throw new Error('Could not decode the selected image')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Encode the original pixel dimensions, without point-size or screen-scale resizing.
|
|
31
|
+
const jpeg = bitmap.representationUsingTypeProperties(host.NSBitmapImageFileTypeJPEG ?? 3, {
|
|
32
|
+
[host.NSImageCompressionFactor]: 0.85,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
if (!jpeg || !Number(jpeg.length)) {
|
|
36
|
+
throw new Error('Could not convert the selected image to JPEG')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const manager = host.NSFileManager.defaultManager
|
|
40
|
+
const filename = `octane-image-${host.NSUUID.UUID().UUIDString}.jpg`
|
|
41
|
+
const uri = String(manager.temporaryDirectory.URLByAppendingPathComponent(filename).path)
|
|
42
|
+
if (manager.fileExistsAtPath(uri)) {
|
|
43
|
+
throw new Error('Temporary image path already exists')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Track ownership before writing, including a partially written output.
|
|
47
|
+
outputs.push(uri)
|
|
48
|
+
temporaryImages().add(uri)
|
|
49
|
+
if (!jpeg.writeToFileAtomically(uri, true)) {
|
|
50
|
+
throw new Error('Could not save the image to temporary storage')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Read back the app-owned file before relinquishing access to the source.
|
|
54
|
+
const readable = host.NSData.dataWithContentsOfFile(uri)
|
|
55
|
+
if (!readable?.isEqualToData(jpeg)) {
|
|
56
|
+
throw new Error('Could not read the saved temporary image')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
name: String(url.lastPathComponent ?? 'image.jpg'),
|
|
61
|
+
uri,
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
if (scoped) {
|
|
65
|
+
url.stopAccessingSecurityScopedResource()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pickSelections(multiple: boolean): PickedImage[] {
|
|
71
|
+
const host = globalThis as any
|
|
72
|
+
if (!host.NSOpenPanel?.openPanel) {
|
|
73
|
+
throw new Error('AppKit NSOpenPanel is unavailable in this macOS host')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const panel = host.NSOpenPanel.openPanel()
|
|
77
|
+
panel.canChooseFiles = true
|
|
78
|
+
panel.canChooseDirectories = false
|
|
79
|
+
panel.allowsMultipleSelection = multiple
|
|
80
|
+
panel.allowedFileTypes = host.NSBitmapImageRep.imageTypes
|
|
81
|
+
if (panel.runModal() !== (host.NSModalResponseOK ?? 1)) {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const outputs: string[] = []
|
|
86
|
+
try {
|
|
87
|
+
const refs: PickedImage[] = []
|
|
88
|
+
if (multiple) {
|
|
89
|
+
const selected = panel.URLs
|
|
90
|
+
for (let index = 0; index < Number(selected?.count ?? 0); index++) {
|
|
91
|
+
refs.push(persistSelection(selected.objectAtIndex(index), outputs))
|
|
92
|
+
}
|
|
93
|
+
} else if (panel.URL) {
|
|
94
|
+
refs.push(persistSelection(panel.URL, outputs))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return refs
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const failures: unknown[] = []
|
|
100
|
+
for (const uri of outputs) {
|
|
101
|
+
try {
|
|
102
|
+
removeOutput(uri)
|
|
103
|
+
} catch (cleanupError) {
|
|
104
|
+
failures.push(cleanupError)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (failures.length) {
|
|
109
|
+
throw new AggregateError([error, ...failures], 'Image selection and temporary cleanup failed')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw error
|
|
113
|
+
}
|
|
114
|
+
}
|
|
4
115
|
|
|
5
116
|
export const media: MediaImpl = {
|
|
6
|
-
async ensure() {
|
|
7
|
-
return 'unsupported'
|
|
117
|
+
async ensure(kind) {
|
|
118
|
+
return kind === 'camera' ? ensureCamera() : 'unsupported'
|
|
8
119
|
},
|
|
9
120
|
async pickImage() {
|
|
10
|
-
return null
|
|
121
|
+
return pickSelections(false)[0] ?? null
|
|
11
122
|
},
|
|
12
123
|
async pickImages() {
|
|
13
|
-
return
|
|
14
|
-
},
|
|
15
|
-
async capturePhoto() {
|
|
16
|
-
return null
|
|
124
|
+
return pickSelections(true)
|
|
17
125
|
},
|
|
126
|
+
capturePhoto,
|
|
18
127
|
}
|
package/src/media.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Media picking + camera capture —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Media picking + camera capture — returns a temporary-JPEG file ref (the
|
|
2
|
+
// same FileRef `files.readBytes`/`files.release` consume; upload paths read
|
|
3
|
+
// the file, never an inline data URL). Camera capture is stills only:
|
|
4
|
+
// @nativescript/camera presents the OS capture UI (UIImagePickerController
|
|
5
|
+
// on iOS, ACTION_IMAGE_CAPTURE on Android).
|
|
5
6
|
import { isAvailable as isCameraAvailable, takePicture } from '@nativescript/camera'
|
|
6
7
|
|
|
7
8
|
import { create as createImagePicker } from '@nativescript/imagepicker'
|
|
@@ -16,9 +17,18 @@ import type {
|
|
|
16
17
|
PickedImage,
|
|
17
18
|
} from './types'
|
|
18
19
|
|
|
20
|
+
import { android } from '@octane-xplat/platform/android'
|
|
21
|
+
|
|
19
22
|
async function ensurePhotos(): Promise<PermissionResult> {
|
|
23
|
+
let picker
|
|
24
|
+
try {
|
|
25
|
+
picker = createImagePicker({ mode: 'single', mediaType: 1 as ImagePickerMediaType })
|
|
26
|
+
} catch {
|
|
27
|
+
// Plugin missing or unusable — capability absence, not user denial.
|
|
28
|
+
return 'unsupported'
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
try {
|
|
21
|
-
const picker = createImagePicker({ mode: 'single', mediaType: 1 as ImagePickerMediaType })
|
|
22
32
|
const permission = (await picker.authorize()) as { authorized?: boolean } | boolean
|
|
23
33
|
return permission === true || (typeof permission === 'object' && permission.authorized === true)
|
|
24
34
|
? 'granted'
|
|
@@ -55,6 +65,11 @@ async function ensureCamera(): Promise<PermissionResult> {
|
|
|
55
65
|
return 'denied'
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
// Framework unlinked or metadata missing — nothing can be granted.
|
|
69
|
+
if (typeof av?.requestAccessForMediaTypeCompletionHandler !== 'function') {
|
|
70
|
+
return 'unsupported'
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
return await new Promise<PermissionResult>((resolve) => {
|
|
59
74
|
av.requestAccessForMediaTypeCompletionHandler(video, (granted: boolean) =>
|
|
60
75
|
resolve(granted ? 'granted' : 'denied'),
|
|
@@ -77,12 +92,13 @@ async function ensureCamera(): Promise<PermissionResult> {
|
|
|
77
92
|
|
|
78
93
|
return await new Promise<PermissionResult>((resolve) => {
|
|
79
94
|
const requestCode = cameraRequestCode++
|
|
95
|
+
const event = Application.android.activityRequestPermissionsEvent
|
|
80
96
|
const onResult = (args: any) => {
|
|
81
97
|
if (args.requestCode !== requestCode) {
|
|
82
98
|
return
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
Application.android.off(
|
|
101
|
+
Application.android.off(event, onResult)
|
|
86
102
|
|
|
87
103
|
resolve(
|
|
88
104
|
args.grantResults?.[0] === android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
@@ -91,9 +107,15 @@ async function ensureCamera(): Promise<PermissionResult> {
|
|
|
91
107
|
)
|
|
92
108
|
}
|
|
93
109
|
|
|
94
|
-
Application.android.on(
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
Application.android.on(event, onResult)
|
|
111
|
+
try {
|
|
112
|
+
activity.requestPermissions([perm], requestCode)
|
|
113
|
+
} catch {
|
|
114
|
+
// A finishing activity rejects the request synchronously —
|
|
115
|
+
// do not leak the global listener.
|
|
116
|
+
Application.android.off(event, onResult)
|
|
117
|
+
resolve('denied')
|
|
118
|
+
}
|
|
97
119
|
})
|
|
98
120
|
} catch (e) {
|
|
99
121
|
console.log('[media] ensureCamera error: ' + (e as Error)?.message)
|
|
@@ -110,8 +132,7 @@ async function persistImage(source: ImageSource, name: string): Promise<PickedIm
|
|
|
110
132
|
throw new Error('Could not save the image to temporary storage')
|
|
111
133
|
}
|
|
112
134
|
|
|
113
|
-
|
|
114
|
-
return { name, uri, dataUrl: `data:image/jpeg;base64,${base64}` }
|
|
135
|
+
return { name, uri }
|
|
115
136
|
} catch (error) {
|
|
116
137
|
// This operation owns this generated path, including a partial save.
|
|
117
138
|
try {
|
package/src/media.web.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Media picking — web leaf.
|
|
2
|
-
//
|
|
1
|
+
// Media picking — web leaf. The picked File gets a blob: object URL — a file
|
|
2
|
+
// handle that previews via `Image.src` and uploads via `files.readBytes`;
|
|
3
|
+
// no inline data URL is produced. The file input supports multiple photos.
|
|
3
4
|
import type {
|
|
4
5
|
CapturePhotoOptions,
|
|
5
6
|
MediaImpl,
|
|
@@ -18,37 +19,43 @@ function selectFiles(multiple: boolean, capture?: 'user' | 'environment'): Promi
|
|
|
18
19
|
input.capture = capture
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
22
|
+
let settled = false
|
|
23
|
+
const settle = (files: File[]) => {
|
|
24
|
+
if (settled) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
resolve(reader.result)
|
|
33
|
-
} else {
|
|
34
|
-
reject(new Error('Could not read the selected image'))
|
|
28
|
+
settled = true
|
|
29
|
+
input.onchange = null
|
|
30
|
+
input.oncancel = null
|
|
31
|
+
if (typeof window !== 'undefined') {
|
|
32
|
+
window.removeEventListener('focus', onFocus)
|
|
35
33
|
}
|
|
34
|
+
|
|
35
|
+
resolve(files)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// Browsers without input.oncancel still return focus to the page when
|
|
39
|
+
// the picker dismisses — wait a beat for a trailing change event.
|
|
40
|
+
const onFocus = () => {
|
|
41
|
+
setTimeout(() => settle(Array.from(input.files ?? [])), 350)
|
|
42
|
+
}
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
input.onchange = () => settle(Array.from(input.files ?? []))
|
|
45
|
+
input.oncancel = () => settle([])
|
|
46
|
+
if (typeof window !== 'undefined') {
|
|
47
|
+
window.addEventListener('focus', onFocus)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
input.click()
|
|
41
51
|
})
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
async function imageRefs(selected: File[]): Promise<PickedImage[]> {
|
|
45
|
-
// Finish every fallible read before allocating preview URLs. A failed batch
|
|
46
|
-
// cannot return its successful siblings, so those need no retained previews.
|
|
47
|
-
const dataUrls = await Promise.all(selected.map(readDataUrl))
|
|
48
55
|
const refs: PickedImage[] = []
|
|
49
56
|
try {
|
|
50
|
-
for (const
|
|
51
|
-
refs.push({ name: file.name, uri: URL.createObjectURL(file)
|
|
57
|
+
for (const file of selected) {
|
|
58
|
+
refs.push({ name: file.name, uri: URL.createObjectURL(file) })
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
return refs
|
|
@@ -89,29 +96,23 @@ export const media: MediaImpl = {
|
|
|
89
96
|
return null
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
// Non-cancel failures resolve null on every leaf — a shared caller
|
|
100
|
+
// never has to catch a read or persistence failure here.
|
|
101
|
+
try {
|
|
102
|
+
const [image] = await imageRefs([file])
|
|
103
|
+
return image ?? null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
94
107
|
},
|
|
95
108
|
async ensure(kind: MediaPermissionKind): Promise<PermissionResult> {
|
|
96
109
|
if (kind === 'photos') {
|
|
97
110
|
return typeof document !== 'undefined' ? 'granted' : 'unsupported'
|
|
98
111
|
}
|
|
99
112
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const status = await navigator.permissions.query({ name: 'camera' as PermissionName })
|
|
106
|
-
if (status.state === 'granted') {
|
|
107
|
-
return 'granted'
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return status.state === 'denied' ? 'denied' : 'unsupported'
|
|
111
|
-
} catch {
|
|
112
|
-
// Some browsers expose Permissions API but reject the camera descriptor.
|
|
113
|
-
return 'unsupported'
|
|
114
|
-
}
|
|
113
|
+
// The file-input capture path needs no camera permission — report the
|
|
114
|
+
// capability the leaf actually delivers, like the photos branch.
|
|
115
|
+
return typeof document !== 'undefined' ? 'granted' : 'unsupported'
|
|
115
116
|
},
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { CapturePhotoOptions, PermissionResult, PickedImage } from './types'
|
|
2
|
+
|
|
3
|
+
interface PhotoSheet {
|
|
4
|
+
permissionStatus(): string
|
|
5
|
+
presentReply(options: CapturePhotoOptions, reply: (result: string | null) => void): void
|
|
6
|
+
dispose(): void
|
|
7
|
+
discard(uri: string): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare const XplatPhotoCaptureSheet: { alloc(): { init(): PhotoSheet } }
|
|
11
|
+
|
|
12
|
+
let active: PhotoSheet | undefined
|
|
13
|
+
|
|
14
|
+
/** Query only. Still-photo authorization belongs to the sheet's Capture action. */
|
|
15
|
+
export async function ensureCamera(): Promise<PermissionResult> {
|
|
16
|
+
if (typeof XplatPhotoCaptureSheet === 'undefined') {
|
|
17
|
+
return 'unsupported'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sheet = XplatPhotoCaptureSheet.alloc().init()
|
|
21
|
+
try {
|
|
22
|
+
const status = String(sheet.permissionStatus())
|
|
23
|
+
return status === 'granted'
|
|
24
|
+
? 'granted'
|
|
25
|
+
: status === 'denied' || status === 'restricted'
|
|
26
|
+
? 'denied'
|
|
27
|
+
: 'unsupported'
|
|
28
|
+
} finally {
|
|
29
|
+
sheet.dispose()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Call from a user action. The sheet owns its preview until it settles. */
|
|
34
|
+
export async function capturePhoto(options: CapturePhotoOptions = {}): Promise<PickedImage | null> {
|
|
35
|
+
if (options.saveToGallery) {
|
|
36
|
+
throw new Error('unsupported: saveToGallery is unavailable for macOS still-photo capture')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (active || typeof XplatPhotoCaptureSheet === 'undefined') {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sheet = XplatPhotoCaptureSheet.alloc().init()
|
|
44
|
+
active = sheet
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false
|
|
47
|
+
let deliveredUri: string | undefined
|
|
48
|
+
const finish = (message: string | null) => {
|
|
49
|
+
let image: PickedImage | null = null
|
|
50
|
+
try {
|
|
51
|
+
if (message) {
|
|
52
|
+
const value = JSON.parse(message) as Partial<PickedImage> & { dataUrl?: unknown }
|
|
53
|
+
if (typeof value.name === 'string' && typeof value.uri === 'string') {
|
|
54
|
+
// The sheet's reply may still carry a dataUrl payload; the
|
|
55
|
+
// public ref is only the file handle.
|
|
56
|
+
image = { name: value.name, uri: value.uri }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// A malformed host result must not replace the current selection.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (settled) {
|
|
64
|
+
if (image && image.uri !== deliveredUri) {
|
|
65
|
+
sheet.discard(image.uri)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
settled = true
|
|
72
|
+
deliveredUri = image?.uri
|
|
73
|
+
if (image) {
|
|
74
|
+
// Shared with the AppKit file picker and files.release; never register
|
|
75
|
+
// a user-owned original or a rejected/late capture result. Only the
|
|
76
|
+
// host's own captures belong in the deletable registry — mirror the
|
|
77
|
+
// sheet's tempDir + octane-capture- guard on the reply's uri. The
|
|
78
|
+
// bridge does not expose standardizedFileURL, so compare against
|
|
79
|
+
// the same unstandardized temporaryDirectory the sheet writes to.
|
|
80
|
+
const manager = (globalThis as any).NSFileManager?.defaultManager
|
|
81
|
+
const tempDir = String(manager?.temporaryDirectory?.path ?? '')
|
|
82
|
+
const name = image.uri.slice(image.uri.lastIndexOf('/') + 1)
|
|
83
|
+
if (
|
|
84
|
+
tempDir !== '' &&
|
|
85
|
+
name.startsWith('octane-capture-') &&
|
|
86
|
+
image.uri === `${tempDir.replace(/\/+$/, '')}/${name}`
|
|
87
|
+
) {
|
|
88
|
+
const state = globalThis as typeof globalThis & {
|
|
89
|
+
__xplatTemporaryFiles?: Set<string>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
;(state.__xplatTemporaryFiles ??= new Set<string>()).add(image.uri)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
sheet.dispose()
|
|
98
|
+
} finally {
|
|
99
|
+
if (active === sheet) {
|
|
100
|
+
active = undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resolve(image)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
sheet.presentReply(options, finish)
|
|
109
|
+
} catch {
|
|
110
|
+
finish(null)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -19,13 +19,11 @@ export interface FileRef {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Result of `media.pickImage()`: a
|
|
23
|
-
* Native URIs point to temporary
|
|
24
|
-
* no longer needed.
|
|
22
|
+
* Result of `media.pickImage()`: a file ref usable directly as `Image.src`
|
|
23
|
+
* and with `files.readBytes()` for upload. Native URIs point to temporary
|
|
24
|
+
* JPEG files; `files.release()` them when the preview is no longer needed.
|
|
25
25
|
*/
|
|
26
|
-
export
|
|
27
|
-
dataUrl: string
|
|
28
|
-
}
|
|
26
|
+
export type PickedImage = FileRef
|
|
29
27
|
|
|
30
28
|
/**
|
|
31
29
|
* Options for `media.capturePhoto()`. Sizes are device-independent pixels;
|
|
@@ -47,10 +45,12 @@ export interface MediaImpl {
|
|
|
47
45
|
pickImage(): Promise<PickedImage | null>
|
|
48
46
|
pickImages(): Promise<PickedImage[]>
|
|
49
47
|
/**
|
|
50
|
-
* Still-image capture through the OS camera UI. Resolves null when the
|
|
51
|
-
* shot is canceled, permission is denied,
|
|
52
|
-
*
|
|
53
|
-
*
|
|
48
|
+
* Still-image capture through the OS camera UI (app-owned sheet on AppKit). Resolves null when the
|
|
49
|
+
* shot is canceled, permission is denied, no camera exists, or the result cannot be
|
|
50
|
+
* read or persisted — call
|
|
51
|
+
* `ensure('camera')` from a user action to check permission separately.
|
|
52
|
+
* AppKit rejects saveToGallery:true before permission or UI; it never writes
|
|
53
|
+
* to Photos or requests microphone access. Use @octane-xplat/camera for movies.
|
|
54
54
|
*/
|
|
55
55
|
capturePhoto(options?: CapturePhotoOptions): Promise<PickedImage | null>
|
|
56
56
|
ensure(kind: MediaPermissionKind): Promise<PermissionResult>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CapturePhotoOptions, PermissionResult, PickedImage } from './types.js';
|
|
2
|
+
/** Query only. Still-photo authorization belongs to the sheet's Capture action. */
|
|
3
|
+
export declare function ensureCamera(): Promise<PermissionResult>;
|
|
4
|
+
/** Call from a user action. The sheet owns its preview until it settles. */
|
|
5
|
+
export declare function capturePhoto(options?: CapturePhotoOptions): Promise<PickedImage | null>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Optional capability — never throws for absence (docs/platform/platform-services.md). */
|
|
2
|
+
export interface Capability<T> {
|
|
3
|
+
supported: boolean;
|
|
4
|
+
ensure(): Promise<'granted' | 'denied' | 'unsupported'>;
|
|
5
|
+
/** usable iff supported && ensured */
|
|
6
|
+
impl: T | null;
|
|
7
|
+
}
|
|
8
|
+
export type PermissionResult = 'granted' | 'denied' | 'unsupported';
|
|
9
|
+
export type MediaPermissionKind = 'camera' | 'photos';
|
|
10
|
+
export interface FileRef {
|
|
11
|
+
name: string;
|
|
12
|
+
/**
|
|
13
|
+
* Opaque reference: blob/object URL on web, filesystem path on native.
|
|
14
|
+
*/
|
|
15
|
+
uri: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Result of `media.pickImage()`: a file ref usable directly as `Image.src`
|
|
19
|
+
* and with `files.readBytes()` for upload. Native URIs point to temporary
|
|
20
|
+
* JPEG files; `files.release()` them when the preview is no longer needed.
|
|
21
|
+
*/
|
|
22
|
+
export type PickedImage = FileRef;
|
|
23
|
+
/**
|
|
24
|
+
* Options for `media.capturePhoto()`. Sizes are device-independent pixels;
|
|
25
|
+
* the delivered image may be larger on high-density screens and may differ
|
|
26
|
+
* from the request when `keepAspectRatio` applies.
|
|
27
|
+
*/
|
|
28
|
+
export interface CapturePhotoOptions {
|
|
29
|
+
width?: number;
|
|
30
|
+
height?: number;
|
|
31
|
+
/** Preserve the sensor aspect ratio when resizing to width/height. Default true. */
|
|
32
|
+
keepAspectRatio?: boolean;
|
|
33
|
+
/** Also write the shot to the OS photo library. Default false. */
|
|
34
|
+
saveToGallery?: boolean;
|
|
35
|
+
/** Preferred lens. Default 'rear'; Android devices may ignore the hint. */
|
|
36
|
+
cameraFacing?: 'front' | 'rear';
|
|
37
|
+
}
|
|
38
|
+
export interface MediaImpl {
|
|
39
|
+
pickImage(): Promise<PickedImage | null>;
|
|
40
|
+
pickImages(): Promise<PickedImage[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Still-image capture through the OS camera UI (app-owned sheet on AppKit). Resolves null when the
|
|
43
|
+
* shot is canceled, permission is denied, no camera exists, or the result cannot be
|
|
44
|
+
* read or persisted — call
|
|
45
|
+
* `ensure('camera')` from a user action to check permission separately.
|
|
46
|
+
* AppKit rejects saveToGallery:true before permission or UI; it never writes
|
|
47
|
+
* to Photos or requests microphone access. Use @octane-xplat/camera for movies.
|
|
48
|
+
*/
|
|
49
|
+
capturePhoto(options?: CapturePhotoOptions): Promise<PickedImage | null>;
|
|
50
|
+
ensure(kind: MediaPermissionKind): Promise<PermissionResult>;
|
|
51
|
+
}
|