@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 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); the macOS AppKit dev host reports
12
- `unsupported`.
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
- plus a `dataUrl` you can hand straight to `Image src`. Cancellation
24
- resolves `null` (or `[]` for `pickImages`), so check the result rather
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<string | null>(null)
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) setPreview(photo.dataUrl)
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 onPress={pickPhoto}>Choose photo</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](../../docs/notes/platform-notes.md).
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.14.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": "./src/index.macos.ts",
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
- "./*": "./src/*"
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.4",
60
+ "@nativescript-community/octane": "0.2.5",
45
61
  "@nativescript/core": "9.1.3",
46
62
  "@nativescript/types": "9.1.4",
47
- "octane": "0.11.0"
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
  }
@@ -1,18 +1,127 @@
1
- // Media — AppKit host leaf. No picker or capture flow is wired on the
2
- // desktop dev host.
3
- import type { MediaImpl } from './types'
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 — store a temporary JPEG for NativeScript
2
- // Image and return a data URL for APIs that persist image payloads. Camera
3
- // capture is stills only: @nativescript/camera presents the OS capture UI
4
- // (UIImagePickerController on iOS, ACTION_IMAGE_CAPTURE on Android).
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(Application.android.activityRequestPermissionsEvent, onResult)
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(Application.android.activityRequestPermissionsEvent, onResult)
95
-
96
- activity.requestPermissions([perm], requestCode)
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
- const base64 = await source.toBase64StringAsync('jpeg', 85)
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. Keep an object URL for previews and a data URL for
2
- // APIs that persist image payloads. The file input supports multiple photos.
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
- input.onchange = () => resolve(Array.from(input.files ?? []))
22
- input.oncancel = () => resolve([])
23
- input.click()
24
- })
25
- }
22
+ let settled = false
23
+ const settle = (files: File[]) => {
24
+ if (settled) {
25
+ return
26
+ }
26
27
 
27
- function readDataUrl(file: File): Promise<string> {
28
- return new Promise((resolve, reject) => {
29
- const reader = new FileReader()
30
- reader.onload = () => {
31
- if (typeof reader.result === 'string') {
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
- reader.onerror = () => reject(reader.error ?? new Error('Could not read the selected image'))
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
- reader.readAsDataURL(file)
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 [index, file] of selected.entries()) {
51
- refs.push({ name: file.name, uri: URL.createObjectURL(file), dataUrl: dataUrls[index]! })
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
- const [image] = await imageRefs([file])
93
- return image ?? null
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
- if (!navigator.permissions?.query) {
101
- return 'unsupported'
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 displayable URI and an upload data URL.
23
- * Native URIs point to temporary JPEG files; delete them when the preview is
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 interface PickedImage extends FileRef {
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, or no camera exists — call
52
- * `ensure('camera')` first to tell those apart. Video capture is not part
53
- * of the contract: no maintained NativeScript substrate exists.
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,12 @@
1
+ {
2
+ "version": 1,
3
+ "files": [
4
+ "index.d.ts",
5
+ "index.macos.d.ts",
6
+ "media.d.ts",
7
+ "media.macos.d.ts",
8
+ "media.web.d.ts",
9
+ "photo-capture.macos.d.ts",
10
+ "types.d.ts"
11
+ ]
12
+ }
@@ -0,0 +1,2 @@
1
+ export * from './types.js';
2
+ export { media } from './media.js';
@@ -0,0 +1,2 @@
1
+ export * from './types.js';
2
+ export { media } from './media.macos.js';
@@ -0,0 +1,2 @@
1
+ import type { MediaImpl } from './types.js';
2
+ export declare const media: MediaImpl;
@@ -0,0 +1,2 @@
1
+ import type { MediaImpl } from './types.js';
2
+ export declare const media: MediaImpl;
@@ -0,0 +1,2 @@
1
+ import type { MediaImpl } from './types.js';
2
+ export declare const media: MediaImpl;
@@ -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
+ }