diff --git a/app/native/README.md b/app/native/README.md index 4c0d0c3..7f12907 100644 --- a/app/native/README.md +++ b/app/native/README.md @@ -2,20 +2,17 @@ Lynx ships no camera element or module. Capture is a **custom native module** the host app registers. The boundary is defined in `src/native/camera.ts`, -which delegates to the `@kealanau/lynx-camera` package (developed at -`~/Documents/Code/lynx-camera`). +which delegates to the `@vyui/camera` package (developed at +`~/Documents/Code/chimera-camera`). ## The JS ⇄ native contract -The host compiles the package's own module — -`node_modules/@kealanau/lynx-camera/ios/LynxCameraModule.swift` — which -provides system-camera capture plus permissions, device enumeration, and the -version-checked install status (`getCameraInstallStatus()`), shown in the -camera screen when capture is unavailable. See -`node_modules/@kealanau/lynx-camera/docs/ios-install.md`. (The package also -accepts a legacy `CameraModule.capture()` shape, deprecated and slated for -removal before its `0.2.0`; the legacy host files that implemented it were -deleted from this repo.) +The host compiles the package's own natives — +`node_modules/@vyui/camera/ios/` — which provide system-camera capture, +the `` live preview element, plus permissions, device enumeration, +and the version-checked install status (`getCameraInstallStatus()`), shown in +the camera screen when capture is unavailable. See +`node_modules/@vyui/camera/INSTALLATION.md`. `src/native/camera.ts` decodes `base64` to an `ArrayBuffer` and hands the rest of the app a plain `CapturedPhoto`. Nothing above the boundary sees `NativeModules`. @@ -25,8 +22,10 @@ the app a plain `CapturedPhoto`. Nothing above the boundary sees `NativeModules` **iOS** — at LynxView bootstrap (see `ios-host/HostSources/ViewController.swift`): ```swift let config = LynxConfig(provider: templateProvider) -config.register(LynxCameraModule.self) +config.register(ChimeraCameraModule.self) ``` +(Hosts that use Lynx's global config can call `ChimeraCamera.register()` +instead; this one builds its own per-view config, which that helper misses.) **Android** — no host exists yet; when one does, compile the package's Android module and register it at startup, and add `` + runtime @@ -52,15 +51,15 @@ The font's family/full/postscript names are `Comico`, `Comico Regular`, and ## Two levels of camera -1. **Capture a photo (implemented here).** System camera → JPEG → the app uploads - it via `usePhotoUpload`. This is all Picknic needs: the film look is applied - server-side at reveal (`api/FilmProcessing`), so no on-device filtering. +1. **Live in-app preview + shutter (the default).** `` — the + package's LynxUI element wrapping `AVCaptureVideoPreviewLayer` (iOS) / + CameraX `PreviewView` (Android). It self-registers when the natives are + compiled in; `captureFromView()` fires its shutter. -2. **Live in-app preview + shutter (not here).** The preview is a native surface, - so it can't be a ``. Register a **custom LynxUI element** wrapping - `AVCaptureVideoPreviewLayer` (iOS) / CameraX `PreviewView` (Android) to get a - `` element in the layout tree. Only needed if you want the - shutter inside the app rather than the system camera UI. +2. **System camera sheet (fallback).** `capturePhoto()` → JPEG → the app uploads + it via `usePhotoUpload`. Kept for the Live toggle and hosts without the + element. Either way the film look is applied server-side at reveal + (`api/FilmProcessing`), so no on-device filtering. ## Why filters aren't here diff --git a/app/native/ios-host/HostSources/ViewController.swift b/app/native/ios-host/HostSources/ViewController.swift index 705ad55..72747d5 100644 --- a/app/native/ios-host/HostSources/ViewController.swift +++ b/app/native/ios-host/HostSources/ViewController.swift @@ -35,9 +35,11 @@ class ViewController: UIViewController { let lynxView = LynxView { builder in let config = LynxConfig(provider: PicknicTemplateProvider()) - // Registers as "CameraModule" on the JS side; LynxCameraView.m + // Registers as "CameraModule" on the JS side. This host builds its + // own per-view config, so ChimeraCamera.register() (which targets + // the global one) wouldn't reach it. ChimeraCameraView.m // self-registers when compiled into the target. - config.register(LynxCameraModule.self) + config.register(ChimeraCameraModule.self) // Persistent KV storage (auth tokens, guest session, intro flag). config.register(NativeKVModule.self) builder.config = config diff --git a/app/native/ios-host/Podfile b/app/native/ios-host/Podfile index d08a388..1985033 100644 --- a/app/native/ios-host/Podfile +++ b/app/native/ios-host/Podfile @@ -2,9 +2,9 @@ # (see README.md step 2) and run `pod install`; open the .xcworkspace after. source 'https://cdn.cocoapods.org/' -platform :ios, '13.0' +platform :ios, '15.0' -# LynxCameraModule.swift does `import Lynx`, so the pods must build as +# ChimeraCameraModule.swift does `import Lynx`, so the pods must build as # Swift-importable modules. If pod install/build fails on "non-modular # header" errors, see README.md "If modular headers fail". use_modular_headers! @@ -32,7 +32,7 @@ post_install do |installer| # Lynx pods trip warnings on recent Xcode; don't let them fail the build. config.build_settings['GCC_TREAT_WARNINGS_AS_ERRORS'] = 'NO' config.build_settings['SWIFT_TREAT_WARNINGS_AS_ERRORS'] = 'NO' - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' end # The Lynx podspec sets -Werror as per-file compiler flags, which fails on # Xcode 26 over iOS-13-era deprecations inside Lynx itself. Neutralize it. diff --git a/app/native/ios-host/Podfile.lock b/app/native/ios-host/Podfile.lock index bfce738..16c709e 100644 --- a/app/native/ios-host/Podfile.lock +++ b/app/native/ios-host/Podfile.lock @@ -153,6 +153,6 @@ SPEC CHECKSUMS: ServalSVG: 6990830fdd5c1e7d545fc8c842a98bea6007dd86 XElement: 49b95dcd304c4a3bd4af335962c45a65173033b3 -PODFILE CHECKSUM: 57c5218801ebde9f848749b5240d322f10b2f72a +PODFILE CHECKSUM: a78528facd848328a6ee06dcdcf8043da4fb4847 COCOAPODS: 1.17.0 diff --git a/app/native/ios-host/README.md b/app/native/ios-host/README.md index e435d5b..b9053cb 100644 --- a/app/native/ios-host/README.md +++ b/app/native/ios-host/README.md @@ -1,14 +1,14 @@ # PicknicHost — iOS dev shell A minimal iOS app that embeds the Lynx runtime, loads the Picknic bundle from -the rspeedy dev server, and compiles in the `@kealanau/lynx-camera` natives so +the rspeedy dev server, and compiles in the `@vyui/camera` natives so the real capture flow works on a physical iPhone (Lynx Go can't compile native modules — this host is how M1/M2 get tested on device). The project is generated by XcodeGen from `project.yml` — no Xcode-GUI setup. -The camera natives are compiled straight from the lynx-camera package repo -(`../../../../lynx-camera/ios`, a sibling checkout of this repo), so compile -fixes land at the source of truth. Both natives build clean against +The camera natives are compiled from the installed package +(`../../node_modules/@vyui/camera/ios`), which is all the package's +CocoaPods podspec does anyway. Both natives build clean against Lynx 3.9.0 / Xcode 26 (verified against the iOS Simulator SDK). ## Prereqs (once) diff --git a/app/native/ios-host/project.yml b/app/native/ios-host/project.yml index 2f3a498..9c7e124 100644 --- a/app/native/ios-host/project.yml +++ b/app/native/ios-host/project.yml @@ -4,7 +4,7 @@ name: PicknicHost options: bundleIdPrefix: app.picknic deploymentTarget: - iOS: '13.0' + iOS: '15.0' createIntermediateGroups: true schemes: PicknicHost: @@ -19,16 +19,17 @@ targets: platform: iOS sources: - path: HostSources - # The lynx-camera package's native sources, referenced straight from the - # package repo (sibling checkout of picknic in ~/Documents/Code) so - # compile fixes land at the source of truth. Swap to - # ../../node_modules/@kealanau/lynx-camera/ios once the package is - # consumed from npm instead of a local tarball. - - path: ../../../../lynx-camera/ios - name: LynxCamera + # @vyui/camera's native sources, compiled from the installed + # package (what the pod would add anyway — see its INSTALLATION.md). + - path: ../../node_modules/@vyui/camera/ios + name: ChimeraCamera excludes: - 'README.md' + # group: pinned — with createIntermediateGroups XcodeGen otherwise nests + # this under the ../.. tree it builds for node_modules and doubles the + # path (app/native/app/native/ios/...), so the build can't find the file. - path: ../ios/Resources/Fonts/Comico-Regular.ttf + group: Fonts buildPhase: resources - path: Assets.xcassets info: @@ -133,7 +134,7 @@ targets: settings: base: SWIFT_VERSION: '5.9' - IPHONEOS_DEPLOYMENT_TARGET: '13.0' + IPHONEOS_DEPLOYMENT_TARGET: '15.0' ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # The Embed Lynx bundle phase runs pnpm build in the repo tree. ENABLE_USER_SCRIPT_SANDBOXING: 'NO' diff --git a/app/package.json b/app/package.json index 8a4f4a7..7860709 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,7 @@ "build": "rspeedy build" }, "dependencies": { - "@kealanau/lynx-camera": "file:vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz", + "@vyui/camera": "^0.0.2", "@vyui/core": "^0.2.4", "@vyui/kit": "^0.3.4", "vue": "^3.5.40", diff --git a/app/src/native/camera.ts b/app/src/native/camera.ts index 1fe44f5..4d76b25 100644 --- a/app/src/native/camera.ts +++ b/app/src/native/camera.ts @@ -1,22 +1,20 @@ -// Native camera boundary — delegates to @kealanau/lynx-camera, the only -// place that touches NativeModules.CameraModule. The adapter understands -// both this app's legacy host module (`capture`, see app/native/ios & -// app/native/android) and the package's newer module (`capturePhoto` etc.), -// and it's absent in Explorer / Lynx Go / web preview. -// Callers must check isCameraAvailable() and degrade. +// Native camera boundary — delegates to @vyui/camera, the only place +// that touches NativeModules.CameraModule. The module is absent in Explorer / +// Lynx Go / web preview, so callers must check isCameraAvailable() and degrade. // Static import (not dynamic): lazy chunks load through Lynx's own chunk // loader, which is unreliable in the web preview. import { - createCameraAdapter, + createCameraModule, getCameraInstallStatus, + getNativeCameraModule, invokeCameraViewMethod, - type CameraAdapter, type CameraInstallStatus, + type CameraModuleClient, type CapturePhotoOptions, type PhotoFile, -} from '@kealanau/lynx-camera'; -import { createMockCameraModule } from '@kealanau/lynx-camera/mock'; +} from '@vyui/camera'; +import { createMockCameraModule } from '@vyui/camera/mock'; import { DEV_SAMPLE_JPEG_BASE64 } from './devSamplePhoto'; export interface CaptureOptions { @@ -31,17 +29,17 @@ export interface CapturedPhoto { mime: string; } -let nativeAdapter: CameraAdapter | null = null; -let devMockAdapter: CameraAdapter | null = null; +let nativeAdapter: CameraModuleClient | null = null; +let devMockAdapter: CameraModuleClient | null = null; // Resolved lazily and re-probed until found: some hosts register // NativeModules after the JS bundle evaluates, so probing once at module // eval would latch "unavailable" for the whole session. Only a real native // adapter is cached; the DEV mock is a per-call fallback so a late-arriving // native module still wins. -function getAdapter(): CameraAdapter | null { +function getAdapter(): CameraModuleClient | null { if (!nativeAdapter) { - nativeAdapter = createCameraAdapter({ optional: true }); + nativeAdapter = createCameraModule({ optional: true }); } if (nativeAdapter) return nativeAdapter; @@ -85,7 +83,9 @@ export async function capturePhoto(options: CaptureOptions = {}): Promise, cb: (r: unknown) => void) => void } } - | undefined; - -function nativePickPhoto() { - try { - if (typeof NativeModules === 'undefined') return null; - const mod = NativeModules?.CameraModule; - return mod && typeof mod.pickPhoto === 'function' ? mod : null; - } catch { - return null; - } -} - -// Old host builds lack pickPhoto; hide the library button when this is false. +// pickPhoto is optional in the native contract — host builds that predate it +// still register a valid module, so hide the library button when it's absent. +// No native module at all means the DEV mock, which does implement it. export function isLibraryPickAvailable(): boolean { - return nativePickPhoto() !== null; -} - -interface PickResult { - base64?: string; - width?: number; - height?: number; - mime?: string; - error?: { code?: string; message?: string } | string; + if (!getAdapter()) return false; + const native = getNativeCameraModule<{ pickPhoto?: unknown }>(); + return !native || typeof native.pickPhoto === 'function'; } export async function pickFromLibrary(options: CaptureOptions = {}): Promise { - const mod = nativePickPhoto(); - if (!mod) throw new Error('The photo library is not available in this runtime.'); - - const result = await new Promise((resolve) => { - mod.pickPhoto!({ quality: options.quality ?? 0.9 }, (r) => resolve((r ?? {}) as PickResult)); - }); + const adapter = getAdapter(); + if (!adapter) throw new Error('The photo library is not available in this runtime.'); - if (result.error) { - const err = result.error; - const code = typeof err === 'string' ? '' : (err.code ?? ''); - const message = typeof err === 'string' ? err : (err.message ?? 'Could not load that photo.'); - if (/cancel/i.test(code + message)) throw new CameraCancelled(); - throw new Error(message); + let photo; + try { + photo = await adapter.pickPhoto({ quality: options.quality ?? 0.9, includeBase64: true }); + } catch (e) { + if (isCancel(e)) throw new CameraCancelled(); + throw e instanceof Error ? e : new Error(String(e)); } - if (!result.base64) throw new Error('Camera returned no image data.'); + if (!photo.base64) throw new Error('Camera returned no image data.'); return { - bytes: base64ToArrayBuffer(result.base64), - width: result.width ?? 0, - height: result.height ?? 0, - mime: result.mime ?? 'image/jpeg', + bytes: base64ToArrayBuffer(photo.base64), + width: photo.width ?? 0, + height: photo.height ?? 0, + mime: photo.mime ?? 'image/jpeg', }; } +// Native rejects carry a ChimeraCameraError `code` (capture/cancelled, +// library/cancelled); older hosts only say it in the message. +function isCancel(e: unknown): boolean { + const code = (e as { code?: unknown })?.code; + const message = e instanceof Error ? e.message : String(e); + return /cancel/i.test(`${typeof code === 'string' ? code : ''} ${message}`); +} + export function toDataUri(photo: CapturedPhoto): string { return `data:${photo.mime};base64,${arrayBufferToBase64(photo.bytes)}`; } diff --git a/app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz b/app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz deleted file mode 100644 index fbc8271..0000000 Binary files a/app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz and /dev/null differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6060ea5..47335d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,9 +10,9 @@ importers: app: dependencies: - '@kealanau/lynx-camera': - specifier: file:vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz - version: file:app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz + '@vyui/camera': + specifier: ^0.0.2 + version: 0.0.2 '@vyui/core': specifier: ^0.2.4 version: 0.2.4(@lynx-js/react@0.116.5(@types/react@18.3.31))(vue-lynx@0.5.1(@rsbuild/core@1.7.6)(@rsbuild/plugin-vue@1.2.9(@rsbuild/core@1.7.6)(@rspack/core@1.7.12(@swc/helpers@0.5.23))(vue@3.5.40(typescript@5.9.3)))(@types/react@18.3.31)(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16)))(vue@3.5.40(typescript@5.9.3)) @@ -129,10 +129,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kealanau/lynx-camera@file:app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz': - resolution: {integrity: sha512-+EPtlnQGkMjSqkpd+aZM0BwjlgUeNj/L9IcmquCYh1jGBMI3vCB81KuvlhGZD8RLhjpSVQb4/Kr/Pie41RbU4w==, tarball: file:app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz} - version: 0.1.0-alpha.0 - '@lynx-js/cache-events-webpack-plugin@0.0.3': resolution: {integrity: sha512-ijKI7zlBgZYo4Hbai3BpsNkKmetNrV8VqbVbFCwlX70+DET2jvxm5Lngzqo4yteHNAFOA58XoMa81bQKFTX7WQ==} engines: {node: '>=18'} @@ -561,6 +557,9 @@ packages: peerDependencies: vue: ^3.5.0 + '@vyui/camera@0.0.2': + resolution: {integrity: sha512-Etyl4PQtc+g5kprFqGpbdBzEMXtLUUJhohhh7a/EPxJnJuIjXN7pcNMpW7rVMhmE0Qi2ivsACcO5zdrtWeilLQ==} + '@vyui/core@0.2.4': resolution: {integrity: sha512-yF95WldpeKEUEIYhdD1GauGvGaJNEk60Kd180fF//0LOvpa1V8gZHYshvRytV5qTyl1VBzhz5pMwAOZ325Qf0w==} peerDependencies: @@ -2204,8 +2203,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kealanau/lynx-camera@file:app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz': {} - '@lynx-js/cache-events-webpack-plugin@0.0.3': dependencies: '@lynx-js/webpack-runtime-globals': 0.0.6 @@ -2751,6 +2748,8 @@ snapshots: dependencies: vue: 3.5.40(typescript@5.9.3) + '@vyui/camera@0.0.2': {} + '@vyui/core@0.2.4(@lynx-js/react@0.116.5(@types/react@18.3.31))(vue-lynx@0.5.1(@rsbuild/core@1.7.6)(@rsbuild/plugin-vue@1.2.9(@rsbuild/core@1.7.6)(@rspack/core@1.7.12(@swc/helpers@0.5.23))(vue@3.5.40(typescript@5.9.3)))(@types/react@18.3.31)(tslib@2.8.1)(webpack@5.108.3(postcss@8.5.16)))(vue@3.5.40(typescript@5.9.3))': dependencies: '@iconify/types': 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a0055eb..fe86903 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,5 +8,4 @@ allowBuilds: minimumReleaseAgeExclude: - '@vyui/core' - '@vyui/kit' - # Local tarball in app/vendor, not on the npm registry — age can't be checked. - - '@kealanau/lynx-camera' + - '@vyui/camera'