The tray's right-click menu is drawn by the app: one tree from tray.rs (stable submenu keys), one frameless always-on-top menu-N window per panel rendered by tray-menu.html with the design system, placed inside the monitor's work area. A pure panel book keeps transient and pinned panels apart; pinned panels (pin button, Ctrl+click, middle click) are draggable, step aside from each other and are saved in state.json (menuPins) and restored at start. tray_update pushes the tree to the open panels, which update in place; closed panels are destroyed. The native menu, rendered from the same tree, stays as the fallback and on Linux. Only menu-* windows get the menu_* commands (menu.json). In-app context menus get the same behaviour through a pure menu stack (ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels survive the next menu, are draggable and re-read their entries from live sources so data changes update them in place. Also drops a stray diff3 marker line from CHANGELOG.md.
50 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 withbun 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.tsper 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.statusreturns 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.statuscompares 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.hellowith 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_rolloutshare / halt / resume / complete,owner.ios_phasedon / pause / resume / complete,owner.reply_sendone approved draft,owner.testflight_testersadds the testers of.storebound/testers.csv,owner.prices_importandowner.prices_play_products/_play_subscriptions/_apple_subscriptionswith--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]) andowner.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 setsSTOREBOUND_OWNER_CONFIRMEDfor that one child, which answers the command's question, and adds the owner-only flags (--yesof apple submit / resubmit,--allow-price-changes);OWNER_ALLOWED_ARGSnames 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.tsandsrc-tauri/tests/d3_writes.rspin 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.rspins 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 fromcatalog.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 viareviews.approve), TestFlight (apple.testflight_status), Checks (offlinescan: rejection risks with guideline, evidence file:line, fix and skill, owner-accepted ones marked; offlinecheck;validatewith fix hints). A finding whose fix an existing command fully determines carriesaction: { method, args }(cli/lib/fix-actions.ts) and gets a Fix button: a confirm-gated write (dry run, the native dialog showing it, then the real run). Mapped today: privacy-labels ->scan.write(scan --write-only: the detected Data safety / App Privacy answers into the config, the dialog shows the config diff), Play listing gaps ->play.listing, App Store missing localization and copyright ->apple.listing(editable version only), App Review contact ->apple.review_details. No fix reaches a submit, release or publish (tests/fix-actions.test.ts). Findings whose fix is a code or project-file change (aiFix: true, AI_FIX_RULES: ATS, foreground service types, purpose strings, target SDK, ...) get Fix with AI: the host (src-tauri/src/ai_fix.rs) runs the owner's local Claude Code (claude -p, found on PATH or its usual install folders) in the app folder with only Read, Edit, Write, Grep and Glob (Bash, web tools and sub-agents disallowed), a prompt it builds from the finding's fields (the window cannot send free text), a 10 minute timeout with a heartbeat, and kill by PID (process group) on Cancel or app exit. It refuses while the app folder has uncommitted files (and lists them), so the diff afterwards is the run's own work; the window shows it with Keep and Undo (the host restores exactly the files that run changed and removes the ones it created) and rescans to say whether the finding is gone. Without claude the button is Copy for AI (the same prompt). The host only starts the claude binary, git and kill / taskkill there (tests/security.rs, tests/ai_fix.rs). - 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-Limitheader (user-hour-lim / user-hour-rem, about 3600 per key per hour) reaches the window as the ToolResult'squota(cli/lib/quota.ts: a marked stderr line underserve, taken out of the output byrunner.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.
- lanes
- 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, orhealth --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 withstorebound 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_cancelsends$/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.tsxmaps 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 intosrc/ui/store-marks/bybun tools/site-icons.tsand 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 frombun 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, fromtools/brand-assets.py, seebrand/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 checkgives 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 listreads the apps an account can see:- App Store Connect:
GET /v1/apps, the newest version, and an icon from the newest build'siconAssetToken, fetched by the CLI and returned as a data URL; - Play: the Reporting API
apps:search.
- App Store Connect:
- Serve exposes both as
apps.listandaccount.check(kindaccount, one account's key per call, run in an empty temp folder). The host caches app lists underacct:<id>.
One card per app
src/lib/identity.ts builds one card per app, pure and tested:
-
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.
-
The remaining store apps pair iOS + Android, in this order:
- manual pairs;
- the same id;
- a close id (
.ios/.androidsuffixes, case, punctuation); - the same name.
Unpairs block automatic pairs.
-
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 minimalstorebound.config.json(store ids, placeholder links) into its temp folder. - Only
apple.status,play.status,health,reviews.list,apple.testflight_status,apple.ppo_statusandlisting.livework this way (tabs Releases, Listings, Health, Reviews, TestFlight, Growth), plus the one writelisting.set(listing text per language, see "Editors"), andportfolio.statustakes 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 --asoandscan(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.tsby 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 likecheckbefore anything is written),store/listing.md/store/appstore.mdfield 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) get a Listings tab too: the live texts per language (
listing.live=storebound listing live), edited with the same counters and checks and applied straight to the store per language (listing.set=storebound listing set: the store's dry run in the window, the native dialog, a PATCH of only the edited fields). It is the one write a store-only app may run (VIRTUAL_WRITESin serve.ts,STORE_ONLY_WRITESin policy.rs); its token is bound to the store ids instead of a folder. URLs, contact, catalog and art need an app folder (they send what its config holds). - Captions: the screenshot captions per language and scene (the shots copy file) are edited in Store art as a text edit of the JSON (its own layout, one scene per line, stays).
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 buildfinds it (system.toolsandroidSdk). - 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):
- 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." - 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. - 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) |
Every command, one place (desktop-coverage.md). cli/lib/desktop-coverage.ts maps each CLI command to the
window place and the methods that run it (serve methods, or host: commands of src-tauri); bun tools/gen-desktop-coverage.ts writes desktop-coverage.md from it and
tests/desktop-coverage.test.ts fails when a command has no place, names a method the window never calls, or is
"not for the desktop" outside the short list (mcp, serve, desktop, api). A new command needs an entry there.
More tools tab (src/screens/tabs/Tools.tsx). The commands without a screen of their own (TOOLBOX in the
coverage map: build and upload, ad-hoc devices, store features, text and ASO, captures, quality, Apple Ads, workflows
and AI tools) get one form each, built from the method's argument schema (system.methods): enums as selects,
booleans as checkboxes, strings with the sidecar's pattern; empty fields keep the command's defaults. Never a
free-text shell: the sidecar validates every value. Reads run and show the command's output; writes go through
useWrite (dry run, native dialog, confirm token; build, upload, signing and device registration have their own
question answered by that token). Owner-only steps without a window button (OWNER_STEPS: offer codes, win-back,
promoted enable, pre-order enable / end, custom page and event submit, TestFlight expire, Apple Ads changes,
RevenueCat sync, shots login) are listed with their terminal command and a Copy button.
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. Twelve app tabs (More tools last) 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>-gplrelease builds from github.com/BtbN/FFmpeg-Builds. - Verification: the app ships a manifest (
src-tauri/tools-manifest.json, written bybun tools/tools-manifest.tsbefore each desktop release,--checktests 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'schecksums.sha256. Downloads and their redirects may only use HTTPS and the hosts indownloads::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
gplvariants). 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 --verifywith 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'sversionis the app's too (tools/desktop-version.tskeeps the copies equal, CI andsrc-tauri/tests/d5.rscheck 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 whosepackage.jsonpins 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 andfile: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 fromstorebound.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.desktopwith thestorebound://handler, registered withupdate-desktop-database/xdg-mimewhen 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]),--yesskips the question, without a terminal and--yesit prints the command and exits 3.--dryand--jsonchange nothing;--downloadonly saves the checked installer.storebound-desktop --versionprints the version (Windows, Linux).- Accounts live in the machine's credential profiles,
~/.storebound/credentials/<name>.jsonwith 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 tocredentials/.removed/. At start and whenever the list is shown,profiles::syncadds 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~/.sshand~/.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 desktopin an app folder,storebound apps add,ws initand the App folders screen add to it;storebound apps folderslists it;storebound wswithout a workspace file works over it. The app keeps its per-folder extras (accounts) instate.jsonunder the same id and syncs at start, when the list is shown and before astorebound://openlink (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.jsonor beta/desktop/beta/latest.json) when asked; Install shows a native dialog, then the updater downloads the bundle, checks its minisign signature againstplugins.updater.pubkeyand restarts (update_check,update_install). In the background the app also checks the stable channel a minute after start and every 30 minutes, installs a signed update silently and restarts with the same arguments (auto_updateinsrc-tauri/src/lib.rs). - Tray and start at logon: the first run registers a logon entry (
tauri-plugin-autostart,--hidden): the app starts silently in the tray, without a window. Closing the window keeps it running in the tray; the tray icon opens it, the tray menu's Quit (macOS: Cmd+Q) quits. Turn the logon entry off in the OS (Windows: Settings > Apps > Startup). Window state never restores "visible", so a--hiddenstart stays hidden. - Tray menu panels (
src-tauri/src/menu_panel.rs,src/traymenu/, pagetray-menu.html): a right click on the tray icon opens the app's own menu instead of the OS menu. tray.rs builds one tree (menu_model, submenus with a stable key such asapps, the same in every language); every panel (the root and each submenu) is its own frameless, always-on-topmenu-Nwindow that measures itself and callsmenu_ready, and Rust places it inside the monitor's work area (the root away from the taskbar or menu bar, a submenu beside its parent, flipped at the edge). The panel book (pure,tests/menu_panel.rs) keeps transient and pinned panels apart: a new menu, Esc, a pick or focus leaving every menu closes the transient ones; pinned ones (pin button, Ctrl+click, middle click) stay, can be dragged by the header, step aside from other pins and are saved in state.json (menuPins) and restored at start.tray_updatepushes the tree to the open panels (menu-model), which update in place; nothing polls, and a closed panel is destroyed. Onlymenu-*windows get themenu_*commands (capabilities/menu.json,guard_menu). If a panel cannot be built or never reports ready, the native menu (rendered from the same tree) goes on the tray and pops once; Linux always uses it (its tray sends no clicks). The left-click card stays as it was. Look at the panels without Tauri:bunx vitein apps/desktop and open/tray-menu.html(a dev-only demo on fixture data;?still=1stops the ticking values). - Pinned context menus in the window (
src/ui/ContextMenu.tsx, pure stack insrc/ui/menu-stack.ts): every menu has a pin button; a submenu's pin button, Ctrl / Cmd + click or a middle click on a submenu row tears it off. Pinned panels survive the next menu, are dragged by the header and re-read their entries from the newest builder the screen registered (useContextMenu()/useMenuButton()register on every render; pass an id such asapp:<key>when rows can reorder), so a store read updates them in place; when the thing is gone they say so instead of closing. storebound desktopinstall / update: before the installer runs it stops the running app (only processes started from the exact install path:%LOCALAPPDATA%\storebound\storebound-desktop.exe,storebound.app/Contents/MacOS/storebound-desktop, the AppImage; a graceful close, then by PID after 10 s). After the install it reads the real version of the installed files (Windows: the exe's PE version resource, never the registry entry; macOS: Info.plist; Linux:--version) and fails on a mismatch. "opened" is printed only when a process without--hiddenruns from the installed path within 15 s; otherwise it says so and nameshost.log.storebound://open?path=<absolute folder>(sent bystorebound desktopin 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-folderevent). A folder without a storebound config gets a message, nothing else.storebound-desktop --self-test: starts the bundled sidecar, checks thesession.hellohandshake, 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: truemakes Tauri runObject.freeze(Object.prototype)before any page script. Dependencies that assignx.constructor = ...orx.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.jsoncarriesversion(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 asstate.json.v1.bak. An unreadable file is kept asstate.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_listmarks themmissing; 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_resetmovesstate.jsontostate.json.reset-<time>.bakafter a native confirmation; keys stay in~/.storebound/credentials).public/boot.jsruns 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) andwebview.log(window.onerror, unhandled rejections, boundaries, throughlog_webview, at most 60 entries a minute). Rotated at 512 KB (one.1.logkept); home folder shown as~, keys, tokens, passwords and e-mail addresses replaced. - Boot check (
bun tools/desktop-boot-check.ts, inbun run ciand the macOS release): the builtdist/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.