Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 19 additions & 20 deletions app/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<camera-view>` 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`.
Expand All @@ -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 `<uses-feature camera>` + runtime
Expand All @@ -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).** `<camera-view>` — 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 `<view>`. Register a **custom LynxUI element** wrapping
`AVCaptureVideoPreviewLayer` (iOS) / CameraX `PreviewView` (Android) to get a
`<camera-preview>` 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

Expand Down
6 changes: 4 additions & 2 deletions app/native/ios-host/HostSources/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <camera-view> 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
Expand Down
6 changes: 3 additions & 3 deletions app/native/ios-host/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion app/native/ios-host/Podfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions app/native/ios-host/README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
19 changes: 10 additions & 9 deletions app/native/ios-host/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: PicknicHost
options:
bundleIdPrefix: app.picknic
deploymentTarget:
iOS: '13.0'
iOS: '15.0'
createIntermediateGroups: true
schemes:
PicknicHost:
Expand All @@ -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:
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 44 additions & 62 deletions app/src/native/camera.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -85,17 +83,18 @@ export async function capturePhoto(options: CaptureOptions = {}): Promise<Captur
const adapter = getAdapter();
if (!adapter) throw new Error('Camera is not available in this runtime.');

const captureOptions: CapturePhotoOptions = {};
// includeBase64 is off by default in the package; putBlob needs the bytes
// in JS, and JS can't read the returned temp-file path.
const captureOptions: CapturePhotoOptions = { includeBase64: true };
if (options.quality !== undefined) captureOptions.quality = options.quality;
if (options.facing !== undefined) captureOptions.facing = options.facing;

let photo;
try {
photo = await adapter.capturePhoto(captureOptions);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (/cancel/i.test(message)) throw new CameraCancelled();
throw e instanceof Error ? e : new Error(message);
if (isCancel(e)) throw new CameraCancelled();
throw e instanceof Error ? e : new Error(String(e));
}

if (!photo.base64) throw new Error('Camera returned no image data.');
Expand Down Expand Up @@ -126,61 +125,44 @@ export async function captureFromView(selector: string, options: CaptureOptions
};
}

// Library pick goes through NativeModules directly: the installed package
// tarball predates adapter.pickPhoto.
// ponytail: switch to adapter.pickPhoto at the next tarball repack.
declare const NativeModules:
| { CameraModule?: { pickPhoto?: (opts: Record<string, unknown>, 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<CapturedPhoto> {
const mod = nativePickPhoto();
if (!mod) throw new Error('The photo library is not available in this runtime.');

const result = await new Promise<PickResult>((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)}`;
}
Expand Down
Binary file removed app/vendor/kealanau-lynx-camera-0.1.0-alpha.0.tgz
Binary file not shown.
17 changes: 8 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading