storebound/docs/desktop.md

43 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).

Console parity (what the window can view, edit and write for each console screen): console-parity.md.

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, written into the machine's credential profiles (~/.storebound/credentials, files 0600 in folders 0700, the same store the CLI reads: profiles.rs), 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 shared credential profiles 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. Every read is saved in app data (cache.sqlite, keyed by app + method + arguments, results only, never keys); a card opens with the saved read at once and "read 3 min ago", a small spinner while it refreshes, and a failed read keeps the previous one with "couldn't refresh: ". Nothing that was not read is shown as fine. Settings clears the saved reads.
  • Automatic reading (src/lib/scheduler.ts, src/lib/freshness.ts, store.tsx). On start, when the window shows again after a minute hidden, and every N minutes while it is visible (Settings > Store reads: auto-read on by default, every 15 min; 0 = only on start; paused while hidden), the window reads everything of every app that is past its TTL: status and submission state 5 min, TestFlight 5 min, reviews 15 min, catalog / prices / listings / checks 30 min, health / analytics / ASO 6 h. "Refresh all" in the toolbar reads everything now; each card has a refresh icon. Writes invalidate the app's reads. All reads go through one scheduler:
    • lanes apple, play, local, each with at most Settings > parallel (default 4) at once; a read that needs both stores takes a slot in each;
    • starts on one credential (account) at least 250 ms apart;
    • App Store Connect's X-Rate-Limit header (user-hour-lim / user-hour-rem, about 3600 per key per hour) reaches the window as the ToolResult's quota (cli/lib/quota.ts: a marked stderr line under serve, taken out of the output by runner.ts). Below 20 % left, starts are spread over the rest of the hour; at 100 left (kept for the owner's own commands) the key pauses 10 minutes;
    • 429 / RESOURCE_EXHAUSTED (Play Developer API, Reporting API; the CLI already retries inside one command): the read runs again with exponential backoff and equal jitter (5 s base, 5 min cap, 4 attempts), and its key waits too;
    • priorities: the app on screen first, then the live portfolio status, then the rest; one in-flight read per key. The sidebar's status line says "Reading 12 of 48 · App Store limit 3,412/3,600 left this hour". The boot check's auto-read case (13 apps, cold cache) loads everything without a click and finds no "not read yet" on any tab.
  • Health issue detail. A top issue (Play) or diagnostic signature (App Store) opens in place: full title and type, reports / users, first and last versionCode, Android API range, last report, a sample stack (health.issue = health --issue <id> via errorReports:search, or health --signature <id> via the signature's logs; cached like every read), "Open in Play Console" (issueUri), "Copy for AI" (compact markdown with the top 15 frames) and a likely cause with its fix for known signatures (cli/lib/crash-hints.ts, shared with storebound health).
  • 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 live in the credential profiles shared with the CLI. The state file keeps labels, studio ids and the profile name of each account only (see "One product with the CLI" below).
  • 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 every saved state of the app is removed; the shared credential profiles and the app folder list stay (the CLI uses them).

Editors (console parity)

Everything the stores' consoles let the owner edit and storebound can write is edited in the window; the parity table is console-parity.md.

  • Where: Listings (texts per language for both stores side by side, store details: URLs, contact, copyright, promo video), App info (App Store categories, age rating answers, content rights, price, export compliance, App Review sign-in, notes and contact; Play category, countries, managed publishing, target ages, delete-account page; Data safety and App Privacy answers; promoted purchases, custom product pages, in-app events), Catalog (each product and subscription: names and descriptions per language, USD price, reference name, review note, offers), TestFlight (What to Test per locale, Beta App Description, Beta App Review notes, groups), Store art (scene order, the frame of each scene).
  • While typing: live counters against the store limits (characters, as the consoles count them) and the checks of check --aso and scan (cli/lib/listing-rules.ts, the same code: required fields, keyword field rules, other platforms, other apps' names, prices and claims, placeholder text, beta wording, keyword stuffing); URLs and emails.
  • Unsaved edits (src/lib/edits.ts) live outside the tabs for the whole session: switching tabs or apps keeps them, the tab shows a dot, undo / redo / discard, and reloading the window with unsaved edits asks first.
  • Save ("Review and save"): the dry run of project.edit (storebound edit --ops, cli/edit.ts) in the window shows old -> new, the diff per file and the checks; an error blocks the save. Save then goes through the native dialog and writes the files: storebound.config.ts by path as a text edit (cli/lib/config-path.ts: comments, order, quotes and formatting stay, only the value changes; the edited config is loaded and validated like check before anything is written), store/listing.md / store/appstore.md field blocks (cli/lib/listing-edit.ts), the App Review contact in .storebound/credentials.json (masked everywhere, the demo account password is never read). A folder under version control shows the files as uncommitted; storebound never commits.
  • Apply to the stores: each store write's dry run (old -> new per store and language) is shown in the window first, then the native dialog, then the real run. Price changes of live products stay the owner's price decision.
  • Pull from the store (play.listing_pull, apple.listing_pull): the live texts into the files, the diff in the dialog first; unsaved edits stay pending on top.
  • Store-only apps (no folder) stay read-only in the window: a store write sends what the files hold (see console-parity.md).
  • tests/edit.test.ts (round trips, checks, dry run, CLI), apps/desktop/tests/edits.test.ts (unsaved edits, undo), and the boot check's editor case (edit a field, counter and limit check, review, save: the fixture file changes by one line, the config keeps its comments, Apply shows the dry run per language).

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 (by credential profile name, the same profiles the app uses), 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 (each account keeps its profile; accounts of older apps get a profile name to create); storebound ws desktop export --merge --apply adds a workspace to state.json while the app is closed (backup kept, nothing removed; accounts carry their profile, so their keys are already there). 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
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 <form> --json, console verify <form> --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<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.

One product with the CLI (0.8)

The CLI and the app are one product: one version number, one set of store accounts, one list of app folders.

  • One version. package.json's version is the app's too (tools/desktop-version.ts keeps the copies equal, CI and src-tauri/tests/d5.rs check it), and both are released together (docs/desktop-release.md). The app runs its bundled CLI (the sidecar), which is that same version. A folder whose package.json pins another storebound (devDependencies.storebound) gets a notice on its app page: "This project uses storebound X, the app runs Y" (workspace::pin_differs; tags, ranges and file: specs give none).
  • npx storebound desktop (cli/desktop.ts, the plans in cli/lib/desktop-install.ts): the app has the CLI's version: open it on the current folder. Not installed: download it for this OS (the CLI's own version from storebound.dev/desktop/dl/<version>/latest.json, else the channel's newest), check size, SHA-256 and the minisign signature, install it for this user without admin rights (macOS: the .app from the read-only mounted DMG into /Applications when writable, else ~/Applications, no extended attributes touched; Windows: the NSIS installer with /S, per user; Linux: the AppImage into ~/.local/bin plus ~/.local/share/applications/storebound.desktop with the storebound:// handler, registered with update-desktop-database / xdg-mime when they exist; a .deb is only printed, it needs root), then open it. Older app: the same path as an update (never while the app runs); newer app: opened with a note, a downgrade only with --force. In a terminal it asks once ([Y/n]), --yes skips the question, without a terminal and --yes it prints the command and exits 3. --dry and --json change nothing; --download only saves the checked installer. storebound-desktop --version prints the version (Windows, Linux).
  • Accounts live in the machine's credential profiles, ~/.storebound/credentials/<name>.json with key files in <name>/ (0600, folders 0700; src-tauri/src/profiles.rs, the CLI's cli/lib/credentials.ts and cli/lib/accounts.ts). An app account is one store of one profile (Account.profile); its id for a profile the CLI made is the CLI workspace's (sha256("<store>:<profile>"), 16 hex). Adding a key in the app writes the profile that already has that key (same key id / service account e-mail), else one named after the label; removing one moves its key file to credentials/.removed/. At start and whenever the list is shown, profiles::sync adds an account for every profile store the app does not have yet. Migration: accounts of earlier versions kept their keys in the OS keychain (dev.storebound.desktop); the first start of 0.8 writes each into a profile (keeping the account's id, label, studio and app mappings), reads it back and only then deletes the keychain entry. An account whose keychain entry is missing stays, marked "key not moved from the keychain yet" (host.log says which). Why files and not the keychain: the CLI has no runtime dependencies and runs on three systems and in agents' terminals; profile files were already its model, so one store means the app writes those files (owner-only modes, like ~/.ssh and ~/.appstoreconnect/private_keys) instead of the CLI learning three keychain APIs.
  • App folders are the machine's list ~/.storebound/projects.json (src-tauri/src/registry.rs, the CLI's cli/lib/registry.ts): storebound desktop in an app folder, storebound apps add, ws init and the App folders screen add to it; storebound apps folders lists it; storebound ws without a workspace file works over it. The app keeps its per-folder extras (accounts) in state.json under the same id and syncs at start, when the list is shown and before a storebound://open link (a folder already on the list is selected without a question).

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, which first puts the folder on the machine's list): the host accepts one plain absolute path (policy::deep_link_open_path), syncs the list, selects that app folder when it is on it, 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 ~/.storebound/credentials). 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. One browser (killed at the end), a page per case, the cases in parallel against the one build, and no fixed sleeps: each step waits for its element, its text or a quiet window (no host call in flight, no DOM change).

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.