diff --git a/CHANGELOG.md b/CHANGELOG.md index faad266..8243ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,34 @@ launch). ## [Unreleased] -No unreleased changes yet. +### Added + +- Self-contained recipe URL share links that import and select a recipe from + `?share=...#library`, excluding structured reasoning from the shared payload. +- Recipe genealogy display in the detail metadata for recipes derived from a + parent recipe. +- macOS WebUSB camera release runbook covering `ptpcamerad`, `icdd`, stale + Chromium WebUSB sessions, clean-profile recovery, restore commands, and + verification steps. + +### Fixed + +- macOS camera claim-collision recovery now points at both macOS camera daemons + and stale browser sessions instead of only Image Capture. +- macOS setup retry now reopens the WebUSB picker instead of silently reusing a + stale paired device. +- macOS setup copy now includes a camera power-cycle step, and advanced setup + suspends live camera daemons because `launchctl disable` can leave existing + processes running. +- Optional local macOS camera helper can expose daemon status plus one-click + release/restore actions to the setup wizard during hardware QA. +- When macOS camera services are left disabled/suspended, a compact restore + control remains available outside the setup wizard. +- Failed WebUSB connect attempts now clean up partially opened transports or raw + USB devices. +- Camera slot reads now emit partial results and slot-level failures, with a + timeout for stuck slots instead of leaving the UI indefinitely in the scanning + state. ## [0.1.0] - 2026-05-05 diff --git a/PROGRESS.md b/PROGRESS.md index d57b0f4..9f5d4c7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,10 +1,109 @@ # Progress -> **Note:** This project was named *FilmFork* through 2026-05-04. References +> **Note:** This project was named _FilmFork_ through 2026-05-04. References > to "FilmFork" or `@filmfork/*` in entries below describe the project's > prior name; the current name is **Latent** and packages are `@latent/*`. > See `CHANGELOG.md` for the rename entry. +## 2026-05-06 — Phase 6 polish pass + +Phase 6 moved forward from general polish into concrete recipe portability +and genealogy features. + +- Added self-contained recipe URL share via `?share=...#library`. Opening a + shared URL imports and selects the shared recipe locally. +- URL share excludes structured `reasoning` by default, preserving the V1 + privacy rule that reasoning stays out of share links unless explicitly + exported elsewhere. +- Added recipe genealogy display in the detail metadata. Creator duplicates + already preserve `parentRecipeId`; the UI now shows the parent recipe name + when it is available locally, or a shortened parent id when it is not. +- Added targeted tests for share encode/decode, URL import, share-link copy, + and parent metadata display. +- Added a Phase 6 polish plan tracking remaining WCAG 2.2 AA and production + CSP validation. + +Remaining Phase 6 work: + +- Run the WCAG 2.2 AA audit on a production-like build. +- Validate final CSP headers once Phase 7 chooses the portal hosting target. +- Smoke test URL share on the deployed portal. + +## 2026-05-06 — X-S20 macOS WebUSB release fix + +Hardware validation found a macOS/WebUSB claim-collision path where the camera +appeared in the browser picker but Latent could not claim the PTP interface. +The original recovery path was too narrow: it implied Image Capture alone, +recommended only `killall ptpcamerad`, and could reuse a stale paired WebUSB +device after the setup flow. + +- Confirmed the effective release path on X-S20 FW 3.30: handle both + `ptpcamerad` and `icdd`, reopen the WebUSB picker, and use a clean Chrome + profile when the normal profile holds stale WebUSB state. +- Reproduced the claim bug after re-enabling `ptpcamerad` and `icdd`. + `launchctl disable` marked the services disabled but left live processes + running; suspending the live daemons plus a full camera power-cycle/battery + reseat cleared the stale PTP session. +- Added an optional localhost macOS camera helper for hardware QA. When started + with `npm run macos-camera-helper`, the setup wizard can read daemon status + and run release/restore actions from the web UI. +- Moved the post-release restore affordance into a compact portal so users can + close the setup panel without losing the ability to restore macOS services. +- Updated the app-side macOS wizard and copy to cover macOS services and stale + browser sessions instead of blaming only Image Capture. +- Updated the connection manager so macOS setup retries force a picker reopen + with `autoSelectPaired: false`. +- Hardened failed WebUSB connection cleanup so partially opened transports/raw + devices are closed. +- Hardened preset reads so the UI receives partial results, slot-level + failures, and a timeout for stuck reads instead of staying indefinitely on + "Reading custom slots from the camera." +- Documented the incident, commands, restore path, app boundary, and test + checklist in + [`docs/qa/macos-webusb-camera-release.md`](./docs/qa/macos-webusb-camera-release.md). +- Follow-up: an X-T20 can now reach the connected/no-slots state, which should + be investigated as a legacy model preset-read capability issue rather than a + macOS release failure. + +## 2026-05-05 — Latent 0.1.0 hardware-backed alpha + +Latent `0.1.0` was tagged after the rename, repo flattening, camera-flow +work, RAF preview work, launch documentation, and UI polish. This makes Phase +4 alpha-complete, advances Phase 6, and starts Phase 7 launch work. + +- Camera connection stability moved into `@latent/camera-connection`, with + explicit connection states, structured error classification, stale-session + cleanup, reconnect handling, and macOS PTP claim-collision guidance. +- Recipe library expanded beyond the Phase 3 shell: import/export, delete, + factory default restore, camera imports, deduplication, rename persistence, + and local-only storage behavior are implemented and tested. +- Recipe creator is implemented in the web app: users can start from + photographic intents, duplicate an existing look, edit schema-backed Fuji + settings, save into the local library, and export validated JSON without + connecting hardware. +- Custom-slot flows are implemented for the verified field set: read camera + C1-C4 presets, import them as recipes/backups, write recipes to selected + slots, verify writes, and restore previously imported backups. +- RAF preview workspace is implemented: local RAF files can be rendered + through the connected camera, with diagnostic parameter-group renders for + investigating camera-output mismatches. +- Public OSS launch materials are in place: README/README.it, screenshots, + onboarding, use cases, hardware test plan, launch checklist, governance + files, GitHub templates, funding metadata, and FilmKit relationship docs. +- Hands-on hardware validation has focused on X-S20 and X-M5. Other Fujifilm + bodies remain community-report territory until the hardware checklist is + run against them. + +Remaining V1 work after `0.1.0`: + +- Phase 5: replace the `@latent/ai-agent` stub with the real AI helper, or + explicitly defer it from the public V1 launch. +- Phase 6: finish WCAG 2.2 AA audit and production CSP validation. +- Phase 7: finish ADRs, trademark review, final seed list, release checklist, + and deploy path. +- Hardware QA: collect repeatable reports beyond X-S20/X-M5 and keep the + write whitelist narrow until fields are proven on real bodies. + ## 2026-05-04 — Rename to Latent + repo restructure End-of-session bookkeeping: project renamed from FilmFork to Latent, diff --git a/README.it.md b/README.it.md index cf12e8d..7614b1c 100644 --- a/README.it.md +++ b/README.it.md @@ -28,8 +28,11 @@ nelle ricette sono marchi dei rispettivi proprietari. ## Cosa fa oggi -- **Libreria ricette:** cerca, importa, esporta, elimina, ripristina i default - e conserva tutto localmente nel browser. +- **Libreria ricette:** cerca, importa, esporta, condivide link, elimina, + ripristina i default e conserva tutto localmente nel browser. +- **Creator ricette:** parte da intenti fotografici, duplica look esistenti + mantenendo metadati di parentela, modifica controlli Fujifilm validati dallo + schema ed esporta JSON senza hardware collegato. - **Connessione camera:** si collega via WebUSB su browser Chromium, recupera da refresh/unplug/sleep e guida l'utente quando macOS prende il controllo dell'interfaccia PTP. @@ -90,14 +93,33 @@ Dove Latent deve andare: nvm use # Node 22, pinnato dal repo npm install npm run validate # typecheck + lint + test + licenze + lockstep -cd apps/web -npm run dev # Vite su http://localhost:5173 +npm run dev:macos # Web app + helper locale macOS per la camera ``` -Apri `http://localhost:5173` in Chrome, Edge o Arc. WebUSB funziona solo su +Apri `http://127.0.0.1:5173/` in Chrome, Edge o Arc. WebUSB funziona solo su `localhost` o HTTPS. La libreria funziona senza hardware; i flussi camera richiedono un corpo Fujifilm in modalita' USB/PTP. +`npm run dev:macos` avvia i due processi locali necessari per lo sviluppo +hardware su macOS: + +- `http://127.0.0.1:5173/` - app web Vite. +- `http://127.0.0.1:5174/` - helper locale macOS per la camera. + +L'helper e' volutamente locale. Il browser non puo' eseguire `launchctl`, +sospendere `ptpcamerad`/`icdd` o ripristinare i servizi camera di macOS da +solo, quindi Latent usa un helper su localhost per esporre nell'app azioni +esplicite di Release e Restore. Ferma lo stack con `Ctrl+C`; se durante il +test hai rilasciato i servizi camera macOS, usa il controllo Restore nell'app +prima di scollegare tutto. + +Per lavoro solo browser, senza controllo dei servizi camera, puoi ancora +avviare direttamente l'app: + +```bash +npm --workspace @latent/web run dev -- --host 127.0.0.1 +``` + ## Note hardware Durante lo sviluppo sono stati provati Fujifilm X-S20 e X-M5. Altri corpi @@ -114,7 +136,9 @@ Prima di scrivere sulla camera: prendere l'interfaccia PTP. La checklist manuale e' in -[`docs/qa/hardware-test-plan.md`](./docs/qa/hardware-test-plan.md). +[`docs/qa/hardware-test-plan.md`](./docs/qa/hardware-test-plan.md). Le note +per sbloccare collisioni macOS/Image Capture/WebUSB sono in +[`docs/qa/macos-webusb-camera-release.md`](./docs/qa/macos-webusb-camera-release.md). ## Casi d'uso diff --git a/README.md b/README.md index 1323116..bae5880 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,11 @@ trademarks of their respective owners. ## What Latent Does Today -- **Recipe library:** browse, search, import, export, delete, restore factory - defaults, and keep everything local in the browser. +- **Recipe library:** browse, search, import, export, share links, delete, + restore factory defaults, and keep everything local in the browser. - **Recipe creator:** start from photographic intents, duplicate an existing - look, edit schema-backed Fujifilm settings, and export a validated JSON - recipe without connecting hardware. + look with parent metadata, edit schema-backed Fujifilm settings, and export a + validated JSON recipe without connecting hardware. - **Camera connection:** connect over WebUSB in Chromium browsers, recover from refresh/unplug/camera sleep, and surface macOS claim-collision guidance. - **Custom slots:** read camera-side C1-C4 recipes, import them into the @@ -105,14 +105,32 @@ Where Latent should go next: nvm use # Node 22, pinned by the repo npm install npm run validate # typecheck + lint + tests + license + lockstep -cd apps/web -npm run dev # Vite at http://localhost:5173 +npm run dev:macos # Web app + local macOS camera helper ``` -Open `http://localhost:5173` in Chrome, Edge, or Arc. WebUSB works only on +Open `http://127.0.0.1:5173/` in Chrome, Edge, or Arc. WebUSB works only on `localhost` or HTTPS. The recipe library works without hardware; camera flows need a Fujifilm body set to USB/PTP mode. +`npm run dev:macos` starts the two local processes needed for hardware-backed +macOS development: + +- `http://127.0.0.1:5173/` - Vite web app. +- `http://127.0.0.1:5174/` - local macOS camera helper. + +The helper is intentionally local. Browsers cannot run `launchctl`, suspend +`ptpcamerad`/`icdd`, or restore macOS camera services on their own, so Latent +uses a localhost helper to expose explicit Release and Restore actions in the +app. Stop the stack with `Ctrl+C`; if you released macOS camera services during +testing, use the in-app Restore control before disconnecting. + +For browser-only work that does not need camera service control, you can still +run the app directly: + +```bash +npm --workspace @latent/web run dev -- --host 127.0.0.1 +``` + ## Hardware Notes Development has been exercised with Fujifilm X-S20 and X-M5 bodies. Other @@ -129,7 +147,9 @@ Before writing to a camera: claim the PTP interface. Manual release checks live in -[`docs/qa/hardware-test-plan.md`](./docs/qa/hardware-test-plan.md). +[`docs/qa/hardware-test-plan.md`](./docs/qa/hardware-test-plan.md). macOS +Image Capture/WebUSB release notes live in +[`docs/qa/macos-webusb-camera-release.md`](./docs/qa/macos-webusb-camera-release.md). ## Use Cases diff --git a/ROADMAP.md b/ROADMAP.md index f6d038a..ddfb63f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,15 +1,15 @@ # Latent V1 Roadmap -| Phase | Output | Status | -| ------ | --------------------------------------------------------------- | ----------- | -| 1 | Foundation & core packages | ✅ Complete | -| 2-min | WebUSB transport (no hardware) | ✅ Complete | -| 2-full | X-S20 validation rig + first real round-trip | ✅ Complete | -| 3-base | Web app shell + recipe library + connect button | ✅ Complete | -| 4 | Camera flows (read/write slots, backup restore, RAF preview) | In progress | -| 5 | AI agent (5 modes, iteration loop, EXIF strip) | Planned | -| 6 | Polish (responsive UI, URL share, genealogy, export, WCAG, CSP) | In progress | -| 7 | Launch (ADRs, TM search, seed list, deploy) | Planned | +| Phase | Output | Status | +| ------ | --------------------------------------------------------------- | ----------------- | +| 1 | Foundation & core packages | ✅ Complete | +| 2-min | WebUSB transport (no hardware) | ✅ Complete | +| 2-full | X-S20 validation rig + first real round-trip | ✅ Complete | +| 3-base | Web app shell + recipe library + connect button | ✅ Complete | +| 4 | Camera flows (read/write slots, backup restore, RAF preview) | ✅ Alpha complete | +| 5 | AI agent (5 modes, iteration loop, EXIF strip) | Planned | +| 6 | Polish (responsive UI, URL share, genealogy, export, WCAG, CSP) | In progress | +| 7 | Launch (ADRs, TM search, seed list, online portal, deploy) | In progress | The full V1 design lives in [`docs/specs/2026-05-03-fujicomp-v1-design.md`](./docs/specs/2026-05-03-fujicomp-v1-design.md) @@ -18,8 +18,28 @@ authoritative). Phase plans are in [`docs/plans/`](./docs/plans/). ## Current Launch Focus -- Stabilize the X-S20 and X-M5 WebUSB workflows with real hardware. -- Keep custom-slot writes limited to verified fields. -- Improve RAF preview parity by validating parameter groups against camera - output. -- Prepare public OSS onboarding, use cases, and hardware-report paths. +- Keep X-S20 and X-M5 WebUSB workflows stable while collecting more hardware + reports from other Fujifilm bodies. +- Keep custom-slot writes limited to verified fields, with backup, read-back, + and restore paths treated as part of the write workflow. +- Improve RAF preview parity by validating remaining parameter groups against + camera output, especially known Kelvin/white-balance limitations. +- Finish Phase 6 polish items that are not yet complete: WCAG 2.2 AA audit and + production CSP validation. +- Finish Phase 7 launch items: ADRs, trademark review, final seed list, + dedicated online portal, release checklist, and deploy path. +- Decide the portal hosting target. Vercel is likely the fastest static/PWA + launch path; GCP is better if Latent needs tighter infrastructure control, + custom observability, or future server-side services. +- Decide whether Phase 5 AI agent remains in V1 scope or moves behind the + post-alpha launch line. + +## Known Hardware Follow-Ups + +- macOS release can be automated locally for `ptpcamerad`/`icdd`, but a stale + camera-side PTP session may still require full body power-cycle or battery + reseat. A future native transport/helper should investigate whether a lower + level USB reset can replace the physical battery step. +- X-T20 currently reaches the connected/no-readable-slots state; investigate + whether legacy custom settings expose different PTP operations, slot counts, + or property mappings before widening model support. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4ca1f63..5d1391e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,12 +9,15 @@ import { CameraRecipesPanel } from "./components/camera/CameraRecipesPanel"; import { RawPreviewPanel } from "./components/camera/RawPreviewPanel"; import { useCameraStore } from "./stores/camera"; import { useT } from "./i18n"; +import { decodeRecipeShareFromLocation } from "./lib/recipe-share"; type Workspace = "camera" | "raf" | "library" | "create"; export function App(): JSX.Element { const t = useT(); const loadSeedRecipes = useRecipesStore((s) => s.loadSeedRecipes); + const importRecipe = useRecipesStore((s) => s.importRecipe); + const loaded = useRecipesStore((s) => s.loaded); const selectedId = useRecipesStore((s) => s.selectedRecipeId); const recipes = useRecipesStore((s) => s.recipes); const cameraState = useCameraStore((s) => s.state); @@ -24,11 +27,23 @@ export function App(): JSX.Element { const [workspace, setWorkspace] = useState(() => initialWorkspace()); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileOverviewOpen, setMobileOverviewOpen] = useState(false); + const [shareImportHandled, setShareImportHandled] = useState(false); useEffect(() => { void loadSeedRecipes(); }, [loadSeedRecipes]); + useEffect(() => { + if (!loaded || shareImportHandled) return; + setShareImportHandled(true); + try { + const sharedRecipe = decodeRecipeShareFromLocation(window.location); + if (sharedRecipe) importRecipe(sharedRecipe); + } catch { + // Invalid shared URLs should never block the local library. + } + }, [importRecipe, loaded, shareImportHandled]); + useEffect(() => { localStorage.setItem("latent-theme-v1", theme); }, [theme]); diff --git a/apps/web/src/components/RecipeDetail.tsx b/apps/web/src/components/RecipeDetail.tsx index be8eb45..7b9192c 100644 --- a/apps/web/src/components/RecipeDetail.tsx +++ b/apps/web/src/components/RecipeDetail.tsx @@ -18,6 +18,7 @@ import { signedNumber, } from "./format"; import { downloadRecipeJson, serializeRecipeJson } from "../lib/recipe-json"; +import { recipeShareUrl } from "../lib/recipe-share"; export interface RecipeDetailProps { recipe: RecipeType; @@ -41,6 +42,7 @@ export function RecipeDetail({ recipe }: RecipeDetailProps): JSX.Element { const writeStatus = useCameraStore((s) => s.writeStatus); const writeRecipeToSlot = useCameraStore((s) => s.writeRecipeToSlot); const [copied, setCopied] = useState(false); + const [shared, setShared] = useState(false); const [showWalkthrough, setShowWalkthrough] = useState(false); const [editingName, setEditingName] = useState(false); const [draftName, setDraftName] = useState(recipe.name); @@ -63,6 +65,16 @@ export function RecipeDetail({ recipe }: RecipeDetailProps): JSX.Element { setTimeout(() => setCopied(false), 1500); }; + const handleShare = async (): Promise => { + try { + await navigator.clipboard?.writeText(recipeShareUrl(recipe)); + setShared(true); + setTimeout(() => setShared(false), 1500); + } catch { + setShared(false); + } + }; + const handleDelete = (): void => { if (!window.confirm(t("detail.delete.confirm"))) return; deleteRecipe(recipe.id); @@ -126,6 +138,10 @@ export function RecipeDetail({ recipe }: RecipeDetailProps): JSX.Element { rafInputRef.current?.click(); }; + const parentRecipe = recipe.parentRecipeId + ? (recipes.find((candidate) => candidate.id === recipe.parentRecipeId) ?? null) + : null; + return (
@@ -269,6 +285,12 @@ export function RecipeDetail({ recipe }: RecipeDetailProps): JSX.Element { label={t("detail.metadata.tags")} value={recipe.tags?.length ? recipe.tags.join(", ") : "—"} /> + @@ -305,11 +327,13 @@ export function RecipeDetail({ recipe }: RecipeDetailProps): JSX.Element { onDiagnose={() => openRafPicker("diagnostic")} onToggleFavorite={() => toggleFavorite(recipe.id)} onCopy={() => void handleCopy()} + onShare={() => void handleShare()} onDownload={() => downloadRecipeJson(recipe)} onDelete={handleDelete} onWrite={handleWrite} onRestore={handleRestore} slotBackups={slotBackups} + shared={shared} /> @@ -326,9 +350,14 @@ function Param({ label, value }: { label: string; value: string }): JSX.Element ); } +function formatParentRecipeId(parentRecipeId: string | undefined): string { + return parentRecipeId ? `Parent ${parentRecipeId.slice(0, 8)}` : "—"; +} + function RecipeCommandPanel({ recipe, copied, + shared, cameraConnected, rawPreviewStatus, writeStatus, @@ -337,6 +366,7 @@ function RecipeCommandPanel({ onDiagnose, onToggleFavorite, onCopy, + onShare, onDownload, onDelete, onWrite, @@ -345,6 +375,7 @@ function RecipeCommandPanel({ }: { recipe: RecipeType; copied: boolean; + shared: boolean; cameraConnected: boolean; rawPreviewStatus: RawPreviewStatus; writeStatus: CameraWriteStatus; @@ -353,6 +384,7 @@ function RecipeCommandPanel({ onDiagnose: () => void; onToggleFavorite: () => void; onCopy: () => void; + onShare: () => void; onDownload: () => void; onDelete: () => void; onWrite: (slot: number) => void; @@ -489,7 +521,9 @@ function RecipeCommandPanel({ {cameraActionMode === "write" ? (

- {cameraConnected ? t("detail.action.write.body") : t("detail.cameraWrite.disconnected")} + {cameraConnected + ? t("detail.action.write.body") + : t("detail.cameraWrite.disconnected")}

{[1, 2, 3, 4].map((slot) => { @@ -541,7 +575,7 @@ function RecipeCommandPanel({ "rounded-md border px-3 py-2 text-xs transition-colors", !writeDisabled ? "border-zinc-800 text-zinc-300 hover:border-zinc-700 hover:bg-zinc-900" - : "cursor-not-allowed border-zinc-900 text-zinc-700", + : "cursor-not-allowed border-zinc-900 text-zinc-700", )} > @@ -620,6 +654,13 @@ function RecipeCommandPanel({ > {copied ? t("detail.copyJson.copied") : t("detail.copyJson")} +
- )} overlay={overlay} />; + return ( + + + +
+ } + overlay={overlay} + /> + ); } if (state.kind === "error") { - return + } + overlay={overlay} /> - )} overlay={overlay} />; + ); } return } overlay={overlay} />; } function Stack({ main, overlay }: { main: JSX.Element; overlay: JSX.Element }): JSX.Element { - return
{main}{overlay}
; + return ( +
+ {main} + {overlay} +
+ ); } diff --git a/apps/web/src/components/camera/CameraRecipesPanel.tsx b/apps/web/src/components/camera/CameraRecipesPanel.tsx index 7925d0e..2bdd141 100644 --- a/apps/web/src/components/camera/CameraRecipesPanel.tsx +++ b/apps/web/src/components/camera/CameraRecipesPanel.tsx @@ -26,6 +26,7 @@ export function CameraRecipesPanel(): JSX.Element | null { const t = useT(); const state = useCameraStore((s) => s.state); const presets = useCameraStore((s) => s.presets); + const presetReadStatus = useCameraStore((s) => s.presetReadStatus); const [selectedSlot, setSelectedSlot] = useState(null); const visible = isCameraAlive(state.kind) || presets.length > 0; const cameraLabel = @@ -72,6 +73,13 @@ export function CameraRecipesPanel(): JSX.Element | null {
{t("camera.recipes.slotsRead", { n: presets.length })}
{t("camera.recipes.readOnly")}
+ {presetReadStatus.kind === "success" && presetReadStatus.failures.length > 0 ? ( +

+ {t("camera.recipes.readPartial", { + slots: presetReadStatus.failures.map((failure) => `C${failure.slot}`).join(", "), + })} +

+ ) : null} + + , + document.body, + ); + } + + return ( +
+
+ {t("camera.macos.helper.title")} + +
+

+ {helperStatus + ? `ptpcamerad ${formatService(helperStatus.services.ptpcamerad)} · icdd ${formatService( + helperStatus.services.icdd, + )}` + : helperError} +

+
+ + +
+
+ ); +} + +function formatService(service: { + disabled: boolean; + suspendedPids: number[]; + runningPids: number[]; +}): string { + const launchState = service.disabled ? "disabled" : "enabled"; + if (service.runningPids.length > 0) { + return `${launchState}, running ${service.runningPids.join(",")}`; + } + if (service.suspendedPids.length > 0) { + return `${launchState}, suspended ${service.suspendedPids.join(",")}`; + } + return `${launchState}, stopped`; +} diff --git a/apps/web/src/components/camera/MacosSetupWizard.tsx b/apps/web/src/components/camera/MacosSetupWizard.tsx index 6358234..c842cd9 100644 --- a/apps/web/src/components/camera/MacosSetupWizard.tsx +++ b/apps/web/src/components/camera/MacosSetupWizard.tsx @@ -1,5 +1,7 @@ import type { JSX } from "react"; +import { createPortal } from "react-dom"; import { useT } from "../../i18n"; +import { MacosServicesControl } from "./MacosServicesControl"; interface MacosSetupWizardProps { acknowledged: boolean; @@ -11,10 +13,11 @@ interface MacosSetupWizardProps { onClose: () => void; } -const BASIC_COMMAND = "killall ptpcamerad"; +const BASIC_COMMAND = "killall ptpcamerad icdd"; const ADVANCED_COMMAND = - "launchctl disable gui/$(id -u)/com.apple.ptpcamerad && killall ptpcamerad"; -const ENABLE_COMMAND = "launchctl enable gui/$(id -u)/com.apple.ptpcamerad"; + "launchctl disable gui/$(id -u)/com.apple.ptpcamerad && launchctl disable gui/$(id -u)/com.apple.icdd && killall -STOP ptpcamerad icdd"; +const ENABLE_COMMAND = + "killall -CONT ptpcamerad icdd; launchctl enable gui/$(id -u)/com.apple.ptpcamerad && launchctl enable gui/$(id -u)/com.apple.icdd"; export function MacosSetupWizard({ acknowledged, @@ -26,56 +29,141 @@ export function MacosSetupWizard({ onClose, }: MacosSetupWizardProps): JSX.Element { const t = useT(); + if (typeof document === "undefined") return <>; - if (acknowledged) { - return ( -
-

{t("camera.macos.done.title")}

- - {ENABLE_COMMAND} - -
- -
+
+ {acknowledged ? ( + + ) : ( + + )} +
- ); - } + , + document.body, + ); +} +function ConfirmedStep({ onReset }: { onReset: () => void }): JSX.Element { + const t = useT(); return ( -
-

{t("camera.macos.setup.title")}

-

{t("camera.macos.basic.body")}

- - {BASIC_COMMAND} - - - {!showAdvanced ? ( -
+ ); +} + +function ReleaseStep({ + showAdvanced, + onRunBasic, + onRunAdvanced, + onShowAdvanced, +}: { + showAdvanced: boolean; + onRunBasic: () => void; + onRunAdvanced: () => void; + onShowAdvanced: () => void; +}): JSX.Element { + const t = useT(); + return ( + <> + +
+ - ) : null} + {!showAdvanced ? ( + + ) : null} +
{showAdvanced ? ( -
+

{t("camera.macos.advanced.title")}

- - {ADVANCED_COMMAND} - -

{t("camera.macos.advanced.enable")}

- - {ENABLE_COMMAND} - -
) : null} -
+ + ); +} + +function CommandBlock({ + command, + tone = "default", +}: { + command: string; + tone?: "default" | "success" | "warning"; +}): JSX.Element { + const border = + tone === "success" + ? "border-emerald-500/20" + : tone === "warning" + ? "border-amber-500/20" + : "border-zinc-800"; + return ( + + {command} + ); } diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index 753c279..e2be71d 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -25,6 +25,8 @@ export const en = { "Select a recipe from the library to read its full set of camera parameters and a step-by-step setup walkthrough.", "detail.copyJson": "Copy as JSON", "detail.copyJson.copied": "Copied", + "detail.share": "Copy share link", + "detail.share.copied": "Link copied", "detail.downloadJson": "Download .json", "detail.delete": "Delete", "detail.delete.hideDefault": "Hide", @@ -79,7 +81,7 @@ export const en = { "detail.cameraRestore.section": "One-click restore", "detail.cameraRestore.slot": "Restore C{slot}: {name}", "detail.cameraRestore.slotHint": "Writes this backup back to camera slot C{slot}.", - "detail.cameraRestore.confirm": "Restore camera slot C{slot} from backup \"{backup}\"?", + "detail.cameraRestore.confirm": 'Restore camera slot C{slot} from backup "{backup}"?', "detail.setupWalkthrough": "Set up on camera", "detail.setupWalkthrough.intro": "Follow these steps to enter the recipe in a custom slot on your camera.", @@ -89,6 +91,7 @@ export const en = { "detail.metadata.camera": "Target camera", "detail.metadata.created": "Added", "detail.metadata.tags": "Tags", + "detail.metadata.parent": "Parent recipe", "detail.favourite.add": "Add to favourites", "detail.favourite.remove": "Remove from favourites", "detail.favourite.short.add": "save", @@ -122,7 +125,7 @@ export const en = { "camera.notSupported.short": "WebUSB unavailable", "camera.error.macos-claim-collision.title": "macOS is holding the camera", "camera.error.macos-claim-collision.body": - "Image Capture is claiming exclusive access. Open the macOS setup to release it.", + "macOS or another browser session has exclusive access. Open the setup to release it and reopen the USB picker.", "camera.error.macos-claim-collision.action": "Open setup", "camera.error.camera-off.title": "Camera not responding", "camera.error.camera-off.body": "Power-cycle the camera and check the cable, then click retry.", @@ -148,14 +151,21 @@ export const en = { "camera.error.unknown.action": "Retry", "camera.macos.beta.title": "macOS camera support is beta", "camera.macos.beta.body": - "macOS may auto-mount cameras for Image Capture or Photos. Start with the temporary release command; the persistent fix is advanced.", + "macOS may auto-mount cameras for Image Capture or Photos, and stale browser sessions can keep USB access open. Start with the temporary release command; the persistent fix is advanced.", "camera.macos.beta.ack": "I understand", "camera.macos.setup.title": "Release the camera from macOS", - "camera.macos.basic.body": "Run this temporary command, then confirm.", + "camera.macos.basic.body": + "Run this temporary command, power-cycle the camera, then confirm.", "camera.macos.ran": "I've run it", "camera.macos.showAdvanced": "Show advanced option", - "camera.macos.advanced.title": "Advanced — disables Image Capture until re-enabled", + "camera.macos.advanced.title": "Advanced — disables and suspends macOS camera services", "camera.macos.advanced.enable": "Re-enable command:", + "camera.macos.helper.title": "Local helper", + "camera.macos.helper.offline": "Start npm run macos-camera-helper for one-click release.", + "camera.macos.helper.refresh": "Refresh", + "camera.macos.helper.release": "Release", + "camera.macos.helper.restore": "Restore", + "camera.macos.helper.paused": "macOS camera services paused", "camera.macos.done.title": "macOS setup confirmed", "camera.macos.reset": "Reset macOS setup status", "camera.macos.close": "Close", @@ -166,6 +176,9 @@ export const en = { "camera.recipes.slotsRead": "{n} slots read", "camera.recipes.readOnly": "Read only", "camera.recipes.scanning": "Reading custom slots from the camera", + "camera.recipes.readPartial": "Could not read {slots}; showing the slots that did respond.", + "camera.recipes.readFailed": "No custom slots could be read.", + "camera.recipes.noneRead": "The camera did not return any readable custom slots.", "camera.recipes.empty": "Connect a camera to read custom slots.", "camera.recipes.inspector": "slot inspector", "camera.recipes.import": "Import as recipe", diff --git a/apps/web/src/i18n/it.ts b/apps/web/src/i18n/it.ts index be3104d..e527ad8 100644 --- a/apps/web/src/i18n/it.ts +++ b/apps/web/src/i18n/it.ts @@ -28,6 +28,8 @@ export const it: Record = { "Seleziona una ricetta dalla libreria per leggere tutti i parametri della fotocamera e la procedura di configurazione passo passo.", "detail.copyJson": "Copia come JSON", "detail.copyJson.copied": "Copiato", + "detail.share": "Copia link", + "detail.share.copied": "Link copiato", "detail.downloadJson": "Scarica .json", "detail.delete": "Elimina", "detail.delete.hideDefault": "Nascondi", @@ -86,7 +88,7 @@ export const it: Record = { "detail.cameraRestore.section": "Ripristino immediato", "detail.cameraRestore.slot": "Ripristina C{slot}: {name}", "detail.cameraRestore.slotHint": "Riscrive questo backup nello slot C{slot} della fotocamera.", - "detail.cameraRestore.confirm": "Ripristinare lo slot C{slot} dal backup \"{backup}\"?", + "detail.cameraRestore.confirm": 'Ripristinare lo slot C{slot} dal backup "{backup}"?', "detail.setupWalkthrough": "Configura sulla fotocamera", "detail.setupWalkthrough.intro": "Segui questi passaggi per inserire la ricetta in uno slot personalizzato della fotocamera.", @@ -96,6 +98,7 @@ export const it: Record = { "detail.metadata.camera": "Fotocamera di riferimento", "detail.metadata.created": "Aggiunta il", "detail.metadata.tags": "Tag", + "detail.metadata.parent": "Ricetta sorgente", "detail.favourite.add": "Aggiungi ai preferiti", "detail.favourite.remove": "Rimuovi dai preferiti", "detail.favourite.short.add": "salva", @@ -129,7 +132,7 @@ export const it: Record = { "camera.notSupported.short": "WebUSB non disponibile", "camera.error.macos-claim-collision.title": "macOS sta usando la fotocamera", "camera.error.macos-claim-collision.body": - "Acquisizione Immagine sta richiedendo accesso esclusivo. Apri la configurazione macOS per liberarla.", + "macOS o un'altra sessione del browser ha accesso esclusivo. Apri la configurazione per liberarla e riaprire il selettore USB.", "camera.error.macos-claim-collision.action": "Apri configurazione", "camera.error.camera-off.title": "La fotocamera non risponde", "camera.error.camera-off.body": @@ -158,15 +161,22 @@ export const it: Record = { "camera.error.unknown.action": "Riprova", "camera.macos.beta.title": "Il supporto fotocamera su macOS è beta", "camera.macos.beta.body": - "macOS può montare automaticamente le fotocamere per Acquisizione Immagine o Foto. Parti dal comando temporaneo; la correzione persistente è avanzata.", + "macOS può montare automaticamente le fotocamere per Acquisizione Immagine o Foto, e una sessione browser bloccata può tenere aperto l'accesso USB. Parti dal comando temporaneo; la correzione persistente è avanzata.", "camera.macos.beta.ack": "Ho capito", "camera.macos.setup.title": "Libera la fotocamera da macOS", - "camera.macos.basic.body": "Esegui questo comando temporaneo, poi conferma.", + "camera.macos.basic.body": + "Esegui questo comando temporaneo, spegni e riaccendi la fotocamera, poi conferma.", "camera.macos.ran": "L'ho eseguito", "camera.macos.showAdvanced": "Mostra opzione avanzata", "camera.macos.advanced.title": - "Avanzato — disabilita Acquisizione Immagine finché non la riattivi", + "Avanzato — disabilita e sospende i servizi fotocamera di macOS", "camera.macos.advanced.enable": "Comando per riattivare:", + "camera.macos.helper.title": "Helper locale", + "camera.macos.helper.offline": "Avvia npm run macos-camera-helper per lo sblocco one-click.", + "camera.macos.helper.refresh": "Aggiorna", + "camera.macos.helper.release": "Sblocca", + "camera.macos.helper.restore": "Ripristina", + "camera.macos.helper.paused": "Servizi fotocamera macOS sospesi", "camera.macos.done.title": "Configurazione macOS confermata", "camera.macos.reset": "Reimposta stato configurazione macOS", "camera.macos.close": "Chiudi", @@ -177,6 +187,9 @@ export const it: Record = { "camera.recipes.slotsRead": "{n} slot letti", "camera.recipes.readOnly": "Sola lettura", "camera.recipes.scanning": "Lettura degli slot custom dalla fotocamera", + "camera.recipes.readPartial": "Impossibile leggere {slots}; mostro gli slot che hanno risposto.", + "camera.recipes.readFailed": "Nessuno slot custom è stato letto.", + "camera.recipes.noneRead": "La fotocamera non ha restituito slot custom leggibili.", "camera.recipes.empty": "Collega una fotocamera per leggere gli slot custom.", "camera.recipes.inspector": "ispettore slot", "camera.recipes.import": "Importa come ricetta", diff --git a/apps/web/src/lib/macos-camera-helper.ts b/apps/web/src/lib/macos-camera-helper.ts new file mode 100644 index 0000000..cb9769a --- /dev/null +++ b/apps/web/src/lib/macos-camera-helper.ts @@ -0,0 +1,63 @@ +const HELPER_BASE_URL = "http://127.0.0.1:5174"; + +export interface MacosCameraHelperServiceStatus { + disabled: boolean; + pids: number[]; + suspendedPids: number[]; + runningPids: number[]; +} + +export interface MacosCameraHelperStatus { + ok: true; + platform: string; + uid: number; + services: { + ptpcamerad: MacosCameraHelperServiceStatus; + icdd: MacosCameraHelperServiceStatus; + }; +} + +export interface MacosCameraHelperResponse { + status: MacosCameraHelperStatus; +} + +export function canUseMacosCameraHelper(): boolean { + if (typeof window === "undefined" || typeof navigator === "undefined") return false; + const host = window.location.hostname; + return ( + (host === "localhost" || host === "127.0.0.1") && + /Mac/i.test(navigator.userAgent) + ); +} + +export function macosCameraHelperNeedsRestore(status: MacosCameraHelperStatus): boolean { + return Object.values(status.services).some( + (service) => service.disabled || service.suspendedPids.length > 0, + ); +} + +export async function getMacosCameraHelperStatus( + signal?: AbortSignal, +): Promise { + const response = await fetch( + `${HELPER_BASE_URL}/status`, + signal ? { signal } : undefined, + ); + if (!response.ok) throw new Error(`Helper status failed: ${response.status}`); + const body = (await response.json()) as MacosCameraHelperResponse; + return body.status; +} + +export async function releaseMacosCameraServices(): Promise { + const response = await fetch(`${HELPER_BASE_URL}/release`, { method: "POST" }); + if (!response.ok) throw new Error(`Helper release failed: ${response.status}`); + const body = (await response.json()) as MacosCameraHelperResponse; + return body.status; +} + +export async function restoreMacosCameraServices(): Promise { + const response = await fetch(`${HELPER_BASE_URL}/restore`, { method: "POST" }); + if (!response.ok) throw new Error(`Helper restore failed: ${response.status}`); + const body = (await response.json()) as MacosCameraHelperResponse; + return body.status; +} diff --git a/apps/web/src/lib/recipe-share.ts b/apps/web/src/lib/recipe-share.ts new file mode 100644 index 0000000..96ae647 --- /dev/null +++ b/apps/web/src/lib/recipe-share.ts @@ -0,0 +1,65 @@ +import { Recipe, type RecipeType } from "@latent/recipe-schema/browser"; + +export const RECIPE_SHARE_PARAM = "share"; + +interface RecipeSharePayload { + v: 1; + recipe: RecipeType; +} + +export function recipeForUrlShare(recipe: RecipeType): RecipeType { + const shareable = { ...recipe }; + delete shareable.reasoning; + return Recipe.parse(shareable); +} + +export function encodeRecipeShare(recipe: RecipeType): string { + return encodeBase64Url( + JSON.stringify({ + v: 1, + recipe: recipeForUrlShare(recipe), + } satisfies RecipeSharePayload), + ); +} + +export function decodeRecipeShare(value: string): RecipeType { + const parsed: unknown = JSON.parse(decodeBase64Url(value)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid recipe share payload"); + } + const payload = parsed as Partial; + if (payload.v !== 1) throw new Error("Unsupported recipe share version"); + return Recipe.parse(payload.recipe); +} + +export function recipeShareUrl(recipe: RecipeType, baseHref = window.location.href): string { + const url = new URL(baseHref); + url.searchParams.set(RECIPE_SHARE_PARAM, encodeRecipeShare(recipe)); + url.hash = "library"; + return url.toString(); +} + +export function decodeRecipeShareFromLocation( + location: Pick, +): RecipeType | null { + const value = new URLSearchParams(location.search).get(RECIPE_SHARE_PARAM); + if (!value) return null; + return decodeRecipeShare(value); +} + +function encodeBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function decodeBase64Url(value: string): string { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} diff --git a/apps/web/src/stores/camera.ts b/apps/web/src/stores/camera.ts index 368cc79..67fd859 100644 --- a/apps/web/src/stores/camera.ts +++ b/apps/web/src/stores/camera.ts @@ -5,6 +5,7 @@ import type { ConnectionState, ErrorReason, ManagerNotifications, + PresetReadFailure, RawPreset, } from "@latent/camera-connection"; import { LatentError, patchProfile } from "@latent/ptp-fuji"; @@ -20,6 +21,7 @@ const MACOS_PERSISTENT_DISABLE_KEY = "latent:macos-persistent-disable-v1"; export interface CameraStore { state: ConnectionState; presets: RawPreset[]; + presetReadStatus: PresetReadStatus; writeStatus: CameraWriteStatus; rawPreviewStatus: RawPreviewStatus; rawPreviewFile: File | null; @@ -56,6 +58,11 @@ export type CameraWriteStatus = | { kind: "success"; slot: number; recipeName: string; propertiesWritten: number } | { kind: "error"; slot: number; recipeName: string; message: string }; +export type PresetReadStatus = + | { kind: "idle" } + | { kind: "scanning" } + | { kind: "success"; failures: PresetReadFailure[] }; + export type RawPreviewStatus = | { kind: "idle" } | { @@ -114,6 +121,7 @@ export const useCameraStore = create((set, get) => { return { state: { kind: "idle" }, presets: [], + presetReadStatus: { kind: "idle" }, writeStatus: { kind: "idle" }, rawPreviewStatus: { kind: "idle" }, rawPreviewFile: null, @@ -397,7 +405,12 @@ export function wireCameraManager(nextManager: ConnectionManager): void { unwireManager.push( nextManager.subscribe((state) => { - useCameraStore.setState({ state }); + const previous = useCameraStore.getState().state; + const nextPatch: Partial = { state }; + if (!isAlive(previous) && isAlive(state)) { + nextPatch.presetReadStatus = { kind: "scanning" }; + } + useCameraStore.setState(nextPatch); publishCameraDiagnostics(useCameraStore.getState()); if (state.kind === "error") { // Surface the underlying error so DevTools shows the real cause @@ -410,7 +423,7 @@ export function wireCameraManager(nextManager: ConnectionManager): void { store.resetMacosSetupStatus(); useCameraStore.setState({ macosWizardOpen: false, macosShowAdvanced: true }); publishCameraDiagnostics(useCameraStore.getState()); - } else if (!store.macosSetupAcknowledged) { + } else if (store.macosBetaAcknowledged && !store.macosSetupAcknowledged) { useCameraStore.setState({ macosWizardOpen: true }); publishCameraDiagnostics(useCameraStore.getState()); } @@ -428,8 +441,11 @@ export function wireCameraManager(nextManager: ConnectionManager): void { ); unwireManager.push( - nextManager.onNotification("presets-read", ({ presets }) => { - useCameraStore.setState({ presets }); + nextManager.onNotification("presets-read", ({ presets, failures }) => { + useCameraStore.setState({ + presets, + presetReadStatus: { kind: "success", failures }, + }); publishCameraDiagnostics(useCameraStore.getState()); }), ); @@ -469,6 +485,10 @@ function writeFlag(key: string, value: boolean): void { } } +function isAlive(state: ConnectionState): boolean { + return state.kind === "connected" || state.kind === "degraded"; +} + function safeLocalStorage(): Storage | null { const storage = globalThis.localStorage; if ( diff --git a/apps/web/tests/App.test.tsx b/apps/web/tests/App.test.tsx index 7707ff1..14e8f98 100644 --- a/apps/web/tests/App.test.tsx +++ b/apps/web/tests/App.test.tsx @@ -3,6 +3,32 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { App } from "../src/App"; import { useRecipesStore } from "../src/stores/recipes"; import { resetCameraManagerForTests, useCameraStore } from "../src/stores/camera"; +import { encodeRecipeShare } from "../src/lib/recipe-share"; +import type { RecipeType } from "@latent/recipe-schema/browser"; + +const sharedRecipe: RecipeType = { + id: "88888888-8888-4888-8888-888888888888", + schemaVersion: 1, + name: "Shared Link Chrome", + description: "Imported from a self-contained URL.", + author: "Latent", + tags: ["shared-link"], + createdAt: "2026-05-06T08:00:00.000Z", + capabilitySetId: "x-s20-fw1.10", + cameraModel: "X-S20", + filmSimulation: "ClassicChrome", + dynamicRange: "DR400", + whiteBalance: { mode: "Daylight", shiftR: 1, shiftB: -1 }, + highlightTone: 0, + shadowTone: 1, + color: 2, + sharpness: 0, + noiseReduction: -4, + clarity: 0, + grainEffect: { strength: "Weak", size: "Small" }, + colorChromeEffect: "Weak", + colorChromeEffectBlue: "Weak", +}; describe("", () => { beforeEach(() => { @@ -56,6 +82,15 @@ describe("", () => { expect(screen.getByRole("heading", { name: "Build a recipe" })).toBeInTheDocument(); }); + it("imports and selects a recipe shared in the URL", async () => { + window.history.replaceState(null, "", `/?share=${encodeRecipeShare(sharedRecipe)}#library`); + + render(); + + expect((await screen.findAllByText("Shared Link Chrome")).length).toBeGreaterThan(0); + expect(useRecipesStore.getState().selectedRecipeId).toBe(sharedRecipe.id); + }); + it("switches between first-class workspaces from the hash", () => { render(); diff --git a/apps/web/tests/CameraConnect.test.tsx b/apps/web/tests/CameraConnect.test.tsx index c704207..088509a 100644 --- a/apps/web/tests/CameraConnect.test.tsx +++ b/apps/web/tests/CameraConnect.test.tsx @@ -264,6 +264,16 @@ describe("", () => { expect(screen.getByRole("alert")).toHaveTextContent(text); }); + it("Open setup opens the macOS setup wizard even before beta acknowledgement", () => { + useCameraStore.setState({ state: errorConnectionState("macos-claim-collision") }); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open setup/i })); + + expect(screen.getByRole("dialog")).toHaveTextContent(/release the camera from macOS/i); + expect(screen.getByText("killall ptpcamerad icdd")).toBeInTheDocument(); + }); + it("cable-unplugged title is Camera unplugged", () => { useCameraStore.setState({ state: errorConnectionState("cable-unplugged") }); render(); @@ -306,6 +316,7 @@ describe("", () => { }, }, ], + failures: [], }); expect(useCameraStore.getState().presets).toHaveLength(1); expect(window.__LATENT_CAMERA_STATE__?.decodedPresets).toEqual([ diff --git a/apps/web/tests/RecipeDetail.test.tsx b/apps/web/tests/RecipeDetail.test.tsx index 6358987..81f654d 100644 --- a/apps/web/tests/RecipeDetail.test.tsx +++ b/apps/web/tests/RecipeDetail.test.tsx @@ -81,6 +81,24 @@ describe("", () => { expect(screen.getByText("editorial, neutral")).toBeInTheDocument(); }); + it("shows the parent recipe name when genealogy metadata is available", () => { + const child: RecipeType = { + ...sample, + id: "66666666-6666-4666-8666-666666666666", + name: "Editorial Negative Copy", + parentRecipeId: sample.id, + }; + useRecipesStore.setState({ + recipes: [sample, child], + selectedRecipeId: child.id, + }); + + render(); + + expect(screen.getByText("Parent recipe")).toBeInTheDocument(); + expect(screen.getByText("Editorial Negative")).toBeInTheDocument(); + }); + it("renames the selected recipe from the detail header", () => { const renameRecipe = vi.spyOn(useRecipesStore.getState(), "renameRecipe"); render(); @@ -111,11 +129,30 @@ describe("", () => { expect(parsed.filmSimulation).toBe("ClassicNegative"); }); + it("copies a self-contained recipe share URL", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + window.history.replaceState(null, "", "/?theme=dark#library"); + + render(); + fireEvent.click(screen.getByRole("button", { name: /copy share link/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledTimes(1); + }); + const copiedUrl = new URL(writeText.mock.calls[0]![0] as string); + expect(copiedUrl.searchParams.get("theme")).toBe("dark"); + expect(copiedUrl.searchParams.get("share")).toBeTruthy(); + expect(copiedUrl.hash).toBe("#library"); + }); + it("download JSON creates a recipe file download", () => { const createObjectURL = vi.fn(() => "blob:recipe"); const revokeObjectURL = vi.fn(); Object.assign(URL, { createObjectURL, revokeObjectURL }); - const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); render(); fireEvent.click(screen.getByRole("button", { name: /download .json/i })); @@ -131,13 +168,9 @@ describe("", () => { const walkthroughBtn = screen.getByRole("button", { name: /set up on camera/i, }); - expect( - screen.queryByText(/follow these steps/i), - ).not.toBeInTheDocument(); + expect(screen.queryByText(/follow these steps/i)).not.toBeInTheDocument(); fireEvent.click(walkthroughBtn); - expect( - screen.getByText(/follow these steps/i), - ).toBeInTheDocument(); + expect(screen.getByText(/follow these steps/i)).toBeInTheDocument(); }); it("starts a RAF preview for the current recipe from the detail action", () => { diff --git a/apps/web/tests/macos-camera-helper.test.ts b/apps/web/tests/macos-camera-helper.test.ts new file mode 100644 index 0000000..cc05086 --- /dev/null +++ b/apps/web/tests/macos-camera-helper.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + macosCameraHelperNeedsRestore, + type MacosCameraHelperStatus, +} from "../src/lib/macos-camera-helper"; + +function status( + ptpcamerad: Partial, + icdd: Partial, +): MacosCameraHelperStatus { + return { + ok: true, + platform: "darwin", + uid: 501, + services: { + ptpcamerad: { + disabled: false, + pids: [], + suspendedPids: [], + runningPids: [], + ...ptpcamerad, + }, + icdd: { + disabled: false, + pids: [], + suspendedPids: [], + runningPids: [], + ...icdd, + }, + }, + }; +} + +describe("macos camera helper client", () => { + it("requires restore when a service is disabled or suspended", () => { + expect( + macosCameraHelperNeedsRestore( + status({ disabled: true, suspendedPids: [93856] }, { runningPids: [17376] }), + ), + ).toBe(true); + }); + + it("does not require restore when services are enabled and not suspended", () => { + expect( + macosCameraHelperNeedsRestore( + status({ runningPids: [93856] }, { runningPids: [17376] }), + ), + ).toBe(false); + }); +}); + diff --git a/apps/web/tests/macos-setup-wizard.test.tsx b/apps/web/tests/macos-setup-wizard.test.tsx index d4717d8..b3f1217 100644 --- a/apps/web/tests/macos-setup-wizard.test.tsx +++ b/apps/web/tests/macos-setup-wizard.test.tsx @@ -101,7 +101,7 @@ describe("MacosSetupWizard", () => { useCameraStore.setState({ macosBetaAcknowledged: true }); emitState(macosError()); render(); - expect(screen.getByText("killall ptpcamerad")).toBeInTheDocument(); + expect(screen.getByText("killall ptpcamerad icdd")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /i've run it/i })); expect(dispatch).toHaveBeenCalledWith({ type: "MACOS_SETUP_ATTEMPTED", @@ -136,6 +136,9 @@ describe("MacosSetupWizard", () => { render(); fireEvent.click(screen.getByRole("button", { name: /show advanced option/i })); expect(screen.getByText(/launchctl disable/)).toBeInTheDocument(); + expect(screen.getByText(/killall -STOP ptpcamerad icdd/)).toBeInTheDocument(); + expect(screen.getByText(/killall -CONT ptpcamerad icdd/)).toBeInTheDocument(); + expect(screen.getAllByText(/com\.apple\.icdd/)).toHaveLength(2); expect(screen.getByText(/launchctl enable/)).toBeInTheDocument(); }); diff --git a/apps/web/tests/recipe-share.test.ts b/apps/web/tests/recipe-share.test.ts new file mode 100644 index 0000000..e6dbee6 --- /dev/null +++ b/apps/web/tests/recipe-share.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { RecipeType } from "@latent/recipe-schema/browser"; +import { + decodeRecipeShare, + decodeRecipeShareFromLocation, + encodeRecipeShare, + recipeForUrlShare, + recipeShareUrl, +} from "../src/lib/recipe-share"; + +const recipe: RecipeType = { + id: "77777777-7777-4777-8777-777777777777", + schemaVersion: 1, + name: "Shared Chrome", + description: "A recipe with share-safe metadata.", + author: "Latent", + parentRecipeId: "66666666-6666-4666-8666-666666666666", + tags: ["shared", "chrome"], + createdAt: "2026-05-06T08:00:00.000Z", + capabilitySetId: "x-s20-fw1.10", + cameraModel: "X-S20", + filmSimulation: "ClassicChrome", + exposureCompensation: 0, + dynamicRange: "DR400", + dRangePriority: "Off", + whiteBalance: { mode: "Daylight", shiftR: 2, shiftB: -3 }, + highlightTone: -1, + shadowTone: 1, + color: 2, + sharpness: -1, + noiseReduction: -4, + clarity: 0, + grainEffect: { strength: "Weak", size: "Small" }, + colorChromeEffect: "Weak", + colorChromeEffectBlue: "Strong", + smoothSkinEffect: "Off", + reasoning: [ + { + parameter: "Tone", + visualEffect: "Adds contrast.", + reason: "Private reasoning should not be embedded in share URLs.", + }, + ], +}; + +describe("recipe URL share", () => { + it("builds share-safe recipes without structured reasoning", () => { + const shareable = recipeForUrlShare(recipe); + + expect(shareable.id).toBe(recipe.id); + expect(shareable.parentRecipeId).toBe(recipe.parentRecipeId); + expect(shareable.reasoning).toBeUndefined(); + }); + + it("round-trips a recipe through the URL payload", () => { + const encoded = encodeRecipeShare(recipe); + const decoded = decodeRecipeShare(encoded); + + expect(decoded.id).toBe(recipe.id); + expect(decoded.name).toBe(recipe.name); + expect(decoded.parentRecipeId).toBe(recipe.parentRecipeId); + expect(decoded.reasoning).toBeUndefined(); + }); + + it("creates a library URL and decodes it from the location search params", () => { + const url = recipeShareUrl(recipe, "https://latent.example/app?theme=dark#raf"); + const location = new URL(url); + + expect(location.hash).toBe("#library"); + expect(location.searchParams.get("theme")).toBe("dark"); + expect(location.searchParams.get("share")).toBeTruthy(); + expect(decodeRecipeShareFromLocation(location)?.id).toBe(recipe.id); + }); +}); diff --git a/docs/README.md b/docs/README.md index faaf9a1..666f61b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ humans and Claude/Codex agents working in the repo. repository and release checklist before a public announcement - [`qa/hardware-test-plan.md`](./qa/hardware-test-plan.md) — manual camera stability checks +- [`qa/macos-webusb-camera-release.md`](./qa/macos-webusb-camera-release.md) — + release runbook for macOS Image Capture/WebUSB claim collisions End-user legal pages such as privacy policy and terms can live alongside the marketing site when it exists; repo-critical technical docs stay here. diff --git a/docs/plans/2026-05-06-latent-v1-phase-6-polish.md b/docs/plans/2026-05-06-latent-v1-phase-6-polish.md new file mode 100644 index 0000000..d5545b6 --- /dev/null +++ b/docs/plans/2026-05-06-latent-v1-phase-6-polish.md @@ -0,0 +1,37 @@ +# Latent V1 — Phase 6 Polish Plan + +Phase 6 turns the hardware-backed alpha into a shareable, launch-ready web +app surface without changing the core camera safety model. + +## Scope + +- Responsive polish for the existing Camera, RAF, Library, and Creator + workspaces. +- Recipe portability: import/export, JSON download, and self-contained URL + share links. +- Recipe genealogy: duplicated/derived recipes keep parent metadata and show + their source in the detail view. +- Accessibility and security launch checks: WCAG 2.2 AA audit and production + CSP validation. + +## Delivered + +- Responsive desktop/mobile polish is in the `0.1.0` alpha UI. +- Recipe import/export and JSON download are implemented. +- URL share is implemented as a self-contained `?share=` payload that imports + and selects a shared recipe on load. +- URL share excludes structured `reasoning` by default so explanatory/private + generation notes are not embedded in public links. +- Recipe genealogy is implemented for creator duplicates via `parentRecipeId` + and surfaced in recipe metadata. + +## Remaining Acceptance + +- Run a WCAG 2.2 AA audit on the production-like build, including keyboard + navigation, focus visibility, semantic labels, contrast, and reduced-motion + behavior. +- Validate the final production CSP after the hosting target is chosen. WebUSB + requires HTTPS or localhost; RAF files and recipe JSON must remain local. +- Smoke test URL share on the deployed portal once Phase 7 chooses Vercel or + GCP. +- Re-run full validation before marking Phase 6 complete in `ROADMAP.md`. diff --git a/docs/plans/README.md b/docs/plans/README.md index 3a6d95d..d543baf 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -12,6 +12,13 @@ final plan stays as historical record. - `2026-05-03-fujicomp-v1-phase-1-foundation.md` — Phase 1 (foundation, recipe-schema, ptp-fuji fork). Closed; superseded by Phase 2-min and Phase 3-base, summaries in `../../PROGRESS.md`. +- `2026-05-06-latent-v1-phase-6-polish.md` — Phase 6 polish. URL share and + genealogy are implemented; WCAG/CSP launch validation remains open. -Future plans land here as Phase 4 (camera flows), Phase 5 (AI agent), -Phase 6 (polish), Phase 7 (launch) start. +Phase 2-min, Phase 2-full, Phase 3-base, and the Phase 4 alpha camera flows +shipped through implementation sessions without separate phase-plan files. +Their summaries live in `../../PROGRESS.md` and `../../CHANGELOG.md`. + +Future plans should cover the remaining open work: Phase 5 (AI agent, unless +deferred), Phase 6 finish items (WCAG and CSP), and Phase 7 launch closure +(ADRs, trademark review, final seed list, portal, deploy). diff --git a/docs/qa/hardware-test-plan.md b/docs/qa/hardware-test-plan.md index c130c31..9ab0660 100644 --- a/docs/qa/hardware-test-plan.md +++ b/docs/qa/hardware-test-plan.md @@ -44,9 +44,9 @@ Run every item against each supported physical setup before release. - [ ] 12. With `ptpcamerad` able to claim the camera, connect and confirm the first claim collision shows the macOS beta warning. - [ ] 13. Acknowledge the beta warning, retry the collision, and confirm the beta warning is not shown again in the same browser profile. -- [ ] 14. Run the safer-first `killall ptpcamerad` command from the wizard, reconnect, and confirm only the setup-acknowledged flag is stored. -- [ ] 15. Use the advanced persistent command, reconnect successfully, and confirm both setup-acknowledged and persistent-disable flags are stored. -- [ ] 16. Re-enable `ptpcamerad`, reproduce a claim collision, and confirm the stale persistent flag is cleared and the UI explains that macOS may have re-enabled the daemon. +- [ ] 14. Run the safer-first `killall ptpcamerad icdd` command from the wizard, power-cycle the camera, reconnect, and confirm only the setup-acknowledged flag is stored. +- [ ] 15. Use the advanced persistent command for both `com.apple.ptpcamerad` and `com.apple.icdd`, confirm it suspends live daemon processes, power-cycle or battery-reset the camera, reconnect successfully, and confirm both setup-acknowledged and persistent-disable flags are stored. +- [ ] 16. Re-enable `ptpcamerad` and `icdd`, reproduce a claim collision, and confirm the stale persistent flag is cleared and the UI explains that macOS or another browser session may have re-enabled the claim path. ### Edge Environment @@ -99,4 +99,3 @@ Run every item against each supported physical setup before release. | 25 | | | | 26 | | | | 27 | | | - diff --git a/docs/qa/macos-webusb-camera-release.md b/docs/qa/macos-webusb-camera-release.md new file mode 100644 index 0000000..e5bbd02 --- /dev/null +++ b/docs/qa/macos-webusb-camera-release.md @@ -0,0 +1,348 @@ +# macOS WebUSB Camera Release Runbook + +Operational notes for releasing a Fujifilm camera when Chromium/WebUSB reports +that macOS or another session has exclusive access to the PTP interface. + +Observed during X-S20 hardware validation on 2026-05-06. + +## 2026-05-06 Incident Summary + +Hardware/session: + +- camera: Fujifilm X-S20, firmware 3.30; +- app: local Vite dev server at `http://127.0.0.1:5173/`; +- browser: Google Chrome on macOS; +- failure surface: Latent camera workspace after the WebUSB picker. + +What happened: + +1. The camera appeared in the browser's WebUSB picker, but the connection failed + when the app tried to claim the PTP interface. +2. Latent showed the macOS claim-collision recovery message. The original copy + over-attributed the failure to Image Capture. +3. Clicking "Open setup" did not reliably expose the setup wizard because the + beta acknowledgement and the collision setup UI were coupled too tightly. +4. Running only `killall ptpcamerad` did not fix the session. +5. Process and USB diagnostics showed that the competing owner could be + `ptpcamerad`, `icdd`, or a stale Google Chrome WebUSB session. +6. The successful release path combined three things: + - stop both macOS camera daemons: `killall ptpcamerad icdd`; + - force Latent to reopen the WebUSB picker instead of reusing a paired + device after the setup attempt; + - use a clean Chrome profile when the normal profile kept a stale WebUSB + claim or pairing state. +7. After reconnecting, the camera was detected as `X-S20 · FW 3.30`. +8. A second issue appeared: the app could connect but stay on "Reading custom + slots from the camera" with `0 slots read`. +9. The app now emits partial preset-read results, records per-slot failures, + and times out stuck slot reads instead of leaving the UI indefinitely in the + scanning state. +10. After intentionally re-enabling `ptpcamerad` and `icdd`, the claim bug + reproduced. `launchctl disable ... && killall ...` left the jobs disabled + but still running, so the reliable dev workaround was to suspend the live + daemon PIDs and fully power-cycle the camera. +11. In the observed case, unplugging USB was not enough; removing and + reinserting the camera battery cleared the camera-side stale PTP session. + +Outcome: + +- camera connection recovered; +- camera recipes became visible in the UI; +- the macOS release procedure is now documented here; +- the app contains guardrails for the same class of failure, but the browser + still cannot execute macOS release commands itself. + +Follow-up observed after the fix: + +- A Fujifilm X-T20 can connect far enough for Latent to leave the macOS + claim-collision path, but may show "The camera did not return any readable + custom slots." +- Treat that as a separate model capability or preset-read compatibility issue, + not as evidence that macOS is still holding the camera. +- Debug it through PTP operation/property support, slot count assumptions, and + legacy custom-setting behavior for that body. + +## Symptom + +Latent shows a macOS claim-collision error after the WebUSB picker: + +- the camera appears in the browser picker; +- connection fails at USB interface claim time; +- retrying the same paired device returns to the same error; +- running only `killall ptpcamerad` may not release the camera. + +## Cause + +On macOS, more than one process can interfere with browser PTP access: + +- `ptpcamerad` can claim PTP cameras automatically; +- `icdd`, the Image Capture daemon, can also hold the camera; +- a previous Chromium WebUSB session can leave the browser-side device pairing + or claim state stale until the picker is reopened or the browser profile is + isolated. + +Do not assume the owner is only Image Capture. Diagnose the active owner first +when possible. + +## Fix Boundary + +This is partly an application fix and partly an operating-system procedure. + +Fixed in the app: + +- the setup UI can be reached from the macOS collision state; +- the recovery copy says "macOS or another browser session" instead of blaming + only Image Capture; +- the basic setup command shown by Latent is `killall ptpcamerad icdd`; +- the advanced setup command covers both `com.apple.ptpcamerad` and + `com.apple.icdd`, and suspends live daemon processes with `killall -STOP`; +- the setup copy tells the user to power-cycle the camera after running the + release command; +- after a macOS setup attempt, the connection manager requests the browser + picker again by setting `autoSelectPaired: false`; +- failed WebUSB connection attempts clean up partially opened transports and + raw USB devices; +- preset reads report partial success and slot-level failures; +- stuck preset reads are guarded by a per-slot timeout. +- when the optional local helper is running, the setup wizard can read daemon + status and run release/restore actions through `http://127.0.0.1:5174`. + +Implementation map: + +| Area | Files | Behavior | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS recovery UI | `apps/web/src/components/camera/CameraConnect.tsx`, `apps/web/src/components/camera/MacosSetupWizard.tsx` | Setup is reachable from the collision state and shows commands for both `ptpcamerad` and `icdd`; advanced mode suspends live daemon processes. | +| macOS recovery copy | `apps/web/src/i18n/en.ts`, `apps/web/src/i18n/it.ts` | Copy now says macOS or another browser session may own the camera, avoiding a false single-cause Image Capture diagnosis. | +| Picker retry | `packages/camera-connection/src/manager.ts` | `MACOS_SETUP_ATTEMPTED` reconnects with `autoSelectPaired: false`, forcing the browser picker and avoiding stale paired-device reuse. | +| Failed connect cleanup | `packages/camera-connection/src/drivers/webusb.ts` | Failed connect attempts close partially opened PTP transports or raw USB devices. | +| Preset read guardrail | `packages/camera-connection/src/manager.ts`, `apps/web/src/stores/camera.ts`, `apps/web/src/components/camera/CameraRecipesPanel.tsx` | Slot reads emit snapshots, record per-slot failures, and time out stuck reads instead of leaving the UI in permanent scanning. | +| Local macOS helper | `scripts/macos-camera-helper.ts`, `apps/web/src/lib/macos-camera-helper.ts` | Optional localhost helper reads daemon status and runs release/restore actions from buttons in the setup wizard. | +| Regression tests | `packages/camera-connection/tests/manager.test.ts`, `packages/camera-connection/tests/webusb-driver.test.ts`, `apps/web/tests/CameraConnect.test.tsx`, `apps/web/tests/macos-setup-wizard.test.tsx`, `apps/web/tests/CameraRecipesPanel.test.tsx` | Tests cover picker forcing, cleanup, command text, setup state, partial preset failures, and stuck-slot timeout. | + +Not fixable directly from the web app: + +- a browser page cannot run `killall`, `launchctl`, `kill -STOP`, or reopen + macOS privacy/system dialogs without the optional local helper; +- the browser must still show the WebUSB picker for permission; +- macOS services can restart or reclaim the camera outside the app's control; +- a stale browser profile may need a clean-profile launch or full browser + restart. + +## Diagnose + +List the camera and any exclusive USB owner: + +```bash +ioreg -p IOUSB -l -w0 | rg -i "X-S20|X-M5|Fujifilm|UsbExclusiveOwner" +``` + +Useful interpretations: + +- no `UsbExclusiveOwner` line: macOS may not currently show an owner, but a + stale browser pairing or daemon restart can still make the next claim fail; +- owner is Google Chrome: close/restart the tab or use the clean-profile + browser step; +- owner is a macOS daemon: use the temporary release command first; +- owner changes between attempts: unplug/replug the camera and reopen the + WebUSB picker after each release attempt. + +List likely macOS camera daemons: + +```bash +ps -axo pid,comm,args | rg "ptpcamerad|icdd|Image Capture|Photos" +``` + +If `UsbExclusiveOwner` points at Google Chrome, the browser session is the stale +owner. Use the clean-profile browser step below before changing more macOS +services. + +## Temporary Release + +Use this first. It is session-scoped and less invasive than disabling launch +agents: + +```bash +killall ptpcamerad icdd +``` + +Then: + +1. Unplug and replug the camera. +2. Power-cycle the camera. If the claim remains stuck, remove and reinsert the + battery to clear the camera-side PTP session. +3. Keep the camera awake and in USB/PTP mode. +4. Reopen the WebUSB picker in Latent and select the camera again. + +The app should force the picker after the macOS setup flow; reusing a stale +paired device can reproduce the same claim error. + +## Clean Chromium Profile + +If the active owner is Chrome, or the normal browser profile keeps returning to +the same error, start a disposable profile: + +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --user-data-dir=/private/tmp/latent-chrome-webusb \ + --no-first-run \ + --new-window \ + http://127.0.0.1:5173/ +``` + +This avoids stale WebUSB permissions and stale device state in the normal +profile while keeping the local dev server unchanged. + +Use this when: + +- `killall ptpcamerad icdd` succeeds but Latent still returns to the same + claim-collision state; +- `ioreg` shows Chrome as `UsbExclusiveOwner`; +- resetting the normal profile's site permission does not clear the failure; +- the WebUSB picker does not appear again after the setup flow. + +## Persistent Dev Workaround + +Use only on a development machine while doing hardware validation. This disables +macOS auto-claiming services until they are re-enabled: + +```bash +launchctl disable gui/$(id -u)/com.apple.ptpcamerad +launchctl disable gui/$(id -u)/com.apple.icdd +killall -STOP ptpcamerad icdd +``` + +If the services are already running, `launchctl disable` can mark them disabled +while the current processes remain alive. `killall -STOP` freezes those live +processes so they cannot claim the next camera attach. + +If `killall -STOP` is unavailable or you need to target exact PIDs, stop the +current processes for the test session: + +```bash +ps -axo pid,comm,args | rg "ptpcamerad|icdd" +kill -STOP +kill -STOP +``` + +Record the stopped PIDs. Do not leave them suspended after testing. + +After suspending the daemons, physically reset the camera connection: + +1. Unplug USB. +2. Turn the camera off. +3. If the browser still reports a claim collision after reconnect, remove and + reinsert the battery. +4. Turn the camera on, keep it awake, and reopen the WebUSB picker. + +## Optional Local Helper + +For hardware QA on macOS, the simplest path is the combined development stack: + +```bash +npm run dev:macos +``` + +This starts the Vite app at `http://127.0.0.1:5173/` and the local helper at +`http://127.0.0.1:5174/`. + +If the web app is already running, you can start only the local helper: + +```bash +npm run macos-camera-helper +``` + +The helper listens only on `127.0.0.1:5174` and accepts browser requests only +from `localhost` or `127.0.0.1` origins. When it is running, the macOS setup +wizard shows daemon status plus one-click Release and Restore buttons. +If the wizard is closed while the services are still disabled or suspended, +Latent keeps a compact restore control in a bottom-right portal so the user can +return macOS to its normal state. + +Release runs: + +```bash +launchctl disable gui/$(id -u)/com.apple.ptpcamerad +launchctl disable gui/$(id -u)/com.apple.icdd +killall -STOP ptpcamerad icdd +``` + +Restore runs: + +```bash +killall -CONT ptpcamerad icdd +launchctl enable gui/$(id -u)/com.apple.ptpcamerad +launchctl enable gui/$(id -u)/com.apple.icdd +``` + +The helper cannot power-cycle the camera. If the camera-side PTP session is +stale, still unplug USB and power-cycle the body, including battery reseat when +needed. + +Notes: + +- `launchctl bootout` may be denied or ineffective for protected Apple agents + depending on the macOS version and login session. Prefer `disable` plus + `killall` for this dev workflow. +- If `kill -STOP` is used, the stopped services remain frozen only until they + are continued, killed, or the machine reboots. +- Do not run this workaround on a user's production machine without explaining + how to restore the defaults. + +## Restore macOS Defaults + +When hardware testing is finished, re-enable the services: + +```bash +killall -CONT ptpcamerad icdd +launchctl enable gui/$(id -u)/com.apple.ptpcamerad +launchctl enable gui/$(id -u)/com.apple.icdd +``` + +If you used `kill -STOP` on exact PIDs, resume the same PIDs: + +```bash +kill -CONT +kill -CONT +``` + +A reboot after re-enabling is the cleanest way to return macOS camera handling +to its default state. + +## Verification Checklist + +After applying the release procedure: + +- reopen the WebUSB picker and select the Fujifilm camera; +- confirm Latent shows the connected badge with model and firmware; +- confirm a full camera power-cycle or battery reseat clears a stale PTP + session when USB replug alone does not; +- confirm `Slots read` increments, or a final slot-level failure is shown; +- confirm a failed read does not stay forever on "Reading custom slots from the + camera"; +- export a camera backup only after at least one slot has been read; +- run the targeted tests: + +```bash +npm --workspace @latent/camera-connection test -- manager webusb-driver +npm --workspace @latent/web test -- CameraConnect macos-setup-wizard CameraRecipesPanel +npm test -- scripts/macos-camera-helper.test.ts +npm run typecheck +npm run lint +npm run lockstep-check +``` + +## App-Side Guardrails + +Latent should support this runbook with product behavior: + +- the macOS setup wizard uses `killall ptpcamerad icdd`, not only + `ptpcamerad`; +- advanced setup uses `killall -STOP ptpcamerad icdd` because `launchctl +disable` can leave existing daemon processes running; +- setup copy asks for a camera power-cycle after the release command; +- after setup confirmation, the connection manager reopens the WebUSB picker + instead of silently reusing a stale paired device; +- failed WebUSB connection attempts close any partially opened transport or raw + device; +- preset reads emit partial results and slot-level failures instead of leaving + the UI indefinitely on "Reading custom slots from the camera". diff --git a/package.json b/package.json index d310ae2..6edd669 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "apps/*" ], "scripts": { + "dev:macos": "tsx scripts/dev-macos.ts", "lint": "eslint --no-error-on-unmatched-pattern packages apps", "format": "prettier --write packages apps", "format:check": "prettier --check packages apps", "typecheck": "tsc -b", "test": "vitest run", "test:watch": "vitest", + "macos-camera-helper": "tsx scripts/macos-camera-helper.ts", "license-check": "license-checker --production --excludePrivatePackages --onlyAllow 'MIT;AGPL-3.0;Apache-2.0;BSD-3-Clause;BSD-2-Clause;ISC;CC-BY-4.0;CC0-1.0'", "lockstep-check": "tsx scripts/check-schema-translator-lockstep.ts", "validate": "npm run typecheck && npm run lint && npm run test && npm run license-check && npm run lockstep-check" diff --git a/packages/camera-connection/src/drivers/webusb.ts b/packages/camera-connection/src/drivers/webusb.ts index 5d44a9d..c09815b 100644 --- a/packages/camera-connection/src/drivers/webusb.ts +++ b/packages/camera-connection/src/drivers/webusb.ts @@ -236,54 +236,73 @@ export class WebUsbCameraDriver implements CameraDriver { async connect(opts: ConnectOptions = {}): Promise { throwIfAborted(opts.signal); const device = await this.selectDevice(opts); - throwIfAborted(opts.signal); - await openAndSelect(device, this.configurationValue); - const initialInterface = pickPtpInterface(device); - await claimWithReset(device, initialInterface.interfaceNumber, this.configurationValue); - let iface = pickPtpInterface(device); - let transport = this.transportFactory(device, iface.endpointIn, iface.endpointOut, { - interfaceNumber: iface.interfaceNumber, - }); - let session = this.sessionFactory(transport); + let completed = false; + let cleanedUp = false; + let transport: PtpTransport | undefined; + let session: FujiSessionLike | undefined; try { - await openSessionWithStaging(session, opts.signal); - } catch (err) { - if (nameOf(err) === "AbortError") throw err; - if (!(err instanceof LatentError) || err.stage !== "open") throw err; - await disposeSessionTransport(session, transport); - iface = await recoverFromOpenSessionFailure(device, this.configurationValue); + throwIfAborted(opts.signal); + await openAndSelect(device, this.configurationValue); + const initialInterface = pickPtpInterface(device); + await claimWithReset(device, initialInterface.interfaceNumber, this.configurationValue); + let iface = pickPtpInterface(device); transport = this.transportFactory(device, iface.endpointIn, iface.endpointOut, { interfaceNumber: iface.interfaceNumber, }); session = this.sessionFactory(transport); - await openSessionWithStaging(session, opts.signal); - } - const port = new WebUsbSessionPort(session); - let deviceInfo: DeviceInfo; - try { - deviceInfo = await port.getDeviceInfo(opts.signal); + try { + await openSessionWithStaging(session, opts.signal); + } catch (err) { + if (nameOf(err) === "AbortError") throw err; + if (!(err instanceof LatentError) || err.stage !== "open") throw err; + await disposeSessionTransport(session, transport); + cleanedUp = true; + iface = await recoverFromOpenSessionFailure(device, this.configurationValue); + transport = this.transportFactory(device, iface.endpointIn, iface.endpointOut, { + interfaceNumber: iface.interfaceNumber, + }); + session = this.sessionFactory(transport); + cleanedUp = false; + await openSessionWithStaging(session, opts.signal); + } + const activeTransport = transport; + const activeSession = session; + const port = new WebUsbSessionPort(activeSession); + let deviceInfo: DeviceInfo; + try { + deviceInfo = await port.getDeviceInfo(opts.signal); + } catch (err) { + await disposeSessionTransport(activeSession, activeTransport); + cleanedUp = true; + throw err; + } + + let disposed = false; + const result: DriverConnectResult = { + port, + deviceInfo, + ...(device.serialNumber ? { usbSerialNumber: device.serialNumber } : {}), + dispose: async () => { + if (disposed) return; + disposed = true; + await disposeSessionTransport(activeSession, activeTransport); + }, + }; + + this.activeResult = result; + this.activeSession = activeSession; + this.activeProductId = device.productId; + this.activeUsbSerialNumber = device.serialNumber ?? undefined; + completed = true; + return result; } catch (err) { - await disposeSessionTransport(session, transport); + if (!completed && !cleanedUp && session && transport) { + await disposeSessionTransport(session, transport); + } else if (!completed && !cleanedUp) { + await closeDevice(device); + } throw err; } - - let disposed = false; - const result: DriverConnectResult = { - port, - deviceInfo, - ...(device.serialNumber ? { usbSerialNumber: device.serialNumber } : {}), - dispose: async () => { - if (disposed) return; - disposed = true; - await disposeSessionTransport(session, transport); - }, - }; - - this.activeResult = result; - this.activeSession = session; - this.activeProductId = device.productId; - this.activeUsbSerialNumber = device.serialNumber ?? undefined; - return result; } async disconnect(): Promise { @@ -371,11 +390,20 @@ async function disposeSessionTransport( try { await session.close(); } catch { - try { - await transport.close(); - } catch { - // best-effort cleanup - } + // keep cleanup best-effort + } + try { + await transport.close(); + } catch { + // best-effort cleanup + } +} + +async function closeDevice(device: USBDevice): Promise { + try { + if (device.opened) await device.close(); + } catch { + // best-effort cleanup } } diff --git a/packages/camera-connection/src/index.ts b/packages/camera-connection/src/index.ts index cab55e8..da805ae 100644 --- a/packages/camera-connection/src/index.ts +++ b/packages/camera-connection/src/index.ts @@ -24,4 +24,5 @@ export { ConnectionManager, type ConnectionManagerOptions, type ManagerNotifications, + type PresetReadFailure, } from "./manager.js"; diff --git a/packages/camera-connection/src/manager.ts b/packages/camera-connection/src/manager.ts index 43cd563..77379b3 100644 --- a/packages/camera-connection/src/manager.ts +++ b/packages/camera-connection/src/manager.ts @@ -13,13 +13,23 @@ import type { ConnectionState } from "./types.js"; export type ManagerNotifications = { "setup-confirmed": { advanced: boolean }; - "presets-read": { presets: RawPreset[] }; + "presets-read": { presets: RawPreset[]; failures: PresetReadFailure[] }; }; +export interface PresetReadFailure { + slot: number; + message: string; + category?: string; + stage?: string; +} + export interface ConnectionManagerOptions { shouldAutoconnect?: () => Promise | boolean; + presetReadTimeoutMs?: number; } +const DEFAULT_PRESET_READ_TIMEOUT_MS = 15_000; + type StateSubscriber = (state: ConnectionState) => void; type NotificationSubscriber = ( @@ -156,21 +166,25 @@ export class ConnectionManager { private startConnect(state: Extract): void { const opId = ++this.currentOpId; - void this.runConnect(opId, state.abort); + void this.runConnect(opId, state.abort, state.macosSetupPending ? false : true); } private startReconnect(state: Extract): void { this.clearReconnectTimer(); this.reconnectAbort = state.abort; this.reconnectTimer = setTimeout(() => { - void this.runConnect(++this.currentOpId, state.abort); + void this.runConnect(++this.currentOpId, state.abort, true); }, backoffDelayMs(state.attempt)); } - private async runConnect(opId: number, abort: AbortController): Promise { + private async runConnect( + opId: number, + abort: AbortController, + autoSelectPaired: boolean, + ): Promise { try { const result = await this.driver.connect({ - autoSelectPaired: true, + autoSelectPaired, signal: abort.signal, }); if (opId !== this.currentOpId || !canAcceptConnect(this.state)) { @@ -195,17 +209,36 @@ export class ConnectionManager { port: { getPreset: (slot: number, signal?: AbortSignal) => Promise }, ): Promise { const presets: RawPreset[] = []; + const failures: PresetReadFailure[] = []; for (let slot = 1; slot <= 7; slot++) { if (opId !== this.currentOpId || !isAlive(this.state)) return; try { - presets.push(await port.getPreset(slot)); + const preset = await readPresetWithTimeout( + port, + slot, + this.options.presetReadTimeoutMs ?? DEFAULT_PRESET_READ_TIMEOUT_MS, + ); + presets.push(preset); + this.emitPresetReadSnapshot(opId, presets, failures); } catch (err) { if (isOptionalPresetReadFailure(err)) continue; - return; + failures.push(toPresetReadFailure(slot, err)); + this.emitPresetReadSnapshot(opId, presets, failures); } } + this.emitPresetReadSnapshot(opId, presets, failures); + } + + private emitPresetReadSnapshot( + opId: number, + presets: RawPreset[], + failures: PresetReadFailure[], + ): void { if (opId === this.currentOpId && isAlive(this.state)) { - this.emitNotification("presets-read", { presets }); + this.emitNotification("presets-read", { + presets: [...presets], + failures: [...failures], + }); } } @@ -273,6 +306,40 @@ function abortState(state: ConnectionState): void { } } +async function readPresetWithTimeout( + port: { getPreset: (slot: number, signal?: AbortSignal) => Promise }, + slot: number, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) return port.getPreset(slot); + + const abort = new AbortController(); + let timeout: ReturnType | undefined; + const timeoutError = new LatentError( + "PtpTimeout", + `Timed out reading C${slot} after ${Math.round(timeoutMs / 1000)}s`, + undefined, + { stage: "transfer-in" }, + ); + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + abort.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + }); + const readPromise = port.getPreset(slot, abort.signal); + readPromise.catch(() => undefined); + + try { + return await Promise.race([readPromise, timeoutPromise]); + } catch (err) { + if (nameOf(err) === "AbortError") throw timeoutError; + throw err; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + function toLatentError(err: unknown): LatentError { if (err instanceof LatentError) return err; const name = nameOf(err); @@ -298,3 +365,18 @@ function isOptionalPresetReadFailure(err: unknown): boolean { (err.category === "PtpUnsupportedOperation" || err.category === "PtpStall") ); } + +function toPresetReadFailure(slot: number, err: unknown): PresetReadFailure { + if (err instanceof LatentError) { + return { + slot, + message: err.message, + category: err.category, + ...(err.stage ? { stage: err.stage } : {}), + }; + } + return { + slot, + message: err instanceof Error ? err.message : String(err), + }; +} diff --git a/packages/camera-connection/tests/manager.test.ts b/packages/camera-connection/tests/manager.test.ts index 9b10962..6369c83 100644 --- a/packages/camera-connection/tests/manager.test.ts +++ b/packages/camera-connection/tests/manager.test.ts @@ -29,6 +29,10 @@ function result(port = new FakeSessionPort()): DriverConnectResult { }; } +async function flushAsyncWork(turns = 40): Promise { + for (let i = 0; i < turns; i++) await Promise.resolve(); +} + const err = new LatentError("UsbDisconnect", "gone", undefined, { stage: "transfer-in", }); @@ -49,6 +53,32 @@ describe("ConnectionManager public API", () => { expect(driver.connectCalls[0]).toMatchObject({ autoSelectPaired: true }); }); + it("macOS setup retry forces the browser picker instead of reusing paired devices", async () => { + const driver = new FakeCameraDriver(); + driver.connect = vi.fn(async (opts = {}) => { + driver.connectCalls.push(opts); + if (driver.connectCalls.length === 1) { + throw new LatentError("UsbDisconnect", "busy", undefined, { + stage: "claim", + domException: "NetworkError", + platform: "mac", + }); + } + return result(); + }); + const manager = new ConnectionManager(driver); + + manager.dispatch({ type: "CONNECT_REQUESTED" }); + await Promise.resolve(); + expect(manager.getSnapshot()).toMatchObject({ + kind: "error", + reason: "macos-claim-collision", + }); + + manager.dispatch({ type: "MACOS_SETUP_ATTEMPTED", advanced: false }); + expect(driver.connectCalls.at(-1)).toMatchObject({ autoSelectPaired: false }); + }); + it("connect success commits connected", async () => { const manager = new ConnectionManager(new FakeCameraDriver()); manager.dispatch({ type: "CONNECT_REQUESTED" }); @@ -62,14 +92,15 @@ describe("ConnectionManager public API", () => { const handler = vi.fn(); manager.onNotification("presets-read", handler); manager.dispatch({ type: "CONNECT_REQUESTED" }); - for (let i = 0; i < 10; i++) await Promise.resolve(); - expect(driver.port.getPreset).toHaveBeenCalledWith(1); - expect(driver.port.getPreset).toHaveBeenCalledWith(7); + await flushAsyncWork(); + expect(driver.port.getPreset).toHaveBeenCalledWith(1, expect.any(AbortSignal)); + expect(driver.port.getPreset).toHaveBeenCalledWith(7, expect.any(AbortSignal)); expect(handler).toHaveBeenCalledWith({ presets: expect.arrayContaining([ expect.objectContaining({ slot: 1 }), expect.objectContaining({ slot: 7 }), ]), + failures: [], }); }); @@ -85,7 +116,7 @@ describe("ConnectionManager public API", () => { const handler = vi.fn(); manager.onNotification("presets-read", handler); manager.dispatch({ type: "CONNECT_REQUESTED" }); - for (let i = 0; i < 10; i++) await Promise.resolve(); + await flushAsyncWork(); expect(handler).toHaveBeenCalledWith({ presets: [ expect.objectContaining({ slot: 1 }), @@ -93,9 +124,74 @@ describe("ConnectionManager public API", () => { expect.objectContaining({ slot: 3 }), expect.objectContaining({ slot: 4 }), ], + failures: [], }); }); + it("preset read continues after a non-optional slot failure and reports it", async () => { + const driver = new FakeCameraDriver(); + driver.port.getPreset.mockImplementation(async (slot: number) => { + if (slot === 2) { + throw new LatentError("UsbDisconnect", "C2 transfer failed", undefined, { + stage: "transfer-in", + }); + } + return { slot, name: `C${slot}`, properties: {} }; + }); + const manager = new ConnectionManager(driver); + const handler = vi.fn(); + manager.onNotification("presets-read", handler); + manager.dispatch({ type: "CONNECT_REQUESTED" }); + await flushAsyncWork(); + expect(driver.port.getPreset).toHaveBeenCalledWith(3, expect.any(AbortSignal)); + expect(handler).toHaveBeenCalledWith({ + presets: expect.arrayContaining([ + expect.objectContaining({ slot: 1 }), + expect.objectContaining({ slot: 3 }), + expect.objectContaining({ slot: 7 }), + ]), + failures: [ + { + slot: 2, + message: "C2 transfer failed", + category: "UsbDisconnect", + stage: "transfer-in", + }, + ], + }); + }); + + it("preset read times out a stuck slot and reports later slots", async () => { + vi.useFakeTimers(); + try { + const driver = new FakeCameraDriver(); + driver.port.getPreset.mockImplementation((slot: number) => { + if (slot === 1) return new Promise(() => undefined); + return Promise.resolve({ slot, name: `C${slot}`, properties: {} }); + }); + const manager = new ConnectionManager(driver, { presetReadTimeoutMs: 5 }); + const handler = vi.fn(); + manager.onNotification("presets-read", handler); + manager.dispatch({ type: "CONNECT_REQUESTED" }); + await flushAsyncWork(); + await vi.advanceTimersByTimeAsync(5); + await flushAsyncWork(); + expect(driver.port.getPreset).toHaveBeenCalledWith(2, expect.any(AbortSignal)); + expect(handler).toHaveBeenCalledWith({ + presets: expect.arrayContaining([expect.objectContaining({ slot: 2 })]), + failures: [ + expect.objectContaining({ + slot: 1, + category: "PtpTimeout", + stage: "transfer-in", + }), + ], + }); + } finally { + vi.useRealTimers(); + } + }); + it("raw picker cancellation exits connecting as permission-denied", async () => { const driver = new FakeCameraDriver(); driver.connect = vi.fn(async () => { diff --git a/packages/camera-connection/tests/webusb-driver.test.ts b/packages/camera-connection/tests/webusb-driver.test.ts index 9d4b717..acd24ed 100644 --- a/packages/camera-connection/tests/webusb-driver.test.ts +++ b/packages/camera-connection/tests/webusb-driver.test.ts @@ -174,6 +174,41 @@ describe("WebUsbCameraDriver connect", () => { }); }); + it("closes the USB device when claim recovery still fails", async () => { + const device = new FakeUSBDevice(); + device.claimInterface + .mockRejectedValueOnce(new DOMException("busy", "NetworkError")) + .mockRejectedValueOnce(new DOMException("busy", "NetworkError")); + const usb = new FakeUsb([device]); + + await expect(driverWith(usb).connect()).rejects.toMatchObject({ + stage: "claim", + }); + expect(device.close).toHaveBeenCalledTimes(1); + }); + + it("closes the transport when device info read fails", async () => { + const fakeSession = session({ + getDeviceInfo: vi.fn(async () => { + throw new Error("device info failed"); + }), + }); + const transport = { + send: vi.fn(async () => undefined), + receive: vi.fn(async () => new Uint8Array(0)), + close: vi.fn(async () => undefined), + }; + const driver = new WebUsbCameraDriver({ + usb: new FakeUsb() as unknown as USB, + sessionFactory: () => fakeSession, + transportFactory: () => transport, + }); + + await expect(driver.connect()).rejects.toThrow("device info failed"); + expect(fakeSession.close).toHaveBeenCalledTimes(1); + expect(transport.close).toHaveBeenCalledTimes(1); + }); + it("WebUsbSessionPort reports isOpen from FujiCameraSession state", () => { expect(new WebUsbSessionPort(session()).isOpen()).toBe(true); expect(new WebUsbSessionPort(session({ state: "closed" })).isOpen()).toBe(false); diff --git a/scripts/dev-macos.ts b/scripts/dev-macos.ts new file mode 100644 index 0000000..9cb37b5 --- /dev/null +++ b/scripts/dev-macos.ts @@ -0,0 +1,143 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import process from "node:process"; + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +interface DevProcess { + name: string; + command: string; + args: string[]; +} + +const processes: DevProcess[] = [ + { + name: "web", + command: npmCommand, + args: [ + "--workspace", + "@latent/web", + "run", + "dev", + "--", + "--host", + "127.0.0.1", + "--port", + "5173", + "--strictPort", + ], + }, + { + name: "macos-helper", + command: npmCommand, + args: ["run", "macos-camera-helper"], + }, +]; + +const children = new Set(); +let shuttingDown = false; + +console.log("Starting Latent macOS development stack:"); +console.log("- web app: http://127.0.0.1:5173/"); +console.log("- macOS camera helper: http://127.0.0.1:5174/"); +console.log(""); + +for (const item of processes) { + start(item); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT", 0); +}); + +process.on("SIGTERM", () => { + void shutdown("SIGTERM", 0); +}); + +function start(item: DevProcess): void { + const child = spawn(item.command, item.args, { + cwd: process.cwd(), + detached: process.platform !== "win32", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + children.add(child); + pipeWithPrefix(child.stdout, item.name); + pipeWithPrefix(child.stderr, item.name); + + child.once("error", (err) => { + console.error(`[${item.name}] failed to start: ${err.message}`); + void shutdown("SIGTERM", 1); + }); + + child.once("exit", (code, signal) => { + children.delete(child); + if (shuttingDown) return; + + const reason = signal ? `signal ${signal}` : `exit code ${code ?? 0}`; + console.error(`[${item.name}] stopped unexpectedly with ${reason}`); + void shutdown("SIGTERM", code ?? 1); + }); +} + +function pipeWithPrefix(stream: NodeJS.ReadableStream, name: string): void { + let buffered = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + buffered += chunk; + let newlineIndex = buffered.indexOf("\n"); + while (newlineIndex !== -1) { + writePrefixedLine(name, buffered.slice(0, newlineIndex)); + buffered = buffered.slice(newlineIndex + 1); + newlineIndex = buffered.indexOf("\n"); + } + }); + + stream.on("end", () => { + if (buffered.length > 0) writePrefixedLine(name, buffered); + }); +} + +function writePrefixedLine(name: string, line: string): void { + if (line.trim().length === 0) { + console.log(""); + return; + } + console.log(`[${name}] ${line}`); +} + +async function shutdown(signal: NodeJS.Signals, exitCode: number): Promise { + if (shuttingDown) return; + shuttingDown = true; + + for (const child of children) { + stopChild(child, signal); + } + + setTimeout(() => { + for (const child of children) { + stopChild(child, "SIGKILL"); + } + process.exit(exitCode); + }, 2_500).unref(); + + if (children.size === 0) { + process.exit(exitCode); + } +} + +function stopChild(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void { + if (!child.pid) return; + try { + if (process.platform === "win32") { + child.kill(signal); + } else { + process.kill(-child.pid, signal); + } + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code !== "ESRCH") { + console.error(`Failed to stop child process ${child.pid}: ${nodeErr.message}`); + } + } +} diff --git a/scripts/macos-camera-helper.test.ts b/scripts/macos-camera-helper.test.ts new file mode 100644 index 0000000..ba8b174 --- /dev/null +++ b/scripts/macos-camera-helper.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parseCameraProcesses, parseDisabledServices } from "./macos-camera-helper"; + +describe("macOS camera helper parsers", () => { + it("parses disabled launchctl services", () => { + const disabled = parseDisabledServices(` + "com.apple.ptpcamerad" => disabled + "com.apple.icdd" => enabled + `); + expect(disabled.has("com.apple.ptpcamerad")).toBe(true); + expect(disabled.has("com.apple.icdd")).toBe(false); + }); + + it("parses camera daemon processes and suspended state", () => { + const processes = parseCameraProcesses(` + 14124 T /System/Library/Image Capture/Support/icdd + 93856 S /usr/libexec/ptpcamerad + 11111 S /Applications/Google Chrome.app/Contents/MacOS/Google Chrome + `); + expect(processes).toEqual([ + { name: "icdd", pid: 14124, stat: "T" }, + { name: "ptpcamerad", pid: 93856, stat: "S" }, + ]); + }); +}); + diff --git a/scripts/macos-camera-helper.ts b/scripts/macos-camera-helper.ts new file mode 100644 index 0000000..2851dc8 --- /dev/null +++ b/scripts/macos-camera-helper.ts @@ -0,0 +1,261 @@ +import { execFile } from "node:child_process"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import process from "node:process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const HOST = "127.0.0.1"; +const PORT = Number(process.env.LATENT_CAMERA_HELPER_PORT ?? 5174); +const SERVICES = ["ptpcamerad", "icdd"] as const; +const SERVICE_LABELS: Record = { + ptpcamerad: "com.apple.ptpcamerad", + icdd: "com.apple.icdd", +}; + +type ServiceName = (typeof SERVICES)[number]; + +interface CommandResult { + command: string; + ok: boolean; + stdout: string; + stderr: string; +} + +interface ServiceStatus { + disabled: boolean; + pids: number[]; + suspendedPids: number[]; + runningPids: number[]; +} + +interface HelperStatus { + ok: true; + platform: NodeJS.Platform; + uid: number; + services: Record; +} + +interface HelperResponse { + status: HelperStatus; + commands?: CommandResult[]; +} + +async function main(): Promise { + const server = createServer((req, res) => { + void handleRequest(req, res); + }); + server.listen(PORT, HOST, () => { + console.log(`Latent macOS camera helper listening at http://${HOST}:${PORT}`); + }); +} + +async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (!originAllowed(req.headers.origin)) { + writeJson(res, 403, { ok: false, error: "Origin not allowed" }); + return; + } + + setCors(res, req.headers.origin); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + try { + if (req.method === "GET" && req.url === "/status") { + writeJson(res, 200, { status: await getStatus() }); + return; + } + + if (req.method === "POST" && req.url === "/release") { + const commands = await releaseCameraServices(); + writeJson(res, 200, { status: await getStatus(), commands } satisfies HelperResponse); + return; + } + + if (req.method === "POST" && req.url === "/restore") { + const commands = await restoreCameraServices(); + writeJson(res, 200, { status: await getStatus(), commands } satisfies HelperResponse); + return; + } + + writeJson(res, 404, { ok: false, error: "Not found" }); + } catch (err) { + writeJson(res, 500, { + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +async function releaseCameraServices(): Promise { + return runCommands([ + ["launchctl", ["disable", launchctlTarget("com.apple.ptpcamerad")], false], + ["launchctl", ["disable", launchctlTarget("com.apple.icdd")], false], + ["killall", ["-STOP", ...SERVICES], true], + ]); +} + +async function restoreCameraServices(): Promise { + return runCommands([ + ["killall", ["-CONT", ...SERVICES], true], + ["launchctl", ["enable", launchctlTarget("com.apple.ptpcamerad")], false], + ["launchctl", ["enable", launchctlTarget("com.apple.icdd")], false], + ]); +} + +async function runCommands( + commands: Array<[file: string, args: string[], allowFailure: boolean]>, +): Promise { + const results: CommandResult[] = []; + for (const [file, args, allowFailure] of commands) { + const result = await runCommand(file, args, allowFailure); + results.push(result); + } + return results; +} + +async function runCommand( + file: string, + args: string[], + allowFailure: boolean, +): Promise { + try { + const { stdout, stderr } = await execFileAsync(file, args); + return { + command: formatCommand(file, args), + ok: true, + stdout, + stderr, + }; + } catch (err) { + const failure = err as { + stdout?: string; + stderr?: string; + message?: string; + }; + if (!allowFailure) throw err; + return { + command: formatCommand(file, args), + ok: false, + stdout: failure.stdout ?? "", + stderr: failure.stderr ?? failure.message ?? "", + }; + } +} + +async function getStatus(): Promise { + if (process.platform !== "darwin") { + return { + ok: true, + platform: process.platform, + uid: process.getuid?.() ?? -1, + services: emptyServiceStatuses(), + }; + } + const [disabled, processes] = await Promise.all([readDisabledServices(), readProcesses()]); + return { + ok: true, + platform: process.platform, + uid: process.getuid?.() ?? -1, + services: Object.fromEntries( + SERVICES.map((name) => { + const matches = processes.filter((item) => item.name === name); + return [ + name, + { + disabled: disabled.has(SERVICE_LABELS[name]), + pids: matches.map((item) => item.pid), + suspendedPids: matches + .filter((item) => item.stat.includes("T")) + .map((item) => item.pid), + runningPids: matches + .filter((item) => !item.stat.includes("T")) + .map((item) => item.pid), + }, + ]; + }), + ) as Record, + }; +} + +async function readDisabledServices(): Promise> { + const { stdout } = await execFileAsync("launchctl", ["print-disabled", launchctlDomain()]); + return parseDisabledServices(stdout); +} + +async function readProcesses(): Promise> { + const { stdout } = await execFileAsync("ps", ["-axo", "pid=,stat=,args="]); + return parseCameraProcesses(stdout); +} + +export function parseDisabledServices(output: string): Set { + const disabled = new Set(); + for (const line of output.split("\n")) { + const match = line.match(/"([^"]+)"\s*=>\s*disabled/); + if (match) disabled.add(match[1]!); + } + return disabled; +} + +export function parseCameraProcesses( + output: string, +): Array<{ name: ServiceName; pid: number; stat: string }> { + const processes: Array<{ name: ServiceName; pid: number; stat: string }> = []; + for (const line of output.split("\n")) { + const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.+)$/); + if (!match) continue; + const pid = Number(match[1]); + const stat = match[2]!; + const args = match[3]!; + if (args.includes("/usr/libexec/ptpcamerad") || args.endsWith("ptpcamerad")) { + processes.push({ name: "ptpcamerad", pid, stat }); + } else if (args.includes("/Image Capture/Support/icdd") || args.endsWith("/icdd")) { + processes.push({ name: "icdd", pid, stat }); + } + } + return processes; +} + +function emptyServiceStatuses(): Record { + return { + ptpcamerad: { disabled: false, pids: [], suspendedPids: [], runningPids: [] }, + icdd: { disabled: false, pids: [], suspendedPids: [], runningPids: [] }, + }; +} + +function launchctlDomain(): string { + return `gui/${process.getuid?.() ?? 501}`; +} + +function launchctlTarget(service: string): string { + return `${launchctlDomain()}/${service}`; +} + +function formatCommand(file: string, args: string[]): string { + return [file, ...args].join(" "); +} + +function setCors(res: ServerResponse, origin: string | undefined): void { + if (originAllowed(origin) && origin) { + res.setHeader("Access-Control-Allow-Origin", origin); + } + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "content-type"); +} + +function originAllowed(origin: string | undefined): boolean { + if (!origin) return true; + return /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin); +} + +function writeJson(res: ServerResponse, statusCode: number, body: unknown): void { + res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + void main(); +} + diff --git a/vitest.config.ts b/vitest.config.ts index cb51794..74a0e40 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["packages/*/tests/**/*.test.ts"], + include: ["packages/*/tests/**/*.test.ts", "scripts/**/*.test.ts"], environment: "node", coverage: { provider: "v8",