# Standalone App (Desktop + Android) — Implementation Plan

**Status:** Draft v1.1 · 2026-07-15 — v1.1 removes the embedded HTTP server from the desktop design (owner decision): standalone apps run the engine directly, no localhost server, no SQLite. Supersedes the `DISTRIBUTION.md` §3.1 "backend in-process" sketch.
**Implementation status:** Phases A–D and F completed; Phases E and G in progress · 2026-07-15 — Phase A's full verification gate passes; Phase B is a **GO** on Electron 43.1.1 / Chromium 150; Phase C's POC gateway and browser-engine bundle gates pass; Phase D's real Electron smoke, persistence, solve-window, all-store routing, and no-listening-port checks pass on Linux, and the Windows portable build passed owner verification. Phase E now has the updater lifecycle, all target definitions, and tag-driven release workflow; clean-VM installer and vN→vN+1 update gates remain. Phase F is a **GO** on a Pixel 10a running Android 17 over carrier data, selecting the full walled-store scope for Phase G. Phase G's production app and signed-release path are implemented; real-device product-flow and first GitHub Release installation gates remain.
**Design source:** `DISTRIBUTION.md` (the model and its rationale). This document is the delivery plan: what changes in the codebase, in what order, with what gates.
**Scope:** Desktop app for Windows/Linux/macOS (Electron) and an Android app (Capacitor). iOS is explicitly out of scope (deferred per `DISTRIBUTION.md` §3.2). Cart assembly (`CART-ASSEMBLY.md`) is out of scope — it layers on top of this work later.

> **Supersession note (2026-07-16):** Search IDs, click measurement, `/stats`, `linkDecoration`, and UTM parameters were removed from every runtime. References below remain as historical phase context, not current requirements.

---

## 1. Where the codebase already is (why this plan is smaller than expected)

The load-bearing discovery: **the adapter purity refactor that `DISTRIBUTION.md` §4 calls a prerequisite is already ~80% done.**

- Every `StoreAdapter` is already split into `fetchRaw(q, ctx)` (I/O) and **pure** `parse(raw, q)` (`src/core/types.ts`), and the fixture tests exercise `parse` only. Nothing store-specific needs rewriting for any runtime.
- All I/O already flows through one injected seam: `FetchContext { http: PoliteHttp, browser: BrowserHub }`. Adapters hold no singletons.
- The wire types live in `shared/contract.ts`, shared with the Angular app, which already has a local-storage purchase-plan fallback (`web/src/app/core/purchase-plan.storage.ts`).

### 1.1 The architecture rule (v1.1)

**The standalone apps do not run a server.** The Fastify + SQLite + noVNC stack remains exactly what it is today — the POC/dev deployment — and is not shipped to users. What ships is the **engine**: adapter registry + search orchestration + politeness core, running in the platform's privileged layer, with the Angular UI talking to it through a `SearchGateway` interface instead of REST.

| | POC / dev (today, unchanged) | Desktop (Electron) | Android (Capacitor) |
|---|---|---|---|
| Server | Fastify on LAN | **none** | **none** |
| Engine runs in | Node service | Electron **main process** | in-page (WebView JS) |
| UI ↔ engine | REST (`HttpSearchGateway`) | IPC (`IpcSearchGateway`) | direct call (`LocalSearchGateway`) |
| `http` impl | `PoliteHttp`/undici | `PoliteHttp`/undici (main has full Node) | politeness core + `CapacitorHttp` transport |
| `browser` impl | `BrowserHub` → Docker Chrome via CDP | `ElectronBrowserHub` → hidden `WebContents` | `WebViewFetcher` native plugin |
| `needs_solve` UX | noVNC overlay | make the hidden window visible | modal WebView |
| Purchase plan | SQLite via REST | JSON file in `userData` via IPC storage impl | Preferences/localStorage |
| Search cache | SQLite | in-memory | in-memory |
| Measurement / `/out` / stats | works as today | **dropped** — outbound links open directly, decorated | same as desktop |
| Native modules | better-sqlite3 (fine) | **none** (pure JS — no `@electron/rebuild`) | Capacitor plugins only |

What remains is therefore **three seams and two thin shells**:

1. `FetchContext` fields become *interfaces* with today's classes as the default implementations.
2. An **engine entry point** (`src/engine.ts`) exporting the runtime-agnostic core — no `fastify`, no `better-sqlite3`, no `node:*` imports — plus a `SearchGateway` split in the Angular app.
3. The politeness core (`politeHttp.ts` — rate gap, circuit breaker, cookie jar, serialization) gets a transport seam, so Android reuses the exact politeness behaviour over a non-undici transport.

## 2. Target architecture

### 2.1 Repo layout

```
src/            unchanged (core + adapters) — grows interfaces, loses nothing
  engine.ts     NEW: runtime-agnostic engine entry (no fastify/sqlite/node: imports)
desktop/        NEW: Electron main (main.ts, preload.ts, electronBrowserHub.ts, ipc.ts, electron-builder.yml)
android/        NEW: Capacitor project (generated; thin) + WebViewFetcher plugin
web/            Angular app — gains SearchGateway + a storage seam impl, otherwise untouched
shared/         contract.ts + NEW linkDecoration.ts (UTM/affiliate params, per MONETIZATION-PLAYBOOK Phase 0)
```

### 2.2 The seams (Phase A, everything depends on this)

- **`FetchContext` interfaces.** Define `PoliteFetcher` and `BrowserFetcher` interfaces in `src/core/types.ts` mirroring the *existing public surfaces* (`PoliteHttp.get/…`; `BrowserHub.fetchJson/fetchOriginHtml/fetchPageHtml` + `ChallengeRequiredError`). The classes declare `implements`; adapters and `SearchService` type against the interfaces. Zero behaviour change.
- **Politeness transport.** `PoliteHttp` keeps its undici default; its request execution moves behind a `transport(req) → { status, headers, body }` option (the existing `dispatcher` test seam generalized). The politeness/breaker/cookie logic — the part that must be identical on every platform — becomes transport-independent.
- **Link decoration.** `shared/linkDecoration.ts`: appends `utm_source`/`utm_medium` (affiliate tags later) to outbound store URLs. Used by `/out` server-side in the POC, by direct-open in the standalone apps. Lands now because playbook Phase 0 wants UTM accumulating from the first public release.
- `src/server.ts` **stays as it is** — it no longer needs a factory split, because nothing but the POC boots it.

**Host capabilities — the shell's interface (also Phase A).** Beyond the two fetch seams above, formalize the *whole* set of things a platform shell provides to the engine as one `HostCapabilities` interface, so "solve a challenge" stops being a special-case exception path and becomes a first-class capability alongside the rest:

- `browserFetch` — fetch via a warm per-store session (the `BrowserFetcher` above).
- `presentInteractiveSession(storeId, { until })` — show the user a real, interactive browser surface on this store's session and resolve when a condition is met.
- `openExternal(url)` — open a decorated URL in the system browser / Custom Tab.
- `storage` — persist and read back the purchase plan.

Two rules keep it clean:

- **The engine orchestrates; the adapter only signals intent.** An adapter calls `ctx.browser.fetch…` and nothing else; when that fetch meets a challenge, the *engine* calls `presentInteractiveSession`. Adapters stay pure fetch-and-parse with no platform or UX code — the property that keeps them cheap to maintain and identical across all three runtimes. An adapter never imports or calls a capability that drives UI.
- **One capability, several uses.** Challenge-solving (wait for `cf_clearance`) and the future login flow of `CART-ASSEMBLY.md` §4.1 (wait for an auth cookie) are the *same* capability with a different `until` predicate. Defining `presentInteractiveSession` now means cart/login later is a new *caller*, not a new subsystem. Today's `ChallengeRequiredError` + noVNC / `solve:show` / modal-WebView handling becomes the POC / desktop / Android *implementations* of this one capability.

### 2.3 Engine + gateway (Phase C, the structural centerpiece)

- **`src/engine.ts`** exports `createEngine({ http, browser, cache }) → { stores(), search(storeId, q), refreshOffer(req) }` — the registry, `SearchService`, `normalize`, and outcome mapping behind one object, returning the same `shared/contract.ts` shapes the REST API returns today. Bundler-safe: no `fastify`, `better-sqlite3`, or `node:*` anywhere in its import graph (enforced with an ESLint `no-restricted-imports` zone so it can't regress silently). `cheerio` bundles for the browser (htmlparser2 underneath is pure JS); bundle-size check is a Phase C task, with a `DOMParser`-based fallback for `adapters/util.ts` helpers if it proves too heavy.
- **`SearchGateway` in Angular.** Today `search.service.ts` and `purchase-plan-refresh.service.ts` fetch REST endpoints. They get a gateway interface — `stores()`, `search(storeId, q)`, `refreshOffer(req)`, `openOffer(url)` — with three implementations: `HttpSearchGateway` (today's behaviour; POC and dev), `IpcSearchGateway` (desktop), `LocalSearchGateway` (Android, calls the engine in-page). Outcome types are identical on all three, so components don't change.
- **Purchase-plan storage seam.** `purchase-plan.storage.ts` already abstracts persistence with a local fallback; it gains an injected backend: REST (POC), IPC→JSON file in `userData` (desktop — avoids `file://`-origin localStorage fragility), Preferences/localStorage (Android).
- **Measurement** is POC-only and stays behind the REST gateway; standalone gateways' `openOffer` opens the decorated store URL directly (`shell.openExternal` / Custom Tabs). Accepted loss per `DISTRIBUTION.md` §5.

### 2.4 Desktop (Electron)

- **Main process:** hosts the engine (`createEngine` with `PoliteHttp`/undici — main has full Node — and `ElectronBrowserHub`). No HTTP server, no open port. The `BrowserWindow` loads the built Angular app via an `app://` custom protocol on a persistent session (stable origin, sane storage semantics).
- **IPC surface** (contextIsolation on; preload exposes a minimal typed bridge): `engine:stores`, `engine:search`, `engine:refreshOffer`, `plan:load/save`, `offer:open`, `solve:show`, plus a `solve:cleared` event. This *is* `IpcSearchGateway`'s backend.
- **`ElectronBrowserHub`** implements `BrowserFetcher` with hidden `BrowserWindow`s: one per walled store, session partition `persist:<storeId>` (CF clearance survives restarts — the "warm browser" property). Fetches run via `webContents.executeJavaScript(fetch…)` on the store's origin, mirroring today's CDP approach in `browserHub.ts` — same reasoning: cookies + fingerprint + IP must stay consistent, so no cookie extraction/replay. Challenge detection logic (the `challengeClearMs` non-interactive wait) ports over, as does honouring the `BROWSER_FORCE_SOLVE`-style test switch so the solve flow stays deterministically testable.
- **Solve flow (desktop's `presentInteractiveSession` impl):** a `needs_solve` outcome on desktop carries no noVNC URL; the existing solve overlay in the Angular app branches on gateway kind and renders a "Parādīt pārlūka logu" action → `solve:show` makes the hidden window visible and focused; on clearance detection it re-hides and the existing retry path in `search.service.ts` / `purchase-plan-refresh.service.ts` resumes. The noVNC path remains intact for the POC.
- **Hygiene:** single-instance lock; every store link opens via `shell.openExternal` with `linkDecoration` applied; renderer has no Node access.

### 2.5 Android (Capacitor)

- **No server, same engine:** `LocalSearchGateway` calls `createEngine` in-page, composed with the politeness core over a **`CapacitorHttp` transport** — native networking, so CORS doesn't apply and HTTP/2 comes free (the `TECHNICAL-SPEC.md` §1 HTTP/2-or-403 constraint).
- **Walled stores:** a small native plugin, `WebViewFetcher`, implementing `BrowserFetcher` — hidden Android WebView per store, `loadUrl(origin)`, `evaluateJavascript(fetch…)`, cookies persisted by `CookieManager`. `needs_solve` presents the WebView in a modal sheet (Android's `presentInteractiveSession` impl). Highest-novelty component in the plan; gets its own spike (Phase F). **Fallback if it disappoints: Android v1 ships the open + JSON-API stores only**, walled stores marked "desktop only" — still a useful app.
- **Storage:** purchase plan through the existing local-storage path (promoted from fallback to primary); response cache in-memory per session; outbound links open in Custom Tabs, decorated.

## 3. Delivery sequence

Each phase has a gate; later phases don't start on a failed gate. B and F are timeboxed go/no-go spikes designed to kill assumptions cheaply — their failure reroutes, not cancels.

| Phase | What | Size | Gate |
|---|---|---|---|
| **A** | Seams: `FetchContext` interfaces, politeness transport, `linkDecoration`, **`HostCapabilities` interface** (POC's existing behaviours pointed at it — pure refactor) | 2–3 days | all existing tests green; POC runs unchanged in today's Docker setup |
| **B** | **Electron go/no-go spike** (throwaway code): hidden `WebContents` on a residential IP → trodo JSON API + one CF-walled HTML store, cold and warm | ≤2 days | CF managed challenge clears non-interactively (or via one visible-window solve). **Fail → fallback design: drive the user's own installed Chrome via CDP (today's `browserHub.ts` pointed at a local Chrome), Electron only as UI shell** |
| **C** | Engine + gateways: `src/engine.ts` (with import-zone lint), `SearchGateway` split (`Http` impl extracted, others stubbed), plan-storage seam, cheerio bundle check | ~1 week | POC behaviour byte-identical through `HttpSearchGateway` (same tests + existing outcome-mapping specs); engine bundle executes a partsale fixture search in a plain browser harness |
| **D** | Desktop shell: `desktop/main.ts`, `app://` protocol, IPC bridge + `IpcSearchGateway`, `ElectronBrowserHub`, solve-window flow, JSON-file plan storage | ~1.5 weeks | all 10 stores searchable from the app on Windows + Linux with **no listening port** (checked); plan persists across restarts; solve flow exercised via the force-solve switch |
| **E** | Packaging & release: electron-builder (NSIS + portable, AppImage, dmg), GitHub Actions 3-OS matrix on tag, `electron-updater`, signing decision (see §5) | ~1 week | clean-VM install on Win/Linux; auto-update vN→vN+1 verified |
| **F** | **Android go/no-go spike:** Capacitor skeleton, `CapacitorHttp` transport against open stores on a real device on carrier IP; `WebViewFetcher` PoC on one walled store | ≤3 days | open stores work end-to-end on device. Walled-store result decides scope: full set vs. "desktop-only stores" for Android v1 |
| **G** | Android app: `LocalSearchGateway` wiring, solve modal, plan via local storage, decorated Custom-Tab links, signed APK on GitHub Releases | 1.5–2 weeks | end-to-end search + purchase plan on a real device; APK installs from Releases |
| **H** | Release hygiene: one version across desktop/android, release checklist (incl. a fixture-freshness canary run before tagging), README install docs, status updates in `DISTRIBUTION.md` | 1–2 days | first public tagged release cut by CI |

Sequencing rationale: A→B front-loads the two things that could invalidate the whole design (seams prove cheap, CF-in-Electron proves possible) before structural work. C is the pivot — after it, desktop (D–E) and Android (F–G) are both thin shells over the same engine, and the desktop track no longer contains any architecture the Android track throws away.

**Phase B gate result (2026-07-15): GO — retain embedded Electron Chromium.** The rerunnable harness in `spikes/electron-browser/` was exercised on a residential connection with Electron 43.1.1 / Chromium 150.0.7871.114. From a fresh persistent profile, Trodo's challenge-page navigation primed the session and its same-origin JSON API returned 24 products; Autodoc's managed challenge self-cleared and its HTML results returned 20 products. Both immediate warm probes passed without another challenge. A second Electron process then reused the persisted sessions and passed all four probes again with no visible-window solve. The installed-Chrome/CDP fallback is therefore not selected.

**Phase C gate result (2026-07-15): PASS — retain cheerio.** `src/engine.ts` is now the POC server's actual stores/search/refresh path and is protected by a portable import zone. Angular product services depend on injected search and plan-storage gateways; the HTTP implementations preserve the POC routes and measurement behavior, while typed IPC/local implementations are ready for their platform phases. `npm run check:engine-browser` bundles the complete engine import graph without Node polyfills and executes a Partsale fixture search from that browser IIFE in an isolated no-Node harness; it returns one offer. The minified bundle is 520,524 bytes raw / 163,128 bytes gzip (250 kB gate). Cheerio and its parser dependencies account for 624,934 source bytes but fit the compressed gate, so the `DOMParser` fallback is not selected. Backend tests pass (102), web tests pass (32), and typecheck, lint, and production web build pass.

**Phase D result (2026-07-15): PASS.** The Electron 43.1.1 application loads the production Angular bundle from `app://app/`, exposes only the typed context-isolated preload bridge (`process` is absent in the renderer), discovers all 10 stores through main-process IPC, and persists the JSON purchase plan across separate runs using the same user-data profile. A live all-store search through that bridge returned offers from seven stores; Trodo, RD24, and Autodoc correctly returned `needs_solve` from a cold profile. The deterministic force-solve check then showed the per-store browser window and delivered `solve:cleared` back to the renderer. TCP listener snapshots were identical while Electron was held open and after exit, confirming that the desktop shell opens no listening port. Backend tests pass (105), web tests pass (38), and desktop typecheck, lint, and production build pass. The owner subsequently exercised the Windows portable build: search, challenge/solve behavior, clickouts, shutdown, and immediate relaunch all passed after the reviewed lifecycle fixes. Packaging/install and update checks belong to Phase E.

**Phase E progress (2026-07-15): IN PROGRESS.** electron-builder now defines unsigned x64 NSIS + portable, AppImage, and DMG + ZIP targets with update-safe artifact names. `electron-updater` checks installed NSIS/AppImage builds in the background, downloads updates, and asks before restart; development, smoke, portable, non-AppImage Linux, and unsigned macOS builds are excluded. A tag/version-guarded GitHub Actions workflow runs the fixture gates, builds on all three operating systems, and publishes artifacts plus updater metadata to one release. The x64 AppImage and `latest-linux.yml` were built locally with an embedded differential blockmap. Remaining gates are the first real tag workflow, clean-VM NSIS/AppImage install checks, and an installed vN→vN+1 update. macOS auto-update remains deferred because the v0 signing decision is unsigned.

**Phase F result (2026-07-15): PASS — retain the full Android store set.** A Capacitor 8 Android project now targets API 36/min API 24 and builds a 4.2 MB debug APK. The portable politeness/cookie/retry/breaker implementation is separated from its Node/Undici default and is reused unchanged over a typed `CapacitorHttp` transport. The on-device harness runs all five open stores through the real engine and runs Autodoc cold then warm through a custom per-store native WebView plugin with persistent `CookieManager` state and an interactive full-screen solve fallback. TypeScript, the existing ten politeness tests, transport mapping tests, browser bundling, native Java/APK compilation, and Android Lint pass. The owner then ran the complete harness on a Pixel 10a running Android 17 with carrier data; every check passed. Phase G therefore includes walled stores and the Android-v1 desktop-only fallback is not selected.

**Phase G progress (2026-07-15): IN PROGRESS.** The production Angular app now builds as the Capacitor payload and selects `LocalSearchGateway`, the in-page engine, `PoliteCore` over `CapacitorHttp`, and the full native `WebViewFetcher`. The native fetcher supports navigated HTML plus warm same-origin JSON/HTML fetches, persistent per-store sessions, selector waits, and an auto-monitored full-screen solve flow. The plan uses Android Preferences, and decorated outbound links open in Custom Tabs. The tag workflow requires the repository's Android signing secrets, builds a signed versioned APK, verifies alongside the desktop jobs, and publishes it on the same GitHub Release. A disposable local key produced an APK that passes Android v2 signature verification. Remaining gates are end-to-end search + plan verification on a real device and installing the first APK downloaded from GitHub Releases.

### 3.1 How the `HostCapabilities` interface phases in

The capability interface is not a separate track — it is **defined once in Phase A and each platform fills it in as that platform's phase lands.** Phase A's extra deliverable is only to name the interface and point the POC's *existing* behaviours at it — a behaviour-neutral refactor, which is why the Phase-A gate ("POC runs unchanged", tests green) already covers it. Each platform phase then implements the interface it had to implement anyway; the capability framing just gives those four ad-hoc mechanisms one named contract.

| Capability | Interface | POC impl | Desktop impl | Android impl |
|---|---|---|---|---|
| `browserFetch` | Phase A | exists (Chrome via CDP) | Phase D (`WebContents`) | Phase G (`WebViewFetcher`) |
| `presentInteractiveSession` — challenge solve | Phase A | exists (noVNC) | Phase D (show hidden window) | Phase G (modal WebView) |
| `openExternal` | Phase A | `/out` 302 | Phase D (`shell.openExternal`) | Phase G (Custom Tab) |
| `storage` (plan) | Phase A | SQLite via REST | Phase D (JSON in `userData`) | Phase G (Preferences) |
| `presentInteractiveSession` — **login** | (seam only) | — | deferred → `CART-ASSEMBLY.md` | deferred → `CART-ASSEMBLY.md` |

The login row is the seam deliberately left open: no work in this plan, but because it is the same capability as challenge-solve, cart assembly plugs in as a new caller with a different `until` predicate — the interface won't need reshaping to accept it. That is the payoff of defining the capability now rather than letting solve stay an exception path.

## 4. Verification strategy

- **Adapters:** untouched by design — the existing fixture tests (`parse` is pure) remain the safety net across all runtimes. `scripts/record.ts` and the canary keep working against the unchanged POC server.
- **New unit tests:** politeness core over a fake transport (gap/breaker/serialization identical to today's `PoliteHttp` tests), `linkDecoration`, gateway outcome mapping (mirrors the existing `SearchService` outcome-mapping specs), engine smoke test over fixtures.
- **Structural enforcement:** the `engine.ts` import zone (no `fastify`/`better-sqlite3`/`node:*`) is a lint rule, not a convention.
- **Per-phase manual checklists** live in the phase gates above; desktop's "no listening port" claim is verified explicitly (e.g. `ss -tlnp` while the app runs).
- **What is deliberately not tested in CI:** CF behaviour (needs real residential/carrier IPs — that's what the B/F spikes and release-checklist smoke runs are for).

## 5. Open decisions and risks

| Item | Position |
|---|---|
| **CF vs. Electron's Chromium** (no codecs/DRM of branded Chrome) | The desktop design's load-bearing assumption; Phase B exists to test it in week one. Fallback named in §3. |
| **Code signing** | Costs real money: Windows cert or Azure Trusted Signing (~$10/mo) to avoid SmartScreen warnings; macOS notarization needs the $99/yr Apple ID. **Decision: v0 ships unsigned for Windows/Linux (README explains the SmartScreen click-through), macOS as unsigned DMG/zip "for the brave"; buy signing when downloads justify it.** macOS auto-update stays disabled until that signing decision changes. |
| **cheerio bundle weight in the engine** | Check early in Phase C; `DOMParser` fallback exists but costs adapter-helper rework, so measure before choosing. (Desktop is indifferent — main process runs Node; this matters for Android only.) |
| **ic24 session-gated prices** in embedded sessions | Expected to work (same real-browser session model as today's `fetchOriginHtml`); verify explicitly in Phases B/F. |
| **Android walled stores** | Phase F passed on a Pixel 10a / Android 17 over carrier data. Retain the full store set in Phase G; the "desktop only" fallback is not selected. |
| **Measurement loss in standalone apps** | Accepted per `DISTRIBUTION.md` §5; the POC deployment keeps the full `/out`/stats pipeline for as long as it runs. UTM decoration is the distributed replacement (playbook Phase 4). |
