storebound/docs/desktop.md
CRT 0035931734 console fill: never copy Google session cookies by default (device-bound); own profile in a Chrome started without automation switches
Google binds its session cookies to the device (Device Bound Session Credentials) and signs out
sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is
now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir,
--remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP.
--use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
2026-10-12 07:46:38 +08:00

29 KiB

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 submits for review, 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). 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. The native dialog names the action on its button ("Yes: Change the Play staged rollout"). tests/desktop-d3.test.ts and src-tauri/tests/d3_writes.rs pin the token binding of every write the window offers.
  • 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/<Name>, 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:<id>.

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:<account>:<app id>:<bundle>;android:<account>:<package>.
  • 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.
  • Prices tab: the price sheet compared with the stores and applied (owner.prices_import, an owner action).
  • App info tab: Play Data safety upload, App Store app info, export compliance, App Review details.
  • 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.
  • TestFlight tab: What to Test, internal groups, testers.
  • Reviews tab: reply editor (reviews.reply), approve, send (owner action).

The same model in the terminal

The CLI workspace (storebound.workspace.json, README "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 <form> through the desktop-only method console.fill (a write, never an MCP tool):

  1. The click runs the dry run (console fill <form> --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: { <package>: { developerId, account, at } }, apple: { <bundle id>: { 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 <form> --dry --json / console fill <form> --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
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<major>-latest-<os>-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 (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=<absolute folder> (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-<time> and the app starts empty; entries that do not parse are dropped one by one (named in host.log), settings missing a field keep the rest.
  • Folders that are gone (moved, renamed, deleted, old worktrees): projects_list marks them missing; the app shows "Folder not found" with Locate… (project_locate: keeps the id, accounts, links and saved reads) and Remove from list. Portfolio reads skip them.
  • Error screens. A React error boundary around the whole window and one per screen and app tab show the error, Copy details, Reload and Reset saved state (keeps a backup: state_reset moves state.json to state.json.reset-<time>.bak after a native confirmation; keys stay in the keychain). public/boot.js runs before the bundle and shows the same panel in plain DOM when the bundle fails or the window stays empty.
  • Logs in <app data>/logs: host.log (start, saved state and migration, sidecar start / stop with its last stderr lines, panics) and webview.log (window.onerror, unhandled rejections, boundaries, through log_webview, at most 60 entries a minute). Rotated at 512 KB (one .1.log kept); home folder shown as ~, keys, tokens, passwords and e-mail addresses replaced.
  • Boot check (bun tools/desktop-boot-check.ts, in bun run ci and the macOS release): the built dist/ in headless Chromium with the release CSP and the frozen prototype, a mocked host answering from the saved state fixtures (apps/desktop/tests/fixtures/state: empty, D1 with folders gone, D3 full; the host answers are written by the Rust migration itself, src-tauri/tests/state.rs) and the real sidecar for reads. It opens every screen and app and fails on any uncaught or console error, an error panel or an empty #root; a last case removes the bundle and expects the boot.js panel.

Develop

bun install
bun tools/build-sidecars.ts --host        # apps/desktop/src-tauri/binaries/storebound-<target triple>
cd apps/desktop && bunx tauri dev         # Vite on 127.0.0.1:1420 + the Rust host
cd apps/desktop/src-tauri && cargo test && cargo clippy --all-targets -- -D warnings

Dev-only switches (ignored in release builds): VITE_START=accounts|app:catalog|app:health:2|welcome:3|palette|... opens a screen first, VITE_THEME=light|dark and VITE_LANG=en|tr force theme and language, VITE_AUTORUN=gallery starts the confirm flow on the first app (the same names work as ?start=...&theme=... query parameters on the Vite server). STOREBOUND_DEV_PROJECTS=<dir>:<dir> (debug builds) seeds the app folder list.

Fixture mode (dev builds only): VITE_FIXTURES=1 answers every app command from src/dev/fixtures.ts (made-up store data around the two demo apps; =empty for a first run, =cold for nothing read yet). The window shows a "DEV FIXTURES" badge; release bundles do not contain it (tests/webview.test.ts checks dist/). Screenshots of every screen in both themes: VITE_FIXTURES=1 bunx vite --port 1431 & then bun scripts/screens.ts --base http://127.0.0.1:1431 --out <dir> (playwright with the installed Chrome). Gotchas kept from earlier Tauri apps: Cmd+Q quits and the Window menu keeps an entry for the hidden window; platform configs (tauri.<os>.conf.json) must not set app.windows (Tauri replaces the whole array); Linux sets WEBKIT_DISABLE_DMABUF_RENDERER=1 unless the user set it; a bun build --compile binary needs an ad-hoc re-sign on macOS or the kernel kills it on start.