From fcbe3d10bdadab9ffc03308d21780fa81606b82c Mon Sep 17 00:00:00 2001 From: Giuseppe Albrizio Date: Wed, 6 May 2026 13:32:13 +0200 Subject: [PATCH 1/6] fix: harden macos camera release flow Document the X-S20 WebUSB claim-collision runbook, update macOS recovery commands, force picker retry after setup, clean up failed WebUSB connects, and surface partial preset-read failures. Co-Authored-By: Codex GPT-5 --- CHANGELOG.md | 22 +- PROGRESS.md | 92 +++++- README.it.md | 11 +- README.md | 12 +- ROADMAP.md | 40 ++- apps/web/src/App.tsx | 15 + apps/web/src/components/RecipeDetail.tsx | 50 +++- .../src/components/camera/CameraConnect.tsx | 76 +++-- .../components/camera/CameraRecipesPanel.tsx | 37 ++- .../components/camera/MacosSetupWizard.tsx | 7 +- apps/web/src/i18n/en.ts | 12 +- apps/web/src/i18n/it.ts | 12 +- apps/web/src/lib/recipe-share.ts | 65 +++++ apps/web/src/stores/camera.ts | 28 +- apps/web/tests/App.test.tsx | 35 +++ apps/web/tests/CameraConnect.test.tsx | 11 + apps/web/tests/RecipeDetail.test.tsx | 47 ++- apps/web/tests/macos-setup-wizard.test.tsx | 3 +- apps/web/tests/recipe-share.test.ts | 74 +++++ docs/README.md | 2 + .../2026-05-06-latent-v1-phase-6-polish.md | 37 +++ docs/plans/README.md | 11 +- docs/qa/hardware-test-plan.md | 7 +- docs/qa/macos-webusb-camera-release.md | 272 ++++++++++++++++++ .../camera-connection/src/drivers/webusb.ts | 118 +++++--- packages/camera-connection/src/index.ts | 1 + packages/camera-connection/src/manager.ts | 98 ++++++- .../camera-connection/tests/manager.test.ts | 104 ++++++- .../tests/webusb-driver.test.ts | 35 +++ 29 files changed, 1186 insertions(+), 148 deletions(-) create mode 100644 apps/web/src/lib/recipe-share.ts create mode 100644 apps/web/tests/recipe-share.test.ts create mode 100644 docs/plans/2026-05-06-latent-v1-phase-6-polish.md create mode 100644 docs/qa/macos-webusb-camera-release.md diff --git a/CHANGELOG.md b/CHANGELOG.md index faad266..5c8d815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,27 @@ 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. +- 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..628789a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,10 +1,100 @@ # 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. +- 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..52efa7c 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. @@ -114,7 +117,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..39cb69e 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 @@ -129,7 +129,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..f282c44 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,18 @@ 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. 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} @@ -80,3 +142,74 @@ export function MacosSetupWizard({ ); } + +function MacosHelperPanel({ + status, + error, + busy, + onRefresh, + onRelease, + onRestore, +}: { + status: MacosCameraHelperStatus | null; + error: string | null; + busy: boolean; + onRefresh: () => void; + onRelease: () => void; + onRestore: () => void; +}): JSX.Element | null { + const t = useT(); + if (!canUseMacosCameraHelper() || (!status && !error)) return null; + const serviceSummary = status + ? `ptpcamerad ${formatService(status.services.ptpcamerad)} · icdd ${formatService(status.services.icdd)}` + : error; + return ( +
+
+ {t("camera.macos.helper.title")} + +
+

{serviceSummary}

+
+ + +
+
+ ); +} + +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/i18n/en.ts b/apps/web/src/i18n/en.ts index 3a1e7e8..a873d7a 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -160,6 +160,11 @@ export const en = { "camera.macos.showAdvanced": "Show advanced option", "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.done.title": "macOS setup confirmed", "camera.macos.reset": "Reset macOS setup status", "camera.macos.close": "Close", diff --git a/apps/web/src/i18n/it.ts b/apps/web/src/i18n/it.ts index 94675f7..73bf654 100644 --- a/apps/web/src/i18n/it.ts +++ b/apps/web/src/i18n/it.ts @@ -171,6 +171,11 @@ export const it: Record = { "camera.macos.advanced.title": "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.done.title": "Configurazione macOS confermata", "camera.macos.reset": "Reimposta stato configurazione macOS", "camera.macos.close": "Chiudi", 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..e3c3f30 --- /dev/null +++ b/apps/web/src/lib/macos-camera-helper.ts @@ -0,0 +1,57 @@ +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 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/docs/qa/macos-webusb-camera-release.md b/docs/qa/macos-webusb-camera-release.md index 2861711..fa3b621 100644 --- a/docs/qa/macos-webusb-camera-release.md +++ b/docs/qa/macos-webusb-camera-release.md @@ -104,6 +104,8 @@ Fixed in the app: 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: @@ -114,12 +116,13 @@ Implementation map: | 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; + 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 @@ -231,6 +234,38 @@ After suspending the daemons, physically reset the camera connection: reinsert the battery. 4. Turn the camera on, keep it awake, and reopen the WebUSB picker. +## Optional Local Helper + +For hardware QA, start the local helper before opening Latent: + +```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. + +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 @@ -278,6 +313,7 @@ After applying the release procedure: ```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 diff --git a/package.json b/package.json index d310ae2..4c8efd1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "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/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", From bf885a27a85b96032309bb308b6c8d9446335d03 Mon Sep 17 00:00:00 2001 From: Giuseppe Albrizio Date: Wed, 6 May 2026 13:48:54 +0200 Subject: [PATCH 4/6] fix: keep macos restore control available Move the macOS helper restore affordance into a compact portal that remains visible after closing the setup wizard whenever camera services are disabled or suspended. Co-Authored-By: Codex GPT-5 --- CHANGELOG.md | 2 + PROGRESS.md | 2 + .../src/components/camera/CameraConnect.tsx | 2 + .../camera/MacosServicesControl.tsx | 155 ++++++++++++++++++ .../components/camera/MacosSetupWizard.tsx | 142 +--------------- apps/web/src/i18n/en.ts | 1 + apps/web/src/i18n/it.ts | 1 + apps/web/src/lib/macos-camera-helper.ts | 6 + apps/web/tests/macos-camera-helper.test.ts | 51 ++++++ docs/qa/macos-webusb-camera-release.md | 3 + 10 files changed, 229 insertions(+), 136 deletions(-) create mode 100644 apps/web/src/components/camera/MacosServicesControl.tsx create mode 100644 apps/web/tests/macos-camera-helper.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d05db..8243ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ launch). 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 diff --git a/PROGRESS.md b/PROGRESS.md index 79945e9..9f5d4c7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -47,6 +47,8 @@ device after the setup flow. - 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 diff --git a/apps/web/src/components/camera/CameraConnect.tsx b/apps/web/src/components/camera/CameraConnect.tsx index 0546080..68f6dba 100644 --- a/apps/web/src/components/camera/CameraConnect.tsx +++ b/apps/web/src/components/camera/CameraConnect.tsx @@ -7,6 +7,7 @@ import { DegradedBanner } from "./DegradedBanner"; import { ErrorBanner } from "./ErrorBanner"; import { MacosBetaWarning } from "./MacosBetaWarning"; import { MacosSetupWizard } from "./MacosSetupWizard"; +import { MacosServicesControl } from "./MacosServicesControl"; export function CameraConnect(): JSX.Element { const state = useCameraStore((s) => s.state); @@ -42,6 +43,7 @@ export function CameraConnect(): JSX.Element { onClose={closeMacosWizard} /> ) : null} + {!macosWizardOpen ? : null} ); diff --git a/apps/web/src/components/camera/MacosServicesControl.tsx b/apps/web/src/components/camera/MacosServicesControl.tsx new file mode 100644 index 0000000..8e8118b --- /dev/null +++ b/apps/web/src/components/camera/MacosServicesControl.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState, type JSX } from "react"; +import { createPortal } from "react-dom"; +import { useT } from "../../i18n"; +import { + canUseMacosCameraHelper, + getMacosCameraHelperStatus, + macosCameraHelperNeedsRestore, + releaseMacosCameraServices, + restoreMacosCameraServices, + type MacosCameraHelperStatus, +} from "../../lib/macos-camera-helper"; + +interface MacosServicesControlProps { + variant?: "compact" | "panel"; + forceVisible?: boolean; +} + +export function MacosServicesControl({ + variant = "panel", + forceVisible = false, +}: MacosServicesControlProps): JSX.Element | null { + const t = useT(); + const [helperStatus, setHelperStatus] = useState(null); + const [helperError, setHelperError] = useState(null); + const [helperBusy, setHelperBusy] = useState(false); + + useEffect(() => { + if (!canUseMacosCameraHelper()) return; + void refresh(); + const interval = setInterval(() => { + void refresh({ quiet: true }); + }, 5_000); + return () => clearInterval(interval); + }, []); + + const refresh = async ({ quiet = false }: { quiet?: boolean } = {}): Promise => { + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), 800); + try { + const status = await getMacosCameraHelperStatus(abort.signal); + setHelperStatus(status); + setHelperError(null); + } catch { + setHelperStatus(null); + if (!quiet) setHelperError(t("camera.macos.helper.offline")); + } finally { + clearTimeout(timeout); + } + }; + + const runHelperAction = async ( + action: () => Promise, + ): Promise => { + setHelperBusy(true); + try { + setHelperStatus(await action()); + setHelperError(null); + } catch (err) { + setHelperError(err instanceof Error ? err.message : String(err)); + } finally { + setHelperBusy(false); + } + }; + + if (!canUseMacosCameraHelper()) return null; + const needsRestore = helperStatus ? macosCameraHelperNeedsRestore(helperStatus) : false; + if (!forceVisible && !needsRestore) return null; + if (!forceVisible && !helperStatus && !helperError) return null; + + if (variant === "compact") { + if (typeof document === "undefined") return null; + return createPortal( +
+ {t("camera.macos.helper.paused")} + + +
, + 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 5dff029..d9a2242 100644 --- a/apps/web/src/components/camera/MacosSetupWizard.tsx +++ b/apps/web/src/components/camera/MacosSetupWizard.tsx @@ -1,12 +1,6 @@ -import { useEffect, useState, type JSX } from "react"; +import type { JSX } from "react"; import { useT } from "../../i18n"; -import { - canUseMacosCameraHelper, - getMacosCameraHelperStatus, - releaseMacosCameraServices, - restoreMacosCameraServices, - type MacosCameraHelperStatus, -} from "../../lib/macos-camera-helper"; +import { MacosServicesControl } from "./MacosServicesControl"; interface MacosSetupWizardProps { acknowledged: boolean; @@ -34,61 +28,15 @@ export function MacosSetupWizard({ onClose, }: MacosSetupWizardProps): JSX.Element { const t = useT(); - const [helperStatus, setHelperStatus] = useState(null); - const [helperError, setHelperError] = useState(null); - const [helperBusy, setHelperBusy] = useState(false); - - useEffect(() => { - if (!canUseMacosCameraHelper()) return; - const abort = new AbortController(); - const timeout = setTimeout(() => abort.abort(), 800); - getMacosCameraHelperStatus(abort.signal) - .then((status) => { - setHelperStatus(status); - setHelperError(null); - }) - .catch(() => { - setHelperStatus(null); - setHelperError(t("camera.macos.helper.offline")); - }) - .finally(() => { - clearTimeout(timeout); - }); - return () => { - abort.abort(); - clearTimeout(timeout); - }; - }, [t]); - - const runHelperAction = async ( - action: () => Promise, - ): Promise => { - setHelperBusy(true); - try { - setHelperStatus(await action()); - setHelperError(null); - } catch (err) { - setHelperError(err instanceof Error ? err.message : String(err)); - } finally { - setHelperBusy(false); - } - }; if (acknowledged) { return ( -
+

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

- + {ENABLE_COMMAND} - void runHelperAction(getMacosCameraHelperStatus)} - onRelease={() => void runHelperAction(releaseMacosCameraServices)} - onRestore={() => void runHelperAction(restoreMacosCameraServices)} - /> +
@@ -142,74 +83,3 @@ export function MacosSetupWizard({
); } - -function MacosHelperPanel({ - status, - error, - busy, - onRefresh, - onRelease, - onRestore, -}: { - status: MacosCameraHelperStatus | null; - error: string | null; - busy: boolean; - onRefresh: () => void; - onRelease: () => void; - onRestore: () => void; -}): JSX.Element | null { - const t = useT(); - if (!canUseMacosCameraHelper() || (!status && !error)) return null; - const serviceSummary = status - ? `ptpcamerad ${formatService(status.services.ptpcamerad)} · icdd ${formatService(status.services.icdd)}` - : error; - return ( -
-
- {t("camera.macos.helper.title")} - -
-

{serviceSummary}

-
- - -
-
- ); -} - -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/i18n/en.ts b/apps/web/src/i18n/en.ts index a873d7a..e2be71d 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -165,6 +165,7 @@ export const en = { "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", diff --git a/apps/web/src/i18n/it.ts b/apps/web/src/i18n/it.ts index 73bf654..e527ad8 100644 --- a/apps/web/src/i18n/it.ts +++ b/apps/web/src/i18n/it.ts @@ -176,6 +176,7 @@ export const it: Record = { "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", diff --git a/apps/web/src/lib/macos-camera-helper.ts b/apps/web/src/lib/macos-camera-helper.ts index e3c3f30..cb9769a 100644 --- a/apps/web/src/lib/macos-camera-helper.ts +++ b/apps/web/src/lib/macos-camera-helper.ts @@ -30,6 +30,12 @@ export function canUseMacosCameraHelper(): boolean { ); } +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 { 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/docs/qa/macos-webusb-camera-release.md b/docs/qa/macos-webusb-camera-release.md index fa3b621..c909422 100644 --- a/docs/qa/macos-webusb-camera-release.md +++ b/docs/qa/macos-webusb-camera-release.md @@ -245,6 +245,9 @@ 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: From 5645bc9ed32b1c8cfe3b31f5ed915c26c6c1e13c Mon Sep 17 00:00:00 2001 From: Giuseppe Albrizio Date: Thu, 7 May 2026 18:57:48 +0900 Subject: [PATCH 5/6] chore: add macos dev stack command Add a root dev:macos script that starts the web app and local camera helper together, with prefixed logs and coordinated shutdown. Document the combined macOS hardware QA flow in the English and Italian READMEs plus the camera release QA note. Co-Authored-By: Codex GPT-5 --- README.it.md | 25 ++++- README.md | 24 ++++- docs/qa/macos-webusb-camera-release.md | 31 ++++-- package.json | 1 + scripts/dev-macos.ts | 143 +++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 scripts/dev-macos.ts diff --git a/README.it.md b/README.it.md index 52efa7c..7614b1c 100644 --- a/README.it.md +++ b/README.it.md @@ -93,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 diff --git a/README.md b/README.md index 39cb69e..bae5880 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/qa/macos-webusb-camera-release.md b/docs/qa/macos-webusb-camera-release.md index c909422..e5bbd02 100644 --- a/docs/qa/macos-webusb-camera-release.md +++ b/docs/qa/macos-webusb-camera-release.md @@ -109,15 +109,15 @@ Fixed in the app: 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. | +| 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: @@ -236,7 +236,16 @@ After suspending the daemons, physically reset the camera connection: ## Optional Local Helper -For hardware QA, start the local helper before opening Latent: +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 @@ -329,7 +338,7 @@ 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; +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; diff --git a/package.json b/package.json index 4c8efd1..6edd669 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "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", 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}`); + } + } +} From a982603100f3ebb55996354d9d5b26faa5cb5e47 Mon Sep 17 00:00:00 2001 From: Giuseppe Albrizio Date: Thu, 7 May 2026 19:09:29 +0900 Subject: [PATCH 6/6] fix: move macos setup into drawer Render the macOS release flow through a fixed portal drawer instead of expanding it inside the camera header controls. Keep the helper release and restore actions available inside the drawer while preserving the compact restore portal for closed states. Co-Authored-By: Codex GPT-5 --- .../components/camera/MacosSetupWizard.tsx | 158 ++++++++++++++---- 1 file changed, 121 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/camera/MacosSetupWizard.tsx b/apps/web/src/components/camera/MacosSetupWizard.tsx index d9a2242..c842cd9 100644 --- a/apps/web/src/components/camera/MacosSetupWizard.tsx +++ b/apps/web/src/components/camera/MacosSetupWizard.tsx @@ -1,4 +1,5 @@ import type { JSX } from "react"; +import { createPortal } from "react-dom"; import { useT } from "../../i18n"; import { MacosServicesControl } from "./MacosServicesControl"; @@ -28,58 +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} - + +

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

+ -
) : 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} + ); }