SPEC-0013 Design: Desktop guided setup
Context
📝 ADR-0015 built the
onboarding machinery for a power user at a terminal: doctor detects setup
health, export orchestrates the three upstream exporters with correct flags
(iMessage in copy mode, WhatsApp into a managed root), and sync chains
export → import → media → embed → facts. Those building blocks already exist —
internal/cli/doctor.go holds the detection and health checks,
internal/cli/export.go the exporter wrappers, internal/ingest the
incremental importer, and cmd/msgbrowse-desktop/internal/embedded already
anchors the desktop data_dir to os.UserConfigDir()/msgbrowse.
This spec evolves that CLI onboarding into a consumer surface
(📝 ADR-0020): the desktop
app detects which messaging apps you use and enables imports with a click, with
no terminal, no visible data dir, and no manual tool installs. The exporters
are bundled inside the .app. We reuse the detection, export, and import code
rather than reimplement it — the new work is (1) a shared detection package the
CLI and UI both call, (2) bundled-tool resolution, (3) a Setup web surface with
background export/import jobs, and (4) a signing/notarization release stage.
Goals / Non-Goals
Goals
- Zero-terminal onboarding: download → double-click → click Enable per source.
- Bundled, offline toolchain: relocatable Python + prebuilt venv
(
signal-export,wtsexporter) +imessage-exporterunderContents/Resources, resolved by bundled path, never PATH. - App-owned, hidden
data_dirand managed archive roots (<data_dir>/archives/<source>); paths visible in About/Advanced, never required. - Detect-and-guide OS consent (Full Disk Access, Signal Keychain, WhatsApp container) — never bypass it.
- One shared detection package (
internal/setup) feeding bothdoctorand the UI. - Reuse the existing export orchestration and incremental import unchanged.
Non-Goals
- Scheduled / background auto-refresh in v1 (a launchd agent is a follow-on); Refresh is user-initiated only.
- Linux/Windows bundling in v1 (AppImage/MSI + runtime embedding is an open question); those platforms keep browser mode and the CLI BYO path.
- Mobile.
- Automating OS consent — the app detects and guides, the user grants.
Decisions
Fully-bundled toolchain; paths resolve from the bundle
- Choice: the
.appembeds a relocatable Python runtime, a prebuilt venv withsignal-exportandwhatsapp-chat-exporter, and theimessage-exporterbinary underContents/Resources. In desktop mode a bundled-path resolver (in the newinternal/setuppackage, given the bundle's resources dir) returns absolute paths to each tool and to the Python interpreter; the export jobs invoke those, neverexec.LookPath. - Rationale: this is the only option that is zero-terminal AND offline AND
reproducible (📝 ADR-0020
option (d)). The CLI's
resolveTool(internal/cli/export.go) falls back toLookPath; the desktop path supplies an explicit override so the samerunExportcore drives bundled binaries with no reimplementation. - Alternatives: detect-and-guide manual installs (not zero-terminal); pipx/brew on demand (needs network + a package manager); bundle the native binary but managed-install the Python tools (only one-third offline). All rejected in 📝 ADR-0020.
Relocatable Python: python-build-standalone
- Choice: embed a python-build-standalone distribution and
pip installsignal-exportandwhatsapp-chat-exporterinto a venv built against it, at bundle-build time in CI. - Rationale: python-build-standalone produces a self-contained,
relocatable interpreter designed to be moved into an arbitrary prefix — which
is exactly what living under
Contents/Resourcesand being signed + notarized requires. A venv on top pins the two exporters and their dependencies to hashes we control. - Alternatives: the system Python (rejected — the whole point is not to depend on the user's machine; may be absent or wrong version); PyInstaller (or similar) per tool to freeze each exporter into its own executable (viable, but produces two opaque frozen bundles to sign and re-cut on every upstream bump, and complicates matching the exporters' own runtime expectations; a shared relocatable interpreter + venv is simpler to reason about and to notarize as one tree). python-build-standalone + venv is the recommendation; PyInstaller stays the documented fallback if relocation + signing of the standalone interpreter proves fragile on a given macOS version.
Shared detection package: refactor doctor into internal/setup
- Choice: extract the detection/health logic from
internal/cli/doctor.gointo a reusableinternal/setuppackage that returns structured per-source results (state ∈ Ready / Needs-permission / Not-detected / Enabled, plus the probed location and any permission gap).doctorrenders those results as text lines; the web Setup handler renders them as cards. - Rationale: SPEC-0013 requires the CLI and UI to detect from the same
code (the "same three detections from the same shared code" scenario).
Today's
doctormixes probing with text formatting (report.add); splitting the probe (pure, returns typed results) from the presentation lets both front-ends consume it and keepsdoctor's behavior byte-identical. - Alternatives: duplicating the probes in the web layer (guaranteed drift —
rejected); having the UI shell out to
doctorand parse its text (brittle — rejected).
Managed archive layout
- Choice: the app owns
<data_dir>/archives/{signal,imessage,whatsapp}and writes each exporter only into its source's root;data_dirstays anchored toos.UserConfigDir()/msgbrowse(embedded.resolveDataDir). The three*_archive_rootconfig keys are set to these computed paths in desktop mode; the CLI keeps accepting user-supplied roots. - Rationale: the user never names a path
(📝 ADR-0020 decision (i)).
Reusing the existing config keys means
export/import/doctorneed no new root plumbing — only the desktop app populates them itself. - Alternatives: prompting for paths (rejects the consumer goal); a flat single archive dir (loses per-source containment the media path-guards rely on). Rejected.
Same-origin protection for privileged setup POSTs
- Choice: each state-changing Setup POST (
/setup/enable,/setup/refresh,/setup/recheck) requires BOTH a same-origin check (verifyOrigin/Sec-Fetch-Siteagainst the embedded server's loopback origin) AND a per-session token minted at/setuprender and submitted with the POST; failures return403before any subprocess starts. Bodies are capped withhttp.MaxBytesReader, and the only body field is a source enum — never a path. - Rationale: these POSTs spawn a subprocess that reads a personal database
and writes an archive — a privileged local action. Loopback alone is not
enough: another local process, or a malicious page in the user's browser,
could otherwise POST to
127.0.0.1:<port>/setup/enable. The token + origin check make the exporter undrivable cross-origin even under loopback (📝 ADR-0010's strict CSPform-action 'self'is a complement, not a substitute). - Alternatives: loopback-only with no token (the read-only
/settingsposture — insufficient for a route that launches subprocesses); mutual TLS (the device-sync mechanism per SPEC-0011 — overkill for a same-machine POST).
Signing / notarization pipeline
- Choice: the release matrix gains a macOS signing + notarization stage:
sign every embedded executable (Python runtime, venv compiled extensions,
imessage-exporter) with a Developer ID, sign the.app, then submit it to Apple's notary service and staple the ticket, as a step in the existing desktop CI matrix / release pipeline (SPEC-0010, SPEC-0012). - Rationale: Gatekeeper refuses unsigned embedded binaries, so bundling makes signing non-optional — this is the amendment to 📝 ADR-0017.
- Alternatives: ship unsigned (Gatekeeper blocks the bundled exporters — rejected); ad-hoc signing (not notarizable, still blocked on download — rejected).
Architecture
The bundle carries its own toolchain; the setup service drives export jobs into the managed archives, then the existing importer loads them into the store the web UI already serves.
The "Enable iMessage" flow shows the detect → guide → recheck consent loop and the background export/import job:
Risks / Trade-offs
- App size (+80–120 MB). The Python runtime + venv + native binary dwarf today's single-binary footprint. Accepted per 📝 ADR-0020; delta/size optimization (thinning the interpreter, stripping unused stdlib) is an open question, not a v1 blocker.
- Notarization pipeline + Apple Developer ID. New release surface: an Apple Developer ID (cost + provisioning), a notary submission + staple step, and signing of every embedded executable. Mitigation: a single signing stage in the desktop matrix; failures gate only the desktop release, not the core check.
- Upstream exporter version drift + security re-bundle cadence. We now own shipping security fixes for three third-party tools; a CVE in any of them means re-cutting the venv/binary, re-signing, and re-notarizing. Mitigation: pin + hash the venv build, track upstream releases, and treat a security bump as a normal patch release.
- Bundled-Python supply chain. An embedded interpreter and its dependency tree widen what ships inside the signed app. Mitigation: build the venv from pinned, hash-verified wheels in CI (see Open Questions on reproducibility); the whole tree is signed and notarized, so post-build tampering is detectable.
- FDA UX friction is inherent and unavoidable. macOS will not let any app grant itself Full Disk Access; the user must toggle it in System Settings and the app must be relaunched/rechecked. We can only detect and guide well — the round-trip is a fact of the platform, not a bug we can design away.
Migration Plan
No schema, data, or config-key migrations — the store, the three
*_archive_root keys, and the archives are untouched, and the CLI path is
unchanged.
internal/setup(new, pure Go): refactor detection out ofinternal/cli/doctor.gointo a shared package returning typed per-source results; rewiredoctorto render them (behavior byte-identical) and add the bundled-path resolver. Testable headless withCGO_ENABLED=0.- Setup web surface:
/setup(GET, cards) +/setup/enable//setup/refresh//setup/recheck(POST, same-origin + token) ininternal/web, driving supervised export/import jobs that reuserunExportandinternal/ingest; progress viaaria-live. Served in browser mode too, but the bundled-tool path resolves only in the.app. - Bundling build steps in the desktop CI matrix: fetch
python-build-standalone, build the pinned venv, place the tools under
Contents/Resources, and wire the bundled-path resolver — a new stage in the SPEC-0010 desktop matrix. - Signing + notarization stage: sign every embedded executable, sign the
.app, notarize, and staple — the amendment to 📝 ADR-0017. - CLI unchanged: advanced users keep BYO exporters and user-supplied roots; nothing in the core depends on the bundle.
Rollback at any step is deletion: the bundle and Setup surface are additive, and browser mode plus the CLI onboarding remain the fallback.
Open Questions
- Linux/Windows bundling. AppImage/MSI and their runtime-embedding + signing stories are undesigned; deferred per 📝 ADR-0020.
- Scheduled / background refresh. A launchd agent (and its XDG/Windows equivalents) to auto-refresh in the background is a v1 non-goal; when and how it lands is open.
- Reproducible venv production in CI. How to build the bundled venv deterministically (pinned + hash-verified wheels, a lockfile, a fixed python-build-standalone release) so two CI runs produce byte-comparable toolchains.
- Apple Developer ID provisioning. Who holds the Developer ID, where the signing identity + notary credentials live in CI secrets, and the rotation discipline.
- Delta / size optimization. Whether to thin the interpreter (strip unused stdlib, prune the venv) to claw back part of the +80–120 MB, and whether per-source lazy extraction is worth the complexity. </content>
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's ADR-0023 / SPEC-0018 frontmatter-graph conventions). Run /sdd:graph chain SPEC-0013 for the transitive view.