# storebound desktop (apps/desktop) A Tauri 2 window around the storebound CLI: every app in a workspace, on iOS and Android, in one place. It is in development; this page is for contributors. ## Shape - **One source of logic.** The app never calls a store itself. It runs the storebound CLI as a sidecar (`storebound serve --stdio`, compiled with `bun tools/build-sidecars.ts`) and asks it for everything. - **App-centric.** One app = one product with up to two platforms (iOS bundle id / App Store Connect app id, Android package name), one `storebound.config.ts` per app folder, many apps per workspace. Navigation: Portfolio -> App (Releases, Catalog, Prices, Listings, Store art, Health, Reviews, TestFlight, Growth, App info, Checks) plus the workspace screens (App folders, Accounts, Dependencies, Settings). - **Release trains.** A version (for example 2.3.0) spans both stores: iOS build and Android versionCode, per-store state (TestFlight, review, live, phased release, rollout fraction). `portfolio.status` returns per app the current train (highest version live on a store) and the next one (highest version above it anywhere). - **Catalog parity.** Products (and subscriptions, once the config has them) are defined once; `portfolio.status` compares the config with both stores (ids missing on a store, ids on one store only). Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli/lib/portfolio.ts`. ## Safety model - The sidecar's first request is `session.hello` with a 32-byte secret the Rust host makes per start and writes to the sidecar's stdin. Write methods are dry runs unless the call carries a confirm token: HMAC-SHA256 over the method, the canonical `{ project, args }` and a 60 s expiry, single use. The host mints one only after the owner pressed Apply in a native dialog that shows the summary and the dry run (`write_with_confirm`). - No method releases an approved version, publishes, cancels a submission, sends a build to external testers or removes testers. Under `serve`, the CLI's own terminal confirmations answer no, with one exception: the **owner actions** (`owner.play_rollout` share / halt / resume / complete, `owner.ios_phased` on / pause / resume / complete, `owner.reply_send` one approved draft, `owner.testflight_testers` adds the testers of `.storebound/testers.csv`, `owner.prices_import` and `owner.prices_play_products` / `_play_subscriptions` / `_apple_subscriptions` with `--allow-price-changes`, `owner.ppo_start` / `owner.ppo_stop`, and **sending for review**: `owner.apple_submit` (`apple submit --build n`), `owner.apple_resubmit` (`apple resubmit --build n [--resolve-iaps]`), `owner.apple_iap_submit` (`apple iap-submit [--with-version]`) and `owner.play_production` (`play release --version-code n --production completed | inProgress | draft`)). They are desktop-only (never MCP tools), dry runs from the window like every write, and their real run needs the host token. Only then serve sets `STOREBOUND_OWNER_CONFIRMED` for that one child, which answers the command's question, and adds the owner-only flags (`--yes` of apple submit / resubmit, `--allow-price-changes`); `OWNER_ALLOWED_ARGS` names the one action each such flag belongs to and a dry run never carries one. Builds and versionCodes are picked by number in the window, never "the latest". The native dialog names the action on its button ("Yes: Submit for App Review"); for the submit actions it says that the app goes to review and may go live by itself once approved. Play's "Send changes for review" (Publishing overview) has no API: the Releases tab shows its console plan and a deep link instead. `tests/desktop-d3.test.ts`, `tests/serve.test.ts` and `src-tauri/tests/d3_writes.rs` pin the token binding of every write the window offers and that the MCP server has none of these. - The webview gets no plugin API. It may call the app commands (each re-checks the window label and the page origin in Rust) and listen to two events. Strict CSP, no remote content. `src-tauri/tests/security.rs` pins all of it. - Store keys (Play service account JSON, App Store Connect key id, issuer id and .p8) are picked in native dialogs, stored in the OS keychain (`keyring`), never shown again and passed to the sidecar per call in memory only. ## Screens and data (D2) - **Portfolio**: KPIs (apps, live versions, rolling out, in review, needs attention), one row per app with the version reaching users on each store and its place on the track (build, testing, review, live, rollout %), the next version, the last saved crash rate as a sparkline, catalog parity and the config check; then a "needs attention" list (problems first, then warnings, store read errors, rejected or halted releases, catalog drift, health near a threshold). - **App**: Releases (release timeline, both stores on one track per version; iOS phased release day and share from `apple status --json`), Catalog (parity matrix from `catalog.diff`: matches, differs, missing, not sold there, not compared; price gaps per region), Listings (`project.listings`: per language and store, fields against the limits, rendered screenshots), Health (`health`: daily user-perceived crash and ANR rate against the Play thresholds, worst devices, top issues, App Store metrics and signatures), Reviews (`reviews.list` + `reviews.drafts`: star distribution, filters, the local draft with its send checks, Approve via `reviews.approve`), TestFlight (`apple.testflight_status`), Checks (offline `scan`: rejection risks with guideline, evidence file:line, fix and skill, owner-accepted ones marked; offline `check`; `validate` with fix hints). - **First-run guide**: what the app does, add or start an app folder (`project.init` = `storebound init --profile`, confirmed in a native dialog), store keys into the keychain and which app uses which, local tools, first read. - **Command palette** (Cmd/Ctrl+K): apps, tabs, screens and actions. Cmd/Ctrl+1 Portfolio, +2-5 workspace screens, Cmd/Ctrl+Shift+[ ] app tabs, arrow keys in the tab list. Toasts show background jobs with progress and Cancel. - **Unknown, never false.** Store reads run on demand (they use the account's API quota) and are saved in app data (`cache.sqlite`, results only, never keys); a screen opens with the saved read and its time ("Last read 3 h ago, saved copy"), a failed read keeps the previous one and says so, and nothing that was not read is shown as fine. Settings clears the saved reads. - **Jobs.** The window tags a call with a job id; the host maps sidecar request ids to it, so progress lines reach the right toast, and `job_cancel` sends `$/cancelRequest` (or stops a download). Live portfolio reads run up to four apps at a time with their own accounts and report n / total. - **i18n**: English and Turkish (`src/i18n`), Settings picks the language (default: the system's); `tools/i18n-check.ts` (CI) checks keys, empty messages and placeholders. **Theme**: text tokens keep 7:1 contrast in both themes (`tests/theme.test.ts`), motion at most 200 ms and off with reduced motion. - **Charts, icons, art: libraries only, no hand-drawn SVG** (owner rule, guarded by `tests/no-handmade-svg.test.ts`). Charts use visx (`@visx/*`, MIT; `src/ui/charts.tsx`, `src/ui/LineChart.tsx`): Growth's indexed lines with the 100 baseline, listing-change markers and a crosshair tooltip with the real values; Health's crash / ANR series against the bad behavior threshold (crosshair + tooltip, arrow keys when focused); the portfolio's compact sparklines; the rollout / phased release bar. Widths follow the container (`useParentSize`), colours are the theme tokens, nothing animates. Icons are Phosphor (`@phosphor-icons/react`, MIT), one per-icon import each (`@phosphor-icons/react/dist/csr/`, so only the icons used are bundled); `src/ui/icons.tsx` maps the app's icon names to them. One weight, "regular"; "fill" only marks state (the active nav item, notices, rating stars). Store marks are the stores' own logos from simple-icons (CC0 data), vendored unchanged into `src/ui/store-marks/` by `bun tools/site-icons.ts` and shown as a CSS mask in the neutral text colour, always next to the store's name. They identify the store only: storebound is not affiliated with Apple or Google, and the marks are never styled as its own branding. App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC. Empty states (`src/ui/illustrations.tsx`) are a Phosphor icon on a token-coloured tile with an optional bold badge icon, no artwork. The fixtures' app icons and screenshot stand-ins are PNGs from `bun tools/fixture-art.ts` (Phosphor "fill" icons on CSS gradients, photographed by Chromium). The brand mark (`src/ui/brand.tsx`) is a PNG (`src/ui/logo-mark.png`, from `tools/brand-assets.py`, see `brand/README.md`): no inline SVG at all. Bundle cost: about +97 kB minified, +31 kB gzip for visx (d3-scale / d3-shape and @visx/text for the axes). The Phosphor icons cost about 168 kB minified / 41 kB gzip more than the lucide set they replaced (each per-icon module carries all six weights; 864 kB / 247 kB gzip for the whole webview script). ## Studios, accounts and store-only apps (D3) ### Accounts and studios - A **studio** is a name, a colour and an optional logo. It groups any number of store accounts: - a Play service account, which can be invited to several Play developer accounts and then sees all their apps; - an App Store Connect team key, one per team. - Keys stay in the keychain. The state file keeps labels and studio ids only. - `storebound apps check` gives each account's health, with the fix in words: - ok; - no-apps; - permission (Play: "invite in Play Console > Users and permissions with ..."); - revoked; - api-disabled. - `storebound apps list` reads the apps an account can see: - App Store Connect: `GET /v1/apps`, the newest version, and an icon from the newest build's `iconAssetToken`, fetched by the CLI and returned as a data URL; - Play: the Reporting API `apps:search`. - Serve exposes both as `apps.list` and `account.check` (kind `account`, one account's key per call, run in an empty temp folder). The host caches app lists under `acct:`. ### One card per app `src/lib/identity.ts` builds one card per app, pure and tested: 1. A folder takes the store apps its config names. Order: an explicit link, then the App Store app id, then the bundle id or package name. The folder's mapped accounts win. 2. The remaining store apps pair iOS + Android, in this order: - manual pairs; - the same id; - a close id (`.ios` / `.android` suffixes, case, punctuation); - the same name. Unpairs block automatic pairs. 3. What is left is a single-store app. Overrides (pairs, unpairs, links, studio moves, the ignore list of apps, folders and whole accounts) live in the host state file (`overrides_set`, every key checked in `workspace.rs`). ### Store-only apps - A store-only app has no folder. It is read through a **store target**: `store:ios:::;android::`. - The host checks the target and the account kinds, and passes `virtual: { name, apple, play }`. The sidecar writes a minimal `storebound.config.json` (store ids, placeholder links) into its temp folder. - Only `apple.status`, `play.status`, `health`, `reviews.list`, `apple.testflight_status` and `apple.ppo_status` work this way (tabs Releases, Health, Reviews, TestFlight, Growth), and `portfolio.status` takes store-only entries. - "Link a folder", "Start an app folder here" or "Import from fastlane" turn it into a full app. ### Settings - Studios and accounts. - Pairing and the ignore list. - Store reads: on launch, every 15 min to 12 h (off by default), apps at a time 1-8, icons. - Offline cache: size, keep 7 / 30 / 90 / 365 days or always, clear. - Language, theme and motion. - Notifications, after a read, never on the first one: review / release state changes, new 1-2 star reviews, crash or ANR over the Play threshold. - Dependencies and the CLI sidecar. - Privacy, with a button that reveals the data folder. - Shortcuts. - Updates: channel (stable / beta), check and install (D5). - Reset: native confirmation, then the keychain entries and every saved state are removed. ### Writes in the window Each write is a dry run, then the native dialog, then the real run, with one progress toast and Cancel: - **Listings tab:** Play listing text, App Store version and text, Play graphics, App Store screenshots and previews; local render jobs (screenshots, previews, icons, gallery). - **Catalog tab:** Play products and subscriptions, App Store IAPs and subscriptions, price export; price changes of live products (`owner.prices_play_products`, `owner.prices_play_subscriptions`, `owner.prices_apple_subscriptions`, owner actions: a normal sync holds them back and exits 3, shown as "Waiting for your decision"). - **Prices tab:** the price sheet compared with the stores and applied (`owner.prices_import`, an owner action). - **App info tab:** Play Data safety upload (the deletion question as the CSV answers it and where the answer comes from), App Store app info, export compliance, App Review details; "Console steps without an API": every form of `console plan` (App Store app record, age rating, App Privacy; Play Create app, store settings category, every App content card, content rating, target audience, production countries, production release) with its plan panel (start page, steps, answer sheet, never-press buttons, Copy plan for an AI agent) and the read-only check (`console.verify`, page text pasted or the API), plus "Fill in console" where the scripted path exists. - **Growth tab:** analytics snapshot and report request, PPO create and treatment upload; PPO start / stop (`owner.ppo_start`, `owner.ppo_stop`, owner actions). - **Store art tab:** render, review page. - **Releases tab:** rollout and phased release owner actions; **Send for review**: App Store submission state from `apple status` (waiting / in review, UNRESOLVED_ISSUES with its items, empty drafts labelled harmless), the uploaded builds by number, Submit for review / Resubmit (with the rejected items to mark resolved) / in-app purchases; Google Play versionCodes by number, Send to production (everyone, staged, or a draft for a never-published app), the managed-publishing warning and the console plans for production countries and "Send changes for review". Each button shows the plan (dry run) in the window first (`ui/OwnerAction.tsx`), then the native dialog; progress lines of the real run stream into the panel. - **TestFlight tab:** What to Test, internal groups, testers, "Create missing groups" (`apple testflight groups sync`, dry run first). - **Dependencies screen:** the Android SDK as `play build` finds it (`system.tools` `androidSdk`). - **Job toasts:** the command's progress lines; upload lines ("312/890 MB (35%), part 4/12") drive the bar, and the last 40 lines are in the toast's log. - **Reviews tab:** reply editor (`reviews.reply`), approve, send (owner action). ### The same model in the terminal The CLI workspace (`storebound.workspace.json`, docs/studios.md "Many apps and studios") holds the same studios, accounts (credential profile names instead of keychain entries), folders and overrides. The pairing rules (`cli/lib/identity.ts`) and the release lanes (`cli/lib/train.ts`) are one implementation for both; the window re-exports them (`src/lib/identity.ts`, `src/lib/model.ts`). `storebound ws desktop import` reads `state.json` into a workspace file (the keychain is never read; the profiles to create are listed); `storebound ws desktop export --merge --apply` adds a workspace to `state.json` while the app is closed (backup kept, nothing removed, new accounts get their keys in the Accounts screen). Project ids in an exported state are stable per folder path (16 hex). ### Fill in console Some store forms have no API: the App Store app record (New App), the age rating, App Privacy, Play's Create app form, and the Play content rating and target audience questionnaires. The App info tab has a **Fill in console** button for each (App Privacy card, App info card for the age rating, a "Play Console forms" card, and a notice when the config has no App Store app id yet). It runs `storebound console fill
` through the desktop-only method `console.fill` (a write, never an MCP tool): 1. The click runs the dry run (`console fill --dry --json`): nothing opens. A panel lists the answers from the config; the ones only the owner knows are marked "You answer this". It shows the note: "Console pages change. This is AI-assisted: review every field in the browser and press the final button (Publish, Submit, Save or Create) yourself. storebound moves through the pages for you and never submits or publishes; App Privacy answers stay a draft until you press Publish." 2. **Open the browser** goes through `write_with_confirm`: the host repeats the dry run and shows the answers and the note in a native dialog ("Open the browser" / Cancel). Only that button mints the confirm token. 3. The real run opens a visible Chromium with its own profile. The owner signs in (storebound never types passwords); storebound fills the fields, highlights the uncertain ones, clicks the navigation (Next, App Privacy's Get Started / Edit / data types and its draft Save) and never clicks Submit, Publish, Create or a final Save. The run lasts until the owner closes the browser: no timeout in serve, the runner or the host. Progress lines show in the panel; **Cancel: close the browser** sends `$/cancelRequest`, which stops the command and its browser. The browser is the same as in the terminal (cli/console/session.ts): a Chrome running with remote debugging, else storebound's own profile (the owner signs in there once; the native dialog says so) in the desktop's Chrome for Testing (`STOREBOUND_CHROME`), started by storebound without automation switches and attached with the bundled playwright-core. The desktop never copies the owner's Chrome cookies (Google binds them to the device; see the console-autofill skill); `--use-chrome-session` is a terminal-only opt-in. The tool's `fresh_profile: true` passes `--fresh-profile` (skip attach). storebound opens the form itself after sign-in (navigation clicks only). The own profile is one per store (every studio shares the one login) and keeps the sign-in between runs: launched with session restore, closed gracefully on Cancel. With one login in several Play developer accounts or App Store Connect teams, the run finds the account that has the app and remembers it in `~/.storebound/console-accounts.json` (`{ version, play: { : { developerId, account, at } }, apple: { : { team, teamId?, at } } }`, ids and account names only), the same file the desktop reads. A workspace account's `developerId` (play) / `teamId` (apple) is passed as `STOREBOUND_PLAY_DEVELOPER_ID` / `STOREBOUND_APPLE_TEAM_ID`. ## Every CLI area in the window | CLI | where in the window | |---|---| | `prices export --json` (new, read-only) | Prices: product x territory matrix (both stores, Apple manual / equalized), per-territory table with gaps | | `prices export` | Prices: "Write store/prices.csv" (confirm flow); "Export prices CSV" saves the same sheet anywhere | | `prices import --dry --json` / `prices import` | Prices: compare store/prices.csv or a picked CSV (`price_sheet_pick`), diff table, Apply = `owner.prices_import` | | `inspect --declarations --json` (new) | App info: Data safety, App Privacy suggestions, app info, encryption, review details, "ask the owner" items | | `play data-safety` | App info: Upload (keeps `.storebound/data-safety.uploaded.csv`, so the tab shows changes since; Play cannot read the form back) | | `apple appinfo`, `apple encryption`, `apple review-details` | App info: Push buttons (confirm flow) | | `console fill --dry --json` / `console fill --json` (desktop-only `console.fill`) | App info: "Fill in console" (answers panel, native dialog, visible browser the owner saves in) | | `apple ppo status / create / upload / start / stop` | Growth: tests table, create, upload, Start / Stop owner actions | | `apple status --json` (review submissions, UNRESOLVED_ISSUES items, empty drafts, builds) | Releases: Send for review, App Store card | | `apple submit --build n`, `apple resubmit --build n [--resolve-iaps]`, `apple iap-submit [--with-version]` | Releases: Submit for review / Resubmit / Submit in-app purchases (owner actions) | | `play status --json`, `play release --version-code n --production ...` | Releases: Google Play card, Send to production (owner action) | | `console plan --json`, `console verify --json` | App info: Console steps (every form); Releases: production countries, Send changes for review | | `apple testflight groups sync` | TestFlight: Create missing groups | | `play products` / `play subscriptions` / `apple subscriptions --allow-price-changes` | Catalog: Price changes (owner action) | | `doctor` Android SDK | Dependencies: Android SDK card | | Play store listing experiments | no API: Growth links Play Console and the variant renders | | `analytics compare --json`, `snapshot`, `setup` | Growth: chart (reach, installs, conversion, listing changes as markers), before / after table, snapshot and setup buttons | | `shots gallery --json`, `inspect --render` (new) | Store art: grid per variant / language / size, issues, lightbox; images load lazily one at a time as data: URLs (never cached) | | `shots render`, `shots gallery` | Store art: Render (language, variant), Write review page, Open review page / folder (`project_reveal`) | | `inspect --listing-text --json` (new) | Listings export (Markdown) | | `import fastlane` | First-run guide, App folders, store-only app banner (`project_import_fastlane`: plan in a native dialog first) | **Exports.** Every screen registers what it can save (`src/ui/Exports.tsx`): the "Export" menu at the top right of the top bar and the "Export" group of the command palette. Files: prices CSV (the `prices import` format), listings (md), reviews CSV, health report (md), catalog parity (md), store readiness report (md), store declarations (md), Data safety CSV, analytics CSV, portfolio CSV. `export_save` writes only through a native save dialog (Downloads first), only csv / md / json / txt, sanitized names, at most 16 MB; the reviews export shows a native personal-data note first. ## UX audit (full CLI coverage) - **Two clicks.** From an app page every action is one tab plus one button: prices, Data safety, app info, review details, PPO, analytics, renders. The command palette jumps to Prices and Store art and runs every export. - **One place for exports.** Before, the only export was a write button in Catalog. Now each screen's exports sit in one menu at the top right (the same spot on every screen) and in the palette; disabled items say why. - **Buttons.** Card head actions go from secondary (ghost: open, show) to the primary action at the right end; owner actions are named on the button and in their native dialog. App folders: Import from fastlane, Start an app folder, Add app folder (primary) in the page header, the same three in the empty state; each folder row has Open and Show. - **Empty states say what to do.** Prices not read (Read prices), no products (where to add them), no snapshots (what a snapshot is, the bucket for Play), no PPO tests (create from a variant, or add variants), no renders (Render), no Data safety answers (which skill), store-only app (Link a folder / Start / Import). - **Errors say how to fix.** `fixHint` (src/lib/views.ts) maps known CLI errors (keys, permissions, config values, ffmpeg, Chrome, renders, snapshots, bucket, editable version, fastlane folder, price sheet, price points, network, history) to the step that fixes them, shown under read errors and in failure toasts. - **Ask, never guess.** App info opens with the questions only the owner can answer (age rating, App Privacy linked / tracking, encryption, sign-in, contact), highlighted in amber, each with how to answer. - **First run.** The guide now starts with the studio (pick or create, with a colour), so the keys added two steps later land in it; the app step offers add / start / import from fastlane side by side. - **Tabs.** Eleven app tabs fit 1440 px; narrower windows scroll the tab bar sideways instead of wrapping. R&D on a fastlane importer and a RevenueCat adapter: `docs/research/fastlane-revenuecat.md`. ## Dependencies screen storebound needs no local tool for store work. Screenshots, app previews, icons and captures use: | tool | used for | how the app gets it | |---|---|---| | ffmpeg, ffprobe | icons, renders, app previews, frame crops | download into the app data folder (planned, see below) | | Chrome / Chrome for Testing | screenshot renders, captions, web captures | system Chrome, or Chrome for Testing downloaded into the app data folder (planned) | | Xcode command line tools, simctl | iOS builds, Simulator captures | link to Apple only | | adb | Android emulator / device captures | link to the Android platform tools only | | git | release notes, analytics compare | link only | `system.tools` reports each with its path, version and source (`app` or `system`). The host passes app-managed copies to the sidecar: `STOREBOUND_TOOL_PATH` (app data `tools/bin`, put in front of PATH for every command) and `STOREBOUND_CHROME` (app data `tools/chrome/...`, used as Playwright's `executablePath`). Nothing is installed system-wide and nothing is bundled in the installer. `storebound doctor` prints the same detection (plus playwright) with the install commands for the OS. Download sources (one click on the Dependencies screen or in the first-run guide; `src-tauri/src/downloads.rs`): - **ffmpeg, macOS (arm64, x64):** release builds from ffmpeg.martin-riedl.de (static, notarized zip per tool). - **ffmpeg, Linux (x64, arm64) and Windows (x64):** `ffmpeg-n-latest--gpl` release builds from github.com/BtbN/FFmpeg-Builds. - **Verification:** the app ships a manifest (`src-tauri/tools-manifest.json`, written by `bun tools/tools-manifest.ts` before each desktop release, `--check` tests the URLs) with the exact URL, size and SHA-256 of each archive; a download whose hash differs is deleted. Every pinned hash has two independent sources: macOS archives are downloaded and hashed by the tool and must equal the publisher's `.sha256`; BtbN archives (pinned autobuild tag) use the digest GitHub computes for the release asset, which must equal the publisher's `checksums.sha256`. Downloads and their redirects may only use HTTPS and the hosts in `downloads::DOWNLOAD_HOSTS`. Everything is unpacked and checked in a staging folder; only a verified result replaces the installed copy, and the sidecar restarts so it sees the new tool. - **License:** these ffmpeg builds are GPL (v3 for the BtbN `gpl` variants). The app downloads them only on the owner's click, from the original publisher, shows the license and the source link next to the button, and never redistributes them, so the app itself stays MIT. - **Chrome for Testing:** Google's official JSON endpoints (googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json, Stable channel), archives from storage.googleapis.com over HTTPS. Those endpoints publish no hashes, so the app checks the code signature after unpacking (macOS `codesign --verify` with Google's team, Windows Authenticode); on Linux it checks the version banner. Chrome for Testing is under Google's terms for Chrome / Chromium (BSD-style Chromium license plus third-party notices). - **Progress and cancel** go through the same progress events as sidecar jobs; a cancelled download removes its temporary file. ## Release, updates and the terminal bridge (D5) - Releases: [desktop-release.md](desktop-release.md) (version authority, privacy gate, macOS signing and notarization, Forgejo builds for Linux and Windows, storebound.dev hosting, publish). - **Updates**: Settings > Updates checks storebound.dev (stable `/desktop/latest.json` or beta `/desktop/beta/latest.json`) only when asked; Install shows a native dialog, then the updater downloads the bundle, checks its minisign signature against `plugins.updater.pubkey` and restarts (`update_check`, `update_install`). - **`storebound://open?path=`** (sent by `storebound desktop` in an app folder): the host accepts one plain absolute path (`policy::deep_link_open_path`), selects that app folder when it is in the list, otherwise asks in a native dialog and adds it (`deep-link-folder` event). A folder without a storebound config gets a message, nothing else. - **`storebound-desktop --self-test`**: starts the bundled sidecar, checks the `session.hello` handshake, prints one JSON line and exits, before Tauri starts (no window). The release runs it on the exact artifacts. ## Never a blank window (D6) - **Frozen prototype.** `freezePrototype: true` makes Tauri run `Object.freeze(Object.prototype)` before any page script. Dependencies that assign `x.constructor = ...` or `x.toString = ...` on a plain object (d3-color, d3-format under the visx charts) then throw while the bundle loads ("Cannot assign to read only property"; WebKit: "Attempted to assign to readonly property"), and the window shows only its background colour, before React and before the sidecar starts. `apps/desktop/build/freeze-safe.ts` (a Vite plugin) rewrites those assignments in dependencies into a helper that defines the property when the frozen prototype would refuse it. - **Saved state versions.** `state.json` carries `version` (2). Files without it are format 1 (D1 to D3: projects and accounts, later studios, overrides and settings) and are migrated once (`store::migrate`), the original kept as `state.json.v1.bak`. An unreadable file is kept as `state.json.unreadable-