Compare commits

...

418 commits
v0.8.2 ... main

Author SHA1 Message Date
CRT
73e4614b34 storebound 0.8.77: version in package.json and every copy, CHANGELOG 0.8.77 section 2026-11-09 06:37:51 +07:00
CRT
108b362cd9 iPhone Duo: limit from Apple's reference data instead of a cut to 10, --duo-only; shared asset library helpers in their own module 2026-11-09 06:35:00 +07:00
CRT
5cc3da486d storebound 0.8.76: version in package.json and every copy, CHANGELOG 0.8.76 section 2026-11-08 18:24:15 +07:00
CRT
7e362ef962 ci: AppImage tools from the Forgejo mirror (sha256-checked) into ~/.cache/tauri; GitHub release assets fail too often 2026-11-08 18:20:36 +07:00
CRT
c1bed9440e storebound 0.8.75: version in package.json and every copy, CHANGELOG 0.8.75 section 2026-11-08 17:40:26 +07:00
CRT
793c322f99 CHANGELOG: 0.8.74 was not published (GitHub 504) 2026-11-08 17:37:34 +07:00
CRT
ab97d6d72f storebound 0.8.74: version in package.json and every copy, CHANGELOG 0.8.74 section 2026-11-08 16:58:18 +07:00
CRT
c5173e4cae ci: cache ~/.cache/tauri and retry the Linux bundle (GitHub release 504s); CHANGELOG note for 0.8.73 2026-11-08 16:55:29 +07:00
CRT
f637c5e4e7 storebound 0.8.73: version in package.json and every copy, CHANGELOG 0.8.73 section 2026-11-08 16:26:03 +07:00
CRT
3a22d7f455 ci: install bun from the npm registry (GitHub release assets answer 504 now and then), bun.sh with retries as fallback 2026-11-08 16:23:15 +07:00
CRT
96458bba5c ci: re-run (GitHub download outage) 2026-11-08 16:17:34 +07:00
CRT
b361e22631 ci: re-run (bun download 504) 2026-11-08 15:50:43 +07:00
CRT
b18d480d65 iPhone Duo: accept platform IPHONE_APP_STORE from appAssetLibraryRefData; a Duo failure warns, other sets continue 2026-11-08 15:49:17 +07:00
CRT
f6f9fc3c41 storebound 0.8.72: version in package.json and every copy, CHANGELOG 0.8.72 section 2026-11-08 15:20:10 +07:00
CRT
8573c9f6ae flags.json: regenerated 2026-11-08 15:18:12 +07:00
CRT
1ce4f026e9 api tables: refData and asset library images now wrapped by apple screenshots 2026-11-08 15:15:57 +07:00
CRT
73a962fbdc apple screenshots: iPhone Duo through the App Asset Library (IPHONE_DUO placement group, APP_SCREENSHOT), pitfall 130 2026-11-08 14:19:12 +07:00
CRT
b1043f5d79 storebound 0.8.71: version in package.json and every copy, CHANGELOG 0.8.71 section 2026-11-08 03:33:21 +07:00
CRT
01ea264a21 CHANGELOG: 0.8.71 summary (0.8.69 and 0.8.70 were not published) 2026-11-08 03:30:40 +07:00
CRT
ec381e8664 boot check: the portfolio with a Play page 404 counts no live version 2026-11-08 03:26:22 +07:00
CRT
f34192b546 desktop Portfolio: the Play phase uses the same label as Releases (Not live yet ...) 2026-11-08 03:26:22 +07:00
CRT
d71368e886 storebound 0.8.70: version in package.json and every copy, CHANGELOG 0.8.70 section 2026-11-08 03:17:41 +07:00
CRT
07052cffd2 public MCP: a browser on the bare host goes to the landing page 2026-11-08 03:14:37 +07:00
CRT
b82becf7fb public MCP: the bare host answers MCP (POST /) and names the connect URL (GET /) 2026-11-08 03:14:37 +07:00
CRT
9081ac921d storebound 0.8.69: version in package.json and every copy, CHANGELOG 0.8.69 section 2026-11-08 02:24:27 +07:00
CRT
9c001d1ca9 play: old testing builds below production listed with their replace command (status, validate); 'Not live yet' instead of 'In review'; commit redone without changesNotSentForReview on the auto-send 400; pitfalls #129 2026-11-08 02:18:24 +07:00
CRT
83d351cf42 storebound 0.8.68: version in package.json and every copy, CHANGELOG 0.8.68 section 2026-11-08 02:01:11 +07:00
CRT
d05294ebcd apple submit/resubmit --platform macos: apple.macos.noIap leaves IAPs out, no Game Center switch without apple.macos.gameCenter; pitfalls #128 2026-11-06 21:07:22 +07:00
CRT
36ef9c2c5c tests: placeholder home folders in the launcher test (scrub) 2026-11-06 21:04:14 +07:00
CRT
0950ef2c99 desktop: the app's own CLI on the terminal PATH (launcher rewritten on every start; npm installs stay first) 2026-11-06 21:01:19 +07:00
CRT
31f863ed06 storebound 0.8.67: version in package.json and every copy, CHANGELOG 0.8.67 section 2026-11-04 00:20:45 +07:00
CRT
64cb16e337 apple build --platform macos: apple.macos.prebuild, not the iOS project's build.prebuild 2026-11-04 00:17:04 +07:00
CRT
700fcc4d66 storebound 0.8.66: version in package.json and every copy, CHANGELOG 0.8.66 section 2026-11-03 23:43:46 +07:00
CRT
f1a9229cf9 changelog: macOS entitlements, MCP review-details platform 2026-11-03 23:40:51 +07:00
CRT
68467ffe9b apple build --platform macos: --tauri takes tauri.<name>.conf.json (tauri.appstore.conf.json found by itself), entitlements from --entitlements / apple.macos.entitlements / the merged config, Developer-ID-only keys refused or warned 2026-11-03 23:40:25 +07:00
CRT
d88ed3ec55 mcp: apple_review_details takes platform (macOS version, apple.macos.reviewNotes) 2026-11-03 23:40:25 +07:00
CRT
1e47c2ee60 apple.macos listing / review notes per platform + iOS-only claim warnings; apple upload of a .pkg: bounded pkgutil, macOS processing wait, heartbeats; release 409 pattern 2026-11-03 23:40:25 +07:00
CRT
53d57544bf storebound 0.8.65: version in package.json and every copy, CHANGELOG 0.8.65 section 2026-11-03 23:17:34 +07:00
CRT
d0f568ebc2 validate: removed-from-sale IAPs are no blockers; shots capture --device landscape (Mac / Chromebook frames from a web build); playwright lookup says why each place failed 2026-11-03 23:14:34 +07:00
CRT
cdd9321794 storebound 0.8.64: version in package.json and every copy, CHANGELOG 0.8.64 section 2026-11-02 18:22:39 +07:00
CRT
3a8d9d7551 flags: regenerate for the new apple build / flow options 2026-11-02 18:19:50 +07:00
CRT
f5da7c9f7a validate: macOS versions need Mac screenshots, Play reads survive an ended edit; macOS build numbers across platforms; Mac build prebuild + apple.macAppCheck; signing wording off the Mac; dry flows plan uploads 2026-11-02 18:16:25 +07:00
CRT
b1f1b206d3 storebound 0.8.63: version in package.json and every copy, CHANGELOG 0.8.63 section 2026-11-02 16:43:58 +07:00
CRT
4b6c28aff2 desktop: a second --hidden start stays in the tray (single-instance no longer opens the window) 2026-11-02 16:39:15 +07:00
CRT
a850ba6670 storebound 0.8.62: version in package.json and every copy, CHANGELOG 0.8.62 section 2026-10-30 00:29:52 +07:00
CRT
152e40fb8a doctor: warn when storebound runs from a folder macOS guards (a launchd job hangs opening it) 2026-10-30 00:26:07 +07:00
CRT
724f5b9763 apple build: a line before each slow step (App Store Connect reads, device families, web build) 2026-10-30 00:16:11 +07:00
CRT
064a708fdb storebound 0.8.61: version in package.json and every copy, CHANGELOG 0.8.61 section 2026-10-29 22:40:00 +07:00
CRT
9b0cc0bf10 regenerate the api coverage tables 2026-10-29 22:37:03 +07:00
CRT
1abd0936c3 testflight: description keys by language id or App Store locale, unknown keys are a config error, primary-locale check before Beta App Review 2026-10-29 22:33:59 +07:00
CRT
e185b61c8e storebound 0.8.60: version in package.json and every copy, CHANGELOG 0.8.60 section 2026-10-29 20:24:54 +07:00
CRT
edbcdad15d gradle: comment without a home path 2026-10-29 20:21:52 +07:00
CRT
1c64320b65 desktop-release: a fixed release tool left uncommitted for release --resume does not make the build dirty 2026-10-29 20:19:21 +07:00
CRT
6ebb656018 apple listing: a new version inherits the previous version's release type when apple.releaseType is unset; warn when the config differs 2026-10-29 20:19:21 +07:00
CRT
f5296cc9e9 play release --dry: no bundle upload into a discarded edit (a long upload outlived it: edit expired); shows the planned tracks 2026-10-29 20:19:20 +07:00
CRT
ab69ce020c play build: absolute --dir stays absolute; gradlew without exec bit is made executable 2026-10-29 20:19:16 +07:00
CRT
0ea7292164 release --resume: a fixed tools/desktop-release.ts may be uncommitted too 2026-10-29 20:18:52 +07:00
CRT
02435de966 desktop-release: sign by key path only (a TAURI_SIGNING_PRIVATE_KEY in the env made the signer refuse both) 2026-10-29 20:18:51 +07:00
CRT
96ec7ba60f desktop-release: sign by key path only (a TAURI_SIGNING_PRIVATE_KEY in the env made the signer refuse both) 2026-10-29 20:18:51 +07:00
CRT
17f57c6fb6 storebound 0.8.59: version in package.json and every copy, CHANGELOG 0.8.59 section 2026-10-29 19:10:28 +07:00
CRT
3b1c6160b5 docs: SSE keepalive for long tool calls 2026-10-29 19:06:22 +07:00
CRT
9620768897 mcp --http: SSE keepalive for long tool calls; public App Store search shares one 20-a-minute budget 2026-10-29 19:06:14 +07:00
CRT
c500866295 mcp --http --public: key-less public server (skills, pitfalls, limits, listing lint, App Store search), rate limit, 64 KB bodies 2026-10-29 19:06:14 +07:00
CRT
398d8cd375 storebound 0.8.58: version in package.json and every copy, CHANGELOG 0.8.58 section 2026-10-29 18:41:35 +07:00
CRT
a108aae53f mcp --http: --broker-profiles (store keys from the broker vault, memory only), --read-only, project by folder name under the roots 2026-10-29 18:38:11 +07:00
CRT
164ba390dd check / scan: project walks skip dot folders, worktrees and git-ignored output, no stat per entry (244 s -> 46 s on a big game repo) 2026-10-29 17:29:28 +07:00
CRT
f2d7beb7ce mcp --http: no request timeout (tool calls can run for many minutes) 2026-10-29 16:31:41 +07:00
CRT
8068993917 docs: mcp --http (shared server, project argument, token and introspection auth) 2026-10-29 16:21:50 +07:00
CRT
5f73fcbcf4 mcp --http: one Streamable HTTP server for every session (token or OAuth introspection auth, project argument per call, roots, origin check) 2026-10-29 16:19:53 +07:00
CRT
28a50e7d2e storebound 0.8.57: version in package.json and every copy, CHANGELOG 0.8.57 section 2026-10-27 19:01:14 +07:00
CRT
9a878d6562 apple upload: find the IPA of a build made with --out (build notes it in .storebound/last-apple-build.json; upload --out; the error names every folder searched and the last build's IPA) 2026-10-27 18:58:48 +07:00
CRT
ef80cd2760 storebound 0.8.56: version in package.json and every copy, CHANGELOG 0.8.56 section 2026-10-27 17:17:39 +07:00
CRT
bf95ee55c4 regenerate the api coverage tables 2026-10-27 17:13:34 +07:00
CRT
2dc20b61de regenerate embedded skills 2026-10-27 17:10:59 +07:00
CRT
f64f125de6 docs: escape the table pipes 2026-10-27 17:08:39 +07:00
CRT
5f2e03dc7d docs: apple release type flags and config template line 2026-10-27 17:07:53 +07:00
CRT
85cf494c0a apple: release type (apple.releaseType, apple release --after-approval|--manual), device-family preflight, TestFlight invite needs an installable build 2026-10-27 17:07:37 +07:00
CRT
4763862178 storebound 0.8.55: version in package.json and every copy, CHANGELOG 0.8.55 section 2026-10-26 20:21:17 +07:00
CRT
3dc3104bbc scrub: /Users/Shared is the system shared folder, not a home path 2026-10-26 20:17:46 +07:00
CRT
0858f4e538 path-free: a shared build folder outside home is not a leak (only home and user-name paths are private) 2026-10-26 20:13:11 +07:00
CRT
efa9503a50 storebound 0.8.54: version in package.json and every copy, CHANGELOG 0.8.54 section 2026-10-26 19:16:46 +07:00
CRT
f109c519ae apple build: --derived-data (default <out>/DerivedData with --out), --path-free (Swift/clang prefix maps, no serialized debug options) and an archive path scan 2026-10-26 19:12:21 +07:00
CRT
cb20912f0e storebound 0.8.53: version in package.json and every copy, CHANGELOG 0.8.53 section 2026-10-26 18:17:15 +07:00
CRT
db52afce1d play release: read the production kind inside the release's own edit (no extra edit); the not-sent-for-review tests approve their first release 2026-10-26 18:12:19 +07:00
CRT
ae9d26d3f6 Updates are agent steps: --yes ships an update of a live app (Play production, App Store submit / release) without an owner gate; the first release stays the owner's (exit 3 OWNER_STEP, --first-release); apple release is a ship step 2026-10-26 18:09:50 +07:00
CRT
c247b7e54a release --resume: skip the desktop publish when the version is already live (a second run re-signs and the server never replaces a published file) 2026-10-26 16:52:29 +07:00
CRT
46c841d209 storebound 0.8.52: version in package.json and every copy, CHANGELOG 0.8.52 section 2026-10-26 11:08:21 +07:00
CRT
1953aa59d8 CHANGELOG: 0.8.51 was not published 2026-10-26 11:05:27 +07:00
CRT
05d0e227a1 release: the dev-channel job never gates a release; desktop-dev retries the live dev latest.json on 5xx 2026-10-26 11:05:08 +07:00
CRT
1030cdff0c storebound 0.8.51: version in package.json and every copy, CHANGELOG 0.8.51 section 2026-10-26 10:27:48 +07:00
CRT
5833776a4b analytics and reports for a collector: store-only configs, --json snapshot, App Usage and Play install stock numbers, per-day sales ranges for both stores 2026-10-26 10:20:27 +07:00
CRT
b59665c661 storebound 0.8.50: version in package.json and every copy, CHANGELOG 0.8.50 section 2026-10-25 23:18:12 +07:00
CRT
2c5e30090f credential broker: Play tokens from POST /api/google/token (cached, single flight, local key fallback), grants and APIs per package in validate, Play write preflight, apps check and the desktop Accounts view; Google 403 names the API or grant, no retry 2026-10-25 22:51:32 +07:00
CRT
304407e523 apple signing: App Group association fails early under automatic signing too (API capability + newest profile, steps and deep link, exit before the archive); console plan/verify apple-app-group 2026-10-25 22:35:36 +07:00
CRT
d978bb7a8b desktop icon: the brand mark at every size (small Windows sizes were a plain S tile), each size resampled on its own, 20/30/36/40/60/72/80/96 px added for 125-200 % scaling 2026-10-25 21:08:13 +07:00
CRT
d7c36cbdb6 storebound 0.8.49: version in package.json and every copy, CHANGELOG 0.8.49 section 2026-10-25 20:15:46 +07:00
CRT
d43c95db7e desktop: read StartupApproved through the registry API, no reg.exe (the security test allows no other programs) 2026-10-25 21:11:31 +08:00
CRT
53845c6dd9 desktop: autostart respects Windows Startup apps (turned off there stays off; the Run entry is no longer re-enabled on every start) 2026-10-24 21:13:10 +08:00
CRT
da7ca48ab4 desktop tray: card as tall as its content, card and menu open from the tray icon's edge (never over the taskbar); menu permission files 2026-10-24 19:53:10 +08:00
CRT
6782ac6078 api tables: regenerate for the new Play listing calls 2026-10-24 19:49:26 +08:00
CRT
ed8d138a8e play: a new language is created with everything Play needs to commit it (icon, feature graphic, phone and tablet screenshots); play images creates a missing listing 2026-10-24 19:46:57 +08:00
CRT
2ec1f0ab13 storebound 0.8.48: version in package.json and every copy, CHANGELOG 0.8.48 section 2026-10-24 18:17:14 +07:00
CRT
a889ada137 tests: adhoc register --yes gets 20 s (timed out at 5 s on a busy CI runner) 2026-10-24 19:09:21 +08:00
CRT
5236f002f1 desktop: tray menu panels and pinnable context menus
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.
2026-10-24 13:44:08 +08:00
CRT
445ab1adbc apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs 2026-10-24 13:07:58 +08:00
CRT
5b6e0992ca desktop: boot check finds unsaved marks on the sub views; neutral next-step lines before a read 2026-10-24 13:06:42 +08:00
CRT
0ef27cf2e4 desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention
- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state
  already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the
  context menu list sections and sub views; old tab ids, section and section/sub links all resolve
- Players > Game services (games only): both stores, one row per leaderboard / achievement with its
  languages and each store's state, Game Center locale map, Play API-disabled state with the console plan
- games status --json: items, localeMap, playApiDisabled; inspect summary: games
- sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio
  filter, workspace as a footer icon row
- boot check opens places like a person (section, then sub view)
2026-10-24 13:06:42 +08:00
CRT
98cd1d3c92 embedded: regenerated 2026-10-24 13:02:02 +08:00
CRT
66247f35d9 flags: shots fonts --lang, capture --probe 2026-10-24 13:01:45 +08:00
CRT
b059e83b90 shots fonts usage line 2026-10-24 13:01:32 +08:00
CRT
61b5fa0623 shots: feature rotate, per-language frame fallback, layout-aware capture selectors 2026-10-24 13:00:48 +08:00
CRT
2e1c7cdc3c mas: placeholders the scrub accepts 2026-10-24 12:23:53 +08:00
CRT
634d5dd71d generated API tables refreshed for the macOS build upload 2026-10-24 12:23:52 +08:00
CRT
33a7f73ab9 docs: build-and-sign Mac App Store Tauri section, pitfall 114, changelog 2026-10-24 12:23:36 +08:00
CRT
f588f4e1aa path-free: no cargo trim-paths (nightly only) 2026-10-24 12:23:20 +08:00
CRT
0263f62443 mas build: cargo target folder is src-tauri/target 2026-10-24 12:23:20 +08:00
CRT
5fc15dd773 apple build/upload: flags and help for the macOS Tauri path 2026-10-24 12:23:17 +08:00
CRT
9a937b5512 apple build --platform macos --tauri: Mac App Store build of a Tauri app (sandbox entitlements, profile, productbuild pkg, path-free, checks); apple upload accepts the pkg 2026-10-24 12:22:27 +08:00
CRT
2e8593ed5c pitfalls spacing 2026-10-24 12:17:45 +08:00
CRT
b325b45354 merge fixups: changelog and pitfall numbering 2026-10-24 12:17:01 +08:00
CRT
089c149289 desktop coverage test: shots copy is agent-edited 2026-10-24 12:16:49 +08:00
CRT
8084104a4d desktop coverage entry for shots copy 2026-10-24 12:16:49 +08:00
CRT
fb1609cba7 upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint 2026-10-24 12:16:48 +08:00
CRT
26f9559e1a play-publish-changes: a release is ready only when the store page shows an older version; unknown reads are not determined, never a mismatch 2026-10-24 12:12:37 +08:00
CRT
4e2ffd6b3d generated API tables refreshed for the play-publish-changes form 2026-10-24 12:01:37 +08:00
CRT
1bb43fd4a0 play managed publishing: console plan/verify play-publish-changes, ready-to-publish items in play status, desktop hint 2026-10-24 11:56:07 +08:00
CRT
29a9683ab7 dev channel: Apache serves /desktop/dev/latest.json; the VDS publisher lists releases without the draft filter (it hid the dev drafts) 2026-10-23 23:46:03 +08:00
CRT
9947ea1639 dev channel VDS publisher: send a User-Agent (Cloudflare refuses urllib's default with 403) 2026-10-23 23:17:05 +08:00
CRT
39b2f73ad7 desktop dev update channel: every push to main builds dev bundles (<next patch>-dev.<UTC>), the VDS signs and publishes dev/latest.json (newest 5); app channel setting stable|dev, background updater at start / 15 min / resume / channel switch, deferred while in use, no downgrade, autostart rewritten on every start, card shows version and channel 2026-10-23 23:14:08 +08:00
CRT
4dff7d9b2e desktop open: Windows window check uses real top-level windows (the tray helper windows and MainWindowHandle also answer for a hidden app) 2026-10-23 22:53:30 +08:00
CRT
8df41a26b0 desktop open: a hidden tray copy that is already running counts once its window is visible (Windows), graceful stop wait 3 s on Windows 2026-10-23 22:53:30 +08:00
CRT
d4cf1ad3a1 shots: full-bleed layout (shots.layout bleed) with corner captions, per-size frames, and landscape orientation, on top of the landscape sizes 2026-10-23 22:45:10 +08:00
CRT
9ed1890de8 storebound 0.8.47: version in package.json and every copy, CHANGELOG 0.8.47 section 2026-10-23 16:03:29 +07:00
CRT
8f69a27709 desktop install: stop the running app by exact path, check the installed exe's real version, verify it opened 2026-10-23 15:57:32 +07:00
CRT
a01c78aa20 generated API tables and flags refreshed for the play products clamp (Forgejo ci failed on stale api tables) 2026-10-23 15:56:36 +07:00
CRT
b0641c1fc8 play products: clamp regional prices to Play's min/max, per-product results; apple iap: skip live localizations, per-product results 2026-10-23 16:53:25 +08:00
CRT
882255a32f storebound 0.8.46: version in package.json and every copy, CHANGELOG 0.8.46 section 2026-10-23 14:44:51 +07:00
CRT
84f9793af5 Merge origin/main (0.8.45 tag, not published) 2026-10-23 15:40:56 +08:00
CRT
487e138f9d watch: ingest sink (--notify ingest) posting events to a push ingest endpoint 2026-10-23 15:30:18 +08:00
CRT
960662029e storebound 0.8.45: version in package.json and every copy, CHANGELOG 0.8.45 section 2026-10-23 13:50:24 +07:00
CRT
5b3905679b CHANGELOG: 0.8.44 was not published 2026-10-23 14:48:09 +08:00
CRT
b36b92dc90 Merge origin/main (0.8.44 tag) 2026-10-23 14:48:03 +08:00
CRT
0b0007045c desktop tests: clippy clean (repeat_n in the tray test, unix-only time imports in ai_fix) 2026-10-23 14:47:53 +08:00
CRT
12e56c5a82 storebound 0.8.44: version in package.json and every copy, CHANGELOG 0.8.44 section 2026-10-23 13:16:42 +07:00
CRT
84efa4e913 desktop coverage: place for the status command 2026-10-23 14:14:02 +08:00
CRT
bffbf94278 portfolio test: android identity carries developerId and appId 2026-10-23 14:11:38 +08:00
CRT
987f0775b3 tray card: per-icon Phosphor imports; teaser pins the current app-privacy step 5 text 2026-10-23 14:07:44 +08:00
CRT
e2ff7efcda CHANGELOG: 0.8.43 was not published; notes move to Unreleased 2026-10-23 13:56:01 +08:00
CRT
5379845e78 Merge origin/main (0.8.43 tag) 2026-10-23 13:55:50 +08:00
CRT
960e084bea ship step: apple build plan keeps its version and build in --json; test fixtures pass the scrub (documentation IPs, neutral home paths, 10-char key ids) 2026-10-23 13:55:44 +08:00
CRT
b1ed2ad8ca storebound 0.8.43: version in package.json and every copy, CHANGELOG 0.8.43 section 2026-10-23 12:20:06 +07:00
CRT
6abc1f65dd Merge origin/main (0.8.42 tag, not published); API tables and flags regenerated 2026-10-23 13:16:13 +08:00
CRT
f03a83bf3d Merge origin/main (0.8.42 tag, not published); API tables regenerated 2026-10-23 13:15:34 +08:00
CRT
021c0eef5a Regenerate the API tables with forward slashes 2026-10-23 13:13:45 +08:00
CRT
2e888a01cc docs/ship-step.md, pitfall 110 (pipeline exit 7 and double uploads), CHANGELOG Unreleased 2026-10-23 13:13:28 +08:00
CRT
06a0b424f5 Ship-step contract for pipelines: uniform exit codes (0/1/2/3/4/7) in cli/lib/ship-step.ts, one final --json line, --result-file (atomic, also on failure), --progress json, --env-file with secret redaction, --version-from; idempotent play upload / apple upload / play release / apple submit re-runs end with exit 7; play upload takes --track production and --version-code 2026-10-23 13:13:21 +08:00
CRT
4eac8e8cd8 storebound status: per platform live and next version, build, track, review state and rollout % without uploading (read-only, one JSON line); release status --app also takes an app folder or a store id 2026-10-23 13:13:15 +08:00
CRT
4933279987 gen-api: module names with forward slashes on Windows too (the tables were stale on Linux CI) 2026-10-23 13:13:03 +08:00
CRT
0ed7bc1ca0 storebound 0.8.42: version in package.json and every copy, CHANGELOG 0.8.42 section 2026-10-23 11:30:53 +07:00
CRT
54451c87c8 Regenerate the API tables (wrapped.gen.ts, api-coverage.md); 0.8.41 notes move to Unreleased 2026-10-23 12:29:24 +08:00
CRT
19b4868852 storebound 0.8.41: version in package.json and every copy, CHANGELOG 0.8.41 section 2026-10-23 10:25:06 +07:00
CRT
f3e7c98994 desktop reads: a card that joins a read or gets the 'read just now' answer applies it (no bus event for those: a tab opened again stayed on its skeleton); invalidate before a write's after-refresh, editor save re-reads with force 2026-10-23 11:22:26 +08:00
CRT
fa081666ec CHANGELOG: reads registry and tray card 2026-10-23 11:16:12 +08:00
CRT
fb37911f5e Desktop: generated permissions of the tray card commands 2026-10-23 11:15:40 +08:00
CRT
60a72e6947 Desktop: tray mini card on left click (store status at a glance next to the tray icon; Refresh runs the main window's Refresh all) 2026-10-23 11:15:35 +08:00
CRT
c03938f9dc desktop: one read registry for every read button: single-flight per canonical key, broad reads cover narrow keys, overlapping buttons all busy and disabled (useReadState), 20 s 'read just now' memo, quota pause on the buttons, every read through the scheduler (Check again, Import apps, account Test, Read the stores, Dependencies), writes invalidate only the reads they change; tests/read-registry.test.ts; fixtures ?blocked and window.__fakeCalls 2026-10-23 11:09:51 +08:00
CRT
7345de2f7c Desktop boot check: quota row match as plain text 2026-10-23 10:47:39 +08:00
CRT
2638b90046 Desktop boot check: the reader's quota row uses the short wording 2026-10-23 10:46:49 +08:00
CRT
f052831fea Desktop boot check: the dry-run preview is accepted whenever it shows, also during waits 2026-10-23 10:44:54 +08:00
CRT
26ebb64458 Desktop: tab row measures the room inside the sticky bar's padding (the More button was clipped); editor saves skip the in-app preview (their Review step already showed the dry run) 2026-10-23 10:44:45 +08:00
CRT
83bb9a621e Desktop boot check: say why a tab could not be opened 2026-10-23 10:36:38 +08:00
CRT
8106d7c9a2 Desktop boot check: tabs opened like a person (the More menu when a tab is not in the row); the fake host answers window dry runs like the Rust host (dryRun forced), art.pull preview dry run 2026-10-23 10:31:40 +08:00
CRT
ffaf42c55d Merge origin/main (0.8.40 tag, not published) 2026-10-23 10:26:05 +08:00
CRT
5b7b16ac08 Desktop boot check: accept the in-app dry-run preview before the native confirm 2026-10-23 10:25:26 +08:00
CRT
c7bae75ed0 apple build/upload: accept --allow-version-mismatch (flag list regenerated); 1.0 and 1.0.0 are one version train 2026-10-23 09:57:13 +08:00
CRT
595cbc8a68 storebound 0.8.40: version in package.json and every copy, CHANGELOG 0.8.40 section 2026-10-23 08:54:55 +07:00
CRT
ebd64e0f76 Desktop: tab row reserves enough room for the More menu button 2026-10-23 09:54:20 +08:00
CRT
9ec2af86ac CHANGELOG: 0.8.39 was not published; its notes move to Unreleased 2026-10-23 09:53:32 +08:00
CRT
5f26cef580 Merge origin/main (0.8.39 tag) 2026-10-23 09:53:20 +08:00
CRT
db0e5620b2 CHANGELOG: audit fixes 2026-10-23 09:52:54 +08:00
CRT
0e693b7f74 Desktop: portfolio card ids show the full id as a tooltip 2026-10-23 09:50:11 +08:00
CRT
c0a13b5f87 Desktop: wide tables keep their first column in view while scrolling; compact table density (Settings > Appearance, Ctrl+K) 2026-10-23 09:49:47 +08:00
CRT
364b8ec0d4 Desktop: every write shows a styled in-app dry-run preview (command, changed rows) before the native confirm dialog; i18n house style fix for the developer id hint 2026-10-23 09:48:48 +08:00
CRT
c86b583830 Desktop: Settings search filters the sections (nav and body), Enter jumps to the first match 2026-10-23 09:47:29 +08:00
CRT
78b1b599a5 Desktop: More tools gets a command search across groups and a compact list layout 2026-10-23 09:46:55 +08:00
CRT
e32d0772e9 Desktop: Reviews keyboard: J/K move, R reply, C copy, O open in the console, with a key hint 2026-10-23 09:46:09 +08:00
CRT
ef6651c8d2 Desktop: Prices sheet panel is one row (Export primary, Compare a sheet, rest in ...) 2026-10-23 09:45:19 +08:00
CRT
5b6d5cdac2 Desktop: pill tabs stay one row; tabs that do not fit go into a More menu, the active tab always stays visible 2026-10-23 09:44:43 +08:00
CRT
83b8ce8869 Desktop: sidebar reader as short rows with a progress bar; one Refresh (top bar) instead of two; rolling-out tile lists versions on their own lines; cards without screenshots show live versions per store 2026-10-23 09:43:08 +08:00
CRT
bd048daa86 storebound 0.8.39: version in package.json and every copy, CHANGELOG 0.8.39 section 2026-10-23 08:39:22 +07:00
CRT
c36ad87658 CHANGELOG: next release notes 2026-10-23 09:37:13 +08:00
CRT
b7773d6257 Merge origin/main (0.8.38) 2026-10-23 09:37:04 +08:00
CRT
6a9f9f36c0 Desktop: compact hero drops its min height; app tabs fit one row at 1440 (tighter pills below 1560 px); hero screenshots only on wide windows so the pipeline labels never overlap 2026-10-23 09:32:51 +08:00
CRT
936ee15e38 Desktop: unique option keys in editor selects (App info page error); long package names no longer push App folders cards wide 2026-10-23 09:30:22 +08:00
CRT
43e394ef7d Desktop: account and studio secondary actions move to ... menus; one Import apps button on the home; App folders header keeps one primary action 2026-10-23 09:26:02 +08:00
CRT
7c18b3f7dd Desktop: compact app hero on every tab but Releases; Ctrl+K lists the current app's menu actions; Alt+Up/Down switches apps; shortcuts list; no focus ring on code-focused headings 2026-10-23 09:26:01 +08:00
CRT
a656240bfb Checks: findings with a skill get Open guide (context menu and a link on the card) to the shipped skills/<name>/SKILL.md; known-skill list kept equal to skills/ by a test 2026-10-23 09:22:22 +08:00
CRT
4a74989a95 Open in Google Play Console deep-links the app: Play developer id per credential profile (accounts play-developer, desktop Accounts), shared playAppUrl builder for desktop, CLI and watch; developer chooser instead of the console root 2026-10-23 09:20:35 +08:00
CRT
7c769c18f4 Desktop: folders keep claiming their store records before the config read returns; a store app with a folder's id is never listed twice 2026-10-23 09:18:03 +08:00
CRT
5e82010ace Desktop: app page no longer overflows (page grid minmax, wrapping tabs); app ignore/unpair actions move to a ... menu; unique keys in App info 2026-10-23 09:08:23 +08:00
CRT
dece83c3e8 Desktop: custom context menus everywhere, never the browser's menu
One ContextMenu in the design system (keyboard, type-ahead, submenus, check items,
disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The
native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all.
Menus on sidebar and portfolio apps, app hero, releases, store art, live art,
reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
2026-10-23 08:59:14 +08:00
CRT
944b24fe59 Desktop: release builds on Windows turn off the WebView2 browser context menu and accelerator keys 2026-10-23 08:59:13 +08:00
CRT
26d85bafd0 storebound 0.8.38: version in package.json and every copy, CHANGELOG 0.8.38 section 2026-10-23 07:53:23 +07:00
CRT
64a2073bc4 CHANGELOG: move the signing and version-train notes to Unreleased 2026-10-23 08:52:03 +08:00
CRT
476acf7a6a Merge origin/main (0.8.37) 2026-10-23 08:51:41 +08:00
CRT
e4ebbb825d play build: signing passwords only through the child environment, never argv 2026-10-23 08:51:07 +08:00
CRT
7960063e0e Signed AAB guardrails, injected signing args, IPA version train check, Play notes lint at build 2026-10-23 08:49:43 +08:00
CRT
707db2ebc6 Desktop: app hero without screenshots no longer shows a cropped second copy of the icon 2026-10-23 08:39:51 +08:00
CRT
06dc71523e storebound 0.8.37: version in package.json and every copy, CHANGELOG 0.8.37 section 2026-10-23 01:38:18 +07:00
CRT
618ff9e488 CHANGELOG: next release notes 2026-10-23 02:36:37 +08:00
CRT
76a8190fc6 Merge origin/main (0.8.36) 2026-10-23 02:36:17 +08:00
CRT
4a0bce64a1 Desktop: live store thumbnails load right away (an IntersectionObserver may never fire in a hidden tray window) 2026-10-23 02:36:01 +08:00
CRT
63e3f24f0b storebound 0.8.36: version in package.json and every copy, CHANGELOG 0.8.36 section 2026-10-23 01:08:33 +07:00
CRT
d3190ecdec watch: read a missing App Store bundle id from the API instead of skipping the app 2026-10-23 02:06:56 +08:00
CRT
3ff1da10c6 Merge origin/main (0.8.35) 2026-10-23 01:46:03 +08:00
CRT
9610c9477e Desktop: per-store ignore (hide only the App Store or Google Play side), N ignored show toggle in sidebar and portfolio, Unignore in the app controls; ignore flags sync with the shared app list 2026-10-23 01:35:27 +08:00
CRT
a22dcd87dc storebound 0.8.35: version in package.json and every copy, CHANGELOG 0.8.35 section 2026-10-23 00:15:25 +07:00
CRT
13b91dd423 Ignore dormant apps: per-app and per-store 'ignored' in the app list; apps ignore/unignore, watch, ws and apps list skip them; MCP tools 2026-10-23 00:57:42 +08:00
CRT
75bf62e125 Reviews: a store read that failed everywhere is an error, not an empty fresh list; Reviews tab says Apple's API can lag and Play returns 7 days; pitfall 106 2026-10-23 00:33:27 +08:00
CRT
0e88e4293a Merge origin/main (0.8.34) 2026-10-22 23:59:05 +08:00
CRT
2f25f76377 Desktop boot check: scroll live art tiles one by one and wait for each lazy thumbnail 2026-10-22 23:58:45 +08:00
CRT
95abe98626 storebound 0.8.34: version in package.json and every copy, CHANGELOG 0.8.34 section 2026-10-22 22:53:36 +07:00
CRT
011c2698a4 CHANGELOG: next release note 2026-10-22 23:48:14 +08:00
CRT
088024f357 Merge origin/main (0.8.33) 2026-10-22 23:47:47 +08:00
CRT
8cd4ecd63b Desktop boot check: v2 markup (scan finding guideline pill, catalog screen root, lazy live thumbnails scrolled into view) 2026-10-22 23:47:29 +08:00
CRT
0922a03a47 storebound 0.8.33: version in package.json and every copy, CHANGELOG 0.8.33 section 2026-10-22 22:41:58 +07:00
CRT
5b6df321b9 CHANGELOG: desktop v2 and tray notes for the next release 2026-10-22 23:40:09 +08:00
CRT
1fc835263d Merge remote-tracking branch 'origin/main' 2026-10-22 23:36:34 +08:00
CRT
f131de9df2 Desktop v2 screens: releases (store cards, stage stepper, rollout ring), submit checklist, TestFlight cards; listings as store pages with completeness, framed screenshots with issue badges, product cards, price matrix with gap shading; health tiles and issue cards, store-style review feed with rating distribution, growth funnel, readiness score with fix-first, tools grid, store-info sheet; settings, accounts, folders, dependencies and onboarding in the v2 language 2026-10-22 23:31:22 +08:00
CRT
5a892af3d6 storebound 0.8.32: version in package.json and every copy, CHANGELOG 0.8.32 section 2026-10-22 22:19:38 +07:00
CRT
ce939ee063 Merge origin/main (0.8.31) 2026-10-22 23:18:57 +08:00
CRT
d3d31d6c12 Node: JSON configs are read as data, not imported (store-only apps failed under npx: needs an import attribute); test, pitfall 105 2026-10-22 23:18:30 +08:00
CRT
bd316723f5 Desktop v2 design system (design.css tokens for both themes, ui/ds.tsx: PageHeader, Hero, Section, StatTile, Pipeline, StatusPill, EmptyState, Toolbar, Tabs, Dialog, tint and screenshot hooks), app shell: sidebar status dots, frosted top bar, app page Hero with screenshots and the two-store pipeline, pill tabs with blocker counts 2026-10-22 23:12:03 +08:00
CRT
8b22e548be storebound 0.8.31: version in package.json and every copy, CHANGELOG 0.8.31 section 2026-10-22 21:42:53 +07:00
CRT
62366d95a7 Merge origin/main (0.8.30) 2026-10-22 22:41:09 +08:00
CRT
78c541f2b8 storebound watch: store events for every app on the machine (rejected, approved, live, halted, crash threshold, reviews) to ntfy / Telegram / webhook / OS notification, loop or --once, baseline first, events.jsonl; storebound events and MCP events_recent; docs/watch.md; tests 2026-10-22 22:38:15 +08:00
CRT
4fe26326cc Desktop: start.log line before Tauri starts (version, args, working folder, pid), so a logon start that dies early leaves a trace 2026-10-22 22:31:29 +08:00
CRT
03ef999dac Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests 2026-10-22 22:24:05 +08:00
CRT
5d8a0f5e9a apple listing: a version in PENDING_DEVELOPER_RELEASE refusal names apple release --manual-release --version <v>; play release dry run shows the staged rollout share 2026-10-22 21:52:37 +08:00
CRT
287fdec12e storebound 0.8.30: version in package.json and every copy, CHANGELOG 0.8.30 section 2026-10-22 20:39:57 +07:00
CRT
ba1b9e1fbe Merge origin/main (0.8.29) 2026-10-22 21:36:15 +08:00
CRT
696c12ec18 CHANGELOG: desktop home v2 2026-10-22 21:34:24 +08:00
CRT
27db41c3f0 Desktop portfolio v2: greeting with what is moving, store-style app cards (rendered screenshots or the icon on its tint), two-store release pipeline, crash sparkline, one next-step button; What changed feed and Fix first queue; Cards / Table switch keeps the old table 2026-10-22 21:24:24 +08:00
CRT
e77f2b68f5 storebound 0.8.29: version in package.json and every copy, CHANGELOG 0.8.29 section 2026-10-22 19:51:07 +07:00
CRT
5e043fe17c Merge origin/main (0.8.28) 2026-10-22 20:50:09 +08:00
CRT
abc195aee6 Subscriptions drift on both stores (Apple prices in the dry run, localizations, group names, attributes, territories, intro offers reported; Play offer phases and prices, other-regions price, dropped regions gated, grace period, --prune-languages); pitfall 104, CHANGELOG; test fixtures for the new reads 2026-10-22 20:49:43 +08:00
CRT
e381bc2256 Drift on existing items: offer-code and win-back campaigns that differ are named (Apple cannot edit them), in-app event attributes patched on drafts, a removed custom page deep link cleared, config languages without a version localization named, data safety dry run says Play has no read API; appinfo reads /v1/appPriceSchedules/{id}/manualPrices; a new Play language without an icon warns instead of stopping; fake store lists for the new reads 2026-10-22 20:38:54 +08:00
CRT
f2b841d735 apple appinfo: live app price and availability compared with the config (price change needs --allow-price-changes, territory removals --allow-availability-changes); review-details --dry reads the store and shows old -> new (masked) 2026-10-22 20:33:46 +08:00
CRT
f7ff52dfdd apple iap: existing products are compared with the config: localization name / description, reference name, review note (patched), family sharing (reported; on only), availability territories; all shown in the dry plan, one line each 2026-10-22 20:32:52 +08:00
CRT
4b0f9cd5b8 apple iap: the dry run shows a live price that differs from the config (USA base and overrides) and --allow-price-changes replaces the price schedule; prices diff (the catalog diff report) 2026-10-22 20:28:16 +08:00
CRT
294fa322e2 storebound 0.8.28: version in package.json and every copy, CHANGELOG 0.8.28 section 2026-10-22 15:03:45 +07:00
CRT
cbba936ed8 Entitlements reference check compares forward-slash paths on every OS (false 'not referenced by CODE_SIGN_ENTITLEMENTS' on Windows); test, pitfall 103 2026-10-22 16:03:07 +08:00
CRT
e7874a2c25 Merge origin/main (0.8.27) 2026-10-22 06:22:26 +08:00
CRT
cd0110527c apps relink: after a machine move, missing app folders are pointed at the same app here (last path parts, else the config's app name), ids kept; apps folders hints it; apps add names an app after its config app name 2026-10-22 06:22:08 +08:00
CRT
17535d4b7a storebound 0.8.27: version in package.json and every copy, CHANGELOG 0.8.27 section 2026-10-22 05:05:48 +07:00
CRT
9a907d87e5 Desktop: tray icon, start at logon hidden (autostart --hidden, window state never restores visible), closing hides to the tray, background signed auto-update at start and every 30 min with restart 2026-10-22 05:59:41 +08:00
CRT
9f40a9ea14 Console plan apple-app-privacy: Edit opens Data Collection first on a published app (type list ends in Continue), each type's last step is Publish (live, no draft); Publish is the owner's unless bypass mode is on (verified 2026-10-22) 2026-10-22 05:24:58 +08:00
CRT
aae3a6c62d doctor: at a terminal the used-for column is cut to the window width so rows never wrap; piped output stays whole 2026-10-22 03:45:25 +08:00
CRT
600b6893e5 play listing uploads the hi-res icon (and the feature graphic) for a new Play language in the same edit (403 no high resolution icon set; play images skipped it), pitfall 102; script traces skip test files 2026-10-22 03:33:30 +08:00
CRT
256e8e33ad storebound aab <file> --json (offline packageName / versionCode / versionName / SDKs / sha256, no config) and play release --json result for CI scripts that replace their own Play API calls 2026-10-22 03:28:46 +08:00
CRT
ee35b11341 Direct store API traces: comment lines are skipped and each file names a tool once (a real project listed one script five times, mostly doc comments) 2026-10-22 03:22:23 +08:00
CRT
5bfbcefffa release: a staged npm publish is reported with what happened on 0.8.25 / 0.8.26 (published on its own ~10 min later, nothing to approve): check dist-tags first 2026-10-22 02:50:12 +08:00
CRT
d7e0a84596 Merge origin/main (0.8.26) 2026-10-22 02:49:33 +08:00
CRT
b97f23701f apple build stops before the archive in an SSH / non-GUI session (errSecInternalComponent) with how to run it in the logged-in session, --allow-ssh to skip; relative credential paths resolve from the project and xcodebuild gets an absolute ASC key path; tests, pitfall 101 2026-10-22 02:38:45 +08:00
CRT
25c2d5cd1e storebound 0.8.26: version in package.json and every copy, CHANGELOG 0.8.26 section 2026-10-22 01:33:30 +07:00
CRT
467bacc00a Merge origin/main (0.8.25) 2026-10-22 02:28:42 +08:00
CRT
9bd17c401d Bypass mode (owner opt-in, default off): storebound settings bypass on|off|status and a one-time question in init; MCP owner_step (owner-only commands, dry run first), owner-only api operations with --yes --confirm, console plans may press buttons.final; every action logged to .storebound/owner-actions.log; docs/bypass.md, agent rules, tests 2026-10-22 02:27:34 +08:00
CRT
2598c4953a doctor: build dependencies (JDK with the version the project needs, Android SDK command-line tools, CocoaPods, Node, Bun, bundletool), too-old status, doctor --install <tool> --yes runs the package-manager command (never sudo); desktop Dependencies shows too old; gen-flags works on Windows paths 2026-10-22 02:21:26 +08:00
CRT
a8a889fb35 release: a staged npm publish found on the retry is reported as waiting for the maintainer's approval instead of failing after 5 minutes 2026-10-22 02:17:28 +08:00
CRT
f7dd646e57 Terminal: colors, status emoji and a live spinner line for waits at a TTY; plain lines unchanged when piped, in CI, with NO_COLOR, TERM=dumb, --json or MCP; desktop download progress through the log 2026-10-22 02:15:03 +08:00
CRT
e1634f6abe MCP: multi-line --text goes to the CLI as --from-text <temp file> (console_verify kept only the first line on Windows); long JSON output keeps its start and names a file with the whole output (console_plan lost its start URL); test, pitfall 100 2026-10-22 02:12:26 +08:00
CRT
73d308164d Windows builds and moved credentials: play build runs gradlew.bat through cmd /c and takes C:/ keystore paths as absolute (pitfall 98); check names credential paths missing on this machine and a keystore the stored password / alias do not open (keytool) (pitfall 99); play / apple build write <out>/latest.json for CI; CI install from a git clone documented 2026-10-22 02:10:52 +08:00
CRT
4fcee60ba6 Direct store API calls in a project's scripts: androidpublisher.googleapis.com, the googleapis androidpublisher client, api.appstoreconnect.apple.com, altool / Transporter in CI files and scripts; scan rule direct-store-api, check and release status warnings with file:line and the storebound command; storebound's own checkout skipped; test, pitfall 97 2026-10-22 02:04:28 +08:00
CRT
62eb010998 storebound 0.8.25: version in package.json and every copy, CHANGELOG 0.8.25 section 2026-10-22 00:59:03 +07:00
CRT
4f993a906a fastlane traces: check and release status warn, scan reports fastlane-upload when a Fastfile / Appfile or a CI file or script still runs fastlane supply, deliver or pilot (file:line, storebound import fastlane); test, pitfall 96 2026-10-22 01:39:14 +08:00
CRT
e03cbbe8aa storebound 0.8.24: version in package.json and every copy, CHANGELOG 0.8.24 section 2026-10-21 23:40:26 +07:00
CRT
5317230b08 README: drop the stale static version badge (npm badge is live), Agent Skills badge 39 with a test that keeps it in step; CHANGELOG Unreleased for the Windows fixes 2026-10-21 22:37:51 +08:00
CRT
46f3e56c09 Windows checkouts: .gitattributes keeps text files LF (CRLF skills broke gen-adapters); findInstalled builds macOS/Linux paths with posix join; CONTRIBUTING Windows setup and desktop build 2026-10-21 22:26:30 +08:00
CRT
0ac3cd02b0 Windows: MCP entries start through cmd /c npx or bun cli/index.ts (npx.cmd and the sh launcher cannot be spawned, pitfall 94); desktop download streams with progress and fails after 60 s without data (pitfall 95); getting-started Windows note 2026-10-21 22:21:49 +08:00
CRT
e5dffb5533 docs/HANDOFF.md: what is released, the open work (desktop report view in a stash, two worktrees of other sessions, things not verified live), and the rules that matter 2026-10-21 18:10:58 +07:00
CRT
88d1b58f1f shots: render refuses missing frames and fails on load errors; shots check errors on renders with failed loads (pitfall 93) 2026-10-21 18:09:06 +07:00
CRT
00756e6908 Screenshot guardrails: name every screenshot not COMPLETE on 409 ASSET_IN_POST_PROCESSING (submit, validate; stuck after 5 min in UPLOAD_COMPLETE), apple screenshots --repair-stuck replaces only those in place, part uploads without Expect: 100-continue (failed part deletes the reservation, one retry, never committed), storebound api --confirm <operationId> for agents' plain writes (never owner-only) 2026-10-21 18:08:57 +07:00
CRT
164182012f storebound 0.8.23: version in package.json and every copy, CHANGELOG 0.8.23 section 2026-10-21 11:21:56 +07:00
CRT
4d70be3404 apple submit --yes submits a draft prepared with --no-submit that already holds the version and items (was 'nothing to submit'); iap-submit the same for a draft holding the asked-for items; test, pitfall 90, CHANGELOG 2026-10-21 11:19:50 +07:00
CRT
0d1926f7d7 storebound 0.8.22: version in package.json and every copy, CHANGELOG 0.8.22 section 2026-10-21 05:42:04 +07:00
CRT
327a85e042 console verify: an older page's long labelled radio still counts from page text when the sheet answers Yes / No; tests and the teaser follow the new sign-in details question and the added App Privacy step 2026-10-21 05:39:45 +07:00
CRT
b6ea9159d8 review-details: a reviewContact that is present but has empty fields (or no usable email) is named as such with its file, instead of 'no review contact' 2026-10-21 05:37:45 +07:00
CRT
9c1f146c05 flags regenerated 2026-10-21 05:36:12 +07:00
CRT
aceb994cae testflight distribute: groups of apple.testflight.groups missing on App Store Connect are created first (only the asked-for ones, never removed; dry run shows it); tests, skill, CHANGELOG 2026-10-21 05:35:19 +07:00
CRT
163e666834 Game Center switch before submit: apple submit / resubmit and signing ensure create gameCenterDetails and gameCenterAppVersions when Game Center is in use (config, entitlements or the built IPA), shown in the dry run; validate blocks on either missing; fake 409 BUILD_INDICATES_GAME_CENTER_ENABLED until both exist; pitfall 89 2026-10-21 05:33:53 +07:00
CRT
dd9e059036 desktop coverage: apple game-center, play games and games status mapped to the Game services card 2026-10-21 05:29:55 +07:00
CRT
567d6ca181 gen-flags: Args.required reads are flags too (edit --ops, privacy-manifest --ios, apple listing --version were refused as unknown options); play games client listAll (no false flags) 2026-10-21 05:29:08 +07:00
CRT
d427b5c4a4 apple game-center: literal API paths (query guard reads them); api coverage regenerated 2026-10-21 05:29:08 +07:00
CRT
93d7f8a5fc docs: game-services skill (Game Center + Play Games Services step by step), pitfall 86, commands, CHANGELOG Unreleased; adapters regenerated 2026-10-21 05:29:08 +07:00
CRT
22286c7e02 desktop: Game services card in App info (Game Center and Play Games Services status, ids, counts vs config, pending changes, sync buttons through the confirm-token write, PGS setup plan, console links); en + tr strings; dev fixture 2026-10-21 05:29:02 +07:00
CRT
26f0723641 games status: both stores' game services in one read-only report (CLI, MCP games_status, desktop read method); --json status output stays pure JSON 2026-10-21 05:28:47 +07:00
CRT
a4c31e096b tests: game services on both stores against the fakes (capability ensure, Game Center enable and read-back, leaderboard / achievement create, update, no-op, lost answers), entitlement and APP_ID checks, PGS console plan URLs and verify, validate findings; fake Games Configuration API 2026-10-21 05:28:47 +07:00
CRT
9f48dd57a4 MCP: apple_game_center, apple_game_center_status, play_games, play_games_status (writes dry by default) 2026-10-21 05:28:47 +07:00
CRT
3116c230ae scan: Play Games Services dependency without the APP_ID meta-data or resource (sign-in fails at runtime) 2026-10-21 05:28:47 +07:00
CRT
23f7c5a5eb validate / check: Game Center entitlement vs App ID capability vs config, the PGS APP_ID resource vs Gradle and config, store state vs the games list, player id as User ID reminders (App Privacy USER_ID, Data safety PSL_USER_ACCOUNT) 2026-10-21 05:28:47 +07:00
CRT
5a5d7fd4e7 console plan play-games-services: agent-first PGS setup (games project, Android credential with the app-signing SHA-1, web OAuth client, owner's Publish) with absolute Play Console and Google Cloud links, and console verify from the page text 2026-10-21 05:28:47 +07:00
CRT
ca7289b173 games: one config section for Game Center and Play Games Services (shared leaderboards and achievements); apple game-center (gameCenterDetails, gameCenterAppVersions, leaderboards, achievements and localizations, upsert by vendor id, read back); play games (Games Configuration API upsert, no POST retry); signing ensure adds GAME_CENTER when games.gameCenter 2026-10-21 05:28:47 +07:00
CRT
eebb3a830b docs: desktop.md (coverage map, More tools tab), CHANGELOG Unreleased 2026-10-21 05:14:24 +07:00
CRT
dc6bb088a1 desktop coverage: every CLI command mapped to its window place (cli/lib/desktop-coverage.ts), docs/desktop-coverage.md generated from it, guard test fails on a gap or an unjustified not-for-desktop 2026-10-21 05:14:16 +07:00
CRT
a95aa6dd49 desktop: More tools tab, one typed form per CLI command without a screen (schema from system.methods; reads run, writes dry run + native dialog + token) and the owner steps with their terminal command; en + tr 2026-10-21 05:14:16 +07:00
CRT
ce1b1edcb8 serve: desktop methods for play build / upload / share / app-id, apple build / upload / signing ensure / adhoc register + export, shots fonts / jpeg, install (dry run first; the host token answers their question) 2026-10-21 05:14:16 +07:00
CRT
335a4124ab console plans: guardrails from a real run (2026-10-21): health none label and Sign in details Yes/No + Add details (old labels as altLabels, verify accepts both), finance boxes on step 1, App Privacy Additional Setup Required OK, verify from DOM input states (verify.capture, --from), Save + confirm before every Next on Play pages, IARC Digital purchases boxes and an exact-label in-page helper that refuses patterns and blanket answers; review contact found in parent folders, the profile and more key shapes with the files checked listed; one-line errors for missing required values (Args.required); pitfalls 86-87, tests 2026-10-21 05:03:02 +07:00
CRT
a6dab8f3aa storebound 0.8.21: version in package.json and every copy, CHANGELOG 0.8.21 section 2026-10-21 04:03:24 +07:00
CRT
f5e7b907a9 acceptance: storebound api --yes without a terminal is refused (exit 1, nothing sent) since the TTY rule; the cell still expected a silent dry run and turned every acceptance job red 2026-10-21 04:01:41 +07:00
CRT
dda21d2570 shots record (web): frames at device pixels through Page.captureScreenshot (clip scale = dpr, long side capped at 1920, 3 requests in flight, midpoint timestamps into the ffmpeg concat); Chromium's screencast stayed at CSS size. Warnings for frames smaller than asked and under 20 frames/s; record-web browser test, pitfall 85 2026-10-21 03:59:39 +07:00
CRT
2d7cf6ec2b storebound 0.8.20: version in package.json and every copy, CHANGELOG 0.8.20 section 2026-10-21 03:16:27 +07:00
CRT
ef3c66e8f0 release: the Linux / Windows updater signatures are made on the VDS (desktop-release add-remote, vds-key; tools/vds-sign.ts); the Mac no longer downloads those installers, verifies each returned .sig over its BLAKE2b-512 digest before publishing; release.ts --sign-local keeps the old path 2026-10-21 03:14:53 +07:00
CRT
d94bf67340 tests: the AAB fixture targets API 36 (Play's minimum since 2026-08-31; play upload now refuses 35) 2026-10-21 03:08:43 +07:00
CRT
3a11682a37 shots: app titles get the weights the templates use (variable axis, used range or static weights; font-weight warning), palette.bg as one colour or a CSS gradient string (validated; contrast guard in shots check), photo backgrounds (palette.image, shots.feature.background) with overlay and cover crop 2026-10-21 03:07:26 +07:00
CRT
2ba8278ab7 storebound 0.8.19: version in package.json and every copy, CHANGELOG 0.8.19 section 2026-10-20 13:44:41 +07:00
CRT
353e13d5be shots: Playwright's chromium is taken from the namespace or default of a project's CommonJS playwright; a module without chromium.launch names the resolved file and its exports (a project's playwright 1.57 under a global Bun install gave 'reading launch of undefined'); tests: the review-account fixture typechecks 2026-10-20 13:44:22 +07:00
CRT
ac84ad4bbf storebound 0.8.18: version in package.json and every copy, CHANGELOG 0.8.18 section 2026-10-20 13:11:36 +07:00
CRT
8c1a01d856 config loads without Node's MODULE_TYPELESS_PACKAGE_JSON warning in a CommonJS project; capture sign-in docs use captureAuth and check notes a local capture that signs in with reviewDemoAccount (the App Review account) 2026-10-20 13:11:10 +07:00
CRT
2fdd96e68c storebound 0.8.17: version in package.json and every copy, CHANGELOG 0.8.17 section 2026-10-20 12:44:41 +07:00
CRT
e1e4e05237 shots capture: signed-in web apps (shots.capture.auth request with ${cred:}/${env:} references or storageState), shots login saves a demo sign-in to .storebound/auth.json (0600), expired-session warning, secrets and cookies masked 2026-10-20 12:41:43 +07:00
CRT
3949ff1531 storebound 0.8.16: version in package.json and every copy, CHANGELOG 0.8.16 section 2026-10-20 12:16:54 +07:00
CRT
9ab64bcbdf apps folders add adds the folder (template note, refusals with reasons); MCP: .mcp.json never spawns a missing plugin path (sh fallback to PATH, npx), mcp/serve skip the self-update, spawned handshake test; store build minimums in one dated table: Play target API 36 and App Store Xcode 26 / SDK 26 blocked in scan, validate, play upload/release, apple upload; pitfalls 81-83 2026-10-20 12:15:00 +07:00
CRT
9805582278 storebound 0.8.15: version in package.json and every copy, CHANGELOG 0.8.15 section 2026-10-20 03:31:36 +07:00
CRT
7c04a0653b desktop boot check: match the submit dry run by its fields, not the exact JSON (the call carries platform since the macOS support; 0.8.14's Mac build stopped on it) 2026-10-20 03:29:59 +07:00
CRT
e59539fec8 shots: Mac App Store screenshots (mac size, APP_DESKTOP to the macOS version), a landscape layout (Mac window or bare; Mac, Play Chromebook, Play tablets), safe margins in every template checked by shots check from the render's layout boxes, a contact sheet timeout that scales with images, sizes and load; pitfall 80 2026-10-20 03:29:19 +07:00
CRT
30488c512d release: the desktop typecheck runs before the tag (0.8.14's Mac build stopped on an unused variable after the tag); a staged npm publish (409 previously staged) waits for the maintainer's approval instead of 5 minutes of polling; cross-store: drop the unused variable 2026-10-20 03:16:23 +07:00
CRT
7b4b716fc6 storebound 0.8.14: version in package.json and every copy, CHANGELOG 0.8.14 section 2026-10-20 03:09:33 +07:00
CRT
74101edeca api tables regenerated (the new creative-assets and platform commands wrap more operations) 2026-10-20 03:08:37 +07:00
CRT
9db7180eb9 merge origin/main (cross-store text checker); macOS platform pitfall renumbered to 79 2026-10-20 03:06:16 +07:00
CRT
c2f34c62f3 apple creative-assets: place Header and Search Results assets once Apple approved them (the version page offers only approved assets); waiting files are reported per locale and placement with their upload date and the re-run command, in the run, --verify, validate and the report; iPhone Duo: the published API spec still has no Duo type, the note says the console takes Duo and the API does not yet 2026-10-20 03:04:14 +07:00
CRT
47dfd372fe apple: macOS (tvOS, visionOS) versions are no longer invisible: every version and review submission lookup is per platform; status, listing, listing pull, resubmit, submit, release and screenshots take --platform and otherwise pick the platform that has the version they need (refuse naming both when several do); status, validate, release status, the report and the desktop label versions with their platform; MCP and owner actions get platform; pitfall 77 2026-10-20 03:00:05 +07:00
CRT
edabbec874 cross-store text: other platforms named in App Store text (2.3.10: Android, Google Play, ...) or Play text (Metadata: iOS, App Store, Game Center, ...) are refused by every text write (apple / play listing, play release / upload notes, listing set, whats-new, storebound edit, desktop editor), blockers in check / validate, scan other-platforms / play-other-platforms; listing.allowPlatformMentions for the owner's functional mentions; whats-new splits [apple] / [play] lines per store; storebound api --yes without a TTY is refused (exit 1, stderr) instead of a silent dry run; pitfalls 77, 78 2026-10-20 02:57:25 +07:00
CRT
1736300784 apple creative-assets: a placement App Store Connect refuses on the live version is reported with the live version page's deep link, the live page stays unchanged (new placement first, old one removed after) and the other live slots are not retried; pitfall 76 2026-10-20 02:50:09 +07:00
CRT
98d3bf4410 creative-assets: the dry run plans one upload per file, not per slot (a shared file used by 13 locales read as 26 uploads) 2026-10-19 06:01:56 +07:00
CRT
7e69883dfc creative-assets: the no-files message names the shared creative folder too 2026-10-19 05:47:54 +07:00
CRT
3542b897c8 creative assets: <renders>/creative/header.* / search.* fill every language without its own file (art without words needs no copy per locale; one upload, placed per locale) 2026-10-19 05:47:27 +07:00
CRT
c03291e7f7 apple creative-assets: a live app without an editable version uses the live version (only approved assets are selected there; new ones upload and wait for a review of their own via --add-for-review); pitfall 76: placements read back ACTIVE live, a state the 4.5.1 spec does not list 2026-10-19 05:45:47 +07:00
CRT
183579449c apple creative-assets: select Header and Search Results assets once processed (Prepare for Submission), not once approved: Apple reviews an asset only in a review submission, so 0.8.13 waited forever; upload and select in one run with a bounded processing wait; --add-for-review puts unreviewed assets into the draft review submission (never submits); --verify reads every slot back read-only (MCP apple_creative_assets_verify); a rejected file or one that failed processing twice is not re-uploaded, an archived copy is unarchived; fake commits library assets to PREPARE_FOR_SUBMISSION; pitfall 76 rewritten; apple-name check test only looks at name hints 2026-10-19 05:42:53 +07:00
CRT
3e4015b686 ci: the background acceptance jobs start after the Linux and Windows installer builds (0.8.13's Windows installer waited behind six acceptance jobs on the one Windows runner); release: an npm deprecation already set by an earlier npm-publish run (422) is not an error 2026-10-18 13:38:26 +07:00
CRT
d006f8d932 storebound 0.8.13: version in package.json and every copy, CHANGELOG 0.8.13 section 2026-10-18 13:13:16 +07:00
CRT
e5369090fa apple creative-assets: App Store Header and Search Results (PRODUCT_PAGE_HEADER_ASSET, APP_STORE_SEARCH_RESULTS_ASSET) through the App Asset Library: local size / alpha / video checks, upload once by MD5, select only approved assets, replace on a changed file; check / validate note, MCP tool, gallery and desktop Art row, docs, pitfall 75 2026-10-18 13:10:20 +07:00
CRT
14ffe3545a npx / bunx storebound always run the newest version: a copy started from the npx or bunx cache re-runs the command as storebound@<newest> when npm has a newer one (registry asked at most every 10 minutes, 1.5 s timeout; off with STOREBOUND_NO_UPDATE=1, in CI and offline); a project install gets a one-line note instead 2026-10-18 13:09:06 +07:00
CRT
3381673ae6 iPhone Duo screenshots (render, check, upload skip with console link while the API has no display type) and the medium display class as the required iPhone set, labels as in the console 2026-10-18 13:07:15 +07:00
CRT
0b32df57f0 desktop-release: tauri only signs; the app is notarized with notarytool directly (up to 3 tries, later ones without S3 acceleration) and stapled, because the upload inside tauri bundle was cut off twice for 0.8.12 with no retry 2026-10-18 02:43:59 +08:00
CRT
d2ae0fa765 release: one release at a time (a lock with the pid in ~/.storebound-releases/release.lock): two runs once built into the same target folder and both failed 2026-10-18 02:32:44 +08:00
CRT
ddb813576e desktop-release: notarization retries go by notarytool's status, not its exit code (0.8.12's aborted upload exited without an error), up to 3 tries, the later ones with --no-s3-acceleration 2026-10-18 02:24:34 +08:00
CRT
88078f1d7e desktop-release: a DMG notarization upload cut off before Apple had it (abortedUpload) is submitted once more 2026-10-18 02:11:54 +08:00
CRT
45b705ba85 tests: report fixtures use example.com subdomains and the zero uuid (the repo scrub flagged corp.invalid emails and a real-looking issuer id) 2026-10-18 01:46:36 +08:00
CRT
9bfc107415 release-candidate: the background test jobs (ci, acceptance) never gate a candidate; only the installer builds and the record count (0.8.12 stopped on a red ci job) 2026-10-18 01:45:37 +08:00
CRT
9909b3df73 storebound 0.8.12: version in package.json and every copy, CHANGELOG 0.8.12 section 2026-10-18 01:43:07 +08:00
CRT
af349b3db1 report: storebound report writes one shareable, self-contained readiness page (.storebound/reports/readiness-<date>.html; --md, --json, --no-open, --offline, --out) from scan, check and validate: a verdict per store (Ready to submit / N blockers / Not checked with the reason), findings per store by severity with guideline, evidence, fix and console button, the owner's compliance questions as a checklist, secrets masked; storebound.dev look, light / dark, print; desktop Checks > Report (same code path, opens through project_reveal) and its Markdown export on the same renderer; read-only MCP tool report 2026-10-18 01:42:03 +08:00
CRT
6d95ea00f5 agent rules: every check, rule, template line, message and desktop view covers both stores and every surface 2026-10-18 01:31:17 +08:00
CRT
5a266b952f templates: the 2.3.1 example line says where the same goes on Google Play (no review-notes field: App access 'Any other information' with a sign-in, and the full description for what needs a subscription) 2026-10-18 01:29:36 +08:00
CRT
61cb3338e8 scan: remote config, feature flags and remote paywalls not told to review (App Review 2.3.1, Play Deceptive behavior): remote-features finding for both stores, medium, with deep links, unless the review notes mention them; check prints it as a warning, validate carries it; warn only, the templates get a commented example line; code obfuscators a low note, minify alone nothing; store-pitfalls 74 2026-10-18 01:26:32 +08:00
CRT
9ba97b7e29 release: candidate downloads go through Cloudflare first (the direct route to the origin was throttled per connection: one AppImage took 20+ min there, 3 MB/s through Cloudflare), the origin only as a fallback; a heartbeat logs the bytes every 30 s 2026-10-17 15:59:44 +08:00
CRT
37cda2a39a storebound 0.8.11: version in package.json and every copy, CHANGELOG 0.8.11 section 2026-10-17 14:29:27 +08:00
CRT
824758179b review access: guest-play apps rejected by Play as a login wall (empty name field, disabled Continue, a code link on the first screen): check warns for a Play app with a first-run gate or IAP and no reviewAccess.instructions; with guest steps the play-sign-in-details plan declares one entry without credentials, the dialog's required full-access box as the owner's step; store-pitfalls 73 2026-10-17 12:50:06 +08:00
CRT
d6070cc185 release: when the published version is still missing from the registry after 2 minutes, publish once more (0.8.10's publish answered 0 and left nothing) 2026-10-16 07:48:55 +08:00
CRT
9059b2b32a background tests: ci.yml runs the CI and the acceptance matrix on every push again but nothing waits for them (the gate no longer waits for the parent's run, which a release's own bump push cancels; the record job needs only the installers); tools/ci-status.ts reports main's newest test run and release.ts prints a red one as a warning 2026-10-16 07:17:19 +08:00
CRT
b84538e4cb storebound 0.8.10: version in package.json and every copy, CHANGELOG 0.8.10 section 2026-10-16 06:35:47 +08:00
CRT
b6c4beaada release: the CHANGELOG cut is checked before any version copy changes, and in the dry run (an empty Unreleased left every copy bumped and the next run refused 'not newer') 2026-10-16 06:34:51 +08:00
CRT
1f47b10fa9 CHANGELOG: Unreleased for 0.8.10 2026-10-16 06:33:40 +08:00
CRT
6443a5c442 neutral names in docs, tests and comments (no real app or project names) 2026-10-16 06:14:02 +08:00
CRT
cba4660777 release-candidate: a candidate whose jobs were only cancelled (a newer push to main cancels the older run) says so and gives the tag move + --resume line instead of 'red: fix it' 2026-10-16 05:54:14 +08:00
CRT
8a84ab4089 play: changesNotSentForReview goes only on edits.commit (validate answers 400 "Unknown name changesNotSentForReview: Cannot bind query parameter", found by a game on 220e9bb's real run): a refused commit is committed again with the parameter, a dry run's refused validate only says how the real run commits; the fake Play refuses the parameter on validate and a test asserts validate never gets it 2026-10-16 05:53:18 +08:00
CRT
dcb553fd16 storebound 0.8.9: version in package.json and every copy, CHANGELOG 0.8.9 section 2026-10-16 05:51:05 +08:00
CRT
220e9bb072 play: an edit Play will not send for review (400 'set changesNotSentForReview to true', a game 1.0.1 (5) after a rejection) is validated and committed again on the same edit with changesNotSentForReview=true; dry runs validate that way and say so; prints the Publishing overview link (owner's button), records it in .storebound/ for play status; --not-sent-for-review on release / upload / rollout / listing / images and MCP / desktop; pitfall 72 2026-10-16 05:50:37 +08:00
CRT
dcca4e86e4 storebound 0.8.8: version in package.json and every copy, CHANGELOG 0.8.8 section 2026-10-16 00:09:06 +08:00
CRT
728b216dcc tests: the rtdn email fixture keeps the user-data line out of the service-account comment's window 2026-10-15 22:51:31 +08:00
CRT
a1647e9552 scan: an email hit whose line or the 3 lines above (doc comment, enclosing interface) name a server / config identity is not user data (a game apps/server/src/iap/rtdn.ts: email: string | null under 'Push service account email') 2026-10-15 22:51:02 +08:00
CRT
9171cb1129 scan: the email detector skips server / config identities (a service account or OIDC email of a Pub/Sub push, SMTP / sender / admin addresses, values from the environment); a game's rtdn: { audience; email; token } in apps/server/src/config.ts was flagged as App Privacy Email Address 2026-10-15 22:49:35 +08:00
CRT
ead5da4ab3 release: npm publish + deprecate run by themselves with a granular access token from ~/.storebound-keys/npm-token (0600; npm reads it through a temporary userconfig, never argv, logs or the checkout); bun tools/release.ts npm-token saves it once from the clipboard, npm-publish publishes this checkout's version 2026-10-15 20:13:42 +08:00
CRT
9d41fcdc50 no CI, no acceptance runs (the owner's decision): Forgejo's ci and acceptance jobs are off (if: false), the record job needs only the Linux / Windows installers; release.ts skips ci:quick, the macOS acceptance matrix and its report (TESTS = false); build, signing, notarization, privacy scans and verify stay 2026-10-15 19:58:47 +08:00
CRT
e7e26db4aa acceptance: a cell's timeout grows with the machine's load (like tools/ci.ts) and a cell that only timed out runs once more, marked; a release failed on two cells that take 3 s alone, at load 95 on 10 cores 2026-10-15 19:57:17 +08:00
CRT
e500e88e40 Console pages as absolute deep links: cli/lib/console-links.ts builds the URL of every App Store Connect / Play Console page from apple.appId, play.developerId and play.appId (app list / developer chooser when unknown); every plain 'App Store Connect > ...' / 'Play Console > ...' direction in cli/ and templates/ now carries it, validate / scan / declarations / release plan JSON carry url, the desktop shows an Open button; tests/console-links.test.ts guards it; AGENTS rule 11, pitfall 71 2026-10-15 19:55:37 +08:00
CRT
f898d642d0 desktop AI fix: kill only the agent's own process group with killpg (never pgid <= 1 or our own group) instead of the external kill command; a Forgejo ci run of bc85dab died of SIGTERM in the middle of these tests 2026-10-15 19:45:00 +08:00
CRT
bc85dabf90 storebound 0.8.7: version in package.json and every copy, CHANGELOG 0.8.7 section 2026-10-15 19:38:32 +08:00
CRT
b830a99a5f First in-app purchases left out of a review are caught (pitfall 70): firstProductGaps in validate / release plan (blocker) and the apple submit plan (refuses to leave a never-approved first product out); apple status (WARN + firstProductGaps in --json), release status and the desktop checks warn while a version waits for or is in review without it; iap-review says 'ready, not submitted yet' instead of 'complete' 2026-10-15 19:31:26 +08:00
CRT
1c35a625e1 build-node: TypeScript's own script runs under Bun (node_modules/.bin/tsc is a .exe / .cmd shim on Windows: the Windows acceptance cell packed nothing on dc387f6) 2026-10-15 06:36:24 +08:00
CRT
dc387f6ffb storebound 0.8.6: version in package.json and every copy, CHANGELOG 0.8.6 section 2026-10-15 06:33:52 +08:00
CRT
d72b199645 ci: the acceptance jobs run bun install before packing (prepack builds the package's declarations with the pinned TypeScript; without node_modules npm pack failed and every acceptance cell went red on 5ed787a); build-node names the missing install instead of a bare ENOENT 2026-10-15 06:33:01 +08:00
CRT
5ed787a8ca desktop AI fix: a claude CLI without a sign-in ("Not logged in · Please run /login", seen in a real headless run) shows 'not signed in' with the copy-for-AI fallback instead of a bare failure; the flags were checked against a real claude run (it edited the file) 2026-10-15 06:28:52 +08:00
CRT
c95f2af8ff desktop: Fix with AI on scan findings that need a code change (aiFix: true, AI_FIX_RULES). The host (src-tauri/src/ai_fix.rs) runs the owner's local Claude Code headless in the app folder with Read / Edit / Write / Grep / Glob only (Bash, web tools, sub-agents disallowed) and a prompt it builds from the finding's fields; it refuses while the folder has uncommitted files, streams progress, times out after 10 min with a heartbeat, kills the process group on Cancel or exit, and records the run's changed files so Undo restores exactly those; the window shows the diff with Keep / Undo and rescans. Without claude the button copies the prompt (Copy for AI). Only the claude binary, git and kill / taskkill are started there (tests/security.rs); tests/ai_fix.rs covers the prompt, dirty-tree refusal, undo scope, timeout and cancel 2026-10-15 06:26:29 +08:00
CRT
992f5bcc0e desktop: a Fix button on scan and validate findings storebound can fix itself. Findings carry action { method, args } (cli/lib/fix-actions.ts); privacy-labels runs scan.write (scan --write-only, the dialog shows the config diff), Play listing gaps play.listing, a missing App Store localization or copyright apple.listing (editable version only), the App Review contact apple.review_details; each a dry run, the native dialog, then the confirmed run. --write-only is forbidden for MCP tools; tests pin the mapping, the round trip on a fixture and that no fix reaches a submit, release or publish 2026-10-15 06:14:49 +08:00
CRT
bd83d036ba desktop: long findings wrap inside the Store readiness card (a build-folder path pushed the store chips out); the release timeline's rail no longer breaks after the current step; the rollout bar stays under its stage. scan: function words of the listing languages (German eine / der, French les, Turkish bir, ...) are not repeated keywords; the dev fixture has a long-path finding 2026-10-15 06:07:01 +08:00
CRT
04647c079e package: CommonJS projects get their own declarations (dist/types-cjs; node16 / nodenext refused an ESM-only .d.ts from a CJS file, TS1541); the package-types test covers ES module and CommonJS projects under bundler / node16 / nodenext / node10 and loads the package with import() and require() under Node; pitfall 69 2026-10-15 06:01:33 +08:00
CRT
d717ceb4cd package: ship declarations (dist/types, the "types" condition) and a Node JS entry for 'storebound'; Bun keeps the .ts source. 0.8.5 exported only cli/lib/config.ts, so tsc (next build, vue-tsc, tsc --noEmit) failed on a scaffolded storebound.config.ts and broke a Next app's build; a test type-checks the template against the packed entry under bundler / node16 / nodenext without @types/node. check fails on the template's own placeholders (Example Studio, bare example.com URLs and addresses) 2026-10-15 05:49:05 +08:00
CRT
77f141e223 release: a cancelled parent run is not a failure (a version-only child runs everything instead of stopping at the gate); a queued run gets 20 min before 'no ci run'; release.ts --resume continues a release that stopped after its push (the history rewrite re-pushed every old tag, their release.yml runs took the runners and cancelled 0.8.5's parent) 2026-10-15 02:31:15 +08:00
CRT
a64a7f198d storebound 0.8.5: version in package.json and every copy, CHANGELOG 0.8.5 section 2026-10-15 02:24:45 +08:00
CRT
8de60aaef4 publish: the server-side parallel fetch runs in bash on stdin (the VDS login shell is zsh, which did not split the pid list: the rehearsal's publish stopped there) 2026-10-15 02:13:59 +08:00
CRT
ef81dd1b3e a pre-release version (0.0.0-rehearsal.1) passes the one-version test; release.ts puts the checkout back when the fast gate fails before anything is committed 2026-10-15 01:46:02 +08:00
CRT
cd1e13c1e2 browser tests run only by hand: the suites that launch Chrome / Chromium move to tests/browser/*.browser.ts (bun test, ci, ci:quick and the Forgejo workflows never pick them up; bun run test:browser runs them); the desktop boot check leaves bun run ci (it stays in desktop-release.ts mac at release time); CI=full is gone: Forgejo's ci runs the Rust steps when the push changed a Rust input (STOREBOUND_CI_BASE=HEAD~1), no Playwright install; CONTRIBUTING 2026-10-15 01:25:17 +08:00
CRT
4c7ff89950 docs/pitfalls.md: rows 64-67 (the skill had them) 2026-10-15 01:21:13 +08:00
CRT
6873f2cc68 release candidates: draft assets are read by their attachment uuid (the tag URL is 404 for drafts: the record job could not merge SHA256SUMS); the installers are scanned with the private terms in their build jobs, so the record job needs no apt tools (it spent 5 minutes installing them) 2026-10-15 01:18:55 +08:00
CRT
cdaecc2afb chrome guard: a zombie counts as ended (an orphan's new parent in a container whose PID 1 is no init never reaps it, and kill(pid, 0) still succeeds), so the reaper reports it and the real-process test passes on the Linux runner 2026-10-15 00:49:56 +08:00
CRT
c3b1e8c370 release candidates: every push to main runs the full CI, the acceptance matrix and the Linux / Windows installer builds in parallel (ci.yml) and records the green result per commit as the draft rc-<sha> (green.json last); a version-only bump commit (proven file by file with the version writers) reuses its parent's CI and matrix and rebuilds only the installers; a v* tag only promotes the candidate (release.yml), fails fast on a red or missing commit; acceptance runs carry a CLI key; tools/release.ts does the whole release on the Mac (bump, CHANGELOG cut, fast gate, push, tag, Mac build + notarize in parallel with Forgejo, cached macOS acceptance, add, verify, publish with one upload of the Mac files, /Applications update, npm commands) and --rehearsal; DMG notarization runs while the app and updater bundle are checked; rehearsal channel on the VDS; docs 2026-10-15 00:42:50 +08:00
CRT
89d94d6993 console Chrome never outlives its run: exit kills it, SIGINT / SIGTERM / SIGHUP close it gracefully, a detached watchdog ends it when the parent dies (SIGKILL included; a reused pid is never killed); every start ends headless orphans on storebound profiles and names orphaned windows; headless runs on macOS prefer Chromium / Chrome for Testing over Google Chrome.app (an orphan kept the owner's Chrome from opening from the Dock); console fill closes the window on errors; pitfall 68; tests 2026-10-15 00:28:32 +08:00
CRT
b350781ab5 acceptance: the app fixture carries the owner's answers (unanswered: [], its own name and copyright; template answers are refused before store writes); macOS results of 0.8.4 (123/123 on Node 20, Node 22, Bun), docs/acceptance.md regenerated 2026-10-14 23:56:57 +08:00
CRT
af858a3f36 storebound 0.8.4: version in package.json and every copy, CHANGELOG 0.8.4 section 2026-10-14 23:40:40 +08:00
CRT
2b07f56673 capability check: a checkout without .entitlements is a warning about this checkout only (built from another branch?), naming the current branch and the local branches / worktrees that have an .entitlements file, never a claim about the build; the newest built IPA (highest build number) is read (codesign -d --entitlements on macOS, else the Mach-O signature's entitlements blob) and compared as 'uploaded build N'; without one the output says the App Store Connect API does not expose a build's entitlements; exportedIpas moved to cli/lib/xcode.ts; pitfall 67; tests 2026-10-14 19:17:45 +08:00
CRT
daae3715bf apple signing ensure with automatic signing only adds capabilities (no profiles or certificates; the dry run says so) and lists unused storebound profiles with the exact delete command, never run; every capability write is read back (lag retries, timeout) and fails with the API answers when one did not stick; apple status, signing status and validate compare the App ID, apple.signing.capabilities and the iOS project's entitlements (CODE_SIGN_ENTITLEMENTS too) and name what is missing where; api writes outside a terminal say TTY by design and name the dedicated command; pitfalls 64-66; tests 2026-10-14 19:10:01 +08:00
CRT
357319a775 --json prints only JSON on stdout (withJsonOutput around every command: logs, tables and prompts to stderr; printJson everywhere; test runs every --json command against the fake stores and parses stdout); prices ppp compares like with like: par = the store's own US-equivalent price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up) scaled by the PPP price level; defaults only lower, only price levels <= 0.5, World Bank high-income at par, CN at par on Apple and skipped on Play, never above par (--only-decreases, --max-ratio, --min-ratio, --max-level, --include-high-income; --floor / --ceiling kept), top-10 revenue market drops > 20 % warned (sales reports, else a static list), unchanged regions out of the sheet; price changes of existing products only with --allow-price-changes, TTY or not (a terminal yes covered them), then their own question listing each old -> new, held section in dry runs (play products, prices import, play / apple subscriptions); pitfalls 61-63; tests 2026-10-14 17:36:00 +08:00
CRT
bab3f63227 whats-new drafts from player-facing notes first (whatsNew.source, docs/RELEASE-<version>.md, the CHANGELOG section of the version or Unreleased; --release, --from-git) and only then from the git log; internal jargon warnings (SDK / vendor names, developer and version-control words, file paths, issue ids, code identifiers) with plain alternatives in the draft and whats-new check, one list in cli/lib/jargon.ts, whatsNew.allowWords keeps a word; skill: write from what players feel, never from commit messages; tests 2026-10-14 14:45:19 +08:00
CRT
e9507d8884 check blocks an apple app without app.copyright (App Store Connect requires it; never filled in automatically); validate reads the live version's copyright and says whether the store has one; round-trip test that the config writers (upsertBlock, upsertNestedString, removeKeyPath) keep unknown keys and comments; tests 2026-10-14 14:37:47 +08:00
CRT
73db689386 privacy manifest discovery skips build-output and copy folders (hidden dirs like .native-build, build, DerivedData, node_modules, dist, Products, built .app / .xcarchive) in scan, check and privacy-manifest: a stale .native-build copy was checked instead of the real ios/ manifest; test 2026-10-14 13:14:49 +08:00
CRT
7219ded3e8 template compliance answers never reach a store: init writes unanswered (dataSafety, appPrivacy, reviewNotes, ageRating, accounts, reviewAccess), older configs are recognised by the template values; check / validate / scan block, play data-safety (also --dry), console plan / fill, apple review-details and appinfo refuse. onboard --declarations replaces them on a live app: name and copyright from the stores (a copyright with a private term is left out), the rows the code proves (scan proposal, sign-up methods from auth code and login SDKs), the rest as one owner question list. play data-safety --dry prints the answers that change (old -> new, account and deletion first) against the last uploaded CSV; pitfall 60; tests 2026-10-14 11:36:30 +08:00
CRT
34c2fa22e5 site: first-party, cookieless visit stats (self-hosted Umami at storebound.dev/s/, Do Not Track respected) with events for copy buttons, desktop downloads per OS, the teaser (play, 50 %, complete) and outbound docs / git / npm links; a privacy note in the footer; docs/site-analytics.md and tools/vds/ configs to rebuild the server side; scrub allows a page's public stats website id 2026-10-14 11:11:24 +08:00
CRT
83c14d3a8e tests: console-fill idmsa frame test waits for the frame, its form and its click log instead of reading them right after the sign-in hint (race under load) 2026-10-14 09:13:04 +08:00
CRT
a8c7f77d87 acceptance: macOS results of 0.8.3 from 1c44095 (123/123 on Node 20, Node 22, Bun), docs/acceptance.md regenerated 2026-10-14 08:51:14 +08:00
CRT
1c44095b84 acceptance report expects all 9 runs (Linux, Windows, macOS x node20 / node22 / bun) 2026-10-14 08:45:32 +08:00
CRT
b29f1c407c apple listing: asking for the version that is in review says it is locked (it said "already exists; a new version needs a higher number"); play images: a listing language missing from languages[] is skipped with the fix instead of stopping the run (play listing pushes its text). Both found by the live dry-run sweep; tests 2026-10-14 08:43:59 +08:00
CRT
9d6f741092 acceptance.yml: the release matrix on demand (Linux + Windows x node20/node22/bun, then the report); macOS results of 0.8.3 (123/123 on Node 20, Node 22, Bun) in docs/acceptance/, docs/acceptance.md generated 2026-10-14 08:38:12 +08:00
CRT
17883e1529 acceptance: create the --out folder (both CI runs passed every quick cell, then failed writing acceptance/<run>.json), log the warm-up cell 2026-10-14 08:33:12 +08:00
CRT
2c14e5318a apple: no app info on App Store Connect is a clear error (it crashed apple listing / appinfo with "undefined is not an object (evaluating 'info.id')"); asset uploads use basename (a Windows path sent the whole C:\... path as fileName), reviews send --only and previews names split both separators; tests/matrix-bugs.test.ts 2026-10-14 08:29:31 +08:00
CRT
f58e192001 release acceptance matrix: tools/acceptance.ts runs every user-facing command from the packed npm tarball (npx --package=<tgz> on Node 20 / 22, bun x --bun after bun add <tgz>) in fresh temp folders with a fresh HOME, real piped stdin (n, EOF) and per-cell fake stores over HTTP (STOREBOUND_STORE_MOCK, loopback only); a cell passes only with the expected exit code and no crash text; report -> markdown matrix. Prompts go through stdinIsTTY (STOREBOUND_ASSUME_TTY drives them with piped stdin), STOREBOUND_DESKTOP_APP=none for the install prompt on a machine with the app. release.yml: required acceptance job matrix (Linux + Windows x node20/node22/bun) and report, the matrix attached to the release; ci.yml: the quick subset on Linux and Windows. Scan fixtures for React Native and Expo 2026-10-14 08:29:31 +08:00
CRT
ae681e3ecd release: privacy scan of signed macOS artifacts leaves out Apple code signatures only (copies with codesign --remove-signature on every Mach-O, the DMG's koly signature blob zeroed); any other hit still fails; tests and docs 2026-10-14 08:21:35 +08:00
CRT
3a27c78f49 storebound 0.8.3: prompts read stdin with rt.readLine (node:readline) instead of Bun-only for await (const line of console), which crashed npx storebound desktop on Node/Windows; node-compat test bans Bun-only stdin iteration; Node prompt smoke, also on the Windows release runner 2026-10-14 08:07:40 +08:00
CRT
c3fed8efa2 storebound 0.8.3: version in package.json and every copy, CHANGELOG 0.8.3 section (docs: no private account names in the published package) 2026-10-14 07:54:56 +08:00
672 changed files with 53206 additions and 3798 deletions

View file

@ -9,7 +9,7 @@
"name": "storebound", "name": "storebound",
"source": ".", "source": ".",
"description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.",
"version": "0.8.2", "version": "0.8.77",
"category": "development", "category": "development",
"tags": [ "tags": [
"mobile", "mobile",

View file

@ -1,6 +1,6 @@
{ {
"name": "storebound", "name": "storebound",
"version": "0.8.2", "version": "0.8.77",
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
"author": { "author": {
"name": "CRTkafa" "name": "CRTkafa"

View file

@ -1,6 +1,6 @@
{ {
"name": "storebound", "name": "storebound",
"version": "0.8.2", "version": "0.8.77",
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
"author": { "author": {
"name": "CRTkafa" "name": "CRTkafa"

View file

@ -20,6 +20,16 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes. these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
@ -68,6 +78,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`. Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner. Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
`storebound console fill` is the optional scripted fast path; it never blocks you. `storebound console fill` is the optional scripted fast path; it never blocks you.
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
or "App Store Connect > App Information" alone.
## Tools ## Tools
@ -91,6 +104,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal - `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default - `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly - `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
- `skills/game-services/SKILL.md`: Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages - `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` - `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game - `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game

View file

@ -1,5 +1,4 @@
# `bun run ci` after .forgejo/actions/linux-setup: the target / timings / Playwright caches, bun install, headless # `bun run ci` after .forgejo/actions/linux-setup: the target / timings caches, bun install, Node 20 (the npm bundle's oldest supported Node) and the full CI. Shared by ci.yml and release.yml.
# Chromium, Node 20 (the npm bundle's oldest supported Node) and the full CI. Shared by ci.yml and release.yml.
name: ci-run name: ci-run
description: storebound's full CI (bun run ci) on the Linux runner description: storebound's full CI (bun run ci) on the Linux runner
runs: runs:
@ -21,25 +20,12 @@ runs:
run: | run: |
bun --version && cargo --version bun --version && cargo --version
bun install --frozen-lockfile bun install --frozen-lockfile
# Playwright's Chromium and headless shell (~280 MB), keyed by the browser builds the installed playwright-core
# wants (its browsers.json); the system libraries and fonts (--with-deps) are installed on every run, the job
# container starts clean
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('node_modules/playwright-core/browsers.json') }}
restore-keys: playwright-${{ runner.os }}-
# the job runs as root in a container: storebound adds --no-sandbox to the Chromium it starts itself there
# (cli/shots/browser.ts sandboxArgs), Playwright's own launches already pass it
- name: Headless Chromium (browser tests, desktop boot check)
shell: bash
run: bunx playwright install --with-deps chromium
# the npm bundle runs on Node 20+: tools/ci.ts smoke-tests dist/storebound.mjs with the oldest supported Node # the npm bundle runs on Node 20+: tools/ci.ts smoke-tests dist/storebound.mjs with the oldest supported Node
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
# the runner sets CI=true in the job and it wins over the workflow env above: set CI=full for the command # no browser tests in CI (tests/browser/ runs only with `bun run test:browser`); the changes of this push (against
# itself, or tools/ci.ts skips the desktop Rust tests and clippy when the push did not touch src-tauri # its parent; the checkout has fetch-depth 2) decide the Rust steps, as on a laptop against origin/main
- name: bun run ci - name: bun run ci
shell: bash shell: bash
run: CI=full bun run ci run: STOREBOUND_CI_BASE=HEAD~1 bun run ci

View file

@ -26,6 +26,7 @@ runs:
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache
~/.cache/tauri
~/.cargo/registry/index ~/.cargo/registry/index
~/.cargo/registry/cache ~/.cargo/registry/cache
~/.cargo/git/db ~/.cargo/git/db
@ -35,7 +36,7 @@ runs:
shell: bash shell: bash
run: | run: |
P="${FORGEJO_PATH:-$GITHUB_PATH}" P="${FORGEJO_PATH:-$GITHUB_PATH}"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "$P" echo "$HOME/.bun/bin" >> "$P"
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$P" echo "$HOME/.cargo/bin" >> "$P"

View file

@ -0,0 +1,110 @@
# The release acceptance matrix on demand (the same jobs as release.yml's acceptance + acceptance-report, which gate
# every release): every user-facing command from the packed npm tarball on Linux and Windows x Node 20 / Node 22 / Bun,
# then the markdown matrix (artifact storebound-acceptance). Run it from Actions > acceptance > Run workflow, or with
# the API (POST /repos/{owner}/{repo}/actions/workflows/acceptance.yml/dispatches). macOS: tools/acceptance.ts on the Mac.
name: acceptance
on:
workflow_dispatch:
env:
BUN_VERSION: '1.4.0'
jobs:
acceptance:
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Bun (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- name: Acceptance matrix (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
node --version && bun --version
bun install --frozen-lockfile
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
- name: Acceptance matrix (Windows)
if: ${{ matrix.platform == 'Windows' }}
shell: pwsh
run: |
node --version; bun --version
bun install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw "bun install failed" }
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
path: acceptance/
retention-days: 30
acceptance-report:
needs: [acceptance]
if: ${{ always() }}
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Linux-node20
path: acceptance-runs/Linux-node20
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Linux-node22
path: acceptance-runs/Linux-node22
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Linux-bun
path: acceptance-runs/Linux-bun
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Windows-node20
path: acceptance-runs/Windows-node20
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Windows-node22
path: acceptance-runs/Windows-node22
- uses: actions/download-artifact@v3
continue-on-error: true
with:
name: acceptance-Windows-bun
path: acceptance-runs/Windows-bun
- name: Matrix (markdown), red cells fail the job
run: |
bash tools/ci-install-bun.sh
export PATH="$HOME/.bun/bin:$PATH"
V=$(bun tools/desktop-version.ts --print)
mkdir -p out
bun tools/acceptance.ts report acceptance-runs docs/acceptance --md "out/storebound_${V}_acceptance.md" --expect 9 --strict
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: storebound-acceptance
path: out/
retention-days: 30

View file

@ -1,14 +1,24 @@
# bun run ci on every push to main and every pull request into main: adapters, desktop version, tests, typecheck, # Every push to main is a release candidate (docs/desktop-release.md, "Release candidates"). In parallel:
# desktop i18n / typecheck / build, desktop boot check (headless Chromium), desktop Rust tests and clippy, scrub. Runs on the VDS runner (docs/desktop-release.md). # ci bun run ci (adapters, desktop version, tests, typecheck, desktop i18n / typecheck / build, the boot check
# tools/ci.ts runs the steps as a task graph and the tests as parallel shards (CPU cores - 1, max 8) on the one # in headless Chromium, desktop Rust tests and clippy, scrub); tools/ci.ts runs it as a task graph with
# self-hosted runner, so a job matrix would only queue; CI=full makes it run the Rust steps on every push. # parallel test shards on the one Linux runner; no browser test runs here (tests/browser/, `bun run
# Publishes nothing: site/ is still published by Jenkins; releases come from release.yml (a v* tag). # test:browser` by hand). The Rust steps run when the push changed a Rust input (STOREBOUND_CI_BASE).
# release.yml's ci job runs the same steps (the setup is shared: .forgejo/actions/linux-setup). # acceptance the release acceptance matrix (tools/acceptance.ts): every user-facing command from the packed npm
# tarball with real piped stdin, Linux and Windows x Node 20 / Node 22 / Bun.
# linux AppImage + deb (path-free build), self-tests, both started under Xvfb, privacy scan.
# windows NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
# record when all are green (the installers already scanned with the private terms): the draft release rc-<sha> with the installers, the acceptance runs and matrix and
# green.json (tools/release-candidate.ts record). A v* tag on this commit only promotes it (release.yml).
# gate decides first: a commit that differs from its green parent only in version copies + CHANGELOG (a release's
# version bump, proven file by file: tools/release-candidate.ts gate) reuses the parent's ci and acceptance results
# and builds only the installers, whose embedded version changed.
# Pull requests run ci + acceptance (no installers, no record). rehearsal/* branches are release rehearsals
# (tools/release.ts --rehearsal). site/ is still published by Jenkins.
name: ci name: ci
on: on:
push: push:
branches: [main] branches: [main, 'rehearsal/**']
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
@ -17,19 +27,402 @@ env:
BUN_VERSION: '1.4.0' BUN_VERSION: '1.4.0'
CARGO_TERM_COLOR: never CARGO_TERM_COLOR: never
CARGO_INCREMENTAL: '0' CARGO_INCREMENTAL: '0'
CI: full
jobs: jobs:
gate:
runs-on: ubuntu-22.04
# the self-hosted runners only run code from this repo: pull requests from forks are skipped
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 45
outputs:
reuse: ${{ steps.gate.outputs.reuse }}
from: ${{ steps.gate.outputs.from }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Version-only commit of a green parent?
id: gate
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
EVENT: ${{ github.event_name }}
SHA: ${{ github.sha }}
run: |
# tests never gate a release (the owner's decision 2026-10-16): no waiting on the parent's run, which the
# release's own bump push cancels; every commit builds its installers and runs the tests in the background
echo "reuse=false" >> "$GITHUB_OUTPUT"; echo "from=" >> "$GITHUB_OUTPUT"; echo "tests run in the background; installers build for this commit"
# background tests (the owner's decision 2026-10-16): they run on every push and report, but the record job (the
# release candidate) never waits for them; `bun tools/release.ts` and `bun tools/ci-status.ts` print a red result
ci: ci:
needs: gate
if: github.event_name != 'pull_request' || true
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 # runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
# the self-hosted runner only runs code from this repo: pull requests from forks are skipped
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.forgejo/actions/linux-setup - uses: ./.forgejo/actions/linux-setup
with: with:
cache-key: ci cache-key: ci
rust-components: clippy rust-components: clippy
- uses: ./.forgejo/actions/ci-run - uses: ./.forgejo/actions/ci-run
# The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed
# npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22
# (npx --package=<tgz>) / Bun (bun x). One red cell and the commit is no candidate. macOS runs on the owner's Mac
# (tools/release.ts, cached per CLI key).
# after the installers: the one Windows runner builds the release installer first, the background tests wait
# (0.8.13's Windows installer queued behind six acceptance jobs)
acceptance:
needs: [gate, linux, windows]
if: github.event_name != 'pull_request' || true
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Bun (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- name: Acceptance matrix (Linux)
if: ${{ matrix.platform == 'Linux' }}
shell: bash
run: |
node --version && bun --version
# npm pack runs prepack, which builds the package's declarations with the pinned TypeScript
bun install --frozen-lockfile
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
- name: Acceptance matrix (Windows)
if: ${{ matrix.platform == 'Windows' }}
shell: pwsh
run: |
node --version; bun --version
# npm pack runs prepack, which builds the package's declarations with the pinned TypeScript
bun install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw "bun install failed" }
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
- uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
path: acceptance/
retention-days: 14
linux:
needs: gate
if: github.event_name != 'pull_request'
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
# deb then run on Ubuntu 22.04 and newer, Debian 12)
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
# the path-free build keeps the target folder outside every home folder
CARGO_TARGET_DIR: /tmp/storebound-target
# linuxdeploy (AppImage) runs without FUSE inside the container
APPIMAGE_EXTRACT_AND_RUN: '1'
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
NO_STRIP: '1'
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
steps:
- uses: actions/checkout@v4
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: desktop
packages: patchelf file squashfs-tools p7zip-full xz-utils
# the release target folder (dependencies compiled once; a version bump recompiles only the app crate)
- uses: actions/cache@v4
with:
path: /tmp/storebound-target
key: desktop-target-${{ runner.os }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: desktop-target-${{ runner.os }}-
- name: Install and check
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-linux-x64
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
- name: Build AppImage and deb (path-free)
working-directory: apps/desktop
run: |
rm -rf "$CARGO_TARGET_DIR/release/bundle"
# the AppImage tools come from GitHub releases, which answer 504 now and then (cached in ~/.cache/tauri)
bash ../../tools/ci-tauri-tools.sh
for i in 1 2 3; do bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci && break; [ $i = 3 ] && exit 1; sleep 30; done
- name: Self-test the exact artifacts
run: |
V=$(bun tools/desktop-version.ts --print)
B="$CARGO_TARGET_DIR/release/bundle"
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
chmod +x out/*.AppImage
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
- name: Start the app under Xvfb (AppImage and deb)
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
run: |
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
V=$(bun tools/desktop-version.ts --print)
smoke() { # <label> <executable>
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
local pid=$! ok=0
for i in $(seq 1 90); do
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
echo "$1: window started, UI reached the sidecar"
}
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
smoke deb /tmp/deb/usr/bin/storebound-desktop
- name: Privacy scan (with the private terms; never printed)
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
[ -n "$FORBIDDEN_TERMS" ] || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out/ || rc=$?
rm -f "$RUNNER_TEMP/terms.txt"; exit $rc
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-linux
path: out/
retention-days: 7
# dev channel (tools/desktop-dev.ts): the same commit again with the version <next patch>-dev.<commit UTC>, only in
# this workspace (never committed); a failure here never touches the release candidate above
- name: Dev build (AppImage + deb, dev version)
id: dev
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
set -euo pipefail
V=$(bun tools/desktop-dev.ts set)
bun tools/build-sidecars.ts --target bun-linux-x64
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
bash tools/ci-tauri-tools.sh
for i in 1 2 3; do (cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci) && break; [ $i = 3 ] && exit 1; sleep 30; done
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
chmod +x dev/*.AppImage
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts dev/ || rc=$?
rm -f "$RUNNER_TEMP/terms.txt"; git checkout -- . ; exit $rc
- uses: actions/upload-artifact@v3
if: steps.dev.outcome == 'success'
with:
name: storebound-desktop-dev-linux
path: dev/
retention-days: 3
windows:
needs: gate
if: github.event_name != 'pull_request'
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
runs-on: windows
timeout-minutes: 90
env:
CARGO_TARGET_DIR: C:\storebound-target
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install and check
shell: pwsh
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-windows-x64
- name: Node bundle on Windows (npx storebound) - stdin prompts read with node, clean exit
shell: pwsh
run: |
bun tools/build-node.ts
node --version
bun tools/node-smoke.ts --only prompt
if ($LASTEXITCODE -ne 0) { throw "node prompt smoke failed" }
"n" | node dist/storebound.mjs --version
if ($LASTEXITCODE -ne 0) { throw "node dist/storebound.mjs --version failed" }
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
shell: pwsh
working-directory: apps/desktop
run: |
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
- name: Install per user, self-test, uninstall
shell: pwsh
run: |
$V = bun tools/desktop-version.ts --print
New-Item -ItemType Directory -Force out | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
& $exe --self-test | Tee-Object -Variable st
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
- name: Privacy scan (no .pdb, no paths; with the private terms, never printed)
shell: pwsh
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
if (-not $env:FORBIDDEN_TERMS) { throw "secret FORBIDDEN_TERMS is missing" }
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
$env:STOREBOUND_FORBIDDEN_TERMS = $t
bun tools/privacy-scan.ts out
$rc = $LASTEXITCODE
Remove-Item -Force $t
if ($rc -ne 0) { throw "privacy scan failed" }
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-windows
path: out/
retention-days: 7
# dev channel (tools/desktop-dev.ts): see the linux job
- name: Dev build (NSIS, dev version)
id: dev
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
shell: pwsh
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
$ErrorActionPreference = 'Stop'
$V = bun tools/desktop-dev.ts set
if ($LASTEXITCODE -ne 0) { throw "dev version stamp refused" }
bun tools/build-sidecars.ts --target bun-windows-x64
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
Push-Location apps/desktop
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
if ($LASTEXITCODE -ne 0) { throw "dev build failed" }
Pop-Location
New-Item -ItemType Directory -Force dev | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" dev\
Start-Process -Wait -FilePath "dev\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
& "$env:LOCALAPPDATA\storebound\storebound-desktop.exe" --self-test
if ($LASTEXITCODE -ne 0) { throw "dev self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
$env:STOREBOUND_FORBIDDEN_TERMS = $t
bun tools/privacy-scan.ts dev
$rc = $LASTEXITCODE
Remove-Item -Force $t
git checkout -- .
if ($rc -ne 0) { throw "privacy scan failed" }
- uses: actions/upload-artifact@v3
if: steps.dev.outcome == 'success'
with:
name: storebound-desktop-dev-windows
path: dev/
retention-days: 3
# dev channel: the dev bundles of both jobs go to a Forgejo draft dev-<version> (no tag; the newest 5 kept); the VDS
# (tools/vds/storebound-desktop-dev, a root timer) signs them with the updater key and publishes
# storebound.dev/desktop/dev/latest.json. macOS has no dev build: Developer ID signing needs the Mac's GUI session.
dev:
needs: [linux, windows]
if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.linux.result == 'success' && needs.windows.result == 'success' }}
runs-on: ubuntu-22.04
timeout-minutes: 40
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- name: Tools
run: |
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-dev-linux
path: dev
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-dev-windows
path: dev
- name: Draft dev-<version> (newest 5 kept), then wait for the VDS to publish it
run: |
ls -la dev
bun tools/desktop-dev.ts record --dir dev --yes
V=$(ls dev | sed -n 's/^storebound_\(.*\)_x64-setup\.exe$/\1/p')
bun tools/desktop-dev.ts wait "$V" --minutes 20
record:
needs: [gate, linux, windows]
if: ${{ always() && github.event_name != 'pull_request' && needs.gate.result == 'success' && needs.linux.result == 'success' && needs.windows.result == 'success' }}
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
SHA: ${{ github.sha }}
REUSE: ${{ needs.gate.outputs.reuse }}
FROM: ${{ needs.gate.outputs.from }}
steps:
- uses: actions/checkout@v4
# no apt: the installers were scanned with the private terms in their own jobs; this job only records
- name: Tools
run: |
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-linux
path: cand
- uses: actions/download-artifact@v3
with:
name: storebound-desktop-windows
path: cand
- name: Checksums file of the build jobs is not shipped as is
run: rm -f cand/SHA256SUMS
- name: Record the candidate (draft rc-<sha>, green.json last)
run: |
bun tools/release-candidate.ts record --sha "$SHA" --run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_NUMBER" --dir cand ${FROM:+--reused-from "$FROM"} --yes
bun tools/release-candidate.ts prune --keep 12 --yes

View file

@ -1,20 +1,21 @@
# The release of storebound: one version tag (v0.8.0) -> one Forgejo release with the Linux and Windows installers. # The release of storebound: a version tag (v0.8.5) promotes the release candidate of its commit. Nothing is rebuilt
# or retested here: ci.yml ran the full CI, the acceptance matrix (Linux + Windows x Node 20 / 22 / Bun) and built,
# self-tested and privacy-scanned the Linux and Windows installers for that very commit (for a version-only bump
# commit: the installers, with the parent's CI and matrix, proven version-only file by file), and stored them as the
# draft rc-<sha> with green.json (tools/release-candidate.ts).
# #
# push of a tag v* checks (tag = package.json version, every version copy equal), the full CI, the # push of a tag v* checks (tag = package.json version, every version copy equal, the CHANGELOG section), waits
# AppImage + deb (Linux runner, Docker) and the NSIS installer (Windows runner, VM), the # for the commit's candidate (heartbeat; fails at once when the commit is red or has no ci run,
# privacy scan of every artifact, then ONE release "storebound <version>" with short notes # with the run's link; times out after 40 min), then ONE release "storebound <version>" with the
# (tools/forgejo-release.ts notes: the CHANGELOG summary, install line, links), the # candidate's installers, the acceptance matrix, SHA256SUMS and short notes. Stable versions
# installers and SHA256SUMS; older release entries are deleted afterwards (tags stay). # then delete the older release entries (tags stay). A version with a "-" (0.0.0-rehearsal.1)
# workflow_dispatch dry (default): the same jobs on this commit (or on `tag`), a DRAFT release test-<sha> # is a prerelease: never pruning, never the stable release.
# with the real files, read back, then deleted. dry off + tag: (re)make that tag's release. # workflow_dispatch tag: the same for an existing tag; dry (default): only the plan.
# #
# Not here: macOS. The DMG is built, signed and notarized on the owner's Mac and added to this release with # Not here: macOS. tools/release.ts on the owner's Mac builds, signs and notarizes the DMG in parallel and adds it to
# `bun tools/desktop-release.ts upload-forgejo --out <dir> --yes`; then `publish` (storebound.dev) and `npm publish` # this release, then publishes storebound.dev (docs/desktop-release.md). The updater signing key never reaches a runner.
# (the owner's step). Order and details: docs/desktop-release.md. The updater signing key never reaches a runner. # Secrets: RELEASE_TOKEN (scope write:repository), FORGEJO_ORIGIN (the server's IP: uploads over 100 MB bypass
# Secrets (repo Settings > Actions > Secrets): RELEASE_TOKEN (token, scope write:repository: releases and assets), # Cloudflare). The private terms scan of the installers ran in ci.yml's build jobs (FORBIDDEN_TERMS).
# FORBIDDEN_TERMS (the private terms list of the privacy scan; never printed), FORGEJO_ORIGIN (the server's IP: asset
# uploads over 100 MB bypass Cloudflare).
# Runners on the VDS (labels ubuntu-22.04 and windows): docs/desktop-release.md.
name: release name: release
on: on:
@ -23,283 +24,55 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: Version tag to build (v0.8.0); empty = the selected branch's commit description: Version tag to release (v0.8.5)
type: string type: string
default: '' default: ''
dry: dry:
description: Dry run (a draft release test-<sha> with the real files, deleted at the end) description: Dry run (only the plan)
type: boolean
default: true
windows:
description: Build Windows too (always on for a tag push)
type: boolean type: boolean
default: true default: true
env: env:
BUN_VERSION: '1.4.0' BUN_VERSION: '1.4.0'
CARGO_TERM_COLOR: never
CARGO_INCREMENTAL: '0'
jobs: jobs:
prepare: promote:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
timeout-minutes: 10 timeout-minutes: 50
outputs: env:
ref: ${{ steps.mode.outputs.ref }} FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
tag: ${{ steps.check.outputs.tag }} FORGEJO_URL: ${{ github.server_url }}
version: ${{ steps.check.outputs.version }} FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
dry: ${{ steps.mode.outputs.dry }} TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
steps: steps:
- name: Mode - name: Mode
id: mode id: mode
env: env:
EVENT: ${{ github.event_name }} EVENT: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
IN_TAG: ${{ inputs.tag }}
IN_DRY: ${{ inputs.dry }} IN_DRY: ${{ inputs.dry }}
run: | run: |
# a boolean input arrives as true / false (API: also "on" / "1"); only an explicit no is a real release [ -n "$TAG" ] || { echo "input tag is required" >&2; exit 1; }
case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) want_dry=false;; *) want_dry=true;; esac case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) dry=false;; *) dry=true;; esac
echo "inputs: tag='$IN_TAG' dry='$IN_DRY'" [ "$EVENT" = push ] && dry=false
if [ "$EVENT" = push ]; then ref="refs/tags/$REF_NAME"; dry=false echo "dry=$dry" >> "$GITHUB_OUTPUT"; echo "tag=$TAG dry=$dry"
elif [ -n "$IN_TAG" ]; then ref="refs/tags/$IN_TAG"; dry=$want_dry
else ref="$SHA"; dry=true
[ "$want_dry" = true ] || { echo "a real release needs a tag (input tag), or push the tag" >&2; exit 1; }
fi
echo "ref=$ref" >> "$GITHUB_OUTPUT"; echo "dry=$dry" >> "$GITHUB_OUTPUT"
echo "event=$EVENT ref=$ref dry=$dry"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ steps.mode.outputs.ref }} ref: refs/tags/${{ env.TAG }}
- name: Tag, package.json version and every version copy agree - name: Tag, package.json version, every version copy and the CHANGELOG agree
id: check id: check
env:
TAG_IN: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
DRY: ${{ steps.mode.outputs.dry }}
run: | run: |
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
export PATH="$HOME/.bun/bin:$PATH" export PATH="$HOME/.bun/bin:$PATH"
bun tools/desktop-version.ts bun tools/desktop-version.ts
V=$(bun tools/desktop-version.ts --print) V=$(bun tools/desktop-version.ts --print)
if [ -n "$TAG_IN" ] && [ "$TAG_IN" != "v$V" ]; then echo "tag $TAG_IN is not v$V (package.json): bun tools/desktop-version.ts --set <version>, commit, tag that commit" >&2; exit 1; fi [ "$TAG" = "v$V" ] || { echo "tag $TAG is not v$V (package.json): bun tools/release.ts <version> makes the bump commit and the tag" >&2; exit 1; }
grep -q "^## $V" CHANGELOG.md || { [ "$DRY" = true ] && echo "note: no CHANGELOG section for $V (the notes use Unreleased)"; } || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; } case "$V" in *-*) echo "prerelease $V: the CHANGELOG section is optional";; *) grep -q "^## $V" CHANGELOG.md || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; };; esac
if [ "$DRY" = true ]; then tag="test-$(git rev-parse --short=10 HEAD)"; else tag="$TAG_IN"; fi
echo "version=$V" >> "$GITHUB_OUTPUT"; echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "release $tag (version $V, dry=$DRY)"
ci:
needs: prepare
runs-on: ubuntu-22.04
timeout-minutes: 60
env:
CI: full
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: ci
rust-components: clippy
- uses: ./.forgejo/actions/ci-run
linux:
needs: prepare
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
# deb then run on Ubuntu 22.04 and newer, Debian 12)
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
# the path-free build keeps the target folder outside every home folder
CARGO_TARGET_DIR: /tmp/storebound-target
# linuxdeploy (AppImage) runs without FUSE inside the container
APPIMAGE_EXTRACT_AND_RUN: '1'
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
NO_STRIP: '1'
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: desktop
packages: patchelf file squashfs-tools p7zip-full xz-utils
- name: Install and check
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-linux-x64
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
- name: Build AppImage and deb (path-free)
working-directory: apps/desktop
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
- name: Self-test the exact artifacts
run: |
V=$(bun tools/desktop-version.ts --print)
B="$CARGO_TARGET_DIR/release/bundle"
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
chmod +x out/*.AppImage
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
- name: Start the app under Xvfb (AppImage and deb)
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
run: |
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
V=$(bun tools/desktop-version.ts --print)
smoke() { # <label> <executable>
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
local pid=$! ok=0
for i in $(seq 1 90); do
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
echo "$1: window started, UI reached the sidecar"
}
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
smoke deb /tmp/deb/usr/bin/storebound-desktop
- name: Privacy scan
run: bun tools/privacy-scan.ts out/
- name: Checksums
run: (cd out && sha256sum * | tee SHA256SUMS)
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-linux
path: out/
retention-days: 14
windows:
needs: prepare
# a tag push always builds Windows; a manual run unless "windows" is unticked
if: ${{ github.event_name == 'push' || inputs.windows }}
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
runs-on: windows
timeout-minutes: 90
env:
CARGO_TARGET_DIR: C:\storebound-target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- name: Install and check
shell: pwsh
run: |
bun install --frozen-lockfile
bun tools/desktop-version.ts
bun tools/build-sidecars.ts --target bun-windows-x64
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
shell: pwsh
working-directory: apps/desktop
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
- name: Install per user, self-test, uninstall
shell: pwsh
run: |
$V = bun tools/desktop-version.ts --print
New-Item -ItemType Directory -Force out | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
& $exe --self-test | Tee-Object -Variable st
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
- name: Privacy scan (no .pdb, no paths)
shell: pwsh
run: bun tools/privacy-scan.ts out
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-windows
path: out/
retention-days: 14
release:
needs: [prepare, ci, linux, windows]
# a real release needs every platform; a dry run may skip Windows
if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }}
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
DRY: ${{ needs.prepare.outputs.dry }}
SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.ref }}
- name: Tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends squashfs-tools p7zip-full xz-utils binutils unzip file >/dev/null
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; } [ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
- uses: actions/download-artifact@v3 echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
with: echo "version=$V" >> "$GITHUB_OUTPUT"
name: storebound-desktop-linux - name: Promote the commit's green candidate
path: dist/linux run: bun tools/release-candidate.ts promote --tag "$TAG" --sha "${{ steps.check.outputs.sha }}" --wait 40 ${{ steps.mode.outputs.dry == 'false' && '--yes' || '' }}
- uses: actions/download-artifact@v3 - name: Keep only the newest release (stable versions; tags stay)
if: ${{ needs.windows.result == 'success' }} if: ${{ steps.mode.outputs.dry == 'false' && !contains(steps.check.outputs.version, '-') }}
with:
name: storebound-desktop-windows
path: dist/windows
- name: Collect, privacy scan (private terms), checksums
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
mkdir -p out
find dist -type f ! -name SHA256SUMS -exec cp {} out/ \;
for f in out/*; do case "$f" in *"_${VERSION}_"*) ;; *) echo "$f is not version $VERSION" >&2; exit 1;; esac; done
[ -n "$FORBIDDEN_TERMS" ] || { [ "$DRY" = true ] && echo "note: secret FORBIDDEN_TERMS is missing: generic checks only"; } || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out
rm -f "$RUNNER_TEMP/terms.txt"
(cd out && sha256sum * | tee ../SHA256SUMS.local)
ls -la out
- name: Notes
run: |
bun tools/forgejo-release.ts notes "$VERSION" --tag "$([ "$DRY" = true ] && echo "v$VERSION" || echo "$TAG")" | tee notes.md
[ "$(wc -l < notes.md)" -le 8 ] || { echo "notes are longer than 8 lines" >&2; exit 1; }
- name: Release (create or update, attach, read back)
run: |
if [ "$DRY" = true ]; then
bun tools/forgejo-release.ts upsert --tag "$TAG" --target "$SHA" --title "$TAG (dry run, deleted at the end)" --notes-file notes.md --draft --yes out/*
else
bun tools/forgejo-release.ts upsert --tag "$TAG" --title "storebound $VERSION" --notes-file notes.md --yes out/*
fi
- name: Keep only the newest release (tags stay)
if: ${{ needs.prepare.outputs.dry != 'true' }}
run: bun tools/forgejo-release.ts prune --keep "$TAG" --yes run: bun tools/forgejo-release.ts prune --keep "$TAG" --yes
- name: Dry run - what prune would delete
if: ${{ needs.prepare.outputs.dry == 'true' }}
run: |
KEEP=$(curl -fsS -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_URL/api/v1/repos/$GITHUB_REPOSITORY/releases?limit=1&draft=false" | bun -e 'const r=await Bun.stdin.json(); console.log(r[0]?.tag_name ?? "")')
[ -z "$KEEP" ] || bun tools/forgejo-release.ts prune --keep "$KEEP"
- name: Dry run - delete the test draft
if: ${{ always() && needs.prepare.outputs.dry == 'true' }}
run: bun tools/forgejo-release.ts delete --tag "$TAG" --yes

13
.gitattributes vendored Normal file
View file

@ -0,0 +1,13 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.ico binary
*.icns binary
*.ttf binary
*.woff2 binary
*.mp4 binary
*.bat text eol=crlf
*.cmd text eol=crlf
# kept byte-for-byte as published upstream (CRLF)
cli/data/play-data-safety-form.csv -text
**/icons/LICENSE.txt -text

View file

@ -20,6 +20,16 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes. these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
@ -68,6 +78,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`. Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner. Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
`storebound console fill` is the optional scripted fast path; it never blocks you. `storebound console fill` is the optional scripted fast path; it never blocks you.
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
or "App Store Connect > App Information" alone.
## Tools ## Tools
@ -93,6 +106,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal | | `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default | | `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default |
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly | | `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
| `game-services` | Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section |
| `listing-localization` | Translate the store listing of a mobile app or game into more languages | | `listing-localization` | Translate the store listing of a mobile app or game into more languages |
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` | | `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game | | `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |

View file

@ -1,9 +1,10 @@
{ {
"mcpServers": { "mcpServers": {
"storebound": { "storebound": {
"command": "${CLAUDE_PLUGIN_ROOT}/bin/storebound", "command": "sh",
"args": [ "args": [
"mcp" "-c",
"r=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$r\" ] && [ -x \"$r/bin/storebound\" ]; then exec \"$r/bin/storebound\" mcp; fi; if command -v storebound >/dev/null 2>&1; then exec storebound mcp; fi; exec npx -y storebound@latest mcp"
] ]
} }
} }

View file

@ -21,6 +21,16 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes. these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
@ -69,6 +79,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`. Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner. Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
`storebound console fill` is the optional scripted fast path; it never blocks you. `storebound console fill` is the optional scripted fast path; it never blocks you.
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
or "App Store Connect > App Information" alone.
## Tools ## Tools
@ -92,6 +105,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal - `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default - `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly - `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
- `skills/game-services/SKILL.md`: Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages - `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` - `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game - `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game
@ -124,4 +138,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
- Inner loop: `bun run ci:quick` (typecheck, the tests related to your change against origin/main, scrub; seconds). - Inner loop: `bun run ci:quick` (typecheck, the tests related to your change against origin/main, scrub; seconds).
Before every push: the full `bun run ci` (every check and test, in parallel shards), and check its exit code. Before every push: the full `bun run ci` (every check and test, in parallel shards), and check its exit code.
- Skills are the single source: edit `skills/*/SKILL.md` or `docs/agents-core.md`, then `bun tools/gen-adapters.ts`. - Skills are the single source: edit `skills/*/SKILL.md` or `docs/agents-core.md`, then `bun tools/gen-adapters.ts`.
- Every check, rule, template line, message and desktop view covers both stores (App Store and Google Play) and every
surface (CLI, MCP, desktop, skills); when a store has no equivalent field, say where the same thing goes there.
- Never commit credentials, account / team / issuer / key / app ids or personal data; placeholders use example.com. - Never commit credentials, account / team / issuer / key / app ids or personal data; placeholders use example.com.

View file

@ -1,5 +1,733 @@
# Changelog # Changelog
## Unreleased
## 0.8.77 · 2026-11-08
- **iPhone Duo:** outer and inner files are no longer cut to the first 10: the limit comes from Apple's reference data, and over it nothing changes and nothing is dropped. `apple screenshots --duo-only` writes only the Duo set. `apple cpp` no longer lists creative-assets flags.
## 0.8.76 · 2026-11-08
- 0.8.75 was tagged but not published (GitHub 502/504 in the Linux AppImage bundling). CI now takes the AppImage tools from a checked mirror instead of GitHub.
## 0.8.75 · 2026-11-08
- 0.8.74 was tagged but not published (GitHub release downloads answered 504 during the Linux AppImage bundling); 0.8.75 ships its section.
## 0.8.74 · 2026-11-08
- 0.8.73 was tagged but not published (a GitHub 504 in the Linux AppImage bundling); 0.8.74 ships its section below. CI caches the AppImage tools and retries the Linux bundle.
## 0.8.73 · 2026-11-08
- **iPhone Duo:** the live API names the platform IPHONE_APP_STORE (not IOS); accepted. A Duo problem now warns and the other screenshot sets still upload.
## 0.8.72 · 2026-11-08
- **iPhone Duo screenshots upload.** `apple screenshots` now sends them through the App Asset Library (display class
IPHONE_DUO, placement APP_SCREENSHOT) instead of skipping them as "not in the API" (pitfall 130).
## 0.8.71 · 2026-11-07
- 0.8.69 and 0.8.70 were tagged but not published (the desktop release check stopped them); 0.8.71 ships both sections below. Desktop Portfolio: the Play state of an app uses the same words as Releases ("Not live yet: in review or blocked before review").
## 0.8.70 · 2026-11-07
- Public MCP server: the bare host is the endpoint too. `POST /` answers MCP like `/mcp` (someone who pastes `https://mcp.example.com` into a connector gets connected), `GET /` sends a browser to the landing page and names the connect URL to any other client. A signed-in server keeps `/` closed.
## 0.8.69 · 2026-11-07
- Play: `play status` and `validate` list every build still active on a testing track below the production build, with the `play release --version-code <n> --<track> completed` that replaces it (an old build with a policy issue, e.g. READ_MEDIA_IMAGES, blocks Send changes for review for the whole app). A completed production release the store page does not show yet reads "Not live yet: in review or blocked before review" instead of "In review" (CLI and desktop). A commit with `--not-sent-for-review` that Play answers with 400 "Changes are sent for review automatically" is redone without the parameter (pitfalls #129).
## 0.8.68 · 2026-11-07
- Desktop app: `storebound` works in a terminal with only the app installed. On every start the app puts a launcher for its own CLI on the PATH (Windows: `%LOCALAPPDATA%\storebound\bin`, added to the user PATH once; macOS / Linux: `~/.local/bin`, added to `~/.zprofile` / `~/.profile` when missing). An npm / bun install stays first, a file that is not the app's launcher is never overwritten, AppImage runs skip it, `STOREBOUND_NO_CLI_SHIM=1` turns it off.
- `apple resubmit` / `apple submit --platform macos`: `apple.macos.noIap` leaves every in-app purchase out of the macOS submission and, without `apple.macos.gameCenter: true`, the Game Center switch is not touched (no `gameCenterAppVersions` call); the dry run names what it left out and why (pitfalls #128).
## 0.8.67 · 2026-11-03
- `apple build --platform macos`: runs `apple.macos.prebuild` (new) before `tauri build`, no longer `build.prebuild` / `apple.prebuild`, which belong to the iOS project (a Capacitor sync or OTA gate ran for a Tauri Mac build); the dry run names the skipped one.
## 0.8.66 · 2026-11-03
- `apple.macos: { listing, reviewNotes, gameCenter, noIap }`: an app with iOS and macOS versions gets its own Mac listing file and review notes (`apple listing --platform macos`, `apple review-details --platform macos`, new there); `validate` warns about iOS-only claims in a macOS version's texts (Game Center, tap / swipe, iPhone / iPad, in-app purchases with `noIap`).
- `apple upload <pkg>`: reading the package is bounded (pkgutil signature check 90 s, expand 5 min, then the `apple build` .json) and logged; the processing wait finds macOS builds (it looked only at iOS and sat silently until --timeout); every slow read prints a heartbeat; an SSH / non-GUI session is warned about up front.
- `apple build --platform macos`: `--tauri` takes a `tauri.<name>.conf.json` overlay (merged over tauri.conf.json and passed to `tauri build --config`; `tauri.appstore.conf.json` / `tauri.mas.conf.json` next to it are used by themselves); the Mac App Store entitlements come from `--entitlements`, `apple.macos.entitlements` or the merged config (a two-channel project signed the App Store app with its Developer ID plist); the plan lists the final keys, `get-task-allow` / `cs.disable-library-validation` stop the build (`--allow-entitlements`), other hardened-runtime exceptions warn.
- MCP `apple_review_details` takes `platform`.
- `apple release`: a 409 on a release type change is recognized again (a stray control character had broken the pattern).
## 0.8.65 · 2026-11-03
- `validate`: in-app purchases removed from sale (DEVELOPER_REMOVED_FROM_SALE, REMOVED_FROM_SALE) are no blockers; a warning only when the config still sells one.
- `shots capture --device landscape`: Mac App Store / Chromebook frames from a web build (desktop viewport at `shots.landscape.size`, default 1440x900 @2x) into `<lang>/landscape/`; on by default when the project renders a landscape size. The simulator and emulator sources skip it with a warning.
- `shots render` / `capture`: "playwright not found" now lists each place looked at and why it failed (an import error was reported as not found).
## 0.8.64 · 2026-11-02
- `validate` / `release plan`: a macOS version is checked against Mac screenshots (APP_DESKTOP, with `apple.mac`), not the iPhone / iPad sets; with no editable version, iOS is picked over macOS.
- `validate --play`: reads go through the locked read-only edit and retry once on a fresh edit when Play ends it ("This Edit has been deleted" was shown as a blocker; it is now a warning to re-run at most).
- Build numbers for macOS (`apple build --platform macos`, `apple upload` of a .pkg) come from every build of the app, as `apple next-build` does (a macOS-only list proposed a number an iOS build already had).
- `apple build --platform macos` runs `build.prebuild` / `apple.prebuild` before `tauri build` (`--no-prebuild` skips it), and the new `apple.macAppCheck` command on the built .app before productbuild ({file} = the .app; a nonzero exit stops the build).
- `apple signing status` / the Mac build dry run on Windows or Linux say the keys live on the Mac that made the certificates (build there) instead of suggesting a new certificate.
- Flows: a dry `flow run` plans the upload, TestFlight wait and distribute steps after a dry build instead of failing on the missing IPA; `beta` and `release` need VERSION (no silent 1.0.0); `beta` distributes to the config's internal TestFlight group when GROUP is empty; `release` has optional macOS steps (MAC_TAURI), PLAY_TRACK (e.g. beta = open testing) and a promote owner step.
## 0.8.63 · 2026-11-02
- Desktop: a second start at logon (`--hidden`, e.g. a Startup folder shortcut next to the Run entry) keeps the app in the tray instead of opening its window.
## 0.8.62 · 2026-10-29
- `doctor` warns when storebound runs from a folder macOS guards (~/Desktop, ~/Documents, ~/Downloads, iCloud Drive; e.g. a `bun link` to a checkout there): a launchd job or CI agent without access to it blocks in open() with no output.
- `apple build` prints a line before each slow step (App Store Connect reads, device families, web build), so a run without a terminal that stalls shows where.
## 0.8.61 · 2026-10-29
- TestFlight: `apple.testflight.description` takes a language id or its App Store locale (`en` or `en-US`; an `en-US` key was skipped silently), `check` names an unknown key, and `distribute --submit-review` stops (dry run: warns) when the app's primary locale has no beta localization (Beta App Review answers 422 MISSING_BETA_APP_LOCALIZATION).
## 0.8.60 · 2026-10-29
- `apple listing` creates a new version with the release type of the newest version before it (MANUAL or AFTER_APPROVAL) when `apple.releaseType` is not set; a first version stays MANUAL. The plan names where the type came from, and warns when `apple.releaseType` differs from the previous version's.
- `play release <aab> --dry` uploads nothing: it names the versionCode it would upload and the track changes it would make (the bundle went into a discarded edit, and a long upload could outlive the edit: "This edit has expired").
- `play build`: an absolute `--dir` / `play.build.dir` stays absolute (it was taken as a folder under the project and lost its leading slash); a `gradlew` without the exec bit (committed from Windows) is made executable with a note to commit the mode. The desktop release signs by key path only.
## 0.8.59 · 2026-10-29
- `mcp --http`: a `tools/call` that accepts `text/event-stream` gets SSE keepalive comments every 20 s and then its result, so long calls survive proxies that cut silent responses. The public server's App Store search keeps one budget of 20 calls a minute (Apple's limit).
- `storebound mcp --http --public`: a public, key-less MCP server (no sign-in, no store credentials, no project folders, no child processes, read-only). Tools: `skills_list`, `skill_get`, `pitfalls_search`, `store_limits`, `listing_lint` and `aso_search` (the public iTunes Search API, the only network call). Rate limit per client (`--rate`, 429 with Retry-After; `--trust-proxy` for X-Forwarded-For), 64 KB request bodies, CORS preflight. docs/mcp.md.
## 0.8.58 · 2026-10-29
- `check` and `scan` are fast on big repos: project walks skip dot folders (tool state, worktree copies), linked git worktrees and the folders git ignores (Pods, Carthage, SourcePackages and ios / android stay in), and read entry types from the listing instead of a stat per file. A game repo with worktree copies went from 244 s to 46 s for `check`.
- `storebound mcp --http`: one Streamable HTTP MCP server that every agent session of a machine shares (no process per session). Each tool gets a `project` argument (absolute path inside `--roots`); auth is a local bearer token (`~/.storebound/mcp-token`, made on first start) or, for a server behind TLS, OAuth bearer tokens checked by introspection (RFC 7662) with protected resource metadata (RFC 9728). docs/mcp.md.
## 0.8.57 · 2026-10-27
- `apple upload` finds the IPA of a build made with `--out`: `apple build` notes its IPA and out folder in `.storebound/last-apple-build.json`, and upload searches `--out` (new flag), then that last build's out, then `apple.build.out` / `build/ios`. When nothing is found, the error names every folder it searched and the last build's IPA.
## 0.8.56 · 2026-10-27
- Release type. `apple.releaseType: afterApproval | manual | scheduled` (+ `apple.earliestReleaseDate`): `apple listing` creates versions with it (MANUAL when unset, existing versions untouched) and `apple submit` / `resubmit` set it before submitting when it differs, the dry run showing old -> new. New `apple release --after-approval | --manual | --scheduled <ISO date> [--platform] [--version] [--first-release] --yes` changes it on a version App Review has not approved yet (clear refusal otherwise, with `--manual-release` as the way out); exit 7 when it already has that type; an update is an agent step, after-approval on the first version of an app is exit 3 OWNER_STEP. Pitfall 125.
- Device families. `validate` and `apple build` read the device families of the last live version (build bundle `deviceProtocols`, device models, iPad screenshot sets) and compare them with TARGETED_DEVICE_FAMILY in the Xcode project and `apple.ipad`; a gap stops `apple build` before the archive with the exact fix (ITMS-90101 otherwise comes after the build; `--allow-device-gap` overrides). Offline `check` warns when the live families are unknown or the config and project disagree. Pitfall 126.
- TestFlight. `apple testflight testers --invite` checks that the tester's group has an installable build (past Beta App Review for external groups); without one it adds the tester, skips the invite and prints when it can go out (exit 0). Apple's 409 NO_INSTALLABLE_BUILDS is handled the same way; `distribute --submit-review` prints the hint for testers not invited yet. Pitfall 127.
## 0.8.55 · 2026-10-26
- `apple build --path-free`: a checkout in a shared build folder outside the home folder is no longer a leak needle (dsymutil and the module cache record it; only home and user-name paths count).
## 0.8.54 · 2026-10-26
- `apple build` is hermetic and can be path-free. `--derived-data <dir>` (`apple.build.derivedData`) is xcodebuild `-derivedDataPath`; with `--out` (or `apple.build.out`) it defaults to `<out>/DerivedData` instead of `~/Library/Developer/Xcode/DerivedData` (without `--out` Xcode's folder stays). `--path-free` (`apple.build.pathFree`) remaps the home folder, checkout, out and DerivedData out of the app binary and the archive's dSYMs / Symbols (Swift `-debug-prefix-map` / `-file-prefix-map`, clang `-ffile-prefix-map`; dwarf-with-dsym stays, symbol upload keeps working), scans the archive and stops before the export with the hit count and the first file + offsets (never the paths). `--scan-paths` only scans and warns. A `--out` outside the home folder prints a hint. `--json` / `latest.json` carry `derivedData`, `pathFree` and `leaks`. Store-pitfalls 124.
## 0.8.53 · 2026-10-26
- Updates are agent steps (the owner's standing rule), first releases stay the owner's. `play release <aab> --production completed|inProgress --yes`, `play upload --track production --yes`, `apple submit --build <n> --yes` and `apple release --manual-release --yes` run without an owner gate, in a terminal and in a pipeline, when the store says the app is already live (Play: the production track holds a completed / inProgress / halted release; App Store: a version of the platform is or was `READY_FOR_SALE` / `READY_FOR_DISTRIBUTION` / `REPLACED_WITH_NEW_VERSION`; `cli/lib/release-kind.ts`). The result line carries `releaseKind`; a re-run is exit 7. The first-ever release of an app with `--yes` is exit 3 `OWNER_STEP` ("first release of this app: the owner approves it once"), approved by `--first-release` (after the owner's yes) or bypass mode; a person answering the prompt (no `--yes`) is the approval too. `apple release` is now a ship step (`--json`, `--result-file`, exit 7 when the version is released already). Policy in docs/bypass.md ("Updates are agent steps"), docs/ship-step.md, the release-management and play-first-release skills, AGENTS.md; pitfall 123 / 96.
## 0.8.52 · 2026-10-26
- `analytics snapshot` and `reports` for a collector: a store-only config may name the App Store app by `apple.appId` alone (`bundleId` null or absent; every write still needs it); the Play reports bucket also comes from env `PLAY_REPORTS_BUCKET` or the credential profile's `reportsBucket` when `play.reportsBucket` is missing; `analytics snapshot --json` prints the snapshot as one JSON document on stdout (also when every store failed, with the exit code 1) and `--no-write` skips the file.
- Snapshots carry active-user numbers per day: App Store App Usage (`sessions`, `activeDevices`, `sessionSeconds`, `crashes` from the App Sessions and App Crashes reports; a missing usage report is a `notes` line, not a failure) and Play `activeDeviceInstalls` and `totalUserInstalls` (installs overview file, else the country file summed) next to `installs` (Daily User Installs).
- `reports apple-sales --days N | --from YYYY-MM-DD [--to ...] --json` reads the daily SALES reports of a range into one JSON (per-day summary and totals per currency, range totals per currency, `missing` days listed, never errors); `reports play-sales --days N | --from ...` gives the daily revenue per currency from the estimated sales reports (orders, refunds, charged, refunded, net, by order charged date). MCP `reports` takes `days`, `from`, `to`.
- Release: the dev-channel CI job no longer gates a release.
## 0.8.51 · 2026-10-26
- Tagged but not published (the dev-channel job hit a CDN 502); everything ships in the next version.
## 0.8.50 · 2026-10-25
- App Groups fail early under automatic signing too: `apple signing status` / `ensure` and `apple build` read the App ID's APP_GROUPS capability settings and the newest profile the API lists for each bundle id (an Xcode-managed one too) and compare it with the target's `com.apple.security.application-groups`. A profile without the group (or the capability off) stops `signing ensure` and `apple build` before the archive with the exact portal steps (Certificates, Identifiers & Profiles > Identifiers > the ID > App Groups > Configure > tick the group > Continue > Save), the deep link `.../identifiers/bundleId/edit/<resource id>`, the plan command and `signing ensure --refresh-profile`; a group nothing can prove only warns with the same steps. `apple build --allow-app-group-gap` overrides.
- New `storebound console plan apple-app-group [--bundle-id <id>] [--groups group.a,group.b] [--lookup]` (MCP `console_plan`: `bundleId`, `groups`, `lookup`): the agent-first developer portal plan to assign an App Group to an extension's App ID (start URL per bundle id resource id, portal labels, answer sheet per extension, never-press list: Remove / Delete / Revoke). Its final Save is the agent's own (`buttons.finalByAgent`: a capability edit, not a store submission). `console verify apple-app-group` (MCP `console_verify`, API only) re-reads the App ID and the newest profile and fails while a group is missing or cannot be proven.
- Google access through a credential broker (`~/.storebound/broker.json`: `url`, `apiKeyFile`; env `STOREBOUND_BROKER_URL` / `STOREBOUND_BROKER_KEY_FILE`): Play tokens come from the broker's `POST /api/google/token` (in memory until 5 minutes before expiry, one request per account and scope, never logged; ya29. tokens and the API key redacted in errors), the local service account key stays the fallback when the broker does not answer or does not know the account; a broker that refuses the API key stops without retry. `PLAY_SERVICE_ACCOUNT_EMAIL` names the account (env, profile, project file); a profile opts out with `"broker": false`. The key file must be mode 600 on macOS / Linux. Play write commands read the package's Play permissions and enabled APIs from the broker first and stop before any Play call on a missing one (`CAN_MANAGE_TRACK_APKS`, `CAN_MANAGE_PUBLIC_LISTING`, `CAN_MANAGE_ORDERS`, `CAN_MANAGE_APP_CONTENT`, the Games Configuration API) with the exact permission or API and its link; `validate` lists them (gaps are blockers, SDK config drift a warning) and `--json` carries `credentials.play.source`; `doctor`, `accounts` and `apps check` / the desktop's Accounts view say whether tokens come from the broker or the key file. A Google 403 SERVICE_DISABLED / PERMISSION_DENIED in the Play client names the API or grant with its fix link and is never retried (pitfall 122, docs/credential-broker.md).
## 0.8.49 · 2026-10-25
- `play listing` for a language Play does not have yet uploads, in the same edit as the text, everything Play requires to commit it: icon, feature graphic, 2+ phone screenshots from `store/renders/<lang>/` and the tablet sets when the other languages have them (a set the folder lacks falls back to the default language's). The dry run lists the uploads or refuses with the missing file, instead of a 403 "too few screenshots" at commit. `play images` creates a Play listing that does not exist yet (text from the listing file) together with its images instead of skipping the language (pitfall 102).
## 0.8.48 · 2026-10-24
- Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged.
- Desktop right-click menus in the window work the same way: a menu's pin button keeps it open as a floating panel, a submenu can be torn off (its pin button, Ctrl+click or a middle click), pinned panels survive the next menu, can be dragged, and update in place when the data behind them changes ("Gone" when the thing they were for left the screen).
- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas.
- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them).
- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size.
- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table.
- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/<id>`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides.
- `apple build` over SSH / in CI: the refusal now names the fixes (GUI session `.command` file, a CI runner with an unlocked keychain, a build keychain). New `--keychain-unlock` (keychain password from `STOREBOUND_KEYCHAIN_PASSWORD`, never printed; runs `security unlock-keychain` and `set-key-partition-list`) and `--keychain <path>` (sign with a build keychain).
- `storebound api` writes: the usage, the dry-run message and the refusals show the agent form with an example (`--yes --confirm <operationId>`), and the store-api skill says both flags are needed.
- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch).
- `apple upload` / `play upload` `--pre-check "<cmd with {file}>"` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence <path.json>` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`.
- New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate "<cmd>"`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language.
- `apple game-center --locale-map en=en-GB` (config `games.localeMap`) maps a config language to the live Game Center locale; the dry run suggests it when the live Game Center has exactly one variant of the language.
- `play games`: a 403 SERVICE_DISABLED / accessNotConfigured names the Google Play Game Services Publishing API, the enable URL for the project and `console plan play-games-services`.
- `apple build --platform macos --tauri <src-tauri> [--path-free] [--unsigned]`: Mac App Store build of a Tauri app. Generated sandbox entitlements, Mac App Store profile (installed, `--profile` or from the API), Apple Distribution / Mac Installer Distribution identities, `tauri build --bundles app`, checks of the .app, `productbuild` to a signed .pkg, `pkgutil --check-signature`; `--path-free` remaps local paths in rustc and fails on any leak of the home folder or user name. `apple upload <pkg> --platform macos` uploads it (platform MAC_OS). New certificate types MAC_APP_DISTRIBUTION and MAC_INSTALLER_DISTRIBUTION. Pitfall 119.
- Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `<next patch>-dev.<UTC commit time>` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session).
- `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `<frames>/<lang>/<size>/<name>.png` (feature: `<lang>/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`.
- `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID.
## 0.8.47 · 2026-10-23
- `storebound desktop`: an update no longer reports "installed" while the old app stays. It stops the running app first (exact install path only, graceful close, then by PID), checks the installed exe's own version (Windows PE version resource instead of the registry entry; Info.plist on macOS) and fails with what it found, and prints "opened" only when a visible (not `--hidden`) process started from the installed path, otherwise it names the app's log.
- `play products` / `play subscriptions`: regions outside Play's min / max (a 649.99 USD product in KR: "must be between ₩70 and ₩600,000") are flagged in the dry run (old -> new) and clamped to the bound on write with a warning; `--skip-out-of-range-regions` leaves them out. Limits Play reports in a 400 are learned (`.storebound/play-price-limits.json`) and the write is retried. A product or subscription that fails no longer stops the others: per-product results, exit 1 at the end.
- `apple iap` / `apple subscriptions`: edits to approved (ACTIVE) localizations, which App Store Connect refuses with 409 UNMODIFIABLE, are marked "not editable now" in the dry run and skipped with a warning on write; prices and the other changes still apply. One IAP failing no longer stops the rest (results at the end, exit 1).
## 0.8.46 · 2026-10-23
- `storebound watch` has an `ingest` sink (`--notify ingest`, `ingest` in ~/.storebound/watch.json): a JSON POST per event to a push ingest endpoint with a Bearer token read from a file at send time (refused when group or world readable), 8 KiB body cap, 60 sends a minute, one retry on 5xx; `watch test` covers it.
- Ship-step contract for release pipelines (docs/ship-step.md): `play build | upload | release`, `apple build | upload | submit | resubmit` exit 0 ok, 1 failure, 2 usage, 3 needs the owner (with `error.code` and the console deep link), 4 store refused, 7 nothing to do; `--json` ends with exactly one result line on stdout, `--result-file` writes it atomically (also on failure), `--progress json` gives one JSON line per phase on stderr.
- `--env-file <path>` loads KEY=VALUE secrets before the credentials resolve (POSIX: mode 600 or stricter) and every known secret value is scrubbed from stdout, stderr and the result file; `--version-from version.json` takes the version and build ({version, build}, {versionName, versionCode}, {marketing, build}).
- Re-runs never upload twice: `play upload` with a versionCode Play has and `apple upload` with a build number App Store Connect has end with exit 7 and what is there; `play release` with the same release and `apple submit` of an already submitted build too.
- `play upload --track production` (the pipeline's update step; `--status draft` keeps it for the Console) and `--version-code N` to pick the AAB.
- New `storebound status [--app <name|store id|folder>] [--json]`: per platform the live and next version, build, track, review state and rollout %, read-only. `release status --app` also takes a folder or a store id.
- Desktop: every read goes through one registry: one store call per read however many buttons are clicked, overlapping buttons (Refresh all, card refresh, Check again, Import, Test) are busy and disabled together, a read just finished is not repeated, and all reads share the scheduler's rate limits and backoff.
- Desktop: tray mini card on left click (rejections, releases moving, crash alerts, recent low reviews, refresh, quick actions); right click keeps the menu.
- `apple build` / `apple upload`: `--allow-version-mismatch` is accepted (it was missing from the flag list); 1.0 and 1.0.0 count as the same version train, as on App Store Connect.
- Desktop: custom context menus everywhere (apps, releases, art, reviews, checks, fields, products, accounts, folders) with keyboard navigation, Shift+F10 and submenus; the WebView2 browser menu and browser shortcuts are off.
- Desktop: each app shows once again (a folder project no longer appears a second time as a store-only app); a store app with a folder's bundle id or package is never listed separately.
- Desktop UX pass on every screen: app tabs fit the window, slim app header, secondary actions in "…" menus, Ctrl+K lists app actions, Alt+Up/Down switches apps, accounts and folders decluttered.
- Play Console links open the app's own page: developer id per credential profile (`storebound accounts play-developer <profile> <id>`, editable in Studios & accounts), one link builder for desktop, CLI and watch.
- Desktop: tabs in one row with a More menu, one Refresh, reviews keyboard (J/K, R, C, O), Settings and More tools search, sticky first column and compact density for wide tables, styled in-app dry-run preview before every write.
- Checks: "Open guide" opens the matching skill for every finding.
## 0.8.45 · 2026-10-23
- Tagged but not published (the release upload could not reach the server after an IP change); everything ships in the next version.
## 0.8.44 · 2026-10-23
- Tagged but not published (desktop clippy red on the bump commit); everything ships in the next version.
## 0.8.43 · 2026-10-23
- Tagged but not published (CI red: scrub hits in test fixtures, a ship-step test); everything ships in the next version.
## 0.8.42 · 2026-10-23
- Tagged but not published (generated API tables differed on Linux CI); everything ships in the next version.
## 0.8.41 · 2026-10-23
- Tagged but not published (a generated API table was stale in CI); everything ships in the next version.
## 0.8.40 · 2026-10-23
- Tagged but not published (the desktop boot check stopped it); everything ships in the next version.
## 0.8.39 · 2026-10-23
- Tagged but not published; everything ships in the next version.
## 0.8.38 · 2026-10-23
- Desktop: the app hero no longer shows a cropped second copy of the app icon.
- `play build` passes the upload key (ANDROID_KEYSTORE, ...) to Gradle as injected signing properties (store file and alias as `-P` arguments, the passwords only through `ORG_GRADLE_PROJECT_*` environment variables, never on argv), so a build file that signs only from a local keystore.properties no longer gives an unsigned AAB in CI (pitfall 107).
- `play build`, `play upload` and `play release` of a local AAB verify it is signed (META-INF entries, `jarsigner -verify` when a JDK exists) and fail with the fix before any upload.
- `apple build --version` and `apple upload` refuse an IPA whose CFBundleShortVersionString is not the intended / editable App Store version; `--allow-version-mismatch` overrides (pitfall 108).
- Play release notes that name iOS, iPhone or the App Store are caught by `storebound check` and `play build`, not only at release time (pitfall 109).
## 0.8.37 · 2026-10-22
- `storebound apps ignore|unignore [--store apple|play]`: hide dormant apps, or one store side of an app, from watch, reads and the desktop (MCP apps_ignore / apps_unignore).
- Reviews: a store read that fails is an error, not an empty "no new reviews" list; the Reviews tab notes Apple API lag and Play's 7-day window (pitfall 106).
- Desktop: live store thumbnails load right away, also in a hidden window.
## 0.8.36 · 2026-10-22
- `storebound watch`: a store-only App Store app added without a bundle id is no longer skipped; the bundle id is read from the API with the app's own key.
## 0.8.35 · 2026-10-22
- Desktop v2 ships (0.8.33 and 0.8.34 stopped at the desktop boot check, which still expected the old markup).
## 0.8.34 · 2026-10-22
- Desktop v2 ships (0.8.33 stopped at the desktop boot check, which still expected the old markup).
## 0.8.33 · 2026-10-22
- **Desktop v2**: every screen redesigned in one language: portfolio home, releases (store cards, stage stepper,
rollout ring), submit checklist, TestFlight, listings as store pages with completeness, framed screenshots with issue
badges, products and price matrix, health tiles, review feed with rating distribution, growth funnel, readiness score
with fix-first, tools, settings, accounts, folders, dependencies and onboarding.
- Desktop: tray with a context menu, autostart, silent auto-update and notifications by event kind (rejections too),
also while the window is hidden.
## 0.8.32 · 2026-10-22
- Node: a `storebound.config.json` (and every store-only app) loads again; it needed an import attribute. Pitfall 105.
## 0.8.31 · 2026-10-22
- **`storebound watch`**: store events for every app on the machine (rejected, approved, live, rollout halted, crash
threshold, low or unanswered reviews), sent to ntfy (phone), Telegram, a webhook or an OS notification; runs as a loop
(`--every 15m`) or `--once`; a PM2 recipe for a server is in docs/watch.md. `storebound events` and the MCP tool
`events_recent` let people and agents read what happened.
- **Desktop notifications that arrive.** The app reads the stores in the background from the tray too (it skipped
every read while hidden, so a rejection went unnoticed), every 15 minutes with backoff. Notifications by kind, each
with a switch: rejected, approved, live, halted, crash / ANR, low-rated reviews; quiet hours and mute for an hour.
Opening the window after a notification lands on that app.
- **Tray:** Open, Read the stores now, an Apps menu with each app's state on both stores, Mute, Check for updates,
Settings, Quit; a tooltip with what is in review and what needs you; a red dot on the icon after a rejection or halt.
- `apple listing` refused by an approved version waiting for a manual release now names the way out:
`storebound apple release --manual-release --version <v>`. `play release --dry` shows the staged rollout share.
## 0.8.30 · 2026-10-22
- **Desktop: a new home.** Store-style app cards (your rendered screenshots, or the icon on its tint), a release
pipeline per store (Build -> Testing -> Review -> Live -> Rollout), the crash curve and one next-step button per app;
below, What changed and Fix first. The table view stays one switch away.
## 0.8.29 · 2026-10-22
- **Existing store items are compared with the config, field by field** (pitfall 104). The dry run shows every
difference (old -> new); texts are sent, money and availability need a flag, fields Apple cannot edit are named.
- `apple iap`: price (`--allow-price-changes`), localization names and descriptions, reference name, review note,
family sharing (reported), territories.
- `apple subscriptions`: prices in the dry run, localizations, group names, reference name, review note, level,
territories; intro offers, period and family sharing reported.
- `play subscriptions`: offer phases and prices, other-regions price, regions no longer sold (all price-gated),
grace period, languages missing from the config kept unless `--prune-languages`.
- `apple appinfo`: app price (`--allow-price-changes`) and territories (removals `--allow-availability-changes`);
`apple review-details --dry` shows old -> new.
- Offer-code and win-back campaigns that differ are named; in-app event attributes patched on drafts; a removed
custom page deep link cleared; config languages without a version localization named.
- `prices diff` (the `catalog diff` report).
## 0.8.28 · 2026-10-22
- Windows: no false "entitlements not referenced by CODE_SIGN_ENTITLEMENTS" warning (paths compared with forward slashes). Pitfall 103.
- **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at
the same app on this machine (by its path or its app name), ids kept so the desktop keeps its accounts and settings;
`apps folders` says so when entries are missing. `apps add` names an app after its config's app name.
## 0.8.27 · 2026-10-21
- **Desktop: tray app that starts at logon and updates itself.** First run registers a logon entry (`--hidden`): the app
starts silently in the tray; closing the window keeps it there, the tray menu quits. A signed update is checked a
minute after start and every 30 minutes, installed silently, and the app restarts as it was.
- **App Privacy console plan follows today's page** (verified 2026-10-22): on an app published before, Edit opens the
Data Collection question first and the type list ends in Continue; each type's last step is Publish (it goes live,
no draft). Publish stays the owner's unless bypass mode is on.
- **A new Play language in one run.** `play listing` uploads the hi-res icon (and the feature graphic when rendered) for a
language Play does not have yet, in the same edit (Play refused the commit with 403 otherwise). Pitfall 102.
- Script traces skip test files (`*.test.*`, `*.spec.*`).
- **CI-friendly Play output.** `storebound aab <file.aab> --json` reads packageName, versionCode, versionName, SDKs and
sha256 offline (no config, no upload); `play release ... --json` prints {versionCode, sha256, name, tracks[{track,
status, userFraction}]}. `play status --json` already lists each track's releases.
- **`apple build` stops at once over SSH** (codesign fails there with errSecInternalComponent) and names how to run it
in the logged-in session; relative `ASC_P8` / `PLAY_SERVICE_ACCOUNT` paths are read from the project folder and
xcodebuild gets an absolute key path. Pitfall 101.
## 0.8.26 · 2026-10-21
- **Bypass mode (owner opt-in, default off).** `storebound settings bypass on` (or "y" in `storebound init`) lets the AI
agent press the owner-only buttons too: MCP `owner_step` (submit, release, rollout, testers, replies, prices, ...;
dry run first), owner-only `storebound api` operations with `--yes --confirm <operationId>`, console plans'
`buttons.final`. Every action is logged to `.storebound/owner-actions.log`; `bypass off` restores the old behaviour.
docs/bypass.md.
- **Build dependencies in `doctor` and the desktop Dependencies screen.** JDK (with the version the project needs from
the Android Gradle plugin / jvmToolchain, "too old" when below it), Android SDK command-line tools, CocoaPods, Node.js,
Bun and bundletool join ffmpeg, Chrome, Xcode, adb and git. `storebound doctor --install <tool> --yes` runs this OS's
package-manager command (winget / brew); sudo and admin commands are printed, never run.
- **A livelier terminal.** At a terminal, lines get colors and status emoji (✅ ⚠️ ❌ 📦 🚀 🧪) and every wait ("waiting
for ...", uploads, downloads, progress) is one live spinner line with the elapsed time. Piped, CI, `NO_COLOR`,
`TERM=dumb`, `--json` and MCP keep the plain lines exactly as before.
- **MCP `console_verify` reads multi-line page text whole** (it kept only the first line on Windows), and long JSON
output such as `console_plan` keeps its start and names a file with the whole output. Pitfall 100.
- **Windows builds.** `play build` runs `gradlew.bat` through `cmd /c` (it was looked up on PATH only) and takes
`C:/...` keystore paths as absolute. Pitfall 98.
- **`check` verifies credential files.** Every credential path that does not exist on this machine and an upload keystore
the stored password / alias do not open (keytool) are named by field. Pitfall 99.
- `play build` / `apple build` write `<out>/latest.json` with the AAB / IPA path for CI; CI install from a git clone documented.
- **Scripts that call the store APIs themselves are named.** `androidpublisher.googleapis.com`, the googleapis
androidpublisher client, `api.appstoreconnect.apple.com`, altool and Transporter in CI files and scripts: `check` and
`release status` warn, `scan` reports `direct-store-api`, each with file:line and the storebound command. Pitfall 97.
## 0.8.25 · 2026-10-21
- **fastlane left in a project is named.** `check` and `release status` warn and `scan` reports `fastlane-upload`
when a Fastfile / Appfile or a CI file or script still runs `fastlane supply`, `deliver` or `pilot`, with the
file:line and the way over (`storebound import fastlane`). Pitfall 96.
## 0.8.24 · 2026-10-21
- **Windows: the MCP server starts.** `storebound install` writes `cmd /c npx -y storebound@<version> mcp` (or
`bun <toolchain>/cli/index.ts mcp`) on Windows, where `npx.cmd` and the sh launcher cannot be spawned. Pitfall 94.
- **`storebound desktop` no longer waits forever on a stalled download.** Progress every 10 %, and an error after
60 s without data. Pitfall 95.
- Windows checkouts keep LF line endings (`.gitattributes`); README badges: live npm version, real skill count.
## 0.8.23 · 2026-10-21
- **`apple submit --yes` submits a draft prepared with `--no-submit`.** The second step of the two-step flow
ended with "nothing to submit" when the draft already held the version and every item; it now sends the submit
(same "not ready yet" retry). `iap-submit` the same for a draft that holds the asked-for items. Pitfall 90.
## 0.8.22 · 2026-10-20
- **`apple submit` / `resubmit` switch Game Center on first.** A build with the Game Center entitlement and an app
without a Game Center detail was refused with 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED; submit,
resubmit and `apple signing ensure` now create `gameCenterDetails` and the version's `gameCenterAppVersions` first
(dry run shows it), and `validate` blocks while either is missing. Pitfall 89.
- **`apple testflight distribute` creates missing configured groups first** (only the asked-for ones, never removes
one); tester invites stay the owner's.
- **Console plans follow the live pages (2026-10-21 run).** Play health: "My app does not have any health features"
(verify accepts it, "Other" stays a fallback); Sign in details: "Is any part of your app restricted?" Yes / No and
"Add details" (old labels kept as `altLabels`); finance: the boxes on step 1, step 2 information only; App Privacy:
the "Additional Setup Required" OK that opens the first type's setup by itself is in the steps and UI facts.
- **Save after every page.** Every Play plan step that answers a page with a Save next to Next now says Save, wait
for "Your changes have been saved", then Next; the rule line says it too.
- **IARC by exact labels.** "Purchases of digital goods" is ticked after Yes on "Digital purchases" (the loot-box and
trading follow-ups show only then); the in-page helper matches exact labels and questions and refuses patterns,
radios without their question and blanket group answers.
- **`console verify` reads input states.** The Play declaration plans' `verify.capture` returns every radio /
checkbox with aria-checked / checked / aria-selected as JSON; `console verify <form> --from <file|->` takes it.
- **`apple review-details` finds the contact** in parent folders' `.storebound/credentials.json` and the credential
profile, under more key shapes, and lists every file and key checked when there is none.
- **One-line errors for missing required values:** `apple listing` without `--version` says "--version <x.y.z> is
required (storebound apple status shows the editable version)"; the same for privacy-manifest `--ios`, adhoc
export `--archive`, edit `--ops` and resubmit `--build`. A flag given without a value counts as missing.
- **Every CLI command is reachable in the desktop app.** A new **More tools** tab gives each command without a
screen of its own (play build / upload / share / app-id, apple build / upload / signing / adhoc, bundle, IAP review,
Header and Search Results, custom product pages, in-app events, pre-orders, what's new, listing translate / check,
ASO rank / competitors / suggest, captures, fonts, JPEG copies, triage, originality, privacy manifest, ads scan,
Apple Ads reports, web, RevenueCat diff, PPP sheet, workflows, install for an AI tool) one typed form with the
command's own options, built from the method's schema; never a free-text shell. Reads run there; writes show the
dry run, then the native dialog, then run with the confirm token. Owner-only 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 to copy. New desktop methods in `serve`:
`play.build`, `play.upload`, `play.share`, `play.app_id`, `apple.build`, `apple.upload`, `apple.signing_ensure`,
`apple.adhoc_register`, `apple.adhoc_export`, `shots.fonts`, `shots.jpeg`, `agent.install`.
- **docs/desktop-coverage.md** (generated by `bun tools/gen-desktop-coverage.ts` from `cli/lib/desktop-coverage.ts`):
every command with its window place and methods; 92 commands, 88 covered, 4 not for the desktop (`mcp`, `serve`,
`desktop`, `api`). `tests/desktop-coverage.test.ts` fails when a command has no place, names a method the window
never calls, or is marked not-for-desktop without being on the short list.
- **Game Center and Play Games Services.** A `games` config section (switches per store and one shared list of
leaderboards and achievements). `storebound apple game-center` switches Game Center on for the app and the version
and upserts leaderboards, achievements and their localizations by vendor id; `apple signing ensure` adds the
GAME_CENTER capability. `storebound play games` upserts the Play drafts through the Games Configuration API, and
`console plan play-games-services` walks an agent through the games project, the Android credential with the app
signing SHA-1, the web OAuth client and the owner's Publish. `scan`, `check` and `validate` compare the entitlement,
the App ID and the config, the games_APP_ID resource with the Gradle dependency and the config, and remind the User
ID declarations; `games status`, four MCP tools and a Game services card in the desktop's App info tab. Skill
game-services, pitfall 88.
## 0.8.21 · 2026-10-20
- **`shots record --source web` films at device pixels.** Chromium's screencast always sent frames at the page's CSS
size (430x932 for a 430x932 @2x frame), so a 1080x1920 preview was a 2.5x upscale. The recorder now takes
`Page.captureScreenshot` frames with the clip scaled to the frame's dpr (860x1864 there; long side capped at 1920,
the tallest store video), keeps 3 requests in flight (~45-55 frames/s) and joins them at their real timings as
before. It warns when the first frame is smaller than asked or the rate drops under 20 frames/s.
## 0.8.20 · 2026-10-20
- **Tooling: the desktop updater signatures of the Linux and Windows installers are made on the VDS.** The release
no longer downloads them to the Mac: the VDS fetches them from the Forgejo release, checks their SHA-256 against the
candidate, signs them with its copy of the updater key and sends back only the .sig texts, which the Mac verifies
against the app's public key before publishing. `desktop-release.ts vds-key` installs or rotates that key;
`release.ts --sign-local` keeps the old path.
## 0.8.19 · 2026-10-20
- **`shots capture` / `render` find Playwright's chromium in a project's CommonJS playwright** whether it comes on the
namespace or under `default`; when the module has no `chromium.launch`, the error names the resolved file and its
exports (was "Cannot read properties of undefined (reading 'launch')").
- **Heavy app-style headlines again:** `shots fonts` downloads the weights the templates use (the full `wght` axis of a
variable Google family, else the used range, else the static weights), so a family like Plus Jakarta Sans no longer
falls back to its thin 400 instance. `shots fonts` warns about weights a family or a local file (`{ weight }`) lacks;
`shots check` warns per image when a title was drawn at another weight (`font-weight`).
- **palette.bg as a string:** one colour (flat) or a CSS gradient string now renders as given instead of a white
background; anything else fails config validation ("palette.bg must be a colour, a gradient or an array of 2-3
colours"). `shots check` warns when the title ink has low contrast against the pixels behind it (`contrast`).
- **Photo backgrounds:** `palette.image` (+ `overlay`) and `shots.feature.background` put a cover-cropped photo under a
dark overlay (the Play feature graphic, optionally the screenshots); `shots check` checks the file and its size.
## 0.8.18 · 2026-10-20
- **No more "Reparsing as ES module" warning** for a `.ts` config in a CommonJS project (Node's
MODULE_TYPELESS_PACKAGE_JSON); the app does not need `"type": "module"`.
- **Capture sign-in keeps App Review's account apart:** the docs use `captureAuth` for a local demo sign-in, and
`check` notes a capture against localhost that uses `reviewDemoAccount` (the account `apple review-details` sends).
## 0.8.17 · 2026-10-20
- `shots capture --source web` can sign in first: `shots.capture.auth` is a sign-in request (run once per browser
context, cookies kept; body and headers use `${cred:NAME}` from `.storebound/credentials.json` or `${env:NAME}`,
literal secrets are refused, a failed status stops with the status only) or a `storageState` file. `storebound shots
login` opens a fresh visible browser for a demo sign-in by hand and saves `.storebound/auth.json` (0600, counts
only). A shot that lands on a sign-in page warns "session expired? run storebound shots login again". Secrets and
cookie values are masked in logs and the capture report.
## 0.8.16 · 2026-10-20
- `apps folders add [<folder>]` adds the folder (it listed and added nothing); a template config is added with a note,
a folder without a config is refused with the reason and the fix (pitfall 81).
- MCP: the plugin's `.mcp.json` no longer spawns a plugin path that may be gone (ENOENT, CONNECTION_CLOSED): it falls
back to `storebound` on PATH, then `npx -y storebound@latest`; `mcp` and `serve` skip the self-update (pitfall 82).
- Store build minimums in one dated table (`cli/lib/store-minimums.ts`): `play upload` / `play release` refuse an AAB
below Play's target API level (36 since 2026-08-31) and `apple upload` an IPA built below Xcode 26 / the 26 SDKs
(since 2026-04-28), with the file to change; `scan` / `validate` report the same (`play-target-sdk`, `apple-min-sdk`)
(pitfall 83).
## 0.8.15 · 2026-10-19
- **Mac App Store screenshots.** `apple.mac: true` adds the `mac` size (2880 x 1800; `shots check` takes exactly
Apple's four 16:10 sizes, no alpha) and `apple screenshots --platform macos` uploads it as `APP_DESKTOP` to the macOS
version. `validate` notes a macOS version without Mac screenshots. Art pull maps `APP_DESKTOP` to `mac/`.
- **Landscape layout** in both templates: copy column plus the frame in a Mac window or bare (`shots.landscape`), for
Mac, Play Chromebook (`play.chromebook`; no API type, `play images` links the console) and, with
`shots.landscape.playTablets`, Play's 7" / 10" tablet slots.
- **Safe margins:** text and logo keep 7 % of the width left / right and 5 % of the height at the top in every
template (feature graphics too); `shots check` fails boxes under 6 % / 4 %, measured from the render's layout data.
- **Contact sheet:** the `sheet.png` wait scales with the images, sizes and machine load, with a heartbeat, instead of
a flat 30 s.
- iPhone Duo skip message says plainly that the API has no Duo type and names the console page.
## 0.8.14 · 2026-10-19
- **Header and Search Results: upload, add for review, place once approved.** `storebound apple creative-assets`
uploads each file once into the Asset Library (MD5 upsert: a rejected file or one that failed processing twice is
not uploaded again, an archived copy is unarchived), and places it on the version as soon as Apple approved it: the
version page's slots offer only approved assets. Until then every locale and placement says "uploaded <date>,
waiting for Apple's approval; run `storebound apple creative-assets` again later", and a re-run places it (an
approved old asset stays shown meanwhile). `--add-for-review` adds the unreviewed assets to the app's draft review
submission (never submits; Submit for Review stays the owner's); `--verify` (MCP `apple_creative_assets_verify`)
reads every slot back read-only with the same waiting state, and `validate` / the report list waiting files. A live
app without an editable version uses the live version; if App Store Connect refuses a placement there, the run says
so, leaves the live page unchanged (the new placement goes first, the old one is removed only after Apple accepted
it), links the live version page and does not retry the other live slots. Art without words needs no copy per
locale: `<renders>/creative/header.png` / `search.*` fill every language without its own file, uploaded once and
placed per locale (the dry run counts files, not slots). Pitfall 76 rewritten.
- **iPhone Duo.** Apple's published App Store Connect API spec (re-checked: still the 2026-10-06 file) has no Duo
screenshot or preview type, so `apple screenshots` keeps skipping the Duo sets with the console link; the check /
validate note now says the console takes Duo and the API does not yet.
- **macOS (tvOS, visionOS) versions are no longer invisible.** `apple status`, `listing`, `listing pull`, `resubmit`,
`submit`, `release` and `screenshots` take `--platform ios|macos|tvos|visionos` (MCP tools and the desktop's owner
actions: `platform`); without it they use the platform that has the version they need (a rejected one, an
UNRESOLVED_ISSUES submission, an editable one) and refuse, naming both, when several platforms do. Every version
and review submission lookup is per platform, so `resubmit` finds the macOS submission back from App Review and
`listing --version 0.8.7` works on the rejected macOS 0.8.7 while the iOS 0.8.7 is live. `apple status`, `validate`,
`release status`, the report and the desktop label every version with its platform; validate and release status warn
about another platform's editable or rejected version. `whats-new apply --platform` names it in the push command.
Pitfall 79.
- **Other platforms in store text are refused (both stores).** One checker (`cli/lib/cross-store.ts`) finds Android,
Google Play (Games), Play Store, Galaxy Store, Amazon Appstore, Steam, Microsoft Store, AppGallery (and localized
forms) in App Store text (Guideline 2.3.10) and iOS, iPhone, iPad, App Store, Apple Arcade, Game Center, TestFlight in
Play text (Metadata policy), per locale, field and line with a fix. `apple listing`, `play listing`, `play release` /
`play upload` release notes, `listing set`, `whats-new check` / `apply`, `storebound edit` and the desktop editor
refuse before anything is sent (dry runs show the hits); `check` and `validate` report blockers; `scan` reports
`other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). A functional mention stays only through the
owner's `listing.allowPlatformMentions: [{ store, text, reason }]`.
- **Per-store release notes.** Lines tagged `[apple]` / `[play]` (or a `[play]` line heading a block) in a shared notes
source go only into that store's notes; the draft leaves untagged other-platform lines out of the wrong store and
says so; each store's limit counts its own notes.
- **`storebound api` writes piped or redirected are refused loudly.** `--yes` with stdout not a TTY now exits 1 with
"refused: writes through `storebound api` need an interactive terminal ... Nothing was sent." on stderr instead of
a silent dry run.
## 0.8.13 · 2026-10-18
- **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853)
sets, portrait or landscape, to `shots render`, `shots check` (exact sizes, no alpha) and the gallery. App Store
Connect API 4.5.1 has no Duo `ScreenshotDisplayType`, so `apple screenshots` skips them with a message and the deep
link to the version page's App Previews and Screenshots tab (one constant, `IPHONE_DUO_DISPLAY`, takes the enum
names once Apple adds them). `check` / `validate` note a missing Duo set: optional now, required from April 2027 for
iOS 27.1 SDK builds (a warning after that date, never a blocker), and point Play to the tablet sets (no foldable slot).
- **Required iPhone class follows the console.** `validate` now requires "iPhone with Dynamic Island (medium display)"
(`APP_IPHONE_61`) per locale, met by a 6.9" or 6.5" set that App Store Connect scales down (an ok row, not a
warning). Labels match the console: "iPhone with Dynamic Island (medium display)", "iPhone Duo", "iPad 13" display".
- **App Store Header and Search Results.** `storebound apple creative-assets [--dry]` puts a product page header
(3840 x 1646, 21:9) and a search results asset (3:2, 1920 x 1280 to 3840 x 2560), image or video (5-30 s, 30/60
fps), per locale from `<renders>/<lang>/creative/`, or one universal 5244 x 2950 PNG, into the App Asset Library and
selects them (`PRODUCT_PAGE_HEADER_ASSET`, `APP_STORE_SEARCH_RESULTS_ASSET`) once Apple approved them; MD5 upsert,
read back. Local checks: size, no alpha, video length / fps / container (ffprobe, else skipped with a note).
`check` and `validate` note when there is none (Play: the feature graphic and promo video are the counterparts).
MCP `apple_creative_assets`; config `apple.creativeAssets`; pitfall 76.
## 0.8.12 · 2026-10-17
- **Shareable readiness report.** `storebound report` puts `scan`, `check` and `validate` into one self-contained HTML
page (`.storebound/reports/readiness-<date>.html`, inline CSS, no scripts, light / dark, print-friendly) and opens it
in the browser: a verdict per store ("Ready to submit", "N blockers", "Not checked" with the reason when a store key
is missing), findings per store by severity with guideline, evidence, fix and an Open in App Store Connect / Play
Console button, and the questions only the owner can answer as a checklist. Credentials, key ids, emails and the
review demo account are masked. `--md`, `--json`, `--no-open`, `--offline`, `--out`. The desktop's Checks tab has a
Report button (same code path) and its Markdown export uses the same renderer; MCP gets a read-only `report` tool.
- **Remote config and remote paywalls (App Review 2.3.1, Play Deceptive behavior).** `storebound scan` (and
`validate`; `check` as a warning) reports `remote-features` for both stores when the app ships a remote config /
feature flag SDK (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely, PostHog feature flags,
Unleash, ConfigCat, Amplitude Experiment, Split) or a remote paywall (RevenueCat Paywalls, Superwall, Adapty,
Qonversion, Purchasely, Apphud) and the review notes do not mention it. Warn only: storebound never writes the note;
the config templates carry a commented example line. Code obfuscators are a low note, minify alone nothing
(store-pitfalls 74).
## 0.8.11 · 2026-10-17
- **Guest-play apps rejected as a login wall (Play).** `reviewAccess.instructions` with `signInRequired: false` holds
guest steps: `storebound check` warns when a Play app has a first-run gate (name entry, code / transfer entry) or
in-app purchases and no steps, and the `play-sign-in-details` plan then declares one entry without credentials,
the dialog's required full-access box as the owner's step (store-pitfalls 73).
## 0.8.10 · 2026-10-15
- **Neutral names only.** Docs, pitfalls, tests and comments use example names instead of the projects that reported
issues; the history was rewritten the same way. Older npm versions are withdrawn.
- **Releases:** a candidate whose build jobs were cancelled by a newer push says so and gives the tag move and
`--resume` line.
## 0.8.9 · 2026-10-15
- **Play edits Play will not send for review are committed anyway.** After a rejection, with other pending changes or
managed publishing, Play answers validate / commit with 400 "Changes cannot be sent for review automatically. Please
set the query parameter changesNotSentForReview to true". storebound now commits the same edit again (edits.commit only: validate refuses the parameter with 400 "Unknown name")
with that parameter (a dry run validates that way and says the real run will commit so), then prints "committed;
Play did not send it for review: press Send changes for review in Publishing overview: <link>" (the owner's button).
`--not-sent-for-review` on `play release`, `upload`, `rollout`, `listing` and `images` (MCP / desktop
`not_sent_for_review`) sets it from the start. The API cannot read that changes wait to be sent, so storebound records
its commit in `.storebound/` and `play status` shows "changes committed <time>, waiting for Send changes for review"
(`--review-sent` forgets it). Pitfall 72.
## 0.8.8 · 2026-10-15
- **Console pages as absolute deep links.** Every finding, fix hint, error and plan step that points to an App Store
Connect or Play Console page now prints the readable path plus the absolute URL of that page for the app
(`cli/lib/console-links.ts`; apple.appId, play.developerId and play.appId pick the app, the app list otherwise).
validate, scan, declarations and release plan JSON carry `url`, and the desktop shows it as an Open button. A test
fails on a plain console direction without a URL; AGENTS.md rule 11 and pitfall 71 tell agents the same.
## 0.8.7 · 2026-10-15
- **First in-app purchases left out of a review are caught.** Apple reviews the first in-app purchase of a type (and
the first subscription of a group) only together with an app version. `validate` and `release plan` now block when
one that never passed review is not in the open review submission, and `apple submit` refuses to leave one out;
`apple status`, `release status` and the desktop warn when a version waits for or is in review without it ("not
sellable after approval; add it with the next version, or withdraw the submission and resubmit with it").
`apple iap-review` says "ready, not submitted yet" instead of calling READY_TO_SUBMIT complete.
## 0.8.6 · 2026-10-14
- **Desktop: Fix with AI** on scan findings that need a code change (NSAllowsArbitraryLoads in Info.plist, a missing
foregroundServiceType, purpose strings, target SDK, ...). The app runs your local Claude Code headless in the app
folder with file tools only (no commands, no network, no git push), streams its progress, stops after 10 minutes or
on Cancel, and shows the diff with Keep and Undo (exactly the files it changed), then rescans. It refuses while the
folder has uncommitted files. Without Claude Code installed the button copies the prompt instead (Copy for AI).
- **Desktop: a Fix button on findings** that storebound can fix itself. Scan and validate findings carry a
machine-readable `action` (`--json`); the Checks tab runs it as a write with its dry run in the native dialog
before anything changes. privacy-labels writes the detected answers into the config (`scan --write-only`, the
dialog shows the config diff); missing Play listings, a missing App Store localization or copyright and the App
Review contact push the listing files / config to the store (text only). Nothing submits, releases or publishes.
- **Desktop:** long findings (a file path in a build folder) wrap inside the Store readiness card instead of pushing
the store chips out of it; the release timeline's rail no longer breaks after the current step; the rollout bar
stays under its stage.
- **`scan`: function words of the listing languages** (German "eine", "der", French "les", Turkish "bir", ...) no
longer count as repeated keywords in the Play full description.
- **`import type { ShipConfig } from 'storebound'` type-checks outside Bun.** The package ships declarations
for ES module and CommonJS projects (`dist/types`, `dist/types-cjs`) and a JS entry for Node; Bun keeps the .ts source. 0.8.5 named only
`cli/lib/config.ts`, so tsc (`next build`, vue-tsc, ts-loader, `tsc --noEmit`) failed on a scaffolded
storebound.config.ts and a working Next app stopped building. A test packs the entry and type-checks the template
in both project types under bundler, node16, nodenext and node10 with no @types/node and skipLibCheck off, and loads it with import() and require() under Node. Pitfall 69.
- **`check` fails on the scaffolder's own placeholders**: "2026 Example Studio" and example.com / .org / .net URLs and
addresses in `app`, `accounts` and `reviewAccess` would reach the stores as the app's copyright, contact and legal
pages.
## 0.8.5 · 2026-10-14
Releases take one command and minutes: every push to main is a release candidate (full CI, the acceptance matrix and
the Linux / Windows installers stored per commit), a version tag only promotes it, and `bun tools/release.ts` does
the rest on the Mac.
- **A Chrome storebound starts never outlives its run.** The console window's Chrome is ended on exit, closed
gracefully on Ctrl+C / SIGTERM / SIGHUP, and ended by a watchdog when the run dies in any way (SIGKILL included).
Every start first ends headless storebound Chromes earlier runs left behind (an orphan kept the owner's own Chrome
from opening from the macOS Dock) and names orphaned windows. Headless runs on macOS prefer Chromium / Chrome for
Testing over Google Chrome.app. `console fill` closes the window on an error too. Pitfall 68.
- **Every push to main is a release candidate.** ci.yml runs the full CI, the acceptance matrix (Linux + Windows x
Node 20 / 22 / Bun) and the installer builds in parallel and stores the green result as the draft `rc-<sha>`.
A version-only bump commit (proven file by file) reuses its parent's CI and matrix and rebuilds only the
installers. A `v*` tag promotes the candidate (no rebuild, no retest) and fails at once on a red commit.
- **`bun tools/release.ts <version> --yes`:** bump and CHANGELOG cut, fast gate, commit, push, tag; the Mac build and
notarization in parallel with Forgejo; macOS acceptance cached per CLI key; add, verify, publish (each Mac file
crosses the owner's uplink once); /Applications/storebound.app updated; the npm commands printed. `--rehearsal`
runs it all as a throwaway prerelease and deletes it afterwards.
## 0.8.4 · 2026-10-14
Existing products' prices never change without `--allow-price-changes`; PPP pricing compares like with like and keeps
high-income markets at par. `--json` prints only JSON. Automatic signing creates no profiles, and capabilities are
verified against the App ID, the config and the build. `check` requires a copyright; What's new drafts come from
release notes and flag jargon.
- **`apple signing ensure` with automatic signing creates no profiles or certificates.** With `signing.style:
automatic` (the default) it only adds missing capabilities to the App ID; Xcode owns certificates and profiles, and
the dry run says so. Provisioning profiles storebound made that nothing uses are listed by name with the exact
delete command (never run by storebound).
- **Capability writes are read back.** After adding a capability, `ensure` reads the App ID again (a few tries,
about 45 s at most) and fails with the API's answers when one is missing, instead of reporting success.
- **Capabilities compared in three places.** `apple status`, `apple signing status` / `ensure --dry` and `validate`
compare the App ID, `apple.signing.capabilities` and the iOS project's `.entitlements` (push, Game Center,
associated domains, Sign in with Apple, iCloud, app groups, ...; also an entitlements file not set in
CODE_SIGN_ENTITLEMENTS) and name what is missing where.
- **No false alarm for entitlements on another branch.** A checkout without `.entitlements` is a warning about this
checkout only ("built from another branch?"), naming the current branch and the local branches / worktrees that
have one; it never claims the build lacks them. With a built IPA in build/ios/export, its real entitlements
(codesign, or the Mach-O signature when codesign is missing) are compared as "uploaded build N"; without one the
output says the App Store Connect API does not expose a build's entitlements.
- **`storebound api` writes outside a terminal** say that they need an interactive terminal by design and name the
dedicated command for common operations (bundle id capabilities: `apple signing ensure`, IAPs, listing, ...).
- **`--json` prints only JSON on stdout.** Every command run with `--json` sends its log, step and table lines to
stderr (one place: `withJsonOutput` in cli/lib/log.ts, wrapped around every command); the last commands that printed
JSON with console.log use `printJson`. A test runs every command that reads `--json` against the fake stores and
parses its stdout (`prices ppp --json` printed log lines first).
- **PPP prices compare like with like.** `prices ppp` takes the store's own conversion of the US price (VAT included,
today's rate: Play convertRegionPrices, Apple equalizations; offline base x rate x (1 + VAT)) as par and applies the
PPP price level to it. Defaults: only lower prices (`--only-decreases`), only economies with a price level at most
50 % of the US (`--max-level`), World Bank high-income economies keep par (`--include-high-income` to include
them), mainland China at par on Apple and skipped on Play, never above par (`--max-ratio`, `--min-ratio`; the old
`--floor` / `--ceiling` still work). Germany keeps EUR 5.99 for USD 4.99 (was EUR 3.59). A top-10 revenue market
that drops more than 20 % is warned about (from your sales reports, else a static list). Regions that keep their
price stay out of the sheet (`kept` in `--json`).
- **Creating products never changes a live price.** `play products`, `prices import`, `play subscriptions` and `apple
subscriptions` change prices of existing products only with `--allow-price-changes`, in a terminal too: one "y"
covered them before. With the flag they get their own question listing each old -> new; dry runs show a separate
"price changes on existing products (held)" section.
- **`whats-new` drafts from player-facing notes before the git log.** It takes `whatsNew.source`, then
`docs/RELEASE-<version>.md`, then the CHANGELOG.md section of the version (else Unreleased); version from
`--release`, else package.json. Only without one (or with `--from-git`) it drafts from commit messages, and says so.
- **Internal jargon warnings in release notes.** The draft and `whats-new check` name SDK / vendor names, developer
and version-control words, file paths, issue ids and code identifiers, each with a plain alternative; warnings never
block. One list in cli/lib/jargon.ts; `whatsNew.allowWords` keeps a word. The whats-new skill says to write from what
players feel, never from commit messages.
## 0.8.3 · 2026-10-14
Docs: no private account names in the published package. `npx storebound` prompts work on Node again (the [Y/n]
prompt of `storebound desktop` crashed on Windows).
- **`npx storebound` prompts work on Node.** `storebound desktop` (and every [y/N] or type-the-id prompt) crashed on
Node at the question with "console is not async iterable" (Windows also hit a libuv assertion at exit): prompts now
read stdin through `rt.readLine` (node:readline, closed after one line, EOF is "no"). A test keeps Bun-only stdin
iteration out of cli/, and the Windows release job runs the Node prompt smoke on the runner.
- **Release privacy scan of signed macOS builds leaves out Apple code signatures only.** The Developer ID certificate
in a signature names the developer; the .app, DMG and updater bundle are scanned as copies without signatures, and
any hit elsewhere still fails the release.
## 0.8.2 · 2026-10-14 ## 0.8.2 · 2026-10-14
The desktop app edits everything the consoles do (listings, app info, catalog and prices, TestFlight, store art), with The desktop app edits everything the consoles do (listings, app info, catalog and prices, TestFlight, store art), with
@ -77,7 +805,6 @@ errors are clear messages instead of raw API text.
- **One row per release across the stores.** Versions are matched without trailing .0 parts (App Store "1.0" and Play - **One row per release across the stores.** Versions are matched without trailing .0 parts (App Store "1.0" and Play
"1.0.0" are one row and one timeline); each side still shows the store's own string. "1.0.0" are one row and one timeline); each side still shows the store's own string.
## 0.8.1 · 2026-10-13 ## 0.8.1 · 2026-10-13
`apple build` exports each build into its own folder and fails loudly on any export error; `apple upload` refuses a `apple build` exports each build into its own folder and fails loudly on any export error; `apple upload` refuses a

View file

@ -9,9 +9,22 @@ but fixes, store rule updates and new engines / AI tools are very welcome.
bun install # Bun 1.1+; playwright is a dev dependency (renders, web capture) bun install # Bun 1.1+; playwright is a dev dependency (renders, web capture)
bun run ci:quick # inner loop: typecheck + the tests your change touches + scrub (seconds) bun run ci:quick # inner loop: typecheck + the tests your change touches + scrub (seconds)
bun run ci # full gate before pushing: every check and test (adapters, API tables, typecheck, bun run ci # full gate before pushing: every check and test (adapters, API tables, typecheck,
# desktop build and boot check, Rust host when it changed, scrub) # desktop build, Rust host when it changed, scrub); no browser
bun run test:browser # only by hand: the suites that start Chrome / Chromium (tests/browser/*.browser.ts)
``` ```
**Windows.** `.gitattributes` keeps every text file LF in the working tree (with `core.autocrlf=true` the skills
came out CRLF and `bun tools/gen-adapters.ts` failed with "no frontmatter"). On a checkout made before that file
existed, run `git rm -r --cached -q . && git reset --hard` once on a clean tree. The desktop app builds with
`cd apps/desktop && bun run sidecar && bunx tauri build` (Rust via rustup; WiX and NSIS are downloaded by Tauri) into
`src-tauri/target/release/bundle/nsis/*-setup.exe` and `bundle/msi/*.msi`.
**Browser tests run only by hand.** The suites that launch a browser (console fill / flow / session / persist, web
capture, glyphs and ligatures, local fonts, feature-graphic fit, site overflow) live in `tests/browser/` as
`*.browser.ts`, which `bun test`, `ci`, `ci:quick` and the Forgejo workflows never pick up. Run `bun run test:browser`
when you change that code. The one browser step left in any automatic path is the desktop boot check inside
`tools/desktop-release.ts mac` at release time: the built window must be seen rendering before a build ships.
**Which one when.** Run `bun run ci:quick` while you work: it diffs against `origin/main` (committed, staged, **Which one when.** Run `bun run ci:quick` while you work: it diffs against `origin/main` (committed, staged,
unstaged and new files), maps the changed files to the tests that import them (directly, through other modules, or unstaged and new files), maps the changed files to the tests that import them (directly, through other modules, or
by naming a fixture or page) and runs those, plus `tsc` (incremental) and the scrub. A change to `package.json`, by naming a fixture or page) and runs those, plus `tsc` (incremental) and the scrub. A change to `package.json`,
@ -21,10 +34,10 @@ push and check its exit code: it is the gate, `ci:quick` is not.
`tools/ci.ts` runs the steps as a task graph (independent steps at the same time, output prefixed with the step `tools/ci.ts` runs the steps as a task graph (independent steps at the same time, output prefixed with the step
name, the first failure stops the rest, a time-per-step summary at the end). The tests run as parallel shards name, the first failure stops the rest, a time-per-step summary at the end). The tests run as parallel shards
(CPU cores - 1, at most 8; `STOREBOUND_CI_SHARDS=n` overrides) balanced by the durations of the last run, kept in (CPU cores - 1, at most 8; `STOREBOUND_CI_SHARDS=n` overrides) balanced by the durations of the last run, kept in
`node_modules/.cache/storebound-ci/`; the browser suites get a shard each. So tests must be shard-safe: temp folders `node_modules/.cache/storebound-ci/`. So tests must be shard-safe: temp folders
from `mkdtempSync`, servers on port 0, no fixed sleeps (wait for the event), no state shared through files in the from `mkdtempSync`, servers on port 0, no fixed sleeps (wait for the event), no state shared through files in the
checkout. The desktop Rust host (`cargo test`, `clippy`) runs only when `apps/desktop/src-tauri/` or checkout. The desktop Rust host (`cargo test`, `clippy`) runs only when `apps/desktop/src-tauri/` or
`tools/build-sidecars.ts` changed against `origin/main`; `CI=full bun run ci` always runs it (Forgejo CI does). `tools/build-sidecars.ts` changed against `origin/main` (Forgejo CI: against the pushed commit's parent).
Try the screenshot and video pipeline on the demo game without any store account: see Try the screenshot and video pipeline on the demo game without any store account: see
[examples/demo-game/README.md](examples/demo-game/README.md). [examples/demo-game/README.md](examples/demo-game/README.md).

View file

@ -19,6 +19,16 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes. these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety, 3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that (`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
@ -67,6 +77,9 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`. Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner. Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
`storebound console fill` is the optional scripted fast path; it never blocks you. `storebound console fill` is the optional scripted fast path; it never blocks you.
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
or "App Store Connect > App Information" alone.
## Tools ## Tools
@ -92,6 +105,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal | | `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default | | `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default |
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly | | `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
| `game-services` | Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section |
| `listing-localization` | Translate the store listing of a mobile app or game into more languages | | `listing-localization` | Translate the store listing of a mobile app or game into more languages |
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` | | `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game | | `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |

View file

@ -9,10 +9,9 @@ in-app products and prices, the compliance forms, even the consoles that have no
[![npm](https://img.shields.io/npm/v/storebound?label=npm&color=cb3837)](https://www.npmjs.com/package/storebound) [![npm](https://img.shields.io/npm/v/storebound?label=npm&color=cb3837)](https://www.npmjs.com/package/storebound)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://git.crt.fyi/CRT/storebound/src/branch/main/LICENSE) [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://git.crt.fyi/CRT/storebound/src/branch/main/LICENSE)
[![version 0.6.0](https://img.shields.io/badge/version-0.6.0-informational)](https://git.crt.fyi/CRT/storebound/src/branch/main/CHANGELOG.md)
[![runtime: Node 20+ or Bun 1.1+](https://img.shields.io/badge/runtime-Node%2020%2B%20%7C%20Bun%201.1%2B-f9f1e1)](#install-and-start) [![runtime: Node 20+ or Bun 1.1+](https://img.shields.io/badge/runtime-Node%2020%2B%20%7C%20Bun%201.1%2B-f9f1e1)](#install-and-start)
[![runtime dependencies: 0](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)](https://git.crt.fyi/CRT/storebound/src/branch/main/package.json) [![runtime dependencies: 0](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)](https://git.crt.fyi/CRT/storebound/src/branch/main/package.json)
[![Agent Skills: 25](https://img.shields.io/badge/Agent%20Skills-25-8a63d2)](https://git.crt.fyi/CRT/storebound/src/branch/main/skills) [![Agent Skills: 39](https://img.shields.io/badge/Agent%20Skills-39-8a63d2)](https://git.crt.fyi/CRT/storebound/src/branch/main/skills)
[![MCP: 2026-07-28](https://img.shields.io/badge/MCP-2026--07--28-black)](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/mcp.md) [![MCP: 2026-07-28](https://img.shields.io/badge/MCP-2026--07--28-black)](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/mcp.md)
**Website: [storebound.dev](https://storebound.dev) · Docs: [docs/](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/README.md) · `npx storebound init`** **Website: [storebound.dev](https://storebound.dev) · Docs: [docs/](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/README.md) · `npx storebound init`**

14
apps/desktop/card.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="data:," />
<title>storebound</title>
</head>
<body class="tc-page">
<div id="card"></div>
<script type="module" src="/src/card/main.tsx"></script>
</body>
</html>

View file

@ -1,6 +1,6 @@
{ {
"name": "@storebound/desktop", "name": "@storebound/desktop",
"version": "0.8.2", "version": "0.8.77",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -227,6 +227,20 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf"
dependencies = [
"dirs 6.0.0",
"os_info",
"smappservice-rs",
"thiserror 2.0.21",
"windows-registry",
"windows-result",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@ -855,6 +869,15 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "7.0.0" version = "7.0.0"
@ -2446,6 +2469,18 @@ dependencies = [
"memoffset", "memoffset",
] ]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.18.2" version = "4.18.2"
@ -2724,6 +2759,30 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-service-management"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
dependencies = [
"block2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-security",
]
[[package]] [[package]]
name = "objc2-ui-kit" name = "objc2-ui-kit"
version = "0.3.2" version = "0.3.2"
@ -2818,6 +2877,21 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "os_info"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
dependencies = [
"android_system_properties",
"log",
"nix 0.31.3",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "os_pipe" name = "os_pipe"
version = "1.2.3" version = "1.2.3"
@ -3926,6 +4000,18 @@ version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "smappservice-rs"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-service-management",
"thiserror 2.0.21",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.5" version = "0.6.5"
@ -4010,12 +4096,13 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "storebound-desktop" name = "storebound-desktop"
version = "0.8.2" version = "0.8.77"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"getrandom 0.3.4", "getrandom 0.3.4",
"hmac", "hmac",
"keyring", "keyring",
"libc",
"reqwest", "reqwest",
"rusqlite", "rusqlite",
"rustls", "rustls",
@ -4024,6 +4111,7 @@ dependencies = [
"sha2", "sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-notification", "tauri-plugin-notification",
@ -4036,6 +4124,9 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"url", "url",
"webview2-com",
"windows-core",
"winreg",
"zip", "zip",
] ]
@ -4249,7 +4340,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs", "dirs 7.0.0",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.4", "getrandom 0.3.4",
@ -4299,7 +4390,7 @@ checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs", "dirs 7.0.0",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@ -4369,6 +4460,20 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-autostart"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.6.1" version = "2.6.1"
@ -4531,7 +4636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs 7.0.0",
"flate2", "flate2",
"futures-util", "futures-util",
"http", "http",
@ -5017,7 +5122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 7.0.0",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2", "objc2",
@ -5840,7 +5945,7 @@ dependencies = [
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 7.0.0",
"dom_query", "dom_query",
"dpi", "dpi",
"dunce", "dunce",
@ -5954,7 +6059,7 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"hex", "hex",
"nix", "nix 0.29.0",
"ordered-stream", "ordered-stream",
"rand 0.8.8", "rand 0.8.8",
"serde", "serde",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "storebound-desktop" name = "storebound-desktop"
version = "0.8.2" version = "0.8.77"
description = "storebound desktop: every game's releases on both stores in one window" description = "storebound desktop: every game's releases on both stores in one window"
license = "MIT" license = "MIT"
edition = "2021" edition = "2021"
@ -19,7 +19,7 @@ path = "src/main.rs"
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.12", features = [] } tauri = { version = "2.12", features = ["tray-icon"] }
# Spawns the storebound sidecar from Rust only; no capability grants the plugin's JS API. # Spawns the storebound sidecar from Rust only; no capability grants the plugin's JS API.
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
@ -31,6 +31,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-autostart = "2"
# Store credentials: macOS Keychain, Windows Credential Manager, Secret Service on Linux. # Store credentials: macOS Keychain, Windows Credential Manager, Secret Service on Linux.
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@ -48,6 +49,13 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] } zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] }
# Release builds turn off WebView2's browser context menu and accelerator keys (Back, Refresh, Print, Save as).
[target.'cfg(windows)'.dependencies]
webview2-com = "0.39"
windows-core = "0.62"
# reads Startup apps' StartupApproved value (already in the tree through the autostart plugin)
winreg = "0.56"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
@ -57,3 +65,6 @@ lto = true
opt-level = "s" opt-level = "s"
panic = "abort" panic = "abort"
strip = true strip = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View file

@ -5,7 +5,7 @@ include!("src/commands_list.rs");
fn main() { fn main() {
tauri_build::try_build( tauri_build::try_build(
tauri_build::Attributes::new() tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(COMMANDS)), .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS, MENU_COMMANDS].concat().into_boxed_slice()))),
) )
.expect("tauri build script failed"); .expect("tauri build script failed");
} }

View file

@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "card-window",
"description": "The tray card (a small window next to the tray icon) with the app's own bundled card page only. It may listen to app events (the main window's snapshot) and call the two card commands: read the last snapshot and send a button (hide, open the main window or a route in it, refresh and mute through the main window, open a store console, quit). It never reads the stores or reaches the sidecar; every command re-checks the window label and page origin in Rust.",
"windows": ["card"],
"local": true,
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"allow-card-get",
"allow-card-action"
]
}

View file

@ -35,6 +35,7 @@
"allow-studio-remove", "allow-studio-remove",
"allow-studio-logo", "allow-studio-logo",
"allow-account-update", "allow-account-update",
"allow-account-set-play-developer",
"allow-account-check", "allow-account-check",
"allow-store-apps-refresh", "allow-store-apps-refresh",
"allow-store-apps-cached", "allow-store-apps-cached",
@ -43,6 +44,8 @@
"allow-cache-info", "allow-cache-info",
"allow-data-reveal", "allow-data-reveal",
"allow-notify", "allow-notify",
"allow-tray-update",
"allow-card-snapshot",
"allow-app-reset", "allow-app-reset",
"allow-update-check", "allow-update-check",
"allow-update-install", "allow-update-install",
@ -53,6 +56,9 @@
"allow-project-reveal", "allow-project-reveal",
"allow-project-locate", "allow-project-locate",
"allow-state-reset", "allow-state-reset",
"allow-log-webview" "allow-log-webview",
"allow-ai-fix-info",
"allow-ai-fix-run",
"allow-ai-fix-undo"
] ]
} }

View file

@ -0,0 +1,21 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "menu-window",
"description": "The tray menu panels (menu-1, menu-2...: small frameless windows the app opens on a right click on the tray icon, one per open or pinned submenu) with the app's own bundled tray-menu page only. They may listen to app events (the live menu tree) and call the menu commands: read their panel, report their size, open, pin and close panels, pick a row, which runs the same tray action as the native menu, and start a drag of their own window (a pinned panel's header). They never read the stores or reach the sidecar; every command re-checks the window label and page origin in Rust.",
"windows": ["menu-*"],
"local": true,
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-start-dragging",
"allow-menu-state",
"allow-menu-ready",
"allow-menu-open-sub",
"allow-menu-pin-sub",
"allow-menu-close-subs",
"allow-menu-close",
"allow-menu-dismiss",
"allow-menu-pin",
"allow-menu-select"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Before After
Before After

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-account-set-play-developer"
description = "Enables the account_set_play_developer command without any pre-configured scope."
commands.allow = ["account_set_play_developer"]
[[permission]]
identifier = "deny-account-set-play-developer"
description = "Denies the account_set_play_developer command without any pre-configured scope."
commands.deny = ["account_set_play_developer"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ai-fix-info"
description = "Enables the ai_fix_info command without any pre-configured scope."
commands.allow = ["ai_fix_info"]
[[permission]]
identifier = "deny-ai-fix-info"
description = "Denies the ai_fix_info command without any pre-configured scope."
commands.deny = ["ai_fix_info"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ai-fix-run"
description = "Enables the ai_fix_run command without any pre-configured scope."
commands.allow = ["ai_fix_run"]
[[permission]]
identifier = "deny-ai-fix-run"
description = "Denies the ai_fix_run command without any pre-configured scope."
commands.deny = ["ai_fix_run"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ai-fix-undo"
description = "Enables the ai_fix_undo command without any pre-configured scope."
commands.allow = ["ai_fix_undo"]
[[permission]]
identifier = "deny-ai-fix-undo"
description = "Denies the ai_fix_undo command without any pre-configured scope."
commands.deny = ["ai_fix_undo"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-card-action"
description = "Enables the card_action command without any pre-configured scope."
commands.allow = ["card_action"]
[[permission]]
identifier = "deny-card-action"
description = "Denies the card_action command without any pre-configured scope."
commands.deny = ["card_action"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-card-get"
description = "Enables the card_get command without any pre-configured scope."
commands.allow = ["card_get"]
[[permission]]
identifier = "deny-card-get"
description = "Denies the card_get command without any pre-configured scope."
commands.deny = ["card_get"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-card-snapshot"
description = "Enables the card_snapshot command without any pre-configured scope."
commands.allow = ["card_snapshot"]
[[permission]]
identifier = "deny-card-snapshot"
description = "Denies the card_snapshot command without any pre-configured scope."
commands.deny = ["card_snapshot"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-close"
description = "Enables the menu_close command without any pre-configured scope."
commands.allow = ["menu_close"]
[[permission]]
identifier = "deny-menu-close"
description = "Denies the menu_close command without any pre-configured scope."
commands.deny = ["menu_close"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-close-subs"
description = "Enables the menu_close_subs command without any pre-configured scope."
commands.allow = ["menu_close_subs"]
[[permission]]
identifier = "deny-menu-close-subs"
description = "Denies the menu_close_subs command without any pre-configured scope."
commands.deny = ["menu_close_subs"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-dismiss"
description = "Enables the menu_dismiss command without any pre-configured scope."
commands.allow = ["menu_dismiss"]
[[permission]]
identifier = "deny-menu-dismiss"
description = "Denies the menu_dismiss command without any pre-configured scope."
commands.deny = ["menu_dismiss"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-open-sub"
description = "Enables the menu_open_sub command without any pre-configured scope."
commands.allow = ["menu_open_sub"]
[[permission]]
identifier = "deny-menu-open-sub"
description = "Denies the menu_open_sub command without any pre-configured scope."
commands.deny = ["menu_open_sub"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-pin"
description = "Enables the menu_pin command without any pre-configured scope."
commands.allow = ["menu_pin"]
[[permission]]
identifier = "deny-menu-pin"
description = "Denies the menu_pin command without any pre-configured scope."
commands.deny = ["menu_pin"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-pin-sub"
description = "Enables the menu_pin_sub command without any pre-configured scope."
commands.allow = ["menu_pin_sub"]
[[permission]]
identifier = "deny-menu-pin-sub"
description = "Denies the menu_pin_sub command without any pre-configured scope."
commands.deny = ["menu_pin_sub"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-ready"
description = "Enables the menu_ready command without any pre-configured scope."
commands.allow = ["menu_ready"]
[[permission]]
identifier = "deny-menu-ready"
description = "Denies the menu_ready command without any pre-configured scope."
commands.deny = ["menu_ready"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-select"
description = "Enables the menu_select command without any pre-configured scope."
commands.allow = ["menu_select"]
[[permission]]
identifier = "deny-menu-select"
description = "Denies the menu_select command without any pre-configured scope."
commands.deny = ["menu_select"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-state"
description = "Enables the menu_state command without any pre-configured scope."
commands.allow = ["menu_state"]
[[permission]]
identifier = "deny-menu-state"
description = "Denies the menu_state command without any pre-configured scope."
commands.deny = ["menu_state"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-tray-update"
description = "Enables the tray_update command without any pre-configured scope."
commands.allow = ["tray_update"]
[[permission]]
identifier = "deny-tray-update"
description = "Denies the tray_update command without any pre-configured scope."
commands.deny = ["tray_update"]

View file

@ -31,6 +31,10 @@ pub struct Account {
/// None only for an account of an earlier version whose keychain entry has not moved yet. /// None only for an account of an earlier version whose keychain entry has not moved yet.
#[serde(default)] #[serde(default)]
pub profile: Option<String>, pub profile: Option<String>,
/// Play only: the developer account id (PLAY_DEVELOPER_ID in its credential profile, shared with the CLI), for
/// Play Console deep links. Read from the profile on every list; never a secret.
#[serde(default, rename = "developerId", skip_serializing_if = "Option::is_none")]
pub developer_id: Option<String>,
} }
/// The keychain entry of one account (JSON). /// The keychain entry of one account (JSON).

View file

@ -0,0 +1,471 @@
//! "Fix with AI": one scan finding handed to the owner's local Claude Code CLI, run headless in the app folder.
//!
//! The host spawns exactly two programs here, never anything the webview names: the `claude` binary found on PATH
//! (or its usual install folders) and `git`. claude runs with `-p` and only the file tools (Read, Edit, Write, Grep,
//! Glob); Bash, the web tools, sub-agents and notebooks are disallowed, so it cannot run commands, reach the network
//! or push. The prompt is built here from the finding's fields (the webview cannot pass free text). A run needs the app
//! folder to be clean in git, so everything changed afterwards is the run's own work: the window shows that diff with
//! Keep and Undo, and Undo restores exactly the files this run changed (recorded here, not sent by the webview).
//! Every run has a 10 minute timeout with a heartbeat line every 15 s; a cancel or the app's exit kills the process
//! group by PID.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// The only tools the agent gets.
pub const ALLOWED_TOOLS: &str = "Read,Edit,Write,Grep,Glob";
/// Tools it never gets (commands, network, sub-agents, notebooks), even if a settings file allows them.
pub const DISALLOWED_TOOLS: &str = "Bash,WebFetch,WebSearch,Task,Agent,NotebookEdit,KillShell,BashOutput";
pub const TIMEOUT: Duration = Duration::from_secs(600);
pub const HEARTBEAT: Duration = Duration::from_secs(15);
/// The finding as the scan reports it (only these fields; lengths checked).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AiFinding {
pub rule: String,
pub guideline: String,
pub store: String,
pub title: String,
#[serde(default)]
pub evidence: Vec<Evidence>,
pub fix: String,
pub skill: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Evidence {
pub file: String,
pub line: u64,
#[serde(default)]
pub text: Option<String>,
}
fn short(s: &str, max: usize) -> String {
let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let t = one.trim();
if t.chars().count() > max {
format!("{}...", t.chars().take(max).collect::<String>())
} else {
t.to_string()
}
}
fn ident(s: &str) -> bool {
!s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// A path inside the app folder: relative, no `..`, no option-looking start.
pub fn safe_rel(p: &str) -> bool {
!p.is_empty()
&& p.len() <= 1000
&& !p.starts_with('-')
&& !p.starts_with('/')
&& !p.starts_with('\\')
&& !p.contains(':')
&& !p.chars().any(|c| c.is_control())
&& !p.split(['/', '\\']).any(|seg| seg == "..")
}
pub fn check_finding(f: &AiFinding) -> Result<(), String> {
if !ident(&f.rule) || !ident(&f.skill) {
return Err("bad rule or skill".into());
}
if f.store != "apple" && f.store != "play" {
return Err("bad store".into());
}
if f.title.is_empty() || f.title.len() > 2000 || f.fix.len() > 4000 || f.guideline.len() > 200 || f.evidence.len() > 20 {
return Err("finding too long".into());
}
Ok(())
}
/// The prompt for one finding. The same text is what "Copy for AI" copies.
pub fn build_prompt(f: &AiFinding) -> Result<String, String> {
check_finding(f)?;
let store = if f.store == "apple" { "App Store" } else { "Google Play" };
let mut ev = String::new();
for e in f.evidence.iter().filter(|e| safe_rel(&e.file)).take(5) {
ev.push_str(&format!("- {}:{}", e.file, e.line));
if let Some(t) = e.text.as_deref().filter(|t| !t.trim().is_empty()) {
ev.push_str(&format!(" ({})", short(t, 200)));
}
ev.push('\n');
}
if ev.is_empty() {
ev.push_str("- (no file named: find where it lives first)\n");
}
Ok(format!(
"Fix one {store} rejection risk that `storebound scan` found in this app folder.\n\n\
Finding ({rule}, {guideline}): {title}\n\
Evidence:\n{ev}\
Suggested fix: {fix}\n\
Background: the storebound skill \"{skill}\" covers this topic.\n\n\
Rules:\n\
- Change only what this finding needs, in as few files and lines as possible. No refactoring, reformatting or unrelated edits.\n\
- Only read and edit files in this folder. Do not run commands, install packages, use the network, commit or push.\n\
- Never invent an answer only the owner can give (export compliance, Data safety, App Privacy, age rating, a purpose the app does not have). If the fix needs one, change nothing and say what the owner must decide.\n\
- Keep the project's style (indentation, quoting, file format).\n\
- When done, say in one to three lines what you changed and why.\n",
rule = f.rule,
guideline = short(&f.guideline, 200),
title = short(&f.title, 600),
fix = short(&f.fix, 1500),
skill = f.skill,
))
}
// ---- finding claude ----
/// Folders a GUI app's PATH usually lacks but a terminal install of Claude Code uses.
fn extra_dirs(home: Option<&Path>) -> Vec<PathBuf> {
let mut v = Vec::new();
if let Some(h) = home {
v.push(h.join(".claude/local"));
v.push(h.join(".local/bin"));
v.push(h.join(".npm-global/bin"));
v.push(h.join(".bun/bin"));
v.push(h.join(".volta/bin"));
}
v.push(PathBuf::from("/opt/homebrew/bin"));
v.push(PathBuf::from("/usr/local/bin"));
v
}
/// The search PATH: the process PATH plus the usual install folders (node for an npm install lives there too).
pub fn search_path(path_env: Option<&str>, home: Option<&Path>) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = path_env.map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
for d in extra_dirs(home) {
if !v.contains(&d) {
v.push(d);
}
}
v
}
pub fn find_claude_in(dirs: &[PathBuf]) -> Option<PathBuf> {
let names: &[&str] = if cfg!(windows) { &["claude.exe", "claude.cmd"] } else { &["claude"] };
dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file())
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
}
pub fn find_claude() -> Option<PathBuf> {
find_claude_in(&search_path(std::env::var("PATH").ok().as_deref(), home().as_deref()))
}
/// The arguments claude runs with (the prompt is the only variable part).
pub fn claude_args(prompt: &str) -> Vec<String> {
[
"-p", prompt, "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", ALLOWED_TOOLS,
"--disallowedTools", DISALLOWED_TOOLS,
]
.iter()
.map(|s| s.to_string())
.collect()
}
/// The claude process for one prompt in the app folder: only a binary named claude, only `claude_args`.
pub fn claude_command(claude: &Path, dir: &Path, prompt: &str) -> Result<Command, String> {
let name = claude.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if !matches!(name, "claude" | "claude.exe" | "claude.cmd") || !claude.is_absolute() {
return Err("not the claude binary".into());
}
let mut cmd = Command::new(claude);
cmd.current_dir(dir).args(claude_args(prompt));
if let Ok(p) = std::env::join_paths(search_path(std::env::var("PATH").ok().as_deref(), home().as_deref())) {
cmd.env("PATH", p);
}
Ok(cmd)
}
// ---- git ----
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Change {
/// Path relative to the repository root.
pub path: String,
/// Porcelain status: "M", "D", "A" or "??" (new, untracked).
pub status: String,
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git").current_dir(dir).args(args).stdin(Stdio::null()).output().map_err(|e| format!("git: {e}"))?;
if !out.status.success() {
return Err(format!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim()));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
/// `git status --porcelain -z` of the app folder (renames reported as their new path).
pub fn parse_status(z: &str) -> Vec<Change> {
let mut v = Vec::new();
let mut parts = z.split('\0').filter(|s| !s.is_empty());
while let Some(e) = parts.next() {
if e.len() < 4 {
continue;
}
let (xy, path) = (&e[..2], &e[3..]);
if xy.starts_with('R') || xy.starts_with('C') {
parts.next(); // the old path
}
let status = if xy == "??" { "??".to_string() } else { xy.trim().chars().next().map(String::from).unwrap_or_default() };
v.push(Change { path: path.to_string(), status });
}
v
}
/// Repository root and the app folder's dirty files. Err when the folder is not in a git repository.
pub fn status(dir: &Path) -> Result<(PathBuf, Vec<Change>), String> {
let top = git(dir, &["rev-parse", "--show-toplevel"]).map_err(|_| "the app folder is not in a git repository: Undo needs git".to_string())?;
let top = PathBuf::from(top.trim());
let z = git(dir, &["status", "--porcelain", "-z", "--untracked-files=all", "--", "."])?;
Ok((top, parse_status(&z)))
}
/// What Undo does with the changes of one run: tracked files are checked out again, files the run created are removed.
/// Only paths of that run, only relative ones inside the repository.
pub fn undo_plan(run: &[Change]) -> (Vec<String>, Vec<String>) {
let mut restore = Vec::new();
let mut remove = Vec::new();
for c in run.iter().filter(|c| safe_rel(&c.path)) {
if c.status == "??" || c.status == "A" {
remove.push(c.path.clone());
} else {
restore.push(c.path.clone());
}
}
(restore, remove)
}
pub fn undo(top: &Path, run: &[Change]) -> Result<Vec<String>, String> {
let (restore, remove) = undo_plan(run);
if !restore.is_empty() {
let mut a: Vec<&str> = vec!["checkout", "HEAD", "--"];
a.extend(restore.iter().map(String::as_str));
git(top, &a)?;
}
for p in &remove {
let abs = top.join(p);
if abs.starts_with(top) && abs.is_file() {
std::fs::remove_file(&abs).map_err(|e| format!("{p}: {e}"))?;
}
}
Ok(restore.into_iter().chain(remove).collect())
}
/// The diff of a run's changes (new files shown whole), at most 200 kB.
pub fn diff(top: &Path, run: &[Change]) -> String {
let tracked: Vec<&str> = run.iter().filter(|c| c.status != "??" && safe_rel(&c.path)).map(|c| c.path.as_str()).collect();
let mut out = String::new();
if !tracked.is_empty() {
let mut a = vec!["diff", "--no-color", "HEAD", "--"];
a.extend(tracked);
out.push_str(&git(top, &a).unwrap_or_default());
}
for c in run.iter().filter(|c| c.status == "??" && safe_rel(&c.path)) {
let text = std::fs::read_to_string(top.join(&c.path)).unwrap_or_default();
out.push_str(&format!("--- /dev/null\n+++ b/{}\n{}\n", c.path, text.lines().map(|l| format!("+{l}")).collect::<Vec<_>>().join("\n")));
}
if out.len() > 200_000 {
out.truncate(out.char_indices().take_while(|(i, _)| *i < 200_000).last().map(|(i, _)| i).unwrap_or(0));
out.push_str("\n... (cut)");
}
out
}
// ---- running with a timeout, a heartbeat and kill by PID ----
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Outcome {
pub code: Option<i32>,
pub timed_out: bool,
pub cancelled: bool,
/// The agent's final words (stream-json "result"), else the last output lines.
pub summary: String,
}
/// Running agents by job: PID and the cancel flag.
type Running = Mutex<HashMap<String, (u32, Arc<AtomicBool>)>>;
static RUNNING: OnceLock<Running> = OnceLock::new();
fn running() -> &'static Running {
RUNNING.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Kills the whole process group (claude starts helpers) of a PID.
pub fn kill_tree(pid: u32) {
#[cfg(unix)]
{
// the agent runs in its own process group (pgid = its pid); never signal any other group, least of all ours
// (a CI run once died of SIGTERM in the middle of these tests)
let pgid = pid as libc::pid_t;
// SAFETY: getpgrp has no preconditions; killpg only sends a signal to the group named
let own = unsafe { libc::getpgrp() };
if pgid <= 1 || pgid == own {
return;
}
unsafe { libc::killpg(pgid, libc::SIGTERM) };
std::thread::sleep(Duration::from_millis(300));
unsafe { libc::killpg(pgid, libc::SIGKILL) };
}
#[cfg(windows)]
{
let _ = Command::new("taskkill").args(["/T", "/F", "/PID", &pid.to_string()]).status();
}
}
/// Cancels the run of this job; true when one was running.
pub fn cancel(job: &str) -> bool {
let hit = running().lock().ok().and_then(|m| m.get(job).cloned());
if let Some((pid, flag)) = hit {
flag.store(true, Ordering::SeqCst);
kill_tree(pid);
true
} else {
false
}
}
/// App exit: no agent outlives the window.
pub fn kill_all() {
let all: Vec<(u32, Arc<AtomicBool>)> = running().lock().map(|m| m.values().cloned().collect()).unwrap_or_default();
for (pid, flag) in all {
flag.store(true, Ordering::SeqCst);
kill_tree(pid);
}
}
/// One progress line of claude's stream-json output (None: nothing worth showing).
pub fn progress_line(line: &str) -> Option<String> {
let v: Value = serde_json::from_str(line).ok()?;
match v["type"].as_str()? {
"assistant" => {
let mut out = Vec::new();
for c in v["message"]["content"].as_array()? {
match c["type"].as_str() {
Some("text") => out.push(short(c["text"].as_str().unwrap_or_default().lines().next().unwrap_or_default(), 200)),
Some("tool_use") => {
let i = &c["input"];
let what = i["file_path"].as_str().or(i["pattern"].as_str()).or(i["path"].as_str()).unwrap_or_default();
out.push(short(&format!("{} {}", c["name"].as_str().unwrap_or("tool"), what), 200));
}
_ => {}
}
}
let s = out.into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
(!s.is_empty()).then_some(s)
}
"result" => Some(short(v["result"].as_str().unwrap_or("done"), 400)),
_ => None,
}
}
/// Runs `cmd` in its own process group, calls `on_line` with each progress line (and a heartbeat while it is quiet),
/// kills it on timeout or when `job` is cancelled.
pub fn run(mut cmd: Command, job: &str, timeout: Duration, heartbeat: Duration, mut on_line: impl FnMut(&str)) -> Result<Outcome, String> {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| format!("could not start: {e}"))?;
let pid = child.id();
let flag = Arc::new(AtomicBool::new(false));
if let Ok(mut m) = running().lock() {
m.insert(job.to_string(), (pid, flag.clone()));
}
let (tx, rx) = mpsc::channel::<String>();
let out = child.stdout.take();
let tx2 = tx.clone();
let reader = std::thread::spawn(move || {
if let Some(o) = out {
for l in BufReader::new(o).lines().map_while(Result::ok) {
let _ = tx2.send(l);
}
}
});
let err = child.stderr.take();
let err_reader = std::thread::spawn(move || {
let mut s = String::new();
if let Some(mut e) = err {
let _ = e.read_to_string(&mut s);
}
s
});
drop(tx);
let start = Instant::now();
let mut last = Instant::now();
let mut summary = String::new();
let mut tail: Vec<String> = Vec::new();
let mut timed_out = false;
let code = loop {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(l) => {
last = Instant::now();
if let Some(p) = progress_line(&l) {
if l.contains("\"type\":\"result\"") {
summary = p.clone();
}
on_line(&p);
} else if !l.trim_start().starts_with('{') && !l.trim().is_empty() {
on_line(&short(&l, 300));
tail.push(short(&l, 300));
}
}
Err(mpsc::RecvTimeoutError::Timeout) | Err(mpsc::RecvTimeoutError::Disconnected) => {}
}
if flag.load(Ordering::SeqCst) {
kill_tree(pid);
}
if start.elapsed() > timeout && !timed_out {
timed_out = true;
on_line(&format!("timed out after {} s: stopping it", timeout.as_secs()));
kill_tree(pid);
let _ = child.kill();
}
if last.elapsed() >= heartbeat {
last = Instant::now();
on_line(&format!("still working ({} s)", start.elapsed().as_secs()));
}
if let Ok(Some(st)) = child.try_wait() {
// drain what is left
while let Ok(l) = rx.recv_timeout(Duration::from_millis(50)) {
if let Some(p) = progress_line(&l) {
if l.contains("\"type\":\"result\"") {
summary = p.clone();
}
on_line(&p);
}
}
break st.code();
}
};
let _ = reader.join();
let errs = err_reader.join().unwrap_or_default();
if let Ok(mut m) = running().lock() {
m.remove(job);
}
if summary.is_empty() {
tail.extend(errs.lines().map(|l| short(l, 300)).filter(|l| !l.is_empty()));
summary = tail.iter().rev().take(4).rev().cloned().collect::<Vec<_>>().join("\n");
}
Ok(Outcome { code, timed_out, cancelled: flag.load(Ordering::SeqCst), summary })
}
// ---- runs recorded for Undo ----
/// Finished runs by id: repository root and the files the run changed (what Undo may restore).
pub type Runs = Mutex<HashMap<String, (PathBuf, Vec<Change>)>>;
static RUNS: OnceLock<Runs> = OnceLock::new();
pub fn runs() -> &'static Runs {
RUNS.get_or_init(|| Mutex::new(HashMap::new()))
}

View file

@ -0,0 +1,188 @@
//! The tray card: a small frameless, always-on-top window that a left click on the tray icon shows next to the icon.
//!
//! It is created hidden at start (`create`) and only moved and shown on a click (`toggle`), so it opens instantly. The
//! card never reads the stores: the main window pushes a snapshot of what it already knows (`card_snapshot`, kept here
//! and forwarded as `card-snapshot`), and the card's buttons come back through `card_action`, which reuses the tray
//! menu's paths (`tray-action` read / mute-toggle, `tray-route`), so Refresh is the main window's own Refresh all.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde_json::Value;
pub const LABEL: &str = "card";
/// Logical size of the card.
pub const WIDTH: f64 = 360.0;
pub const HEIGHT: f64 = 480.0;
/// Gap between the card and the tray icon / screen edge (logical px).
pub const GAP: f64 = 8.0;
/// A snapshot larger than this is refused (the card shows a handful of rows).
pub const MAX_SNAPSHOT: usize = 64 * 1024;
/// A rectangle in physical pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
impl Rect {
fn cx(&self) -> f64 { self.x + self.w / 2.0 }
fn cy(&self) -> f64 { self.y + self.h / 2.0 }
}
fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
if hi < lo { lo } else { v.clamp(lo, hi) }
}
/// Top-left corner of the card (physical px) for a tray icon at `tray`, inside `work` (the monitor's work area, without
/// taskbar / menu bar / Dock). The card sits on the side of the icon that faces the screen: above a bottom taskbar,
/// below the macOS menu bar, beside a left or right taskbar; centred on the icon along the bar, and always inside the
/// work area.
pub fn place(tray: Rect, work: Rect, card_w: f64, card_h: f64, gap: f64) -> (f64, f64) {
let (cx, cy) = (tray.cx(), tray.cy());
let (right, bottom) = (work.x + work.w, work.y + work.h);
let (x, y) = if cy >= bottom {
(cx - card_w / 2.0, tray.y - card_h - gap)
} else if cy <= work.y {
(cx - card_w / 2.0, tray.y + tray.h + gap)
} else if cx <= work.x {
(tray.x + tray.w + gap, cy - card_h / 2.0)
} else if cx >= right {
(tray.x - card_w - gap, cy - card_h / 2.0)
} else if cy > work.y + work.h / 2.0 {
// icon inside the work area (auto-hidden taskbar, overflow flyout): open towards the middle
(cx - card_w / 2.0, tray.y - card_h - gap)
} else {
(cx - card_w / 2.0, tray.y + tray.h + gap)
};
(clamp(x, work.x + gap, right - card_w - gap).round(), clamp(y, work.y + gap, bottom - card_h - gap).round())
}
/// Whether a card action word is one the card may send.
pub fn valid_action(a: &str) -> bool {
matches!(a, "hide" | "open" | "route" | "refresh" | "mute" | "quit" | "external" | "ready" | "size")
}
// ---- the last snapshot ----
static SNAPSHOT: Mutex<Option<Value>> = Mutex::new(None);
/// Keeps the main window's snapshot (an object, at most MAX_SNAPSHOT bytes). False when refused.
pub fn set_snapshot(v: Value) -> bool {
if !v.is_object() || v.to_string().len() > MAX_SNAPSHOT {
return false;
}
if let Ok(mut s) = SNAPSHOT.lock() {
*s = Some(v);
}
true
}
pub fn snapshot() -> Option<Value> {
SNAPSHOT.lock().ok().and_then(|s| s.clone())
}
// ---- show / hide ----
static HIDDEN_AT: Mutex<Option<Instant>> = Mutex::new(None);
/// The card lost focus or was closed: remembered so that the click on the tray icon that caused the blur does not
/// open it again at once (a second click closes it).
pub fn note_hidden() {
if let Ok(mut h) = HIDDEN_AT.lock() {
*h = Some(Instant::now());
}
}
fn just_hidden() -> bool {
HIDDEN_AT.lock().ok().and_then(|h| *h).is_some_and(|t| t.elapsed() < Duration::from_millis(300))
}
#[cfg(desktop)]
mod window {
use super::*;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewUrl, WebviewWindowBuilder};
/// The hidden card window, made once at start.
pub fn create<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
let b = WebviewWindowBuilder::new(app, LABEL, WebviewUrl::App("card.html".into()))
.title("storebound")
.inner_size(WIDTH, HEIGHT)
.resizable(false)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(false)
.focused(false)
.shadow(true);
#[cfg(not(target_os = "macos"))]
let b = b.transparent(true);
b.build()?;
Ok(())
}
/// The icon rectangle of the last show, so a size change keeps the card anchored to the icon.
static LAST_ICON: Mutex<Option<Rect>> = Mutex::new(None);
/// Card height in CSS px (what the page measured), clamped; the window keeps WIDTH.
fn height_px(h: f64) -> f64 { h.clamp(120.0, HEIGHT) }
/// Puts the card next to `tray` at its current height: its edge GAP from the icon, inside the work area.
fn anchor<R: Runtime>(app: &AppHandle<R>, w: &tauri::WebviewWindow<R>, tray: Rect, css_h: f64) {
let monitor = app.monitor_from_point(tray.x + tray.w / 2.0, tray.y + tray.h / 2.0).ok().flatten().or_else(|| w.primary_monitor().ok().flatten());
if let Some(m) = monitor {
let s = m.scale_factor();
let wa = m.work_area();
let work = Rect { x: wa.position.x as f64, y: wa.position.y as f64, w: wa.size.width as f64, h: wa.size.height as f64 };
let _ = w.set_size(tauri::PhysicalSize::new((WIDTH * s).round() as u32, (css_h * s).round() as u32));
let (x, y) = place(tray, work, WIDTH * s, css_h * s, GAP * s);
let _ = w.set_position(PhysicalPosition::new(x as i32, y as i32));
}
}
/// The page measured its height: resize the window to it and keep it anchored (the bottom edge stays above a bottom
/// taskbar, no empty band under the content).
pub fn resize<R: Runtime>(app: &AppHandle<R>, css_h: f64) {
let Some(w) = app.get_webview_window(LABEL) else { return };
let h = height_px(css_h);
if let Ok(mut c) = CURRENT_H.lock() { *c = h; }
if let Some(tray) = LAST_ICON.lock().ok().and_then(|t| *t) {
anchor(app, &w, tray, h);
}
}
static CURRENT_H: Mutex<f64> = Mutex::new(HEIGHT);
/// A left click on the tray icon: shows the card next to the icon, or hides it when it is open.
pub fn toggle<R: Runtime>(app: &AppHandle<R>, tray: Rect) {
let Some(w) = app.get_webview_window(LABEL) else { return };
if w.is_visible().unwrap_or(false) {
let _ = w.hide();
note_hidden();
return;
}
if just_hidden() {
return;
}
if let Ok(mut t) = LAST_ICON.lock() { *t = Some(tray); }
let h = CURRENT_H.lock().map(|c| *c).unwrap_or(HEIGHT);
anchor(app, &w, tray, h);
let _ = w.show();
let _ = w.set_focus();
let _ = app.emit_to(LABEL, "card-shown", ());
// the main window refreshes the parts the card shows that it keeps per app (reviews, health from the cache)
let _ = app.emit_to(crate::policy::MAIN, "card-request", ());
}
pub fn hide<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window(LABEL) {
let _ = w.hide();
note_hidden();
}
}
}
#[cfg(desktop)]
pub use window::{create, hide, resize, toggle};

View file

@ -12,6 +12,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore}; use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
use crate::ai_fix;
use crate::profiles::{self, ProfileStore}; use crate::profiles::{self, ProfileStore};
use crate::registry; use crate::registry;
use crate::cache::{Cache, Entry}; use crate::cache::{Cache, Entry};
@ -43,6 +44,16 @@ fn guard(w: &WebviewWindow) -> Res<()> {
policy::require_main(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host) policy::require_main(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
} }
fn guard_card(w: &WebviewWindow) -> Res<()> {
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
}
fn guard_menu(w: &WebviewWindow) -> Res<()> {
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
policy::require_menu(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
}
fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> { fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> {
s.saved.lock().map_err(|_| Fail::host("state poisoned")) s.saved.lock().map_err(|_| Fail::host("state poisoned"))
} }
@ -72,7 +83,8 @@ pub fn sync_machine(s: &AppState) -> Res<()> {
false false
} }
}; };
if report.changed() || folders { let ignored = registry::pull_ignored(&mut st, &s.registry);
if report.changed() || folders || ignored {
persist(s, &st)?; persist(s, &st)?;
} }
Ok(()) Ok(())
@ -256,7 +268,7 @@ pub async fn job_cancel(window: WebviewWindow, state: State<'_, AppState>, job:
if !valid_job(&job) { if !valid_job(&job) {
return Err(Fail::host("bad job id")); return Err(Fail::host("bad job id"));
} }
let mut hit = state.downloads.cancel(&job); let mut hit = state.downloads.cancel(&job) | ai_fix::cancel(&job);
if let Some(r) = state.sidecar.current().await { if let Some(r) = state.sidecar.current().await {
hit |= r.cancel_job(&job) > 0; hit |= r.cancel_job(&job) > 0;
hit |= r.cancel_job(&format!("{job}:p")) > 0; hit |= r.cancel_job(&format!("{job}:p")) > 0;
@ -801,7 +813,11 @@ pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, i
pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> { pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> {
guard(&window)?; guard(&window)?;
sync_machine(&state)?; sync_machine(&state)?;
Ok(saved(&state)?.accounts.clone()) let mut out = saved(&state)?.accounts.clone();
for a in out.iter_mut().filter(|a| a.kind == accounts::AccountKind::Play) {
a.developer_id = a.profile.as_deref().and_then(|p| state.profiles.play_developer_id(p));
}
Ok(out)
} }
fn check_studio(state: &AppState, studio: &Option<String>) -> Res<()> { fn check_studio(state: &AppState, studio: &Option<String>) -> Res<()> {
@ -832,7 +848,7 @@ fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<Str
} }
let id = profiles::account_id(secret.kind(), &profile); let id = profiles::account_id(secret.kind(), &profile);
let id = if st.accounts.iter().any(|a| a.id == id) { store::new_id()? } else { id }; let id = if st.accounts.iter().any(|a| a.id == id) { store::new_id()? } else { id };
let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile) }; let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile), developer_id: None };
st.accounts.push(acc.clone()); st.accounts.push(acc.clone());
persist(state, &st)?; persist(state, &st)?;
Ok(acc) Ok(acc)
@ -1133,6 +1149,23 @@ pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: Str
Ok(out) Ok(out)
} }
/// Sets or clears the Play developer account id of a Play account (PLAY_DEVELOPER_ID in its credential profile, the
/// same field `storebound accounts play-developer` writes): Play Console links then open each app's own page.
#[tauri::command]
pub fn account_set_play_developer(window: WebviewWindow, state: State<'_, AppState>, id: String, developer_id: Option<String>) -> Res<Account> {
guard(&window)?;
let st = saved(&state)?;
let mut a = st.accounts.iter().find(|a| a.id == id).cloned().ok_or_else(|| Fail::host("unknown account"))?;
if a.kind != accounts::AccountKind::Play {
return Err(Fail::host("a Play account only"));
}
let profile = a.profile.clone().ok_or_else(|| Fail::host("this account has no credential profile yet"))?;
let v = developer_id.as_deref().map(str::trim).filter(|v| !v.is_empty());
state.profiles.set_play_developer_id(&profile, v).map_err(Fail::host)?;
a.developer_id = state.profiles.play_developer_id(&profile);
Ok(a)
}
/// Does the account's key work, and can it see apps (sidecar `account.check`). Not cached: it is a live answer. /// Does the account's key work, and can it see apps (sidecar `account.check`). Not cached: it is a live answer.
#[tauri::command] #[tauri::command]
pub async fn account_check(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String, job: Option<String>) -> Res<Value> { pub async fn account_check(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String, job: Option<String>) -> Res<Value> {
@ -1205,6 +1238,10 @@ pub fn overrides_set(window: WebviewWindow, state: State<'_, AppState>, override
let mut st = saved(&state)?; let mut st = saved(&state)?;
st.overrides = overrides.clone(); st.overrides = overrides.clone();
persist(&state, &st)?; persist(&state, &st)?;
// apps the shared list knows carry their ignore flags there (the CLI's watch and portfolio reads honour them)
if let Err(e) = registry::push_ignored(&st, &state.registry) {
crate::logs::host("warn", &format!("ignored apps not written to the app list: {e}"));
}
Ok(overrides) Ok(overrides)
} }
@ -1214,6 +1251,10 @@ pub fn settings_set(window: WebviewWindow, state: State<'_, AppState>, settings:
guard(&window)?; guard(&window)?;
workspace::check_settings(&settings).map_err(Fail::host)?; workspace::check_settings(&settings).map_err(Fail::host)?;
let mut st = saved(&state)?; let mut st = saved(&state)?;
if st.settings.channel != settings.channel {
// a switched channel is checked right away by the background updater (lib.rs auto_update)
crate::updates::kick();
}
st.settings = settings.clone(); st.settings = settings.clone();
persist(&state, &st)?; persist(&state, &st)?;
drop(st); drop(st);
@ -1258,11 +1299,90 @@ pub fn data_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, AppSt
/// A desktop notification (review state changed, a new 1-2 star review, a crash rate over the threshold). The window /// A desktop notification (review state changed, a new 1-2 star review, a crash rate over the threshold). The window
/// decides when, from Settings > Notifications; text is plain and short. /// decides when, from Settings > Notifications; text is plain and short.
#[tauri::command] #[tauri::command]
pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String) -> Res<()> { pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String, target: Option<String>) -> Res<()> {
guard(&window)?; guard(&window)?;
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::<String>(); let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::<String>();
app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string())) app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string()))?;
// desktop notifications have no click callback: remember where this one points; the window goes there when it is next shown
if let Some(t) = target {
crate::tray::set_pending(&t);
}
Ok(())
}
/// The window's summary for the tray (tooltip, attention flag, apps with their state, menu words) after a read.
#[tauri::command]
pub fn tray_update(window: WebviewWindow, app: AppHandle, summary: crate::tray::TraySummary) -> Res<()> {
guard(&window)?;
crate::tray::apply(&app, &summary).map_err(|e| Fail::host(e.to_string()))
}
/// The main window's snapshot for the tray card (what it already read: releases, reviews, health, last read, busy).
/// Kept in the host so the card shows it the moment it opens; forwarded to the card as `card-snapshot`.
#[tauri::command]
pub fn card_snapshot(window: WebviewWindow, app: AppHandle, snapshot: Value) -> Res<()> {
guard(&window)?;
if !crate::card::set_snapshot(snapshot.clone()) {
return Err(Fail::host("card snapshot refused"));
}
let _ = app.emit_to(crate::card::LABEL, "card-snapshot", snapshot);
Ok(())
}
/// The card: the last snapshot of the main window (null before the first one).
#[tauri::command]
pub fn card_get(window: WebviewWindow) -> Res<Option<Value>> {
guard_card(&window)?;
Ok(crate::card::snapshot())
}
/// The card's buttons. `refresh` and `mute` go to the main window as the tray menu's `tray-action` (its own Refresh
/// all and mute), `route` shows the main window on a route (`tray-route`), `external` opens a store console.
#[tauri::command]
pub fn card_action(window: WebviewWindow, app: AppHandle, action: String, arg: Option<String>) -> Res<()> {
guard_card(&window)?;
if !crate::card::valid_action(&action) {
return Err(Fail::host("unknown card action"));
}
let main = || {
if let Some(w) = app.get_webview_window(policy::MAIN) {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
};
match action.as_str() {
"hide" => crate::card::hide(&app),
"ready" => { let _ = app.emit_to(policy::MAIN, "card-request", ()); }
"size" => {
let h: f64 = arg.as_deref().unwrap_or_default().parse().map_err(|_| Fail::host("bad size"))?;
crate::card::resize(&app, h);
}
"open" => { crate::card::hide(&app); main(); }
"refresh" => { let _ = app.emit_to(policy::MAIN, "tray-action", "read"); }
"mute" => { let _ = app.emit_to(policy::MAIN, "tray-action", "mute-toggle"); }
"quit" => app.exit(0),
"route" => {
let r = arg.unwrap_or_default();
if !crate::tray::valid_route(&r) {
return Err(Fail::host("bad route"));
}
crate::card::hide(&app);
main();
let _ = app.emit_to(policy::MAIN, "tray-route", r);
}
"external" => {
let u = url::Url::parse(arg.as_deref().unwrap_or_default()).map_err(|_| Fail::host("not a URL"))?;
if !policy::external_allowed(&u) {
return Err(Fail::host("only the store consoles open from here"));
}
crate::card::hide(&app);
app.opener().open_url(u.as_str(), None::<&str>).map_err(|e| Fail::host(e.to_string()))?;
}
_ => {}
}
Ok(())
} }
/// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders /// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders
@ -1373,7 +1493,7 @@ pub struct UpdateInfo {
pub date: Option<String>, pub date: Option<String>,
} }
async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_updater::Update>> { pub async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_updater::Update>> {
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
let url = url::Url::parse(updates::endpoint(channel)).map_err(|e| Fail::host(e.to_string()))?; let url = url::Url::parse(updates::endpoint(channel)).map_err(|e| Fail::host(e.to_string()))?;
if !updates::endpoint_allowed(&url) { if !updates::endpoint_allowed(&url) {
@ -1384,6 +1504,8 @@ async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_
.endpoints(vec![url]) .endpoints(vec![url])
.map_err(|e| Fail::host(e.to_string()))? .map_err(|e| Fail::host(e.to_string()))?
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
// the no-downgrade rule (updates::should_install), also after a switch from dev back to stable
.version_comparator(|current, remote| updates::should_install(&current.to_string(), &remote.version.to_string()))
.build() .build()
.map_err(|e| Fail::host(updates::error_text(&e.to_string())))?; .map_err(|e| Fail::host(updates::error_text(&e.to_string())))?;
updater.check().await.map_err(|e| Fail::host(updates::error_text(&e.to_string()))) updater.check().await.map_err(|e| Fail::host(updates::error_text(&e.to_string())))
@ -1489,3 +1611,142 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
let _ = app.emit_to(policy::MAIN, "deep-link-folder", id); let _ = app.emit_to(policy::MAIN, "deep-link-folder", id);
Ok(()) Ok(())
} }
// ---- Fix with AI (src/ai_fix.rs): the owner's local Claude Code CLI on one scan finding ----
fn project_dir(state: &AppState, project_id: &str) -> Res<PathBuf> {
let p = saved(state)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
Ok(PathBuf::from(p.path))
}
/// Whether claude is installed, the prompt (also what "Copy for AI" copies) and what is dirty in git.
#[tauri::command]
pub async fn ai_fix_info(window: WebviewWindow, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding) -> Res<Value> {
guard(&window)?;
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
let dir = project_dir(&state, &project_id)?;
let claude = ai_fix::find_claude();
let (git, dirty) = match tauri::async_runtime::spawn_blocking(move || ai_fix::status(&dir)).await.map_err(|e| Fail::host(e.to_string()))? {
Ok((_, d)) => (Ok(()), d),
Err(e) => (Err(e), Vec::new()),
};
Ok(json!({ "claude": claude.is_some(), "prompt": prompt, "git": git.is_ok(), "gitError": git.err(), "dirty": dirty }))
}
/// Runs claude on one finding in the app folder (clean git tree only), streams "ai-fix" lines, returns the run's changes.
#[tauri::command]
pub async fn ai_fix_run(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding, job: String) -> Res<Value> {
guard(&window)?;
if !valid_job(&job) {
return Err(Fail::host("bad job id"));
}
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
let dir = project_dir(&state, &project_id)?;
let claude = ai_fix::find_claude().ok_or_else(|| Fail::host("Claude Code (claude) was not found on PATH"))?;
let handle = app.clone();
let res = tauri::async_runtime::spawn_blocking(move || -> Result<Value, String> {
let (top, dirty) = ai_fix::status(&dir)?;
if !dirty.is_empty() {
return Ok(json!({ "refused": "dirty", "dirty": dirty }));
}
let cmd = ai_fix::claude_command(&claude, &dir, &prompt)?;
let emit_job = job.clone();
let out = ai_fix::run(cmd, &job, ai_fix::TIMEOUT, ai_fix::HEARTBEAT, |line| {
let _ = handle.emit_to(policy::MAIN, "ai-fix", json!({ "job": emit_job, "line": line }));
})?;
let (_, changes) = ai_fix::status(&dir)?;
let diff = ai_fix::diff(&top, &changes);
let run_id = job.clone();
if let Ok(mut m) = ai_fix::runs().lock() {
m.insert(run_id.clone(), (top, changes.clone()));
}
Ok(json!({ "runId": run_id, "outcome": out, "changes": changes, "diff": diff }))
})
.await
.map_err(|e| Fail::host(e.to_string()))?;
res.map_err(Fail::host)
}
/// Undo of one run: restores exactly the files that run changed (recorded by the host) and removes the ones it created.
#[tauri::command]
pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<String>> {
guard(&window)?;
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
}
// ---- the tray menu panels (menu_panel.rs; only `menu-*` windows) ----
/// A panel: what it shows (the whole tree and its path into it).
#[tauri::command]
pub fn menu_state(window: WebviewWindow) -> Res<Option<crate::menu_panel::PanelInfo>> {
guard_menu(&window)?;
Ok(crate::menu_panel::panel_state(&window))
}
/// A panel measured itself (CSS px): sized, placed inside the work area and shown.
#[tauri::command]
pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::ready(&window, width, height);
Ok(())
}
/// Opens a submenu beside this panel at the row whose top is `top` (CSS px).
#[tauri::command]
pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::open_sub(&window, &key, top, focus, false);
Ok(())
}
/// Ctrl / middle click on a submenu row: opens it as its own pinned panel.
#[tauri::command]
pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::open_sub(&window, &key, top, false, true);
Ok(())
}
/// The pointer moved to a plain row: this panel's submenus close.
#[tauri::command]
pub fn menu_close_subs(window: WebviewWindow) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::close_subs(&window);
Ok(())
}
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
#[tauri::command]
pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::close(&window, refocus_parent);
Ok(())
}
/// Esc on the root menu: every transient panel closes.
#[tauri::command]
pub fn menu_dismiss(window: WebviewWindow) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::dismiss(window.app_handle());
Ok(())
}
/// Pins or unpins this panel.
#[tauri::command]
pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::pin(&window, pinned);
Ok(())
}
/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused).
#[tauri::command]
pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> {
guard_menu(&window)?;
if id.len() > 300 || crate::tray::action_of(&id).is_none() {
return Err(Fail::host("unknown menu item"));
}
crate::menu_panel::select(&window, id);
Ok(())
}

View file

@ -27,6 +27,7 @@ pub const COMMANDS: &[&str] = &[
"studio_remove", "studio_remove",
"studio_logo", "studio_logo",
"account_update", "account_update",
"account_set_play_developer",
"account_check", "account_check",
"store_apps_refresh", "store_apps_refresh",
"store_apps_cached", "store_apps_cached",
@ -35,6 +36,8 @@ pub const COMMANDS: &[&str] = &[
"cache_info", "cache_info",
"data_reveal", "data_reveal",
"notify", "notify",
"tray_update",
"card_snapshot",
"app_reset", "app_reset",
"update_check", "update_check",
"update_install", "update_install",
@ -46,4 +49,13 @@ pub const COMMANDS: &[&str] = &[
"project_locate", "project_locate",
"state_reset", "state_reset",
"log_webview", "log_webview",
"ai_fix_info",
"ai_fix_run",
"ai_fix_undo",
]; ];
/// The tray card window's commands (capabilities/card.json grants them to the `card` window only).
pub const CARD_COMMANDS: &[&str] = &["card_get", "card_action"];
/// The tray menu panels' commands (capabilities/menu.json grants them to the `menu-*` windows only).
pub const MENU_COMMANDS: &[&str] = &["menu_state", "menu_ready", "menu_open_sub", "menu_pin_sub", "menu_close_subs", "menu_close", "menu_dismiss", "menu_pin", "menu_select"];

View file

@ -1,11 +1,14 @@
//! storebound desktop: a Tauri shell around the storebound CLI, which runs as a sidecar (`storebound serve --stdio`). //! storebound desktop: a Tauri shell around the storebound CLI, which runs as a sidecar (`storebound serve --stdio`).
//! The GUI never talks to a store itself; every store call is a storebound command. //! The GUI never talks to a store itself; every store call is a storebound command.
pub mod accounts; pub mod accounts;
pub mod ai_fix;
pub mod cache; pub mod cache;
pub mod card;
pub mod commands; pub mod commands;
pub mod confirm; pub mod confirm;
pub mod downloads; pub mod downloads;
pub mod logs; pub mod logs;
pub mod menu_panel;
pub mod policy; pub mod policy;
pub mod profiles; pub mod profiles;
pub mod registry; pub mod registry;
@ -13,6 +16,7 @@ pub mod selftest;
pub mod updates; pub mod updates;
pub mod sidecar; pub mod sidecar;
pub mod store; pub mod store;
pub mod tray;
pub mod workspace; pub mod workspace;
include!("commands_list.rs"); include!("commands_list.rs");
@ -20,7 +24,9 @@ include!("commands_list.rs");
use std::sync::Mutex; use std::sync::Mutex;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent}; use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent};
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
/// WebKitGTK's DMABUF renderer leaves the window blank on some Linux GPU / driver / VM combinations. Turn it off unless /// WebKitGTK's DMABUF renderer leaves the window blank on some Linux GPU / driver / VM combinations. Turn it off unless
@ -33,11 +39,178 @@ pub fn linux_webkit_env() {
} }
} }
/// Started by the logon entry (`--hidden`): the app runs in the tray and shows no window.
fn started_hidden() -> bool {
std::env::args().any(|a| a == "--hidden")
}
/// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with
/// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit,
/// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window
/// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu,
/// rendered from the same tree, stays on the icon there (and anywhere the panels fail).
fn tray(app: &tauri::App) -> tauri::Result<()> {
let handle = app.handle();
let panels = handle.state::<menu_panel::MenuPanels>();
if let Ok(mut n) = panels.nodes.lock() {
*n = tray::initial_nodes();
}
let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false);
if cfg!(target_os = "linux") {
panels.native.store(true, std::sync::atomic::Ordering::Relaxed);
b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?);
}
if let Some(icon) = app.default_window_icon() {
b = b.icon(icon.clone());
}
b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref()))
.on_tray_icon_event(|t, e| {
let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return };
let app = t.app_handle();
// both open from the icon's rectangle (not the click point): their edge sits next to the icon, above a
// bottom taskbar, so nothing starts inside the taskbar
let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0);
let p = rect.position.to_physical::<f64>(scale);
let s = rect.size.to_physical::<f64>(scale);
let icon = if s.width > 0.0 && s.height > 0.0 { card::Rect { x: p.x, y: p.y, w: s.width, h: s.height } } else { card::Rect { x: position.x, y: position.y, w: 1.0, h: 1.0 } };
match button {
MouseButton::Right => menu_panel::open_root(app, menu_panel::Anchor::Tray { x: icon.x, y: icon.y, w: icon.w, h: icon.h }),
MouseButton::Left => {
if app.get_webview_window(card::LABEL).is_none() {
show_main(app);
return;
}
card::toggle(app, icon);
}
_ => {}
}
})
.build(app)?;
Ok(())
}
/// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does.
/// Whether the person turned storebound off in Windows' Startup apps (Settings / Task Manager), from the
/// HKCU\...\Explorer\StartupApproved\Run value named like the Run entry: its first byte is even when enabled (02, 06),
/// odd when disabled (03, 07). No value: not turned off. Pure.
pub fn startup_approved_off(value: &[u8]) -> bool {
value.first().is_some_and(|b| b & 1 == 1)
}
#[cfg(windows)]
fn startup_disabled_by_user() -> bool {
use winreg::{enums::HKEY_CURRENT_USER, RegKey};
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run")
.and_then(|k| k.get_raw_value("storebound"))
.map(|v| startup_approved_off(&v.bytes))
.unwrap_or(false)
}
#[cfg(not(windows))]
fn startup_disabled_by_user() -> bool {
false
}
pub fn dispatch_menu<R: Runtime>(app: &AppHandle<R>, id: &str) {
match tray::action_of(id) {
Some(tray::Action::Open) => show_main(app),
Some(tray::Action::Quit) => app.exit(0),
Some(tray::Action::Event(word)) => {
let _ = app.emit_to(policy::MAIN, "tray-action", word);
}
Some(tray::Action::Route(route)) => {
show_main(app);
let _ = app.emit_to(policy::MAIN, "tray-route", route);
}
None => {}
}
}
/// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is
/// downloaded, installed and the app restarts with the same arguments (`--hidden` stays hidden). Errors are logged.
/// Background updater (docs/desktop-release.md, "Dev channel"): asks the chosen channel's latest.json at start, every
/// 15 minutes, right after a resume from sleep and when Settings switched the channel; installs a strictly newer, signed
/// build silently (the plugin checks the minisign signature first), deferred while the main window is in use (at most
/// updates::MAX_DEFERS checks in a row). Every step goes to host.log as `auto-update ...`.
fn auto_update(app: AppHandle) {
tauri::async_runtime::spawn(async move {
use std::time::{Duration, Instant, SystemTime};
tokio::time::sleep(Duration::from_secs(10)).await;
let mut first = true;
let mut last_check = Instant::now();
let mut last_wall = SystemTime::now();
let mut defers = 0u32;
loop {
let now_wall = SystemTime::now();
let gap = now_wall.duration_since(last_wall).map(|d| d.as_secs()).unwrap_or(0);
last_wall = now_wall;
let kicked = updates::take_kick();
if updates::check_due(first, kicked, gap, last_check.elapsed().as_secs()) {
let why = if first { "start" } else if kicked { "channel switched" } else if gap > updates::TICK_SECS + 60 { "resume" } else { "every 15 min" };
first = false;
last_check = Instant::now();
defers = auto_update_once(&app, why, defers).await;
}
tokio::time::sleep(Duration::from_secs(updates::TICK_SECS)).await;
}
});
}
/// One check of the background updater; returns the new count of deferred installs.
async fn auto_update_once(app: &AppHandle, why: &str, defers: u32) -> u32 {
let state = app.state::<commands::AppState>();
let channel = match state.saved.lock() {
Ok(s) => s.settings.channel.clone(),
Err(_) => return defers,
};
let current = env!("CARGO_PKG_VERSION");
let update = match commands::find_update(app, &channel).await {
Ok(Some(u)) => u,
Ok(None) => {
logs::host("info", &format!("auto-update ({why}): {current} is current on {channel}"));
return 0;
}
Err(e) => {
logs::host("warn", &format!("auto-update ({why}): check of {channel} failed: {}", e.message));
return defers;
}
};
if !updates::should_install(current, &update.version) {
logs::host("info", &format!("auto-update ({why}): {channel} offers {}, not newer than {current}: no downgrade", update.version));
return 0;
}
let main = app.get_webview_window(policy::MAIN);
let visible = main.as_ref().and_then(|w| w.is_visible().ok()).unwrap_or(false);
let focused = main.as_ref().and_then(|w| w.is_focused().ok()).unwrap_or(false);
if updates::defer_install(visible, focused, defers) {
logs::host("info", &format!("auto-update ({why}): {} on {channel} waits, the window is in use ({}/{})", update.version, defers + 1, updates::MAX_DEFERS));
return defers + 1;
}
logs::host("info", &format!("auto-update ({why}): installing {} from {channel} (this is {current})", update.version));
// the sidecar binary lives inside the app bundle that is about to be replaced
state.sidecar.shutdown().await;
match update.download_and_install(|_, _| {}, || {}).await {
Ok(()) => {
logs::host("info", &format!("auto-update: {} installed, restarting", update.version));
app.restart()
}
Err(e) => {
logs::host("warn", &format!("auto-update: install of {} failed: {}", update.version, updates::error_text(&e.to_string())));
0
}
}
}
fn show_main<R: Runtime>(app: &AppHandle<R>) { fn show_main<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window(policy::MAIN) { if let Some(w) = app.get_webview_window(policy::MAIN) {
let _ = w.unminimize(); let _ = w.unminimize();
let _ = w.show(); let _ = w.show();
let _ = w.set_focus(); let _ = w.set_focus();
// the last notification's target (the plugin has no click callback on desktop): the window goes there now
if let Some(route) = tray::take_pending() {
let _ = app.emit_to(policy::MAIN, "tray-route", route);
}
} }
} }
@ -128,13 +301,34 @@ fn dev_seed(data_dir: &std::path::Path) {
} }
} }
/// One line before Tauri starts: a start at logon that dies early (before the host log exists) still leaves a trace
/// with its arguments and working folder (a logon start once left no line at all). Best effort, never fails.
fn early_trace() {
use std::io::Write;
let base = std::env::var_os("APPDATA").or_else(|| std::env::var_os("XDG_DATA_HOME")).map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share")));
let Some(base) = base else { return };
let dir = base.join("dev.storebound.desktop").join("logs");
if std::fs::create_dir_all(&dir).is_err() { return }
let args: Vec<String> = std::env::args().skip(1).collect();
let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default();
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("start.log")) {
let _ = writeln!(f, "{secs} start {} args={args:?} cwd={cwd} pid={}", env!("CARGO_PKG_VERSION"), std::process::id());
}
}
pub fn run() { pub fn run() {
early_trace();
linux_webkit_env(); linux_webkit_env();
let app = tauri::Builder::default() let app = tauri::Builder::default()
// single-instance first: a second start (or a storebound:// link) focuses the running app instead // single-instance first: a second start (or a storebound:// link) focuses the running app instead; a second
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) // logon start (--hidden: the Run entry plus a Startup folder shortcut) stays in the tray
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| if !argv.iter().any(|a| a == "--hidden") { show_main(app) }))
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_window_state::Builder::new().build()) // never restore "visible": a --hidden start would otherwise pop the window up
.plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"])))
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -142,6 +336,10 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.setup(|app| { .setup(|app| {
#[cfg(all(windows, not(debug_assertions)))]
if let Some(w) = app.get_webview_window(policy::MAIN) {
harden_webview(&w);
}
let data_dir = app.path().app_data_dir()?; let data_dir = app.path().app_data_dir()?;
logs::init(&data_dir, app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string())); logs::init(&data_dir, app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string()));
logs::panic_hook(); logs::panic_hook();
@ -177,7 +375,29 @@ pub fn run() {
if let Err(e) = commands::sync_machine(&state) { if let Err(e) = commands::sync_machine(&state) {
logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message)); logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message));
} }
// start at logon, in the tray: rewritten on every start (Windows: the HKCU Run entry then points at the
// running exe, so a moved or updated install fixes itself)
// ...unless the person turned it off (Windows Settings > Apps > Startup / Task Manager): that choice stands
if startup_disabled_by_user() {
logs::host("info", "autostart at logon left off (turned off in Windows Startup apps)");
} else {
match app.autolaunch().enable() {
Ok(()) => logs::host("info", "autostart at logon set (--hidden, tray)"),
Err(e) => logs::host("warn", &format!("autostart not set: {e}")),
}
}
app.manage(state); app.manage(state);
app.manage(menu_panel::MenuPanels::default());
tray(app)?;
// pinned tray menu panels of the last session come back
menu_panel::restore(app.handle());
if let Err(e) = card::create(app.handle()) {
logs::host("warn", &format!("tray card not created: {e}"));
}
if !started_hidden() {
show_main(app.handle());
}
auto_update(app.handle().clone());
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
app.set_menu(menu(app.handle())?)?; app.set_menu(menu(app.handle())?)?;
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@ -201,9 +421,16 @@ pub fn run() {
Ok(()) Ok(())
}) })
.on_window_event(|w, e| { .on_window_event(|w, e| {
// macOS: the close button hides the window (the app stays in the Dock and the Window menu); // the close button hides the window: the app keeps running in the tray (macOS: also the Dock); the tray's
// Cmd+Q and the app menu's Quit still quit. Elsewhere closing the window quits. // Quit (macOS: Cmd+Q) really quits
if cfg!(target_os = "macos") && w.label() == policy::MAIN { if w.label() == card::LABEL {
match e {
WindowEvent::Focused(false) => { let _ = w.hide(); card::note_hidden(); }
WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let _ = w.hide(); card::note_hidden(); }
_ => {}
}
}
if w.label() == policy::MAIN {
if let WindowEvent::CloseRequested { api, .. } = e { if let WindowEvent::CloseRequested { api, .. } = e {
api.prevent_close(); api.prevent_close();
let _ = w.hide(); let _ = w.hide();
@ -237,6 +464,7 @@ pub fn run() {
commands::studio_remove, commands::studio_remove,
commands::studio_logo, commands::studio_logo,
commands::account_update, commands::account_update,
commands::account_set_play_developer,
commands::account_check, commands::account_check,
commands::store_apps_refresh, commands::store_apps_refresh,
commands::store_apps_cached, commands::store_apps_cached,
@ -245,6 +473,8 @@ pub fn run() {
commands::cache_info, commands::cache_info,
commands::data_reveal, commands::data_reveal,
commands::notify, commands::notify,
commands::tray_update,
commands::card_snapshot,
commands::app_reset, commands::app_reset,
commands::update_check, commands::update_check,
commands::update_install, commands::update_install,
@ -256,6 +486,20 @@ pub fn run() {
commands::project_locate, commands::project_locate,
commands::state_reset, commands::state_reset,
commands::log_webview, commands::log_webview,
commands::ai_fix_info,
commands::ai_fix_run,
commands::ai_fix_undo,
commands::card_get,
commands::card_action,
commands::menu_state,
commands::menu_ready,
commands::menu_open_sub,
commands::menu_pin_sub,
commands::menu_close_subs,
commands::menu_close,
commands::menu_dismiss,
commands::menu_pin,
commands::menu_select,
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("storebound desktop failed to start"); .expect("storebound desktop failed to start");
@ -263,11 +507,33 @@ pub fn run() {
app.run(|app, e| match e { app.run(|app, e| match e {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => show_main(app), RunEvent::Reopen { .. } => show_main(app),
// no window left is not a reason to quit (tray app); an explicit exit or the updater's restart carries a code
RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(),
RunEvent::Exit => { RunEvent::Exit => {
if let Some(s) = app.try_state::<commands::AppState>() { if let Some(s) = app.try_state::<commands::AppState>() {
s.sidecar.shutdown_now(); s.sidecar.shutdown_now();
} }
ai_fix::kill_all();
} }
_ => {} _ => {}
}); });
} }
/// Release builds on Windows: no WebView2 browser context menu (Back, Refresh, Save as, Print, More tools) and no
/// browser accelerator keys (Ctrl+R, Ctrl+P, F5, Ctrl+Shift+I...). The page draws its own menus; dev keeps devtools.
#[cfg(all(windows, not(debug_assertions)))]
fn harden_webview(w: &tauri::WebviewWindow) {
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3;
use windows_core::Interface;
let r = w.with_webview(|wv| unsafe {
let Ok(core) = wv.controller().CoreWebView2() else { return };
let Ok(s) = core.Settings() else { return };
let _ = s.SetAreDefaultContextMenusEnabled(false);
if let Ok(s3) = s.cast::<ICoreWebView2Settings3>() {
let _ = s3.SetAreBrowserAcceleratorKeysEnabled(false);
}
});
if let Err(e) = r {
logs::host("warn", &format!("webview menus not turned off: {e}"));
}
}

View file

@ -0,0 +1,749 @@
//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/).
//!
//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label
//! without the live part), so an open or pinned panel finds its submenu again after the values change.
//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The
//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it.
//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child
//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be
//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json).
//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls.
//! - Idle: a closed panel is destroyed, never hidden.
//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same
//! tree) comes back. Linux always uses it (its tray sends no click events).
//!
//! The top half is pure and covered by tests/menu_panel.rs.
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::tray::Entry;
pub const LABEL_PREFIX: &str = "menu-";
pub const PAGE: &str = "tray-menu.html";
pub const MODEL_EVENT: &str = "menu-model";
pub const PANEL_EVENT: &str = "menu-panel";
/// Gap to the work area's edges (physical px at scale 1).
pub const GAP: f64 = 8.0;
/// The estimated size of a panel before it measured itself (logical px).
pub const EST: (f64, f64) = (280.0, 220.0);
// ---------------------------------------------------------------- model
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Node {
Item { id: String, label: String, enabled: bool },
Sep,
Sub { key: String, label: String, items: Vec<Node> },
}
/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '('
/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys.
pub fn sub_key(label: &str) -> String {
let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or("");
let mut out = String::new();
for c in head.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
} else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_end_matches('-').to_string();
if out.is_empty() { "sub".into() } else { out }
}
/// Sibling submenus with the same key get "-2", "-3"...
pub fn unique_keys(mut nodes: Vec<Node>) -> Vec<Node> {
let mut seen: BTreeMap<String, u32> = BTreeMap::new();
for n in nodes.iter_mut() {
if let Node::Sub { key, .. } = n {
let count = seen.entry(key.clone()).or_insert(0);
*count += 1;
if *count > 1 {
*key = format!("{key}-{count}");
}
}
}
nodes
}
/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never
/// changes it), else the key comes from its label.
pub fn from_entries(entries: &[Entry]) -> Vec<Node> {
let nodes = entries
.iter()
.map(|e| match e {
Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled },
Entry::Separator => Node::Sep,
Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) },
})
.collect();
unique_keys(nodes)
}
/// The items of the submenu at `path` (empty path: the root) and its label.
pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> {
let mut current = nodes;
let mut label = None;
for key in path {
let (items, l) = current.iter().find_map(|n| match n {
Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())),
_ => None,
})?;
current = items;
label = Some(l);
}
Some((current, label))
}
// ---------------------------------------------------------------- placement
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Anchor {
/// The tray click point (physical).
Point { x: f64, y: f64 },
/// The tray icon's rectangle (physical): the root opens from the icon's edge that faces the screen, like the card.
Tray { x: f64, y: f64, w: f64, h: f64 },
/// Beside a parent panel: its left and right edge and the row's top (physical).
Beside { left: f64, right: f64, top: f64 },
/// A remembered top-left (pinned panels).
At { x: f64, y: f64 },
}
/// x, y, width, height (physical px).
pub type Rect = (f64, f64, f64, f64);
/// Keeps a panel of size w x h fully inside the work area (with `GAP`).
pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) {
let (wx, wy, ww, wh) = work;
(x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP)))
}
/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the
/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot.
pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) {
let (w, h) = size;
let (wx, wy, ww, wh) = work;
let (x, y) = match anchor {
Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }),
// same rule as the card: the panel's edge sits GAP from the icon, never over the taskbar
Anchor::Tray { x, y, w: iw, h: ih } => return crate::card::place(crate::card::Rect { x, y, w: iw, h: ih }, crate::card::Rect { x: wx, y: wy, w: ww, h: wh }, w, h, GAP),
Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0),
Anchor::At { x, y } => (x, y),
};
clamp_into(x, y, w, h, work)
}
pub fn overlaps(a: Rect, b: Rect) -> bool {
a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3
}
/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column.
/// Gives up after a while (a screen full of pins) and returns the clamped wish.
pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) {
let (mut x, mut y, w, h) = want;
for _ in 0..64 {
let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else {
return clamp_into(x, y, w, h, work);
};
y = hit.1 + hit.3 + GAP;
if y + h > work.1 + work.3 {
y = work.1 + GAP;
x += w + GAP;
if x + w > work.0 + work.2 {
x = work.0 + GAP;
}
}
}
clamp_into(want.0, want.1, w, h, work)
}
// ---------------------------------------------------------------- panel book
#[derive(Clone, Debug, PartialEq)]
pub struct Panel {
pub path: Vec<String>,
pub pinned: bool,
pub parent: Option<String>,
pub anchor: Anchor,
/// Measured and shown once.
pub shown: bool,
/// Physical top-left once shown (kept for pins).
pub pos: Option<(f64, f64)>,
/// Opened from the keyboard: takes focus and starts on its first row.
pub focus: bool,
}
/// A pinned panel as state.json keeps it (`menuPins`).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct PinRecord {
pub path: Vec<String>,
pub x: i32,
pub y: i32,
}
/// Which panels are open, which are pinned, who is whose child.
#[derive(Default, Debug)]
pub struct Book {
pub panels: BTreeMap<String, Panel>,
next: u32,
}
impl Book {
fn add(&mut self, path: Vec<String>, pinned: bool, parent: Option<String>, anchor: Anchor) -> String {
self.next += 1;
let label = format!("{LABEL_PREFIX}{}", self.next);
self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false });
label
}
fn remove_all(&mut self, labels: &[String]) {
for l in labels {
self.panels.remove(l);
}
}
pub fn transient(&self) -> Vec<String> {
self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect()
}
/// Transient panels below `label` (children first).
pub fn descendants(&self, label: &str) -> Vec<String> {
let mut out = Vec::new();
let mut frontier = vec![label.to_string()];
while let Some(l) = frontier.pop() {
for (child, p) in &self.panels {
if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) {
out.push(child.clone());
frontier.push(child.clone());
}
}
}
out
}
/// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones.
pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec<String>) {
let closes = self.transient();
self.remove_all(&closes);
(self.add(vec![], false, None, anchor), closes)
}
/// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open
/// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu).
pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec<String>)> {
let mut path = self.panels.get(parent)?.path.clone();
path.push(key.to_string());
if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) {
return None;
}
let mut closes = self.descendants(parent);
self.remove_all(&closes);
if pinned {
let old: Vec<String> = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect();
for l in old {
closes.extend(self.close(&l));
}
return Some((self.add(path, true, None, anchor), closes));
}
Some((self.add(path, false, Some(parent.to_string()), anchor), closes))
}
/// Closes `label` and its transient children.
pub fn close(&mut self, label: &str) -> Vec<String> {
if !self.panels.contains_key(label) {
return vec![];
}
let mut out = self.descendants(label);
out.push(label.to_string());
self.remove_all(&out);
out
}
/// Every transient panel closes (Esc on the root, a pick, focus left the menus).
pub fn dismiss(&mut self) -> Vec<String> {
let t = self.transient();
self.remove_all(&t);
t
}
/// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children
/// close (returned). A second pinned copy of the same submenu closes too.
pub fn pin(&mut self, label: &str, on: bool) -> Vec<String> {
let Some(p) = self.panels.get(label) else { return vec![] };
let path = p.path.clone();
if !on {
if let Some(p) = self.panels.get_mut(label) {
p.pinned = false;
}
return vec![];
}
let mut closes: Vec<String> = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect();
if let Some(p) = self.panels.get_mut(label) {
p.pinned = true;
p.parent = None;
}
let mine = self.descendants(label);
closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l)));
self.remove_all(&closes);
closes
}
/// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show
/// that their submenu is gone until it comes back (an app that is read again) or they are closed.
pub fn prune(&mut self, nodes: &[Node]) -> Vec<String> {
let gone: Vec<String> = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect();
let mut out = Vec::new();
for l in gone {
if self.panels.contains_key(&l) {
out.extend(self.close(&l));
}
}
out
}
/// The pinned panels to save (only placed ones; in label order, so the order is stable).
pub fn pins(&self) -> Vec<PinRecord> {
self.panels
.values()
.filter(|p| p.pinned)
.filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 }))
.collect()
}
/// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped).
pub fn restore(&mut self, pins: &[PinRecord]) -> Vec<String> {
let mut out = Vec::new();
for r in pins.iter().take(MAX_PINS) {
if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 {
continue;
}
let (x, y) = (r.x as f64, r.y as f64);
let l = self.add(r.path.clone(), true, None, Anchor::At { x, y });
if let Some(p) = self.panels.get_mut(&l) {
p.pos = Some((x, y));
}
out.push(l);
}
out
}
}
/// At most this many pinned panels are restored after a restart.
pub const MAX_PINS: usize = 12;
/// A panel label the app made (`menu-` and a number), for the window / command checks.
pub fn is_menu_label(label: &str) -> bool {
label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit()))
}
// ---------------------------------------------------------------- runtime
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PanelInfo {
pub label: String,
pub path: Vec<String>,
pub pinned: bool,
pub has_parent: bool,
pub focus_first: bool,
pub nodes: Vec<Node>,
}
/// The panels' shared state (managed by the app).
#[derive(Default)]
pub struct MenuPanels {
pub book: Mutex<Book>,
pub nodes: Mutex<Vec<Node>>,
/// The native menu took over (a panel failed, or Linux).
pub native: AtomicBool,
/// Bumped on every move of a pinned panel; the save runs once the moves stop.
moves: AtomicU64,
}
#[cfg(desktop)]
mod runtime {
use super::*;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> {
app.state::<MenuPanels>()
}
fn book<R>(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R {
let s = state(app);
let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner());
f(&mut b)
}
fn nodes(app: &AppHandle) -> Vec<Node> {
state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default()
}
pub fn native_fallback(app: &AppHandle) -> bool {
state(app).native.load(Ordering::Relaxed)
}
/// Puts the native menu (from the current tree) on the tray: the fallback, and Linux.
pub fn set_native(app: &AppHandle) {
let Some(tray) = app.tray_by_id("main") else { return };
match crate::tray::native_menu(app, &nodes(app)) {
Ok(m) => {
let _ = tray.set_menu(Some(m));
}
Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")),
}
}
/// Writes the pinned panels into state.json (`menuPins`).
fn save_pins(app: &AppHandle) {
let pins = book(app, |b| b.pins());
let Some(s) = app.try_state::<crate::commands::AppState>() else { return };
let Ok(mut st) = s.saved.lock() else { return };
if st.menu_pins == pins {
return;
}
st.menu_pins = pins;
if let Err(e) = crate::store::save(&s.data_dir, &st) {
crate::logs::host("warn", &format!("menu pins not saved: {e}"));
}
}
/// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone
/// close when transient. Cheap when nothing changed or no panel is open.
pub fn set_nodes(app: &AppHandle, next: Vec<Node>) {
let changed = {
let s = state(app);
let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner());
let changed = *g != next;
*g = next.clone();
changed
};
if !changed {
return;
}
let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::<Vec<_>>()));
destroy(app, &closes);
for l in open {
let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next);
}
if native_fallback(app) {
set_native(app);
}
}
fn destroy(app: &AppHandle, labels: &[String]) {
for l in labels {
if let Some(w) = app.get_webview_window(l) {
let _ = w.destroy();
}
}
}
fn build_window(app: &AppHandle, label: &str) -> tauri::Result<WebviewWindow> {
let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into()))
.title("storebound")
.inner_size(EST.0, EST.1)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.shadow(true)
.visible(false)
.focused(false);
#[cfg(not(target_os = "macos"))]
let b = b.transparent(true);
let window = b.build()?;
#[cfg(target_os = "macos")]
let _ = window.set_visible_on_all_workspaces(true);
let handle = app.clone();
let me = label.to_string();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Focused(false) => {
let handle = handle.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(160));
close_transient_unless_focused(&handle);
});
}
tauri::WindowEvent::Moved(pos) => {
let pinned = book(&handle, |b| match b.panels.get_mut(&me) {
Some(p) if p.shown => {
p.pos = Some((pos.x as f64, pos.y as f64));
p.pinned
}
_ => false,
});
if pinned {
// a drag sends many moves: save once they stop
let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1;
let handle = handle.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(400));
if state(&handle).moves.load(Ordering::Relaxed) == n {
save_pins(&handle);
}
});
}
}
_ => {}
});
Ok(window)
}
/// Focus left a menu window: every transient panel closes unless one of them has focus now.
fn close_transient_unless_focused(app: &AppHandle) {
let transient = book(app, |b| b.transient());
if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) {
return;
}
book(app, |b| {
for l in &transient {
b.panels.remove(l);
}
});
destroy(app, &transient);
}
/// The panels cannot be shown: the native menu goes on the tray and opens once now.
fn native_popup(app: &AppHandle) {
if !state(app).native.swap(true, Ordering::Relaxed) {
crate::logs::host("warn", "tray menu panels unavailable: the native menu is used");
}
set_native(app);
let handle = app.clone();
let _ = app.run_on_main_thread(move || {
if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) {
let _ = w.popup_menu(&menu);
}
});
}
/// A right click on the tray icon: a fresh root menu next to the icon (`anchor`: Anchor::Tray, physical px).
pub fn open_root(app: &AppHandle, anchor: Anchor) {
if native_fallback(app) {
return; // the native menu is on the tray and opens by itself
}
let (label, closes) = book(app, |b| b.open_root(anchor));
destroy(app, &closes);
let handle = app.clone();
std::thread::spawn(move || {
if let Err(e) = build_window(&handle, &label) {
crate::logs::host("warn", &format!("tray menu panel not built: {e}"));
book(&handle, |b| b.panels.remove(&label));
native_popup(&handle);
return;
}
std::thread::sleep(Duration::from_millis(2500));
if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) {
crate::logs::host("warn", "tray menu panel never reported ready");
let closes = book(&handle, |b| b.close(&label));
destroy(&handle, &closes);
native_popup(&handle);
}
});
}
/// Brings back the pinned panels of the last session (state.json `menuPins`).
pub fn restore(app: &AppHandle) {
let pins = app.try_state::<crate::commands::AppState>().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default();
if pins.is_empty() || native_fallback(app) {
return;
}
let labels = book(app, |b| b.restore(&pins));
let handle = app.clone();
std::thread::spawn(move || {
for l in labels {
if build_window(&handle, &l).is_err() {
book(&handle, |b| b.panels.remove(&l));
}
}
});
}
fn work_area(app: &AppHandle, x: f64, y: f64) -> Option<Rect> {
let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?;
let w = m.work_area();
Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64))
}
fn anchor_point(a: Anchor) -> (f64, f64) {
match a {
Anchor::Point { x, y } | Anchor::At { x, y } => (x, y),
Anchor::Tray { x, y, w, h } => (x + w / 2.0, y + h / 2.0),
Anchor::Beside { right, top, .. } => (right, top),
}
}
fn outer_rect(w: &WebviewWindow) -> Option<Rect> {
let p = w.outer_position().ok()?;
let s = w.outer_size().ok()?;
Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64))
}
fn pinned_rects(app: &AppHandle, skip: &str) -> Vec<Rect> {
let labels: Vec<String> = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect());
labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect()
}
// ---- what the panel page calls (commands.rs checks the window first) ----
pub fn panel_state(window: &WebviewWindow) -> Option<PanelInfo> {
let app = window.app_handle();
let p = book(app, |b| b.panels.get(window.label()).cloned())?;
Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) })
}
/// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time.
pub fn ready(window: &WebviewWindow, width: f64, height: f64) {
let app = window.app_handle().clone();
let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return };
let scale = window.scale_factor().unwrap_or(1.0);
let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil());
let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor));
let Some(work) = work_area(&app, ax, ay) else { return };
let (x, y) = if panel.shown {
let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0));
clamp_into(x, y, size.0, size.1, work)
} else if let Some((x, y)) = panel.pos {
clamp_into(x, y, size.0, size.1, work)
} else {
place_menu(panel.anchor, size, work)
};
let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32));
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
if !panel.shown {
book(&app, |b| {
if let Some(p) = b.panels.get_mut(window.label()) {
p.shown = true;
p.pos = Some((x, y));
}
});
let _ = window.show();
if panel.focus || (panel.parent.is_none() && !panel.pinned) {
let _ = window.set_focus();
}
if panel.pinned {
save_pins(&app);
}
}
}
/// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle
/// click), placed so it covers no other pinned panel.
pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) {
let app = window.app_handle().clone();
let Some((wx, wy, ww, _)) = outer_rect(window) else { return };
let scale = window.scale_factor().unwrap_or(1.0);
let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale };
let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return };
destroy(&app, &closes);
if pinned {
let others = pinned_rects(&app, &label);
if let Some(work) = work_area(&app, wx, wy) {
let est = (EST.0 * scale, EST.1 * scale);
let (x, y) = place_menu(anchor, est, work);
let spot = free_spot((x, y, est.0, est.1), &others, work);
book(&app, |b| {
if let Some(p) = b.panels.get_mut(&label) {
p.pos = Some(spot);
}
});
}
}
book(&app, |b| {
if let Some(p) = b.panels.get_mut(&label) {
p.focus = focus;
}
});
let handle = app.clone();
std::thread::spawn(move || {
if build_window(&handle, &label).is_err() {
book(&handle, |b| b.panels.remove(&label));
}
});
}
/// The pointer moved to a plain row: this panel's transient submenus close.
pub fn close_subs(window: &WebviewWindow) {
let app = window.app_handle().clone();
let closes = book(&app, |b| {
let d = b.descendants(window.label());
for l in &d {
b.panels.remove(l);
}
d
});
destroy(&app, &closes);
}
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
pub fn close(window: &WebviewWindow, refocus_parent: bool) {
let app = window.app_handle().clone();
let (closes, parent, was_pinned) = book(&app, |b| {
let p = b.panels.get(window.label()).cloned();
(b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned))
});
if refocus_parent {
if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) {
let _ = w.set_focus();
let _ = app.emit_to(w.label(), PANEL_EVENT, "focus");
}
}
destroy(&app, &closes);
if was_pinned {
save_pins(&app);
}
}
/// Esc on the root menu: every transient panel closes.
pub fn dismiss(app: &AppHandle) {
let closes = book(app, |b| b.dismiss());
destroy(app, &closes);
}
/// Pin or unpin this panel. Pinning steps it aside from the other pinned panels.
pub fn pin(window: &WebviewWindow, on: bool) {
let app = window.app_handle().clone();
let label = window.label().to_string();
let closes = book(&app, |b| b.pin(&label, on));
destroy(&app, &closes);
if on {
let others = pinned_rects(&app, &label);
if let Some(me) = outer_rect(window) {
if let Some(work) = work_area(&app, me.0, me.1) {
let (x, y) = free_spot(me, &others, work);
if (x, y) != (me.0, me.1) {
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
}
book(&app, |b| {
if let Some(p) = b.panels.get_mut(&label) {
p.pos = Some((x, y));
}
});
}
}
}
let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" });
save_pins(&app);
}
/// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place.
pub fn select(window: &WebviewWindow, id: String) {
let app = window.app_handle().clone();
let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false));
if !pinned {
dismiss(&app);
}
let handle = app.clone();
let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id));
}
}
#[cfg(desktop)]
pub use runtime::*;

View file

@ -43,6 +43,30 @@ pub fn require_main(label: &str, url: &Url, dev: bool) -> Result<(), String> {
Ok(()) Ok(())
} }
/// The tray card window (card.rs): it may call only the card commands (`card_get`, `card_action`).
pub const CARD: &str = "card";
pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> {
if label != CARD {
return Err(format!("not allowed from window '{label}'"));
}
if !is_local_origin(url, dev) {
return Err("not allowed from this page".into());
}
Ok(())
}
/// The tray menu panels (`menu-1`, `menu-2`... menu_panel.rs): they may call only the menu commands.
pub fn require_menu(label: &str, url: &Url, dev: bool) -> Result<(), String> {
if !crate::menu_panel::is_menu_label(label) {
return Err(format!("not allowed from window '{label}'"));
}
if !is_local_origin(url, dev) {
return Err("not allowed from this page".into());
}
Ok(())
}
/// Method kinds the sidecar announces in its `session.hello` descriptors. /// Method kinds the sidecar announces in its `session.hello` descriptors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind { pub enum Kind {
@ -174,9 +198,14 @@ pub fn project_relative(rel: &str) -> Option<std::path::PathBuf> {
Some(p.to_path_buf()) Some(p.to_path_buf())
} }
/// What `project_reveal` may open (not only show in the file manager): a folder, or the gallery page storebound writes. /// What `project_reveal` may open (not only show in the file manager): a folder, the gallery page storebound writes,
/// or a readiness report page of `storebound report` (.storebound/reports/readiness-*.html, no scripts in it).
pub fn openable(rel: &std::path::Path, is_dir: bool) -> bool { pub fn openable(rel: &std::path::Path, is_dir: bool) -> bool {
is_dir || rel.file_name().and_then(|n| n.to_str()) == Some("index.html") if is_dir || rel.file_name().and_then(|n| n.to_str()) == Some("index.html") {
return true;
}
let parts: Vec<&str> = rel.components().filter_map(|c| c.as_os_str().to_str()).collect();
matches!(parts.as_slice(), [".storebound", "reports", name] if name.starts_with("readiness-") && name.ends_with(".html") && name.len() <= 80)
} }
/// Hosts `open_external` may open in the browser: the two store consoles and storebound's own site. /// Hosts `open_external` may open in the browser: the two store consoles and storebound's own site.
@ -185,6 +214,8 @@ pub const EXTERNAL_HOSTS: &[&str] = &[
"appstoreconnect.apple.com", "appstoreconnect.apple.com",
"developer.apple.com", "developer.apple.com",
"storebound.dev", "storebound.dev",
// the guides (skills/<name>/SKILL.md) of check findings, in storebound's public repo
"git.crt.fyi",
// Dependencies screen link-outs // Dependencies screen link-outs
"developer.android.com", "developer.android.com",
"ffmpeg.org", "ffmpeg.org",

View file

@ -56,6 +56,11 @@ pub fn slug(label: &str) -> String {
/// The CLI workspace's account id for a profile's store: sha256("<apple|play>:<profile>"), 16 hex (cli/lib/workspace.ts /// The CLI workspace's account id for a profile's store: sha256("<apple|play>:<profile>"), 16 hex (cli/lib/workspace.ts
/// accountId), so the same account has the same id in `storebound.workspace.json` and in the app. /// accountId), so the same account has the same id in `storebound.workspace.json` and in the app.
/// A Play developer account id: 1-25 digits.
pub fn valid_developer_id(v: &str) -> bool {
!v.is_empty() && v.len() <= 25 && v.bytes().all(|b| b.is_ascii_digit())
}
pub fn account_id(kind: AccountKind, profile: &str) -> String { pub fn account_id(kind: AccountKind, profile: &str) -> String {
let store = match kind { let store = match kind {
AccountKind::Apple => "apple", AccountKind::Apple => "apple",
@ -139,6 +144,24 @@ impl ProfileStore {
out out
} }
/// The Play developer account id a profile keeps (PLAY_DEVELOPER_ID, digits), if any.
pub fn play_developer_id(&self, name: &str) -> Option<String> {
self.read(name).ok().and_then(|m| str_of(&m, "PLAY_DEVELOPER_ID").map(str::trim).filter(|v| valid_developer_id(v)).map(str::to_string))
}
/// Sets or clears (None) PLAY_DEVELOPER_ID in an existing profile; every other field stays.
pub fn set_play_developer_id(&self, name: &str, id: Option<&str>) -> Result<(), String> {
let mut m = self.read(name)?;
match id {
Some(v) if valid_developer_id(v) => { m.insert("PLAY_DEVELOPER_ID".into(), Value::String(v.to_string())); }
Some(_) => return Err("the Play developer id is the digits in play.google.com/console/u/0/developers/<id>".into()),
None => { m.remove("PLAY_DEVELOPER_ID"); }
}
let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "
";
write_private(&self.dir.join(format!("{name}.json")), text.as_bytes())
}
/// The label the profile carries, if any (written by the app; the CLI ignores it). /// The label the profile carries, if any (written by the app; the CLI ignores it).
pub fn label(&self, name: &str) -> Option<String> { pub fn label(&self, name: &str) -> Option<String> {
self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string)) self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string))
@ -367,7 +390,7 @@ pub fn sync(accounts: &mut Vec<Account>, store: &ProfileStore, legacy: &dyn Secr
if accounts.iter().any(|a| a.id == id) { if accounts.iter().any(|a| a.id == id) {
continue; continue;
} }
accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()) }); accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()), developer_id: None });
r.added.push(format!("{id} ({name})")); r.added.push(format!("{id} ({name})"));
} }
} }

View file

@ -76,6 +76,16 @@ pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> {
// the store-only entries the CLI wrote stay as they are // the store-only entries the CLI wrote stay as they are
let keep = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v.get("storeApps").cloned()).filter(|s| s.as_array().is_some_and(|a| !a.is_empty())); let keep = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v.get("storeApps").cloned()).filter(|s| s.as_array().is_some_and(|a| !a.is_empty()));
let mut doc = serde_json::json!({ "version": 1, "projects": entries }); let mut doc = serde_json::json!({ "version": 1, "projects": entries });
// the folders' `ignored` flags (written by the CLI or by push_ignored) stay with their folder
let old = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
if let (Some(old), Some(list)) = (old.as_ref().and_then(|o| o.get("projects")).and_then(Value::as_array), doc.get_mut("projects").and_then(Value::as_array_mut)) {
for e in list {
let ig = e.get("path").and_then(Value::as_str).and_then(|path| old.iter().find(|o| o.get("path").and_then(Value::as_str) == Some(path))).and_then(|o| o.get("ignored")).cloned();
if let (Some(ig), Some(o)) = (ig, e.as_object_mut()) {
o.insert("ignored".into(), ig);
}
}
}
if let Some(s) = keep { if let Some(s) = keep {
doc["storeApps"] = s; doc["storeApps"] = s;
} }
@ -217,3 +227,139 @@ pub fn publish(state: &State, file: &Path, removed: &[String]) -> Result<(), Str
} }
write(file, &list) write(file, &list)
} }
// ---- ignored apps: the registry's `ignored` flags (cli/lib/registry.ts) <-> the app's ignore keys ----
// projects: "ignored": ["apple", "play"] (both = the whole app) <-> "project:<id>" (both) / "project:<id>:ios" / "project:<id>:android"
// storeApps: "ignored": ["apple"] <-> "app:ios:<account id>:<app id>" (android for play)
// The registry is the truth for the apps it lists: `pull_ignored` (start, list shown) replaces the app's keys for those
// apps with the registry's flags, `push_ignored` (the owner ignores or restores in the app) writes them back. Apps
// that are not in the registry (listed by a store account only) keep their keys in the app's state alone.
const SIDES: [&str; 2] = ["apple", "play"];
fn word_of(side: &str) -> &'static str {
if side == "apple" { "ios" } else { "android" }
}
fn sides_in(v: &Value) -> Vec<String> {
let have: Vec<&str> = v.get("ignored").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
SIDES.iter().filter(|s| have.contains(s)).map(|s| s.to_string()).collect()
}
fn is_project_key_of(k: &str, id: &str) -> bool {
k == format!("project:{id}") || k.starts_with(&format!("project:{id}:"))
}
/// The ignore keys of one folder for its ignored store sides (pure).
pub fn project_keys(id: &str, sides: &[String]) -> Vec<String> {
if SIDES.iter().all(|s| sides.iter().any(|x| x == s)) {
return vec![format!("project:{id}")];
}
sides.iter().filter(|s| SIDES.contains(&s.as_str())).map(|s| format!("project:{id}:{}", word_of(s))).collect()
}
/// The ignored store sides of a folder, from the app's ignore keys (pure).
pub fn project_sides(keys: &[String], id: &str) -> Vec<String> {
let mut on = [false, false];
for k in keys {
if *k == format!("project:{id}") {
on = [true, true];
} else if *k == format!("project:{id}:ios") {
on[0] = true;
} else if *k == format!("project:{id}:android") {
on[1] = true;
}
}
SIDES.iter().enumerate().filter(|(i, _)| on[*i]).map(|(_, s)| s.to_string()).collect()
}
/// Whether an ignore key "app:<ios|android>:<account>:<app id>" is this store app (pure).
pub fn app_key_matches(k: &str, store: &str, app_id: &str) -> bool {
let Some(rest) = k.strip_prefix("app:") else { return false };
rest.split(':').next() == Some(word_of(store)) && rest.rsplit(':').next() == Some(app_id)
}
/// The account (id) that reads a store-only entry: the one with its credential profile, else the only account of that store.
pub fn account_for(accounts: &[crate::accounts::Account], store: &str, profile: Option<&str>) -> Option<String> {
use crate::accounts::AccountKind;
let kind = if store == "apple" { AccountKind::Apple } else { AccountKind::Play };
let of_kind: Vec<_> = accounts.iter().filter(|a| a.kind == kind).collect();
match profile {
Some(p) => of_kind.iter().find(|a| a.profile.as_deref() == Some(p)).map(|a| a.id.clone()),
None => (of_kind.len() == 1).then(|| of_kind[0].id.clone()),
}
}
fn parse_doc(file: &Path) -> Option<Value> {
serde_json::from_str::<Value>(&fs::read_to_string(file).ok()?).ok()
}
/// Registry flags -> the app's ignore keys. True when the keys changed.
pub fn pull_ignored(state: &mut State, file: &Path) -> bool {
let Some(doc) = parse_doc(file) else { return false };
let before = state.overrides.ignored.clone();
let mut keys = before.clone();
for p in doc.get("projects").and_then(Value::as_array).into_iter().flatten() {
let Some(path) = p.get("path").and_then(Value::as_str) else { continue };
let Some(id) = state.projects.iter().find(|x| x.path == path).map(|x| x.id.clone()) else { continue };
keys.retain(|k| !is_project_key_of(k, &id));
keys.extend(project_keys(&id, &sides_in(p)));
}
for a in doc.get("storeApps").and_then(Value::as_array).into_iter().flatten() {
let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str), a.get("appId").and_then(Value::as_str)) else { continue };
let Some(acct) = account_for(&state.accounts, store, a.get("account").and_then(Value::as_str)) else { continue };
keys.retain(|k| !app_key_matches(k, store, app_id));
if sides_in(a).iter().any(|s| s == store) {
keys.push(format!("app:{}:{acct}:{app_id}", word_of(store)));
}
}
let mut seen = std::collections::HashSet::new();
keys.retain(|k| seen.insert(k.clone()));
if keys == before {
return false;
}
state.overrides.ignored = keys;
true
}
/// The app's ignore keys -> registry flags (only the apps the registry lists). Writes when something changed.
pub fn push_ignored(state: &State, file: &Path) -> Result<bool, String> {
let Some(mut doc) = parse_doc(file) else { return Ok(false) };
let keys = &state.overrides.ignored;
let mut changed = false;
let mut set = |entry: &mut Value, sides: Vec<String>| {
if sides_in(entry) == sides {
return;
}
changed = true;
if let Some(o) = entry.as_object_mut() {
if sides.is_empty() {
o.remove("ignored");
} else {
o.insert("ignored".into(), serde_json::json!(sides));
}
}
};
if let Some(list) = doc.get_mut("projects").and_then(Value::as_array_mut) {
for p in list {
let Some(id) = p.get("path").and_then(Value::as_str).and_then(|path| state.projects.iter().find(|x| x.path == path)).map(|x| x.id.clone()) else { continue };
set(p, project_sides(keys, &id));
}
}
if let Some(list) = doc.get_mut("storeApps").and_then(Value::as_array_mut) {
for a in list {
let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str).map(str::to_string), a.get("appId").and_then(Value::as_str).map(str::to_string)) else { continue };
if account_for(&state.accounts, &store, a.get("account").and_then(Value::as_str)).is_none() {
continue;
}
let on = keys.iter().any(|k| app_key_matches(k, &store, &app_id));
set(a, if on { vec![store] } else { vec![] });
}
}
if changed {
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n";
let tmp = file.with_extension(format!("tmp-{}", std::process::id()));
fs::write(&tmp, text).map_err(|e| e.to_string())?;
fs::rename(&tmp, file).map_err(|e| e.to_string())?;
}
Ok(changed)
}

View file

@ -45,11 +45,14 @@ pub struct State {
pub overrides: Overrides, pub overrides: Overrides,
#[serde(default)] #[serde(default)]
pub settings: Settings, pub settings: Settings,
/// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start.
#[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")]
pub menu_pins: Vec<crate::menu_panel::PinRecord>,
} }
impl Default for State { impl Default for State {
fn default() -> State { fn default() -> State {
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() } State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] }
} }
} }
@ -121,6 +124,7 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec<String>) {
studios: list(&o, "studios", &mut notes), studios: list(&o, "studios", &mut notes),
overrides: one(&o, "overrides", &mut notes), overrides: one(&o, "overrides", &mut notes),
settings: one(&o, "settings", &mut notes), settings: one(&o, "settings", &mut notes),
menu_pins: list(&o, "menuPins", &mut notes),
}; };
// two entries with one id (hand-edited or merged files): the first one wins // two entries with one id (hand-edited or merged files): the first one wins
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();

View file

@ -0,0 +1,286 @@
//! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification.
//!
//! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model
//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), sets the tooltip and swaps the icon for one with a red dot while
//! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and
//! `tray-action` (`read`, `mute-toggle`).
//!
//! Clicking a desktop notification: the notification plugin has no click callback on Windows, macOS or Linux (its
//! actions are mobile only). So `notify` remembers where the notification points (`set_pending`) and `show_main`
//! hands it to the window as a `tray-route` the next time the window is shown from the tray or the Dock.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::Value;
use tauri::image::Image;
use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::menu_panel::{from_entries, Node};
use tauri::{AppHandle, Runtime};
pub const ID_OPEN: &str = "tray-open";
pub const ID_READ: &str = "tray-read";
pub const ID_MUTE: &str = "tray-mute";
pub const ID_UPDATE: &str = "tray-update";
pub const ID_SETTINGS: &str = "tray-settings";
pub const ID_QUIT: &str = "tray-quit";
pub const ID_APP_PREFIX: &str = "tray-app:";
/// Apps listed in the submenu (more would not fit a menu anyway).
pub const MAX_APPS: usize = 30;
/// Windows cuts a tooltip at 127 UTF-16 units; stay under it everywhere.
pub const MAX_TIP: usize = 120;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct TrayApp {
/// The app's key in the window (opens it).
pub key: String,
pub name: String,
/// Plain words: "App Store: In review · Play: Live".
pub state: String,
}
/// The menu words in the window's language.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TrayLabels {
pub open: String,
pub read_now: String,
pub apps: String,
pub no_apps: String,
pub mute: String,
pub unmute: String,
pub update: String,
pub settings: String,
pub quit: String,
}
impl Default for TrayLabels {
fn default() -> TrayLabels {
TrayLabels {
open: "Open storebound".into(),
read_now: "Read the stores now".into(),
apps: "Apps".into(),
no_apps: "No apps yet".into(),
mute: "Mute notifications for 1 hour".into(),
unmute: "Unmute notifications".into(),
update: "Check for updates".into(),
settings: "Settings".into(),
quit: "Quit storebound".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct TraySummary {
pub tooltip: String,
/// Apps that need the owner (rejected, halted): above 0 the icon gets a red dot.
pub attention: u32,
/// Notifications are muted right now.
pub muted: bool,
pub apps: Vec<TrayApp>,
pub labels: TrayLabels,
}
/// One menu entry, before it is drawn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Entry {
Item { id: String, label: String, enabled: bool },
Separator,
/// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language.
Sub { key: String, label: String, items: Vec<Entry> },
}
fn clean(s: &str, max: usize) -> String {
let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let one = one.split_whitespace().collect::<Vec<_>>().join(" ");
if one.chars().count() <= max {
return one;
}
let mut cut: String = one.chars().take(max.saturating_sub(1)).collect();
cut.push('…');
cut
}
/// The tooltip: the window's text, one line, short; "storebound" when it sent none.
pub fn tooltip_text(s: &TraySummary) -> String {
let t = clean(&s.tooltip, MAX_TIP);
if t.is_empty() { "storebound".into() } else { t }
}
/// The context menu: Open, Read now, Apps (one entry per app with its state), Mute / Unmute, Check for updates,
/// Settings, Quit.
pub fn menu_model(s: &TraySummary) -> Vec<Entry> {
let item = |id: &str, label: &str| Entry::Item { id: id.into(), label: clean(label, 90), enabled: true };
let l = &s.labels;
let apps: Vec<Entry> = if s.apps.is_empty() {
vec![Entry::Item { id: "tray-none".into(), label: clean(&l.no_apps, 90), enabled: false }]
} else {
s.apps
.iter()
.filter(|a| !a.key.is_empty() && !a.key.chars().any(char::is_control))
.take(MAX_APPS)
.map(|a| {
let name = clean(&a.name, 40);
let state = clean(&a.state, 60);
let label = if state.is_empty() { name } else { format!("{name} · {state}") };
Entry::Item { id: format!("{ID_APP_PREFIX}{}", a.key.chars().take(200).collect::<String>()), label, enabled: true }
})
.collect()
};
vec![
item(ID_OPEN, &l.open),
item(ID_READ, &l.read_now),
Entry::Separator,
Entry::Sub { key: "apps".into(), label: clean(&l.apps, 40), items: apps },
Entry::Separator,
item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }),
item(ID_UPDATE, &l.update),
item(ID_SETTINGS, &l.settings),
Entry::Separator,
item(ID_QUIT, &l.quit),
]
}
/// What a menu click does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Show the window.
Open,
Quit,
/// Tell the window (no window needed): `tray-action` with this word.
Event(&'static str),
/// Show the window on this route (JSON for `tray-route`).
Route(String),
}
pub fn action_of(id: &str) -> Option<Action> {
match id {
ID_OPEN => Some(Action::Open),
ID_READ => Some(Action::Event("read")),
ID_MUTE => Some(Action::Event("mute-toggle")),
ID_UPDATE => Some(Action::Route(serde_json::json!({ "screen": "settings", "section": "updates" }).to_string())),
ID_SETTINGS => Some(Action::Route(serde_json::json!({ "screen": "settings" }).to_string())),
ID_QUIT => Some(Action::Quit),
_ => id.strip_prefix(ID_APP_PREFIX).filter(|k| !k.is_empty()).map(|k| Action::Route(serde_json::json!({ "screen": "app", "appId": k, "tab": "releases" }).to_string())),
}
}
/// A notification target the window may be sent to later: a small JSON object of strings with a known `screen`.
pub fn valid_route(json: &str) -> bool {
if json.len() > 400 {
return false;
}
let Ok(Value::Object(o)) = serde_json::from_str::<Value>(json) else { return false };
let screens = ["portfolio", "app", "apps", "accounts", "ignored", "dependencies", "settings"];
o.len() <= 4 && o.values().all(|v| v.as_str().is_some_and(|s| s.len() <= 300 && !s.chars().any(char::is_control))) && o.get("screen").and_then(Value::as_str).is_some_and(|s| screens.contains(&s))
}
/// The default icon with a red dot (white ring) in the top right corner, for "something needs you". RGBA in, RGBA out.
pub fn with_attention_dot(rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
let mut out = rgba.to_vec();
if w == 0 || h == 0 || out.len() != (w as usize) * (h as usize) * 4 {
return out;
}
let side = w.min(h) as f32;
let r = side * 0.27;
let (cx, cy) = (w as f32 - r - side * 0.02, r + side * 0.02);
let blend = |px: &mut [u8], color: [f32; 3], c: f32| {
if c <= 0.0 {
return;
}
let a = px[3] as f32 / 255.0;
let oa = a + (1.0 - a) * c;
for i in 0..3 {
// the dot covers what is under it by `c`; the alpha of the icon only grows
px[i] = (px[i] as f32 * (1.0 - c) + color[i] * c).round().clamp(0.0, 255.0) as u8;
}
px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8;
};
for y in 0..h {
for x in 0..w {
let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
let i = ((y * w + x) * 4) as usize;
let px = &mut out[i..i + 4];
blend(px, [255.0, 255.0, 255.0], (r + 0.5 - d).clamp(0.0, 1.0));
blend(px, [229.0, 57.0, 53.0], (r * 0.78 + 0.5 - d).clamp(0.0, 1.0));
}
}
out
}
// ---- where the last notification pointed ----
static PENDING: Mutex<Option<(String, Instant)>> = Mutex::new(None);
const PENDING_FOR: Duration = Duration::from_secs(2 * 3600);
/// Remembers the route (JSON) of the notification just shown. Ignores anything `valid_route` refuses.
pub fn set_pending(route: &str) {
if valid_route(route) {
if let Ok(mut p) = PENDING.lock() {
*p = Some((route.to_string(), Instant::now()));
}
}
}
/// The remembered route, once (it is forgotten after it is handed out, and after two hours).
pub fn take_pending() -> Option<String> {
let (route, at) = PENDING.lock().ok()?.take()?;
(at.elapsed() < PENDING_FOR).then_some(route)
}
// ---- drawing ----
static LAST: Mutex<Option<String>> = Mutex::new(None);
/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth.
fn native_items<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Vec<Box<dyn IsMenuItem<R>>>> {
let mut out: Vec<Box<dyn IsMenuItem<R>>> = Vec::new();
for n in nodes {
match n {
Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)),
Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)),
Node::Sub { label, items, .. } => {
let children = native_items(app, items)?;
let refs: Vec<&dyn IsMenuItem<R>> = children.iter().map(|b| b.as_ref()).collect();
out.push(Box::new(Submenu::with_items(app, label, true, &refs)?));
}
}
}
Ok(out)
}
/// The native tray menu, rendered from the same tree as the panels.
pub fn native_menu<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Menu<R>> {
let items = native_items(app, nodes)?;
let refs: Vec<&dyn IsMenuItem<R>> = items.iter().map(|b| b.as_ref()).collect();
Menu::with_items(app, &refs)
}
/// The tree before the window has sent anything (English words).
pub fn initial_nodes() -> Vec<Node> {
from_entries(&menu_model(&TraySummary::default()))
}
/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu
/// panels (and to the native menu when that is in use). Skips the work when nothing changed.
pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> {
let Some(tray) = app.tray_by_id("main") else { return Ok(()) };
let key = format!("{s:?}");
if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) {
return Ok(());
}
tray.set_tooltip(Some(tooltip_text(s)))?;
crate::menu_panel::set_nodes(app, from_entries(&menu_model(s)));
if let Some(icon) = app.default_window_icon() {
let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() };
tray.set_icon(Some(img))?;
}
if let Ok(mut l) = LAST.lock() {
*l = Some(key);
}
Ok(())
}

View file

@ -1,22 +1,111 @@
//! Signed updates from storebound.dev. The updater plugin checks `latest.json` of the chosen channel, downloads the //! Signed updates from storebound.dev. The updater plugin checks `latest.json` of the chosen channel, downloads the
//! platform's bundle and refuses it unless its minisign signature matches the public key in tauri.conf.json (the //! platform's bundle and refuses it unless its minisign signature matches the public key in tauri.conf.json (the
//! private key never leaves the release machine, ~/.storebound-keys). Nothing installs without the owner's click in //! private key never leaves the release machine, ~/.storebound-keys). Nothing installs without the owner's click in
//! a native dialog (commands::update_install). Pure parts here so tests can pin them. //! a native dialog (commands::update_install), or by the background updater (lib.rs auto_update). Pure parts here so tests can pin them.
use std::sync::atomic::{AtomicBool, Ordering};
use url::Url; use url::Url;
/// Set when Settings > Updates switched the channel: the background updater checks on its next tick.
pub static KICK: AtomicBool = AtomicBool::new(false);
pub fn kick() {
KICK.store(true, Ordering::SeqCst);
}
pub fn take_kick() -> bool {
KICK.swap(false, Ordering::SeqCst)
}
/// `latest.json` of each channel (static files on storebound.dev, published by tools/desktop-release.ts). /// `latest.json` of each channel (static files on storebound.dev, published by tools/desktop-release.ts).
pub const STABLE: &str = "https://storebound.dev/desktop/latest.json"; pub const STABLE: &str = "https://storebound.dev/desktop/latest.json";
pub const BETA: &str = "https://storebound.dev/desktop/beta/latest.json"; pub const BETA: &str = "https://storebound.dev/desktop/beta/latest.json";
/// Every push to main (Forgejo ci `dev` job, signed on the VDS): `<next patch>-dev.<UTC yyyymmddHHMMSS>`.
pub const DEV: &str = "https://storebound.dev/desktop/dev/latest.json";
/// The channels Settings > Updates accepts (beta is kept for saved states; the window offers stable and dev).
pub const CHANNELS: &[&str] = &["stable", "dev", "beta"];
/// The endpoint of a channel (Settings > Updates); anything unknown is the stable channel. /// The endpoint of a channel (Settings > Updates); anything unknown is the stable channel.
pub fn endpoint(channel: &str) -> &'static str { pub fn endpoint(channel: &str) -> &'static str {
if channel == "beta" { match channel {
BETA "beta" => BETA,
} else { "dev" => DEV,
STABLE _ => STABLE,
} }
} }
/// How often the background updater asks the channel (plus once at start and right after a resume from sleep).
pub const CHECK_EVERY_SECS: u64 = 15 * 60;
/// The background loop's tick; a wall-clock jump of more than this plus a minute between two ticks is a resume.
pub const TICK_SECS: u64 = 5;
/// An install waits while the main window is visible and focused, at most this many checks in a row (so an app that
/// stays open still updates).
pub const MAX_DEFERS: u32 = 4;
/// A semver version as (major, minor, patch) and its prerelease identifiers; None when it is not one.
fn parse(v: &str) -> Option<([u64; 3], Vec<String>)> {
let v = v.trim().trim_start_matches('v');
let v = v.split('+').next()?;
let (core, pre) = match v.split_once('-') {
Some((c, p)) => (c, p.split('.').map(str::to_string).collect::<Vec<_>>()),
None => (v, Vec::new()),
};
let mut n = core.split('.').map(|x| x.parse::<u64>().ok());
let r = [n.next()??, n.next()??, n.next()??];
if n.next().is_some() || pre.iter().any(String::is_empty) {
return None;
}
Some((r, pre))
}
/// semver 2.0 precedence, a > b: a release is above its own prereleases (0.8.48 > 0.8.48-dev.20261023120000), numeric
/// identifiers compare as numbers and sort below alphanumeric ones, a longer prerelease is above its prefix.
pub fn newer(a: &str, b: &str) -> bool {
use std::cmp::Ordering::{Equal, Greater, Less};
let (Some((ac, ap)), Some((bc, bp))) = (parse(a), parse(b)) else { return false };
match ac.cmp(&bc) {
Greater => return true,
Less => return false,
Equal => {}
}
match (ap.is_empty(), bp.is_empty()) {
(true, true) | (false, true) => return false,
(true, false) => return true,
_ => {}
}
for (x, y) in ap.iter().zip(bp.iter()) {
let o = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(i), Ok(j)) => i.cmp(&j),
(Ok(_), Err(_)) => Less,
(Err(_), Ok(_)) => Greater,
_ => x.cmp(y),
};
if o != Equal {
return o == Greater;
}
}
ap.len() > bp.len()
}
/// The no-downgrade rule: an update installs only when the channel's version is strictly newer than this build. A dev
/// install switched back to stable stays on its dev build until a stable release passes it.
pub fn should_install(current: &str, offered: &str) -> bool {
newer(offered, current)
}
/// The background updater defers an install while the owner works in the main window (visible and focused), at most
/// MAX_DEFERS checks in a row. A hidden window can report focus, so only a visible one counts.
pub fn defer_install(visible: bool, focused: bool, defers_so_far: u32) -> bool {
visible && focused && defers_so_far < MAX_DEFERS
}
/// Whether the background updater asks the channel now: the first tick, a kick (channel switched), a resume from sleep
/// (the wall clock moved much further than one tick), or CHECK_EVERY_SECS since the last check.
pub fn check_due(first: bool, kicked: bool, wall_gap_secs: u64, since_check_secs: u64) -> bool {
first || kicked || wall_gap_secs > TICK_SECS + 60 || since_check_secs >= CHECK_EVERY_SECS
}
/// Only storebound.dev over HTTPS, without port or user info, may serve updates. /// Only storebound.dev over HTTPS, without port or user info, may serve updates.
pub fn endpoint_allowed(url: &Url) -> bool { pub fn endpoint_allowed(url: &Url) -> bool {
url.scheme() == "https" && url.host_str() == Some("storebound.dev") && url.port().is_none() && url.username().is_empty() && url.password().is_none() && url.path().starts_with("/desktop/") url.scheme() == "https" && url.host_str() == Some("storebound.dev") && url.port().is_none() && url.username().is_empty() && url.password().is_none() && url.path().starts_with("/desktop/")

View file

@ -269,9 +269,39 @@ pub struct CacheSettings {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct NotifySettings { pub struct NotifySettings {
/// Other release state changes (submitted, in review).
pub review_state: bool, pub review_state: bool,
/// A version the store rejected.
pub rejected: bool,
/// A version that passed review (ready for release).
pub approved: bool,
/// A version that went live or started rolling out.
pub live: bool,
pub low_reviews: bool, pub low_reviews: bool,
/// A Play crash / ANR rate over the threshold, or a halted rollout.
pub crash: bool, pub crash: bool,
pub quiet_hours: QuietHours,
}
/// No notification between `from` and `to` (HH:MM, local time; the window may cross midnight).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct QuietHours {
pub enabled: bool,
pub from: String,
pub to: String,
}
impl Default for QuietHours {
fn default() -> QuietHours {
QuietHours { enabled: false, from: "23:00".into(), to: "08:00".into() }
}
}
/// "HH:MM" with a valid hour and minute.
pub fn valid_hhmm(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 5 && b[2] == b':' && b.iter().enumerate().all(|(i, c)| i == 2 || c.is_ascii_digit()) && s[..2].parse::<u8>().is_ok_and(|h| h < 24) && s[3..].parse::<u8>().is_ok_and(|m| m < 60)
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -280,7 +310,7 @@ pub struct Settings {
pub refresh: RefreshSettings, pub refresh: RefreshSettings,
pub cache: CacheSettings, pub cache: CacheSettings,
pub notify: NotifySettings, pub notify: NotifySettings,
/// Update channel (placeholder until the updater ships): stable | beta. /// Update channel (Settings > Updates): stable | dev (beta kept for saved states). crate::updates::endpoint.
pub channel: String, pub channel: String,
} }
@ -300,7 +330,7 @@ impl Default for CacheSettings {
impl Default for NotifySettings { impl Default for NotifySettings {
fn default() -> NotifySettings { fn default() -> NotifySettings {
NotifySettings { review_state: true, low_reviews: true, crash: true } NotifySettings { review_state: true, rejected: true, approved: true, live: true, low_reviews: true, crash: true, quiet_hours: QuietHours::default() }
} }
} }
@ -327,8 +357,11 @@ pub fn check_settings(s: &Settings) -> Result<(), String> {
if !KEEP_DAYS.contains(&s.cache.keep_days) { if !KEEP_DAYS.contains(&s.cache.keep_days) {
return Err("keep saved reads: 0 (always), 7, 30, 90 or 365 days".into()); return Err("keep saved reads: 0 (always), 7, 30, 90 or 365 days".into());
} }
if !["stable", "beta"].contains(&s.channel.as_str()) { if !valid_hhmm(&s.notify.quiet_hours.from) || !valid_hhmm(&s.notify.quiet_hours.to) {
return Err("channel: stable or beta".into()); return Err("quiet hours: times as HH:MM (00:00 to 23:59)".into());
}
if !crate::updates::CHANNELS.contains(&s.channel.as_str()) {
return Err("channel: stable or dev".into());
} }
Ok(()) Ok(())
} }

View file

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "storebound", "productName": "storebound",
"mainBinaryName": "storebound-desktop", "mainBinaryName": "storebound-desktop",
"version": "0.8.2", "version": "0.8.77",
"identifier": "dev.storebound.desktop", "identifier": "dev.storebound.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@ -20,7 +20,8 @@
"minWidth": 880, "minWidth": 880,
"minHeight": 560, "minHeight": 560,
"resizable": true, "resizable": true,
"center": true "center": true,
"visible": false
} }
], ],
"security": { "security": {
@ -49,7 +50,7 @@
"form-action": "'none'" "form-action": "'none'"
}, },
"freezePrototype": true, "freezePrototype": true,
"capabilities": ["main-window"] "capabilities": ["main-window", "card-window", "menu-window"]
} }
}, },
"bundle": { "bundle": {
@ -87,6 +88,7 @@
}, },
"updater": { "updater": {
"endpoints": ["https://storebound.dev/desktop/latest.json"], "endpoints": ["https://storebound.dev/desktop/latest.json"],
"windows": { "installMode": "quiet" },
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK"
} }
} }

View file

@ -9,7 +9,7 @@ const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PR
#[test] #[test]
fn shown_metadata_carries_no_secret() { fn shown_metadata_carries_no_secret() {
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()) }; let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()), developer_id: None };
let v = serde_json::to_value(&a).unwrap(); let v = serde_json::to_value(&a).unwrap();
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "profile", "studio"]); assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "profile", "studio"]);
assert_eq!(v["kind"], "play"); assert_eq!(v["kind"], "play");

View file

@ -0,0 +1,178 @@
//! Fix with AI (src/ai_fix.rs): the prompt, the dirty-tree refusal, Undo restricted to the run's own files, the
//! timeout and kill by PID, and that claude only ever gets the file tools.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(unix)]
use std::time::{Duration, Instant};
use storebound_desktop::ai_fix::*;
fn finding() -> AiFinding {
AiFinding {
rule: "ats".into(),
guideline: "Apple: App Transport Security".into(),
store: "apple".into(),
title: "NSAllowsArbitraryLoads is true".into(),
evidence: vec![
Evidence { file: "ios/App/Info.plist".into(), line: 42, text: Some("NSAllowsArbitraryLoads\ttrue".into()) },
Evidence { file: "../outside.txt".into(), line: 1, text: None },
],
fix: "Remove NSAllowsArbitraryLoads; add exception domains only where needed.".into(),
skill: "review-rejections".into(),
}
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("sb-aifix-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git").current_dir(dir).args(args).env("GIT_AUTHOR_NAME", "T").env("GIT_AUTHOR_EMAIL", "t@example.com").env("GIT_COMMITTER_NAME", "T").env("GIT_COMMITTER_EMAIL", "t@example.com").output().unwrap();
assert!(ok.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&ok.stderr));
}
fn repo(name: &str) -> PathBuf {
let d = tmp(name);
git(&d, &["init", "-q"]);
fs::create_dir_all(d.join("app")).unwrap();
fs::write(d.join("app/Info.plist"), "a\n").unwrap();
fs::write(d.join("app/Other.swift"), "b\n").unwrap();
git(&d, &["add", "."]);
git(&d, &["commit", "-q", "-m", "init"]);
d
}
#[test]
fn prompt_names_the_finding_and_the_limits() {
let p = build_prompt(&finding()).unwrap();
for s in ["ats", "NSAllowsArbitraryLoads is true", "ios/App/Info.plist:42", "review-rejections", "Change only what this finding needs", "Do not run commands", "commit or push", "Never invent an answer"] {
assert!(p.contains(s), "missing {s}");
}
// evidence outside the folder is left out; control characters are flattened
assert!(!p.contains("outside.txt"));
assert!(!p.contains('\t'));
let mut bad = finding();
bad.rule = "ats; rm -rf".into();
assert!(build_prompt(&bad).is_err());
bad = finding();
bad.store = "web".into();
assert!(build_prompt(&bad).is_err());
bad = finding();
bad.title = "x".repeat(5000);
assert!(build_prompt(&bad).is_err());
}
#[test]
fn claude_gets_only_the_file_tools() {
let a = claude_args("hi");
assert_eq!(a[0], "-p");
let allowed = &a[a.iter().position(|x| x == "--allowedTools").unwrap() + 1];
assert_eq!(allowed, "Read,Edit,Write,Grep,Glob");
let denied = &a[a.iter().position(|x| x == "--disallowedTools").unwrap() + 1];
for t in ["Bash", "WebFetch", "WebSearch", "Task"] {
assert!(denied.split(',').any(|x| x == t), "{t} not denied");
}
assert!(!a.iter().any(|x| x.contains("dangerously") || x == "bypassPermissions"));
}
#[test]
fn finds_claude_in_the_usual_folders() {
let d = tmp("which");
let home = d.join("home");
fs::create_dir_all(home.join(".local/bin")).unwrap();
let dirs = search_path(Some(""), Some(&home));
assert!(find_claude_in(&dirs).is_none() || !find_claude_in(&dirs).unwrap().starts_with(&home));
let name = if cfg!(windows) { "claude.exe" } else { "claude" };
fs::write(home.join(".local/bin").join(name), "").unwrap();
assert_eq!(find_claude_in(&dirs).unwrap(), home.join(".local/bin").join(name));
}
#[test]
fn dirty_tree_is_reported_and_clean_tree_is_empty() {
let d = repo("dirty");
let (_, clean) = status(&d.join("app")).unwrap();
assert!(clean.is_empty());
fs::write(d.join("app/Other.swift"), "changed\n").unwrap();
fs::write(d.join("app/new.txt"), "n\n").unwrap();
let (_, dirty) = status(&d.join("app")).unwrap();
let mut paths: Vec<(String, String)> = dirty.iter().map(|c| (c.path.clone(), c.status.clone())).collect();
paths.sort();
assert_eq!(paths, vec![("app/Other.swift".into(), "M".into()), ("app/new.txt".into(), "??".into())]);
// not a repository: refused with a reason
assert!(status(&tmp("nogit")).unwrap_err().contains("git"));
}
#[test]
fn undo_restores_only_the_runs_files() {
let d = repo("undo");
// the run changed Info.plist and created new.txt; Other.swift was changed later by the owner, not by the run
fs::write(d.join("app/Info.plist"), "fixed\n").unwrap();
fs::write(d.join("app/new.txt"), "n\n").unwrap();
let (_, run) = status(&d.join("app")).unwrap();
fs::write(d.join("app/Other.swift"), "owner edit\n").unwrap();
let diff_text = diff(&d, &run);
assert!(diff_text.contains("+fixed") && diff_text.contains("+++ b/app/new.txt"));
let done = undo(&d, &run).unwrap();
assert_eq!(done.len(), 2);
assert_eq!(fs::read_to_string(d.join("app/Info.plist")).unwrap(), "a\n");
assert!(!d.join("app/new.txt").exists());
assert_eq!(fs::read_to_string(d.join("app/Other.swift")).unwrap(), "owner edit\n");
// paths outside the repository never reach git or the file system
let evil = vec![Change { path: "../x".into(), status: "M".into() }, Change { path: "/etc/hosts".into(), status: "??".into() }, Change { path: "-f".into(), status: "M".into() }];
assert_eq!(undo_plan(&evil), (vec![], vec![]));
}
#[test]
fn status_parser_handles_renames_and_new_files() {
let z = "R new.txt\0old.txt\0 M a b.txt\0?? n/x.txt\0";
assert_eq!(parse_status(z), vec![
Change { path: "new.txt".into(), status: "R".into() },
Change { path: "a b.txt".into(), status: "M".into() },
Change { path: "n/x.txt".into(), status: "??".into() },
]);
}
#[test]
fn stream_json_lines_become_progress() {
assert_eq!(progress_line(r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"ios/App/Info.plist"}}]}}"#).unwrap(), "Edit ios/App/Info.plist");
assert_eq!(progress_line(r#"{"type":"result","result":"Removed NSAllowsArbitraryLoads."}"#).unwrap(), "Removed NSAllowsArbitraryLoads.");
assert!(progress_line(r#"{"type":"system","subtype":"init"}"#).is_none());
assert!(progress_line("not json").is_none());
}
#[cfg(unix)]
#[test]
fn timeout_kills_the_process_group_with_heartbeats() {
let mut c = Command::new("sh");
c.args(["-c", "sleep 30 & sleep 30; echo never"]);
let t = Instant::now();
let mut lines = Vec::new();
let o = run(c, "t-timeout", Duration::from_millis(800), Duration::from_millis(300), |l| lines.push(l.to_string())).unwrap();
assert!(t.elapsed() < Duration::from_secs(10));
assert!(o.timed_out);
assert!(lines.iter().any(|l| l.starts_with("still working")));
assert!(lines.iter().any(|l| l.starts_with("timed out")));
assert!(!lines.iter().any(|l| l.contains("never")));
}
#[cfg(unix)]
#[test]
fn cancel_kills_by_pid() {
let h = std::thread::spawn(|| {
let mut c = Command::new("sh");
c.args(["-c", "sleep 30"]);
run(c, "t-cancel", Duration::from_secs(60), Duration::from_secs(60), |_| {}).unwrap()
});
let end = Instant::now() + Duration::from_secs(5);
while !cancel("t-cancel") {
assert!(Instant::now() < end, "never started");
std::thread::sleep(Duration::from_millis(50));
}
let o = h.join().unwrap();
assert!(o.cancelled && !o.timed_out);
assert!(!cancel("t-cancel"));
}

View file

@ -0,0 +1,77 @@
//! The tray card's placement next to the tray icon, always inside the monitor's work area.
use storebound_desktop::card::*;
const W: f64 = 360.0;
const H: f64 = 480.0;
fn r(x: f64, y: f64, w: f64, h: f64) -> Rect { Rect { x, y, w, h } }
fn inside(p: (f64, f64), work: Rect) {
assert!(p.0 >= work.x && p.0 + W <= work.x + work.w, "x {p:?} outside {work:?}");
assert!(p.1 >= work.y && p.1 + H <= work.y + work.h, "y {p:?} outside {work:?}");
}
#[test]
fn windows_bottom_taskbar_opens_above_the_icon() {
let work = r(0.0, 0.0, 1920.0, 1032.0);
let p = place(r(1700.0, 1040.0, 24.0, 32.0), work, W, H, 8.0);
assert_eq!(p, (1532.0, 1032.0 - H - 8.0));
inside(p, work);
}
#[test]
fn icon_in_the_corner_is_clamped_into_the_work_area() {
let work = r(0.0, 0.0, 1920.0, 1032.0);
let p = place(r(1900.0, 1040.0, 24.0, 32.0), work, W, H, 8.0);
assert_eq!(p.0, 1920.0 - W - 8.0);
inside(p, work);
}
#[test]
fn macos_menu_bar_opens_below_the_icon() {
let work = r(0.0, 25.0, 1440.0, 875.0);
let p = place(r(1200.0, 0.0, 22.0, 24.0), work, W, H, 8.0);
assert_eq!(p.1, 33.0);
inside(p, work);
}
#[test]
fn side_taskbars_open_beside_the_icon() {
let work = r(60.0, 0.0, 1860.0, 1080.0);
let left = place(r(10.0, 900.0, 40.0, 40.0), work, W, H, 8.0);
assert_eq!(left.0, 68.0);
inside(left, work);
let work = r(0.0, 0.0, 1860.0, 1080.0);
let right = place(r(1870.0, 100.0, 40.0, 40.0), work, W, H, 8.0);
assert_eq!(right.0, 1860.0 - W - 8.0);
inside(right, work);
}
#[test]
fn second_monitor_with_negative_origin() {
let work = r(-2560.0, 0.0, 2560.0, 1400.0);
let p = place(r(-200.0, 1410.0, 24.0, 30.0), work, W, H, 8.0);
inside(p, work);
}
#[test]
fn icon_inside_the_work_area_and_a_tiny_screen() {
let work = r(0.0, 0.0, 1920.0, 1080.0);
inside(place(r(900.0, 1000.0, 24.0, 24.0), work, W, H, 8.0), work);
inside(place(r(900.0, 20.0, 24.0, 24.0), work, W, H, 8.0), work);
// smaller than the card: top-left of the work area, no panic
let tiny = r(0.0, 0.0, 300.0, 300.0);
assert_eq!(place(r(100.0, 310.0, 20.0, 20.0), tiny, W, H, 8.0), (8.0, 8.0));
}
#[test]
fn actions_and_snapshots_are_checked() {
for a in ["hide", "open", "route", "refresh", "mute", "quit", "external", "ready"] {
assert!(valid_action(a));
}
assert!(!valid_action("sidecar_call"));
assert!(!set_snapshot(serde_json::json!([1, 2])));
assert!(!set_snapshot(serde_json::json!({ "x": "y".repeat(MAX_SNAPSHOT) })));
assert!(set_snapshot(serde_json::json!({ "busy": false })));
assert_eq!(snapshot().unwrap()["busy"], false);
}

View file

@ -32,6 +32,10 @@ fn app_folder_paths() {
assert!(openable(Path::new("store/renders/index.html"), false)); assert!(openable(Path::new("store/renders/index.html"), false));
assert!(openable(Path::new("store/renders"), true)); assert!(openable(Path::new("store/renders"), true));
assert!(!openable(Path::new("store/renders/run.sh"), false)); assert!(!openable(Path::new("store/renders/run.sh"), false));
assert!(openable(Path::new(".storebound/reports/readiness-2026-10-18.html"), false));
assert!(!openable(Path::new(".storebound/reports/readiness-2026-10-18.sh"), false));
assert!(!openable(Path::new(".storebound/credentials.json"), false));
assert!(!openable(Path::new("other/reports/readiness-2026-10-18.html"), false));
assert!(!openable(Path::new("store/prices.csv"), false)); assert!(!openable(Path::new("store/prices.csv"), false));
} }

View file

@ -41,10 +41,13 @@ fn updater_has_a_real_minisign_key_and_only_storebound_dev() {
assert_eq!(c["plugins"]["updater"]["endpoints"], serde_json::json!([updates::STABLE])); assert_eq!(c["plugins"]["updater"]["endpoints"], serde_json::json!([updates::STABLE]));
// updater bundles are signed by tools/desktop-release.ts on the release Mac, never by a CI runner // updater bundles are signed by tools/desktop-release.ts on the release Mac, never by a CI runner
assert_eq!(c["bundle"]["createUpdaterArtifacts"], false); assert_eq!(c["bundle"]["createUpdaterArtifacts"], false);
for ch in ["stable", "beta", "nightly", ""] { assert_eq!(c["plugins"]["updater"]["windows"]["installMode"], "quiet");
for ch in ["stable", "beta", "dev", "nightly", ""] {
assert!(updates::endpoint_allowed(&Url::parse(updates::endpoint(ch)).unwrap()), "{ch}"); assert!(updates::endpoint_allowed(&Url::parse(updates::endpoint(ch)).unwrap()), "{ch}");
} }
assert_eq!(updates::endpoint("beta"), updates::BETA); assert_eq!(updates::endpoint("beta"), updates::BETA);
assert_eq!(updates::endpoint("dev"), updates::DEV);
assert_eq!(updates::DEV, "https://storebound.dev/desktop/dev/latest.json");
assert_eq!(updates::endpoint("anything"), updates::STABLE); assert_eq!(updates::endpoint("anything"), updates::STABLE);
for bad in ["http://storebound.dev/desktop/latest.json", "https://storebound.dev:8443/desktop/latest.json", "https://evil.test/desktop/latest.json", "https://storebound.dev/latest.json"] { for bad in ["http://storebound.dev/desktop/latest.json", "https://storebound.dev:8443/desktop/latest.json", "https://evil.test/desktop/latest.json", "https://storebound.dev/latest.json"] {
assert!(!updates::endpoint_allowed(&Url::parse(bad).unwrap()), "{bad}"); assert!(!updates::endpoint_allowed(&Url::parse(bad).unwrap()), "{bad}");
@ -55,12 +58,12 @@ fn updater_has_a_real_minisign_key_and_only_storebound_dev() {
#[test] #[test]
fn open_deep_link_takes_one_plain_absolute_path() { fn open_deep_link_takes_one_plain_absolute_path() {
let p = |s: &str| deep_link_open_path(&Url::parse(s).unwrap()); let p = |s: &str| deep_link_open_path(&Url::parse(s).unwrap());
let abs = if cfg!(windows) { "C:%5Cgames%5CDemo App" } else { "%2FUsers%2Fyou%2Fgames%2FDemo App" }; let abs = if cfg!(windows) { "C:%5Cgames%5Cdemo-app" } else { "%2FUsers%2Fyou%2Fgames%2Fdemo-app" };
let want = if cfg!(windows) { "C:\\games\\Demo App" } else { "/Users/you/games/Demo App" }; let want = if cfg!(windows) { "C:\\games\\demo-app" } else { "/Users/you/games/demo-app" };
assert_eq!(p(&format!("storebound://open?path={abs}")), Some(PathBuf::from(want))); assert_eq!(p(&format!("storebound://open?path={abs}")), Some(PathBuf::from(want)));
assert_eq!(p(&format!("storebound://open/?path={abs}")), Some(PathBuf::from(want))); assert_eq!(p(&format!("storebound://open/?path={abs}")), Some(PathBuf::from(want)));
for bad in [ for bad in [
"storebound://open?path=games%2FDemo App".to_string(), "storebound://open?path=games%2Fdemo-app".to_string(),
"storebound://open?path=".to_string(), "storebound://open?path=".to_string(),
"storebound://open".to_string(), "storebound://open".to_string(),
format!("storebound://open?path={abs}&path={abs}"), format!("storebound://open?path={abs}&path={abs}"),
@ -103,3 +106,49 @@ fn deep_links_to_folders_ask_before_adding() {
let body = &cmds[at..]; let body = &cmds[at..];
assert!(body.find("confirm_dialog_with(").unwrap() < body.find("download_and_install(").unwrap()); assert!(body.find("confirm_dialog_with(").unwrap() < body.find("download_and_install(").unwrap());
} }
#[test]
fn dev_versions_sort_and_never_downgrade() {
// a dev build of the next patch is above the current stable, below that next stable, and later stamps go up
assert!(updates::newer("0.8.48-dev.20261023120000", "0.8.47"));
assert!(updates::newer("0.8.48", "0.8.48-dev.20261023120000"));
assert!(updates::newer("0.8.48-dev.20261023120001", "0.8.48-dev.20261023120000"));
assert!(updates::newer("0.8.48-dev.20261024000000", "0.8.48-dev.9"), "numeric identifiers compare as numbers");
assert!(updates::newer("0.8.49-dev.1", "0.8.48"));
assert!(updates::newer("0.10.0", "0.9.9"));
assert!(!updates::newer("0.8.48", "0.8.48"));
assert!(!updates::newer("nonsense", "0.8.48"));
// switched from dev back to stable: stays on the dev build until a stable release passes it
assert!(!updates::should_install("0.8.48-dev.20261023120000", "0.8.47"));
assert!(updates::should_install("0.8.48-dev.20261023120000", "0.8.48"));
assert!(updates::should_install("0.8.47", "0.8.48-dev.20261023120000"));
assert!(!updates::should_install("0.8.48-dev.20261023120000", "0.8.48-dev.20261023120000"));
}
#[test]
fn background_updater_schedule_and_defer() {
assert!(updates::check_due(true, false, 0, 0), "at start");
assert!(updates::check_due(false, true, 0, 0), "channel switched");
assert!(updates::check_due(false, false, 3600, 5), "resume from sleep");
assert!(updates::check_due(false, false, 5, updates::CHECK_EVERY_SECS));
assert!(!updates::check_due(false, false, 5, 60));
assert_eq!(updates::CHECK_EVERY_SECS, 900);
assert!(updates::defer_install(true, true, 0));
assert!(!updates::defer_install(true, true, updates::MAX_DEFERS), "an open window still updates after a few checks");
assert!(!updates::defer_install(false, true, 0), "a hidden window can report focus");
assert!(!updates::defer_install(true, false, 0));
let _ = updates::take_kick();
updates::kick();
assert!(updates::take_kick());
assert!(!updates::take_kick());
}
#[test]
fn startup_apps_choice_is_read_from_startup_approved() {
use storebound_desktop::startup_approved_off;
assert!(!startup_approved_off(&[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]));
assert!(!startup_approved_off(&[0x06, 0, 0, 0]));
assert!(startup_approved_off(&[0x03, 0, 0, 0, 0x20, 0xD1, 0xD3, 0xB1, 0xF0, 0xB3, 0xDC, 0x01]));
assert!(startup_approved_off(&[0x07]));
assert!(!startup_approved_off(&[]));
}

View file

@ -38,7 +38,7 @@ impl SecretStore for MapStore {
} }
fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account { fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account {
Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string) } Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string), developer_id: None }
} }
#[test] #[test]
@ -46,7 +46,7 @@ fn ids_match_the_cli() {
// cli/lib/workspace.ts accountId and cli/lib/registry.ts folderId give the same values // cli/lib/workspace.ts accountId and cli/lib/registry.ts folderId give the same values
assert_eq!(profiles::account_id(AccountKind::Apple, "acme"), "747351dbf2569d17"); assert_eq!(profiles::account_id(AccountKind::Apple, "acme"), "747351dbf2569d17");
assert_eq!(profiles::account_id(AccountKind::Play, "default"), "82a8c2561d950999"); assert_eq!(profiles::account_id(AccountKind::Play, "default"), "82a8c2561d950999");
assert_eq!(registry::folder_id("/w/Star Dash"), "f4994b9746e7eb18"); assert_eq!(registry::folder_id("/w/star-dash"), "c27311c5023dc3ba");
assert!(profiles::valid_name("default") && profiles::valid_name("acme-games.2") && !profiles::valid_name("../x") && !profiles::valid_name("-a")); assert!(profiles::valid_name("default") && profiles::valid_name("acme-games.2") && !profiles::valid_name("../x") && !profiles::valid_name("-a"));
assert_eq!(profiles::slug("Acme Games: Play!"), "acme-games-play"); assert_eq!(profiles::slug("Acme Games: Play!"), "acme-games-play");
assert_eq!(profiles::slug("???"), "account"); assert_eq!(profiles::slug("???"), "account");
@ -133,19 +133,19 @@ fn project(id: &str, path: &str, play: Option<&str>) -> Project {
fn the_folder_list_is_the_machines() { fn the_folder_list_is_the_machines() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("projects.json"); let file = dir.path().join("projects.json");
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", Some("acc"))], ..State::default() }; let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/star-dash", Some("acc"))], ..State::default() };
// first start: the app's list becomes the shared one // first start: the app's list becomes the shared one
assert!(!registry::sync(&mut st, &file).unwrap()); assert!(!registry::sync(&mut st, &file).unwrap());
assert_eq!(registry::read(&file).unwrap()[0].id, "aaaaaaaaaaaaaaaa"); assert_eq!(registry::read(&file).unwrap()[0].id, "aaaaaaaaaaaaaaaa");
// the CLI adds a folder (storebound desktop / apps add): the app shows it, the first keeps its accounts // the CLI adds a folder (storebound desktop / apps add): the app shows it, the first keeps its accounts
let mut list = registry::read(&file).unwrap(); let mut list = registry::read(&file).unwrap();
list.push(Entry { id: registry::folder_id("/w/a game"), path: "/w/a game".into(), name: "a game".into(), added: "t".into() }); list.push(Entry { id: registry::folder_id("/w/demo-game"), path: "/w/demo-game".into(), name: "demo-game".into(), added: "t".into() });
registry::write(&file, &list).unwrap(); registry::write(&file, &list).unwrap();
assert!(registry::sync(&mut st, &file).unwrap()); assert!(registry::sync(&mut st, &file).unwrap());
assert_eq!(st.projects.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["/w/Star Dash", "/w/a game"]); assert_eq!(st.projects.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["/w/star-dash", "/w/demo-game"]);
assert_eq!(st.projects[0].play_account.as_deref(), Some("acc")); assert_eq!(st.projects[0].play_account.as_deref(), Some("acc"));
// the CLI removes one (apps remove): it leaves the app with its overrides // the CLI removes one (apps remove): it leaves the app with its overrides
st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/a game"))); st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/demo-game")));
registry::write(&file, &list[..1]).unwrap(); registry::write(&file, &list[..1]).unwrap();
assert!(registry::sync(&mut st, &file).unwrap()); assert!(registry::sync(&mut st, &file).unwrap());
assert_eq!(st.projects.len(), 1); assert_eq!(st.projects.len(), 1);
@ -155,7 +155,7 @@ fn the_folder_list_is_the_machines() {
registry::write(&file, &[list[0].clone(), Entry { id: registry::folder_id("/w/cli"), path: "/w/cli".into(), name: "cli".into(), added: String::new() }]).unwrap(); registry::write(&file, &[list[0].clone(), Entry { id: registry::folder_id("/w/cli"), path: "/w/cli".into(), name: "cli".into(), added: String::new() }]).unwrap();
registry::publish(&st, &file, &[]).unwrap(); registry::publish(&st, &file, &[]).unwrap();
let paths: Vec<String> = registry::read(&file).unwrap().into_iter().map(|e| e.path).collect(); let paths: Vec<String> = registry::read(&file).unwrap().into_iter().map(|e| e.path).collect();
assert_eq!(paths, vec!["/w/Star Dash", "/w/third", "/w/cli"]); assert_eq!(paths, vec!["/w/star-dash", "/w/third", "/w/cli"]);
// bad entries are dropped, not the whole file // bad entries are dropped, not the whole file
assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::<Vec<_>>(), vec![registry::folder_id("/a")]); assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::<Vec<_>>(), vec![registry::folder_id("/a")]);
} }
@ -173,7 +173,7 @@ fn store_only_apps_join_their_profiles_account_and_survive_folder_writes() {
assert_eq!(apps.iter().map(|a| a.app_id.as_str()).collect::<Vec<_>>(), vec!["6400000999", "com.example.legacy"]); assert_eq!(apps.iter().map(|a| a.app_id.as_str()).collect::<Vec<_>>(), vec!["6400000999", "com.example.legacy"]);
assert_eq!(apps[1].bundle_id.as_deref(), Some("com.example.legacy")); assert_eq!(apps[1].bundle_id.as_deref(), Some("com.example.legacy"));
// the app writes its folder list: the store-only entries stay // the app writes its folder list: the store-only entries stay
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", None)], ..State::default() }; let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/star-dash", None)], ..State::default() };
registry::publish(&st, &file, &[]).unwrap(); registry::publish(&st, &file, &[]).unwrap();
assert_eq!(registry::read_store_apps(&file).len(), 2); assert_eq!(registry::read_store_apps(&file).len(), 2);
assert!(registry::sync(&mut st, &file).is_ok()); assert!(registry::sync(&mut st, &file).is_ok());
@ -207,3 +207,56 @@ fn a_folder_that_pins_another_storebound_says_so() {
assert!(!pin_differs(spec, "0.8.0"), "{spec}"); assert!(!pin_differs(spec, "0.8.0"), "{spec}");
} }
} }
#[test]
fn ignored_flags_travel_between_the_registry_and_the_apps_ignore_keys() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("projects.json");
// as `storebound apps ignore` wrote it: one folder fully, one store side of another, one store-only app
let (one, two) = (dir.path().join("one").to_string_lossy().to_string(), dir.path().join("two").to_string_lossy().to_string());
let doc = json!({ "version": 1, "projects": [
{ "id": "aaaaaaaaaaaaaaaa", "path": one, "name": "One", "added": "t", "ignored": ["apple", "play"] },
{ "id": "bbbbbbbbbbbbbbbb", "path": two, "name": "Two", "added": "t", "ignored": ["play"] }],
"storeApps": [{ "store": "apple", "appId": "6400000999", "account": "teamA", "name": "Demo", "ignored": ["apple"] }] });
std::fs::write(&file, doc.to_string()).unwrap();
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", &one, None), project("bbbbbbbbbbbbbbbb", &two, None)], ..State::default() };
st.accounts = vec![account("acct1", "Team A", AccountKind::Apple, Some("teamA"))];
st.overrides.ignored = vec!["account:zzz".into(), "app:ios:other:6400000999".into()];
assert!(registry::pull_ignored(&mut st, &file));
assert!(st.overrides.ignored.contains(&"account:zzz".to_string()));
assert!(st.overrides.ignored.contains(&"project:aaaaaaaaaaaaaaaa".to_string()));
assert!(st.overrides.ignored.contains(&"project:bbbbbbbbbbbbbbbb:android".to_string()));
assert!(st.overrides.ignored.contains(&"app:ios:acct1:6400000999".to_string()));
assert!(!st.overrides.ignored.contains(&"app:ios:other:6400000999".to_string()));
assert!(!registry::pull_ignored(&mut st, &file), "second pull changes nothing");
// the owner restores one folder's play side and ignores the other's apple side in the app
st.overrides.ignored.retain(|k| k != "project:bbbbbbbbbbbbbbbb:android" && k != "app:ios:acct1:6400000999");
st.overrides.ignored.push("project:bbbbbbbbbbbbbbbb:ios".into());
assert!(registry::push_ignored(&st, &file).unwrap());
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
assert_eq!(doc["projects"][0]["ignored"], json!(["apple", "play"]));
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
assert!(doc["storeApps"][0].get("ignored").is_none());
assert!(!registry::push_ignored(&st, &file).unwrap());
// writing the folder list keeps the flags of the folders that stay
let list = registry::read(&file).unwrap();
registry::write(&file, &list).unwrap();
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
}
#[test]
fn a_play_developer_id_is_kept_in_the_profile_for_the_cli() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
store.save("studio", "Studio", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
assert_eq!(store.play_developer_id("studio"), None);
store.set_play_developer_id("studio", Some("5550000000000000001")).unwrap();
assert_eq!(store.play_developer_id("studio").as_deref(), Some("5550000000000000001"));
let m = store.read("studio").unwrap();
assert_eq!(m.get("PLAY_DEVELOPER_ID").and_then(|v| v.as_str()), Some("5550000000000000001"));
assert!(m.contains_key("PLAY_SERVICE_ACCOUNT"));
assert!(store.set_play_developer_id("studio", Some("abc")).is_err());
store.set_play_developer_id("studio", None).unwrap();
assert_eq!(store.play_developer_id("studio"), None);
}

View file

@ -0,0 +1,214 @@
//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the
//! panel book (transient vs pinned, parents, restore after a restart).
use storebound_desktop::menu_panel::*;
use storebound_desktop::store::State;
use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary};
const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0);
const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 };
fn summary(state: &str, apps_label: &str) -> TraySummary {
TraySummary {
apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }],
labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() },
..TraySummary::default()
}
}
fn tree() -> Vec<Node> {
from_entries(&[
Entry::Item { id: "open".into(), label: "Open".into(), enabled: true },
Entry::Separator,
Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] },
Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] },
])
}
#[test]
fn keys_ignore_live_parts_and_stay_unique() {
assert_eq!(sub_key("Profile: Work"), "profile");
assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first");
assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue");
assert_eq!(sub_key("\u{2716}"), "sub");
let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]);
assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2"));
}
#[test]
fn find_walks_keys() {
let t = tree();
let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap();
assert_eq!((items.len(), label), (0, Some("Recently lowered")));
assert!(find(&t, &["nope".into()]).is_none());
assert_eq!(find(&t, &[]).unwrap().0.len(), 4);
}
#[test]
fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() {
let en = from_entries(&menu_model(&summary("App Store: In review", "Apps")));
let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar")));
let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap();
assert_eq!(key(&en), "apps");
assert_eq!(key(&tr), "apps");
// a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves
assert_ne!(en, tr);
let (items, _) = find(&tr, &["apps".into()]).unwrap();
assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live")));
// the same summary twice: the same tree (nothing is pushed)
assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps"))));
}
#[test]
fn the_tree_serializes_as_the_panel_page_reads_it() {
let v = serde_json::to_value(tree()).unwrap();
assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true }));
assert_eq!(v[1], serde_json::json!({ "kind": "sep" }));
assert_eq!(v[2]["kind"], "sub");
assert_eq!(v[2]["key"], "fleet");
}
#[test]
fn root_opens_away_from_the_taskbar_and_stays_inside() {
// bottom taskbar, icon at the right: above and left of the click
let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK);
assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0);
// macOS menu bar at the top: below it
let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0));
assert!(y >= 25.0 + GAP);
// a second monitor left of the primary (negative x)
let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0));
assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0);
// a panel taller than the work area: pinned to the top edge, never above it
let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK);
assert_eq!(y, GAP);
}
#[test]
fn tray_root_sits_just_above_the_icon_never_over_the_taskbar() {
// icon in a bottom taskbar (the icon is below the work area): bottom edge exactly GAP above the icon's top
let icon = Anchor::Tray { x: 1800.0, y: 1044.0, w: 32.0, h: 32.0 };
let (_, y) = place_menu(icon, (260.0, 300.0), WORK);
assert_eq!(y + 300.0, 1040.0 - GAP);
// even when the reported work area wrongly includes the taskbar, the panel stays above the icon
let (_, y) = place_menu(icon, (260.0, 300.0), (0.0, 0.0, 1920.0, 1080.0));
assert!(y + 300.0 <= 1044.0 - GAP);
}
#[test]
fn submenus_open_beside_and_flip_left_at_the_edge() {
let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK);
assert!(x + 280.0 <= 1602.0);
let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK);
assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y));
// a row near the bottom: the submenu moves up into the work area
let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK);
assert!(y + 300.0 <= 1040.0 - GAP);
}
#[test]
fn pinned_panels_step_aside_and_stay_inside() {
let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)];
let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK);
assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o)));
assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0);
// no room left below: the next column
let col = [(8.0, 8.0, 280.0, 1000.0)];
let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK);
assert!(x >= 288.0);
// nothing in the way: where it was
assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0));
}
#[test]
fn several_menus_open_and_pinned_ones_survive_a_new_menu() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op");
let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap();
assert_eq!(b.descendants(&root).len(), 2);
// pinning the Fleet panel detaches it; the root closes, its own child stays its own
let closes = b.pin(&fleet, true);
assert_eq!(closes, vec![root.clone()]);
assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none());
assert_eq!(b.descendants(&fleet), vec![deep.clone()]);
let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
assert_eq!(closes, vec![deep]);
assert!(b.panels.contains_key(&fleet));
assert_eq!(b.transient(), vec![root2.clone()]);
// a pick / Esc: transient panels go, the pinned one stays
assert_eq!(b.dismiss(), vec![root2]);
assert_eq!(b.panels.len(), 1);
// unpinned, it behaves like a menu again and goes with the next one
b.pin(&fleet, false);
let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
assert!(closes.contains(&fleet));
}
#[test]
fn a_torn_off_submenu_keeps_one_pinned_copy() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap();
let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap();
assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1));
assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]);
// the root stays open (a tear-off is not a pick)
assert!(b.panels.contains_key(&root));
// closing a panel that is already gone does nothing
assert!(b.close("menu-99").is_empty());
}
#[test]
fn a_live_update_closes_nothing_that_still_exists() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap();
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
assert!(b.prune(&tree()).is_empty());
// the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its
// submenu goes too (it shows that it is gone)
let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]);
assert_eq!(b.prune(&smaller), vec![fleet]);
assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile));
}
#[test]
fn pins_are_saved_in_the_state_and_restored_after_a_restart() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap();
// not placed yet: nothing to save
assert!(b.pins().is_empty());
b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6));
let pins = b.pins();
assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]);
// through state.json
let st = State { menu_pins: pins.clone(), ..State::default() };
let text = serde_json::to_string(&st).unwrap();
assert!(text.contains("\"menuPins\""));
let back: State = serde_json::from_str(&text).unwrap();
assert_eq!(back.menu_pins, pins);
// an empty list is not written at all (older files stay as they were)
assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins"));
let mut fresh = Book::default();
let mut saved = back.menu_pins.clone();
saved.push(saved[0].clone()); // a duplicate is dropped
let labels = fresh.restore(&saved);
assert_eq!(labels.len(), 1);
let r = &fresh.panels[&labels[0]];
assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none());
// a restored pin survives the next menu
let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 });
assert!(closes.is_empty());
let many: Vec<PinRecord> = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect();
assert_eq!(Book::default().restore(&many).len(), MAX_PINS);
}
#[test]
fn panel_labels_are_menu_and_a_number() {
assert!(is_menu_label("menu-1") && is_menu_label("menu-204"));
for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] {
assert!(!is_menu_label(bad), "{bad}");
}
}

View file

@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use serde_json::{json, Value}; use serde_json::{json, Value};
use storebound_desktop::policy::*; use storebound_desktop::policy::*;
use storebound_desktop::COMMANDS; use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS};
use url::Url; use url::Url;
fn root() -> PathBuf { fn root() -> PathBuf {
@ -24,9 +24,10 @@ fn u(s: &str) -> Url {
#[test] #[test]
fn one_capability_for_the_main_window_with_exactly_the_app_commands() { fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
let dir = root().join("capabilities"); let dir = root().join("capabilities");
let files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect(); let mut files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(files.len(), 1, "one capability file expected, found {files:?}"); files.sort();
let cap = read_json(&files[0]); assert_eq!(files.len(), 3, "main, card and menu capability files expected, found {files:?}");
let cap = read_json(&dir.join("main.json"));
assert_eq!(cap["identifier"], "main-window"); assert_eq!(cap["identifier"], "main-window");
assert_eq!(cap["windows"], json!(["main"])); assert_eq!(cap["windows"], json!(["main"]));
assert!(cap.get("webviews").is_none(), "no webview-scoped grants"); assert!(cap.get("webviews").is_none(), "no webview-scoped grants");
@ -46,6 +47,69 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
} }
} }
#[test]
fn the_card_window_gets_only_the_card_commands() {
let cap = read_json(&root().join("capabilities/card.json"));
assert_eq!(cap["identifier"], "card-window");
assert_eq!(cap["windows"], json!(["card"]));
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
assert_ne!(cap["local"], json!(false));
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
let mut want: BTreeSet<String> = CARD_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
want.insert("core:event:allow-listen".into());
want.insert("core:event:allow-unlisten".into());
assert_eq!(got, want);
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
for c in CARD_COMMANDS {
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
let body = &cmds[at..];
let open = body.find('{').unwrap();
assert!(body[open + 1..].trim_start().starts_with("guard_card(&window)?;"), "{c} must start with guard_card(&window)?");
}
assert!(require_card("card", &u("tauri://localhost/card.html"), false).is_ok());
assert!(require_card("main", &u("tauri://localhost/"), false).is_err());
assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err());
}
#[test]
fn the_menu_panels_get_only_the_menu_commands() {
let cap = read_json(&root().join("capabilities/menu.json"));
assert_eq!(cap["identifier"], "menu-window");
assert_eq!(cap["windows"], json!(["menu-*"]));
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
assert_ne!(cap["local"], json!(false));
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
let mut want: BTreeSet<String> = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
want.insert("core:event:allow-listen".into());
want.insert("core:event:allow-unlisten".into());
// a pinned panel is dragged by its header (data-tauri-drag-region): only its own window
want.insert("core:window:allow-start-dragging".into());
assert_eq!(got, want);
// the menu commands never leak into the main or card capability
for other in ["main.json", "card.json"] {
let cap = read_json(&root().join("capabilities").join(other));
let perms = cap["permissions"].to_string();
for c in MENU_COMMANDS {
assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}");
}
}
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
for c in MENU_COMMANDS {
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
let body = &cmds[at..];
let open = body.find('{').unwrap();
assert!(body[open + 1..].trim_start().starts_with("guard_menu(&window)?;"), "{c} must start with guard_menu(&window)?");
}
assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok());
assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok());
for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] {
assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}");
}
assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err());
assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
}
#[test] #[test]
fn every_registered_command_is_in_the_list_and_nothing_else() { fn every_registered_command_is_in_the_list_and_nothing_else() {
let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap(); let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap();
@ -56,7 +120,7 @@ fn every_registered_command_is_in_the_list_and_nothing_else() {
.map(|s| s.trim().trim_start_matches("commands::").to_string()) .map(|s| s.trim().trim_start_matches("commands::").to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(); .collect();
assert_eq!(registered, COMMANDS.iter().map(|s| s.to_string()).collect::<Vec<_>>()); assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
// every command checks the caller first // every command checks the caller first
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
for c in COMMANDS { for c in COMMANDS {
@ -84,7 +148,7 @@ fn csp_is_strict_and_the_config_has_no_backdoors() {
assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}"); assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}");
assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}"); assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}");
} }
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window"])); assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-window"]));
assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true)); assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true));
assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false)); assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false));
let windows = conf["app"]["windows"].as_array().unwrap(); let windows = conf["app"]["windows"].as_array().unwrap();
@ -179,6 +243,16 @@ fn sidecar_starts_with_fixed_arguments_from_one_place() {
assert!(src.contains("Command::new(&bin)\n .args(policy::SIDECAR_ARGS)")); assert!(src.contains("Command::new(&bin)\n .args(policy::SIDECAR_ARGS)"));
continue; continue;
} }
// Fix with AI: only the claude binary (claude_command checks its name), git, and kill / taskkill by PID
if p.ends_with("ai_fix.rs") {
for (i, _) in src.match_indices("Command::new(") {
let arg = &src[i + "Command::new(".len()..];
let ok = ["claude)", "\"git\")", "\"kill\")", "\"taskkill\")"];
assert!(ok.iter().any(|o| arg.starts_with(o)), "unexpected program: {}", &arg[..arg.find(')').unwrap_or(20).min(40)]);
}
assert!(src.contains("if !matches!(name, \"claude\" | \"claude.exe\" | \"claude.cmd\")"));
continue;
}
// other programs: only the download checks (unpack, signature, version banner), with fixed program names // other programs: only the download checks (unpack, signature, version banner), with fixed program names
if src.contains("Command::new(") { if src.contains("Command::new(") {
assert!(p.ends_with("downloads.rs"), "{} starts a program", p.display()); assert!(p.ends_with("downloads.rs"), "{} starts a program", p.display());

View file

@ -0,0 +1,122 @@
//! The tray's pure parts: menu model, tooltip, click actions, notification targets, the attention dot, settings.
use storebound_desktop::tray::*;
use storebound_desktop::workspace::{check_settings, valid_hhmm, Settings};
fn summary() -> TraySummary {
TraySummary {
tooltip: "storebound · 2 in review · 1 needs you".into(),
attention: 1,
muted: false,
apps: vec![
TrayApp { key: "k1".into(), name: "First".into(), state: "App Store: In review · Play: Live".into() },
TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() },
],
labels: TrayLabels::default(),
}
}
fn ids(m: &[Entry]) -> Vec<String> {
m.iter().filter_map(|e| if let Entry::Item { id, .. } = e { Some(id.clone()) } else { None }).collect()
}
#[test]
fn menu_has_every_entry_in_order() {
let m = menu_model(&summary());
assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]);
let Entry::Sub { label, items, .. } = &m[3] else { panic!("the Apps submenu comes after the first separator") };
assert_eq!(label, "Apps");
assert_eq!(items.len(), 2);
let Entry::Item { id, label, enabled } = &items[0] else { panic!() };
assert_eq!((id.as_str(), *enabled), ("tray-app:k1", true));
assert!(label.contains("First") && label.contains("In review"), "{label}");
let Entry::Item { label, .. } = &items[1] else { panic!() };
assert_eq!(label, "Second", "an app without a state shows its name only");
}
#[test]
fn mute_entry_follows_the_state_and_empty_apps_say_so() {
let mut s = summary();
s.muted = true;
s.apps.clear();
let m = menu_model(&s);
let Entry::Item { label, .. } = m.iter().find(|e| matches!(e, Entry::Item { id, .. } if id == ID_MUTE)).unwrap() else { panic!() };
assert_eq!(label, "Unmute notifications");
let Entry::Sub { items, .. } = &m[3] else { panic!() };
assert!(matches!(&items[0], Entry::Item { enabled: false, label, .. } if label == "No apps yet"));
}
#[test]
fn tooltip_is_one_short_line() {
assert_eq!(tooltip_text(&summary()), "storebound · 2 in review · 1 needs you");
let mut s = summary();
s.tooltip = "a\nb\t c".into();
assert_eq!(tooltip_text(&s), "a b c");
s.tooltip = "x".repeat(500);
assert_eq!(tooltip_text(&s).chars().count(), MAX_TIP);
s.tooltip = " ".into();
assert_eq!(tooltip_text(&s), "storebound");
}
#[test]
fn clicks_map_to_actions() {
assert_eq!(action_of(ID_OPEN), Some(Action::Open));
assert_eq!(action_of(ID_QUIT), Some(Action::Quit));
assert_eq!(action_of(ID_READ), Some(Action::Event("read")));
assert_eq!(action_of(ID_MUTE), Some(Action::Event("mute-toggle")));
assert_eq!(action_of("tray-app:k1"), Some(Action::Route(r#"{"appId":"k1","screen":"app","tab":"releases"}"#.into())));
assert_eq!(action_of("tray-app:"), None);
assert_eq!(action_of("other"), None);
let Some(Action::Route(r)) = action_of(ID_UPDATE) else { panic!() };
assert!(valid_route(&r) && r.contains("updates"));
}
#[test]
fn only_known_small_routes_are_remembered() {
assert!(valid_route(r#"{"screen":"app","appId":"k1","tab":"releases"}"#));
assert!(valid_route(r#"{"screen":"portfolio"}"#));
assert!(!valid_route(r#"{"screen":"evil"}"#));
assert!(!valid_route(r#"{"screen":"app","appId":5}"#));
assert!(!valid_route("[]"));
assert!(!valid_route("not json"));
assert!(!valid_route(&format!(r#"{{"screen":"app","appId":"{}"}}"#, "a".repeat(500))));
// handed out once
set_pending(r#"{"screen":"portfolio"}"#);
assert_eq!(take_pending().as_deref(), Some(r#"{"screen":"portfolio"}"#));
assert_eq!(take_pending(), None);
set_pending(r#"{"screen":"evil"}"#);
assert_eq!(take_pending(), None);
}
#[test]
fn attention_dot_changes_only_the_corner() {
let (w, h) = (32u32, 32u32);
let base: Vec<u8> = std::iter::repeat_n([10u8, 20, 30, 255], (w * h) as usize).flatten().collect();
let out = with_attention_dot(&base, w, h);
assert_eq!(out.len(), base.len());
let at = |v: &[u8], x: u32, y: u32| -> [u8; 4] { let i = ((y * w + x) * 4) as usize; [v[i], v[i + 1], v[i + 2], v[i + 3]] };
assert_eq!(at(&out, 2, 30), [10, 20, 30, 255], "the bottom left stays");
let dot = at(&out, 25, 6);
assert!(dot[0] > 200 && dot[1] < 90 && dot[2] < 90, "red in the middle of the dot: {dot:?}");
// transparent icon: the dot is opaque, the rest stays clear
let clear = vec![0u8; (w * h * 4) as usize];
let out = with_attention_dot(&clear, w, h);
assert_eq!(at(&out, 2, 30)[3], 0);
assert_eq!(at(&out, 25, 6)[3], 255);
// a wrong buffer size is returned unchanged
assert_eq!(with_attention_dot(&[1, 2, 3], w, h), vec![1, 2, 3]);
}
#[test]
fn quiet_hours_settings_are_checked_and_default_off() {
let d = Settings::default();
assert!(!d.notify.quiet_hours.enabled);
assert_eq!((d.notify.quiet_hours.from.as_str(), d.notify.quiet_hours.to.as_str()), ("23:00", "08:00"));
assert!(d.notify.rejected && d.notify.approved && d.notify.live);
assert!(valid_hhmm("00:00") && valid_hhmm("23:59"));
assert!(!valid_hhmm("24:00") && !valid_hhmm("7:00") && !valid_hhmm("12:60") && !valid_hhmm("ab:cd"));
let mut s = d.clone();
s.notify.quiet_hours.from = "25:00".into();
assert!(check_settings(&s).is_err());
let old: Settings = serde_json::from_value(serde_json::json!({ "notify": { "crash": false } })).unwrap();
assert!(!old.notify.crash && old.notify.rejected && !old.notify.quiet_hours.enabled, "an older file gets the new defaults");
}

View file

@ -1,36 +1,46 @@
// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), // Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line
// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh),
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide. // the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
import { DryRunPreview } from './ui/DryRunPreview';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api'; import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api';
import { devSwitch } from './dev/switches'; import { devSwitch } from './dev/switches';
import { type Key, t } from './i18n'; import { type Key, t } from './i18n';
import { ago, num } from './lib/format'; import { ago, num } from './lib/format';
import { groupByStudio, type UnifiedApp } from './lib/identity'; import type { UnifiedApp } from './lib/identity';
import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
import { usePrefs } from './prefs-ctx'; import { usePrefs } from './prefs-ctx';
import { Accounts } from './screens/Accounts'; import { Accounts } from './screens/Accounts';
import { AppFolders } from './screens/AppFolders'; import { AppFolders } from './screens/AppFolders';
import { AppView } from './screens/AppView'; import { AppView } from './screens/AppView';
import { Dependencies } from './screens/Dependencies'; import { Dependencies } from './screens/Dependencies';
import { Ignored } from './screens/Ignored'; import { Ignored } from './screens/Ignored';
import { useShowIgnored } from './lib/show-ignored';
import { Onboarding } from './screens/Onboarding'; import { Onboarding } from './screens/Onboarding';
import { Portfolio } from './screens/Portfolio'; import { Portfolio } from './screens/Portfolio';
import { Settings } from './screens/Settings'; import { Settings } from './screens/Settings';
import { useData } from './store'; import { useData, useReadState } from './store';
import { Icon, type IconName, Mark } from './ui/icons'; import { Icon, type IconName, Mark } from './ui/icons';
import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit'; import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit';
import { type Command, Palette } from './ui/Palette'; import { type Command, Palette } from './ui/Palette';
import { ExportMenu, useExportItems, useRunExport } from './ui/Exports'; import { ExportMenu, useExportItems, useRunExport } from './ui/Exports';
import { ErrorBoundary } from './ui/ErrorBoundary'; import { ErrorBoundary } from './ui/ErrorBoundary';
import { Toasts } from './ui/Toasts'; import { Toasts } from './ui/Toasts';
import { useContextMenu } from './ui/ContextMenu';
import { appMenu } from './ui/app-menu';
import { isEditable, isItem, type MenuInput } from './ui/menu-logic';
import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model';
const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' };
export const WS_KEY: Record<WorkspaceScreen, Key> = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' }; export const WS_KEY: Record<WorkspaceScreen, Key> = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' };
const SIDE_KEY = 'storebound.side.v1'; const SIDE_KEY = 'storebound.side.v1';
const STUDIO_KEY = 'storebound.side.studio.v1';
function loadCollapsed(): string[] { function loadCollapsed(): string[] {
try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; } try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; }
} }
function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } }
const GROUP_KEY: Record<SideGroup | 'ignored', Key> = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' };
export function App({ onTheme }: { onTheme: () => void }) { export function App({ onTheme }: { onTheme: () => void }) {
const d = useData(); const d = useData();
@ -40,11 +50,38 @@ export function App({ onTheme }: { onTheme: () => void }) {
const [welcome, setWelcome] = useState(false); const [welcome, setWelcome] = useState(false);
const [started, setStarted] = useState(false); const [started, setStarted] = useState(false);
const [collapsed, setCollapsed] = useState<string[]>(loadCollapsed); const [collapsed, setCollapsed] = useState<string[]>(loadCollapsed);
const [studioFilter, setStudioFilterState] = useState<string | null>(loadStudio);
const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } };
const exports = useExportItems(); const exports = useExportItems();
const runExport = useRunExport(); const runExport = useRunExport();
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []); const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
// Refresh all is busy while any read it covers is in flight (its own pass, a card's refresh, Check again, auto-read)
const allReads = useReadState({ tags: ['all'] });
const [showIgnored, setShowIgnored] = useShowIgnored();
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]);
const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]);
// the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each
const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null;
const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]);
const sideRows = useMemo(() => visible.filter(inStudio).map(a => {
const e = d.statusFor(a), l = d.liveFor(a);
return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) };
}), [visible, d, inStudio]);
const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [
...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length),
...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []),
];
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it)
const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app)));
const stepApp = useCallback((dir: 1 | -1) => {
if (!sideOrder.length) return;
const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1;
const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!;
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' });
}, [sideOrder, route, go, tabsOf]);
const toggleGroup = (id: string) => setCollapsed(c => { const toggleGroup = (id: string) => setCollapsed(c => {
const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id]; const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id];
try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ } try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ }
@ -67,33 +104,44 @@ export function App({ onTheme }: { onTheme: () => void }) {
const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); }); const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); });
// `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder // `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder
const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); }); const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); });
return () => { un.then(f => f()); unFolder.then(f => f()); }; // the tray menu (an app, Settings, Check for updates) and the first show after a notification
const unTray = onTrayRoute(s => { const r = parseRouteJson(s); if (r) go(r); });
return () => { un.then(f => f()); unFolder.then(f => f()); unTray.then(f => f()); };
}, [go, d.reload]); }, [go, d.reload]);
const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined;
const tabs = app ? tabsFor(!!app.project) : []; const game = app ? gameOf(app) : false;
const tabs = app ? tabsOf(app) : [];
const sections = app ? sectionsFor(!!app.project, game) : [];
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections,
// Cmd/Ctrl+Shift+[ ] the previous / next section
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey; const mod = e.metaKey || e.ctrlKey;
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; } if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; }
if (palette || welcome) return; if (palette || welcome) return;
if (e.altKey && !mod && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !isEditable(e.target as HTMLInputElement)) { e.preventDefault(); stepApp(e.key === 'ArrowDown' ? 1 : -1); return; }
if (mod && !e.shiftKey && !e.altKey && /^[1-6]$/.test(e.key)) { if (mod && !e.shiftKey && !e.altKey && /^[1-6]$/.test(e.key)) {
e.preventDefault(); e.preventDefault();
const n = Number(e.key); const n = Number(e.key);
go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! }); go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! });
return; return;
} }
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) { if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) {
const sec = sections[Number(e.key) - 1];
if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); }
return;
}
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) {
e.preventDefault(); e.preventDefault();
const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1); const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1);
go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! }); go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! });
} }
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [palette, welcome, route, go, tabs]); }, [palette, welcome, route, go, app, sections, game, stepApp]);
const closeWelcome = useCallback(() => { const closeWelcome = useCallback(() => {
setWelcome(false); setWelcome(false);
@ -107,11 +155,30 @@ export function App({ onTheme }: { onTheme: () => void }) {
const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') }; const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') };
const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name; const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name;
for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) }, for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) },
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) }); run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) });
c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) });
c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) });
c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) }); c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) });
WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) })); WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) }));
for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) }); for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) });
if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); // every section of the open app (1-5) and every sub view in it
if (app) sections.forEach((sec, i) => {
const subs = subsOf(sec, !!app.project, game);
c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) });
if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
});
// the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is
// also one Ctrl+K away; opening a tab is already above
if (app) {
const skip = new Set([t('cm.open'), t('cm.openTab')]);
const add = (items: MenuInput, prefix: string) => items.forEach((e, i) => {
if (!e || !isItem(e) || skip.has(e.label) || e.disabled) return;
const title = prefix ? `${prefix} · ${e.label}` : e.label;
if (e.submenu) { add(e.submenu, title); return; }
if (e.onSelect) c.push({ id: `appcmd-${prefix}-${i}-${e.label}`, group: t('palette.gApp', { name: app.name }), title, icon: e.icon, hint: e.shortcut, keywords: app.name, run: e.onSelect });
});
add(appMenu(app, d, { route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }, { showIgnored }), '');
}
for (const x of exports) c.push({ id: `export-${x.id}`, group: G.exp, title: t('palette.export', { what: x.label }), icon: x.icon ?? 'download', keywords: `export save download ${x.kind}`, hint: x.disabled || `.${x.kind}`, run: () => { runExport(x); } }); for (const x of exports) c.push({ id: `export-${x.id}`, group: G.exp, title: t('palette.export', { what: x.label }), icon: x.icon ?? 'download', keywords: `export save download ${x.kind}`, hint: x.disabled || `.${x.kind}`, run: () => { runExport(x); } });
if (app?.project) { if (app?.project) {
const p = app.project.id; const p = app.project.id;
@ -124,13 +191,14 @@ export function App({ onTheme }: { onTheme: () => void }) {
c.push({ id: 'act-check', group: G.act, title: t('action.checkConfigs'), icon: 'shield', run: () => { d.refreshPortfolio(false); } }); c.push({ id: 'act-check', group: G.act, title: t('action.checkConfigs'), icon: 'shield', run: () => { d.refreshPortfolio(false); } });
c.push({ id: 'act-add', group: G.act, title: t('action.addFolder'), icon: 'folderPlus', run: () => go({ screen: 'apps' }) }); c.push({ id: 'act-add', group: G.act, title: t('action.addFolder'), icon: 'folderPlus', run: () => go({ screen: 'apps' }) });
c.push({ id: 'act-account', group: G.act, title: t('action.addAccount'), icon: 'key', run: () => go({ screen: 'accounts' }) }); c.push({ id: 'act-account', group: G.act, title: t('action.addAccount'), icon: 'key', run: () => go({ screen: 'accounts' }) });
c.push({ id: 'act-density', group: G.act, title: t('action.toggleDensity'), icon: 'grid', keywords: 'dense compact table density rows', run: () => update({ ...prefs, density: prefs.density === 'compact' ? 'comfortable' : 'compact' }) });
c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme }); c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme });
c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) }); c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) });
return c; return c;
}, [d, visible, app, tabs, route, go, onTheme, exports, runExport]); }, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]);
const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }] const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }]
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }] : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])]
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }]; : [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
const lastRead = d.live?.at ?? null; const lastRead = d.live?.at ?? null;
// the app on screen is read first by the background reader // the app on screen is read first by the background reader
@ -138,26 +206,22 @@ export function App({ onTheme }: { onTheme: () => void }) {
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]); useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]); const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined); const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length; const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
const appItem = (a: UnifiedApp) => { const menu = useContextMenu();
const e = d.statusFor(a); const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
const missing = !!a.project?.missing; <div role="listitem" key={a.key}>
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); <button {...menu(() => appMenu(a, d, nav, { showIgnored }), `app:${a.key}`)} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
return ( onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' })}
<div role="listitem" key={a.key}> title={`${a.name} · ${st.text}`}>
<button className="nav nav-app" aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })} <span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{(st.tone === 'bad' || st.tone === 'warn') && <span className={`ds-sdot ${st.tone}`} role="img" aria-label={st.tone === 'bad' ? t('pf.blocker') : t('pf.warning')} />}</span>
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' })}> <span className="grow side-t"><span className="side-n">{a.name}</span><span className={`side-s ${st.tone}`} data-testid="side-status">{st.text}</span></span>
<AppIcon name={a.name} src={d.iconFor(a)} /> {!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
<span className="grow">{a.name}</span> {!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>} </button>
{missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span> </div>
: bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>} );
</button> const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE];
</div>
);
};
return ( return (
<NavCtx.Provider value={nav}> <NavCtx.Provider value={nav}>
@ -171,35 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
</button> </button>
</div> </div>
<div className="side-scroll"> <div className="side-scroll">
<button className="nav" aria-current={current({ screen: 'portfolio' })} onClick={() => go({ screen: 'portfolio' })}> <div className="side-label" id="apps-label"><span>{t('nav.appsLabel')} <span className="count">{visible.length}</span></span>
<Icon name="portfolio" weight={current({ screen: 'portfolio' }) ? 'fill' : 'regular'} /><span className="grow">{t('nav.portfolio')}</span><span className="count">{visible.length}</span>
</button>
<div className="side-label" id="apps-label"><span>{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')}</span>
<button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button> <button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button>
</div> </div>
{d.studios.length > 1 && (
<div className="side-studios" role="group" aria-label={t('side.studioFilter')}>
<button aria-pressed={!studioOn} onClick={() => setStudioFilter(null)}>{t('side.allStudios')}</button>
{d.studios.map(s => <button key={s.id} aria-pressed={studioOn === s.id} onClick={() => setStudioFilter(studioOn === s.id ? null : s.id)}><StudioMark studio={s} size="sm" />{s.name}</button>)}
</div>
)}
{groups.map(g => { {groups.map(g => {
const s = d.studios.find(x => x.id === g.studio) ?? null; const gid = `g:${g.id}`;
const gid = g.studio ?? 'none';
const open = !collapsed.includes(gid); const open = !collapsed.includes(gid);
if (!d.studios.length) return <div role="list" aria-labelledby="apps-label" key={gid}>{g.rows.map(r => appItem(r.app))}</div>;
return ( return (
<div className="side-group" key={gid}> <div className="side-group" key={gid} data-group={g.id}>
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${gid}`} onClick={() => toggleGroup(gid)}> <button className="side-group-head" aria-expanded={open} aria-controls={`sg-${g.id}`} onClick={() => toggleGroup(gid)}>
<Icon name="chevronRight" className="chev" /><StudioMark studio={s} size="sm" /><span className="grow">{s?.name ?? t('studio.none')}</span><span className="count">{g.rows.length}</span> <Icon name="chevronRight" className="chev" /><span className="grow">{t(GROUP_KEY[g.id])}</span><span className="count">{g.rows.length}</span>
</button> </button>
{open && <div role="list" id={`sg-${gid}`} aria-label={s?.name ?? t('studio.none')}>{g.rows.map(r => appItem(r.app))}</div>} {open && <div role="list" id={`sg-${g.id}`} aria-label={t(GROUP_KEY[g.id])}>{g.rows.map(r => appItem(r.app, r.st))}</div>}
</div> </div>
); );
})} })}
{!visible.length && <p className="side-empty">{t('nav.noApps')}</p>} {!visible.length && !showIgnored && <p className="side-empty">{t('nav.noApps')}</p>}
<div className="side-label"><span>{t('nav.workspace')}</span></div> {ignoredApps.length > 0 && <button className="link side-ignored-toggle" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)} data-testid="toggle-ignored">{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>}
{WORKSPACE.map((s, i) => ( </div>
<button key={s} className="nav" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${t(WS_KEY[s])} (${MOD} ${i + 2})`}> <div className="side-ws" role="group" aria-label={t('nav.workspace')}>
<Icon name={WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} /><span className="grow">{t(WS_KEY[s])}</span> {WS_ROW.map((s, i) => {
{s === 'accounts' && <span className="count">{d.accounts.length}</span>} const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]);
{s === 'ignored' && ignoredCount > 0 && <span className="count">{ignoredCount}</span>} const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0;
</button> return (
))} <button key={s} className="nav-ws" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${label} (${MOD} ${i + 1})`} aria-label={label} data-screen={s}>
<Icon name={s === 'portfolio' ? 'portfolio' : WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} />
{n > 0 && <span className="ws-n">{n}</span>}
</button>
);
})}
</div> </div>
<div className="side-foot" aria-live="polite"> <div className="side-foot" aria-live="polite">
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} /> <div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
@ -221,8 +291,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
<span className="spacer" /> <span className="spacer" />
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>} {FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span> <span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all"> <button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length || allReads.busy} aria-busy={allReads.busy} title={allReads.busy ? t('read.busyShared') : t('action.refreshAllHint')} data-testid="refresh-all">
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')} <Icon name="refresh" className={allReads.busy ? 'spin' : undefined} />{t('action.refreshAll')}
</button> </button>
<ExportMenu /> <ExportMenu />
</header> </header>
@ -243,21 +313,27 @@ export function App({ onTheme }: { onTheme: () => void }) {
{palette && <ErrorBoundary where="palette"><Palette commands={commands} onClose={() => setPalette(false)} /></ErrorBoundary>} {palette && <ErrorBoundary where="palette"><Palette commands={commands} onClose={() => setPalette(false)} /></ErrorBoundary>}
{welcome && <ErrorBoundary where="welcome"><Onboarding onClose={closeWelcome} /></ErrorBoundary>} {welcome && <ErrorBoundary where="welcome"><Onboarding onClose={closeWelcome} /></ErrorBoundary>}
<ErrorBoundary where="toasts"><Toasts /></ErrorBoundary> <ErrorBoundary where="toasts"><Toasts /></ErrorBoundary>
<ErrorBoundary where="preview"><DryRunPreview /></ErrorBoundary>
</NavCtx.Provider> </NavCtx.Provider>
); );
} }
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */ /** The background reader in the sidebar foot: progress (count + thin bar) and the App Store hourly limit on their own
* short rows, so nothing is cut off; the long wording stays in the tooltip. */
function ReaderLine() { function ReaderLine() {
const d = useData(); const d = useData();
const r = d.reader; const r = d.reader;
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0]; const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
const parts: string[] = []; const busy = r.total > 0 && (r.running || r.queued);
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total })); const head = busy ? t('reader.progress', { done: r.done, total: r.total }) : !d.reader.autoRead ? t('reader.off') : d.ready && d.apps.length ? t('reader.upToDate') : '';
else if (!d.reader.autoRead) parts.push(t('reader.off')); const paused = Object.keys(r.paused).length > 0;
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate')); if (!head && !q && !paused) return null;
if (Object.keys(r.paused).length) parts.push(t('reader.backoff')); return (
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) })); <div className="reader-line" data-testid="reader-line" aria-live="polite">
if (!parts.length) return null; {head && <div className="row" title={head}><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span>{head}</span></div>}
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>; {busy && <div className="reader-bar" role="progressbar" aria-valuemin={0} aria-valuemax={r.total} aria-valuenow={r.done} aria-label={head}><i style={{ width: `${Math.round((r.done / Math.max(1, r.total)) * 100)}%` }} /></div>}
{paused && <div className="row" title={t('reader.backoff')}><Icon name="pause" className="sr-icon" /><span>{t('reader.backoffShort')}</span></div>}
{q && <div className="row" title={t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) })}><Icon name="gauge" className="sr-icon" /><span>{t('reader.appleQuotaShort', { left: num(q.remaining), limit: num(q.limit) })}</span></div>}
</div>
);
} }

View file

@ -6,6 +6,7 @@ import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
import type { AccountCheck, AppsListResult, DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol'; import type { AccountCheck, AppsListResult, DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol';
import type { DeclarationsResult, ListingText, RendersResult } from '../../../cli/lib/desktop-protocol'; import type { DeclarationsResult, ListingText, RendersResult } from '../../../cli/lib/desktop-protocol';
import type { Overrides } from './lib/identity'; import type { Overrides } from './lib/identity';
import type { TraySummary } from './lib/tray';
export type { export type {
AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult, AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult,
@ -24,7 +25,7 @@ export interface SidecarStatus { running: boolean; version: string; protocol: st
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */ /** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null } export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null }
export type AccountKind = 'play' | 'apple'; export type AccountKind = 'play' | 'apple';
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null } export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null; /** Play: the developer account id (PLAY_DEVELOPER_ID in the profile) for Play Console deep links. */ developerId?: string | null }
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const; export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
export type StudioColor = (typeof STUDIO_COLORS)[number]; export type StudioColor = (typeof STUDIO_COLORS)[number];
/** A user-named group of store accounts (and so of apps). */ /** A user-named group of store accounts (and so of apps). */
@ -33,10 +34,12 @@ export interface Settings {
/** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */ /** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 }; refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 };
cache: { keepDays: 0 | 7 | 30 | 90 | 365 }; cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
notify: { reviewState: boolean; lowReviews: boolean; crash: boolean }; /** reviewState: other state changes (submitted, in review); rejected / approved / live: own toggles; crash also covers a halted rollout. quietHours: no notification between from and to (HH:MM, may cross midnight). */
channel: 'stable' | 'beta'; notify: { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: { enabled: boolean; from: string; to: string } };
/** Update channel: stable (npm releases) or dev (every push to main); beta is kept for saved states. */
channel: 'stable' | 'dev' | 'beta';
} }
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' }; export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, rejected: true, approved: true, live: true, lowReviews: true, crash: true, quietHours: { enabled: false, from: '23:00', to: '08:00' } }, channel: 'stable' };
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings } export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null } export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number } export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }
@ -45,7 +48,7 @@ export interface CacheEntry<T = unknown> { value: T; at: string }
export interface DownloadInfo { tool: 'ffmpeg' | 'chrome'; available: boolean; version: string | null; size: number | null; license: string; licenseUrl: string; source: string; installed: boolean; folder: string } export interface DownloadInfo { tool: 'ffmpeg' | 'chrome'; available: boolean; version: string | null; size: number | null; license: string; licenseUrl: string; source: string; installed: boolean; folder: string }
export interface DownloadEvent { job: string; tool: string; stage: 'resolve' | 'download' | 'verify' | 'unpack' | 'install' | 'done' | 'error' | 'cancelled'; received: number; total: number | null; message?: string } export interface DownloadEvent { job: string; tool: string; stage: 'resolve' | 'download' | 'verify' | 'unpack' | 'install' | 'done' | 'error' | 'cancelled'; received: number; total: number | null; message?: string }
export type { IconResult as Icon }; export type { IconResult as Icon };
export interface UpdateInfo { current: string; channel: 'stable' | 'beta'; available: string | null; notes: string | null; date: string | null } export interface UpdateInfo { current: string; channel: 'stable' | 'dev' | 'beta'; available: string | null; notes: string | null; date: string | null }
/** What a failed command rejects with (sidecar.rs `Fail`). */ /** What a failed command rejects with (sidecar.rs `Fail`). */
export interface Fail { code: number; message: string; data?: unknown } export interface Fail { code: number; message: string; data?: unknown }
@ -69,6 +72,13 @@ async function listen<T>(event: string, cb: (payload: T) => void): Promise<Unlis
return tauriListen<T>(event, e => cb(e.payload)); return tauriListen<T>(event, e => cb(e.payload));
} }
/** The finding fields the host builds the Fix with AI prompt from. */
export interface AiFinding { rule: string; guideline: string; store: 'apple' | 'play'; title: string; evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string }
export interface AiChange { path: string; status: string }
export interface AiFixInfo { claude: boolean; prompt: string; git: boolean; gitError: string | null; dirty: AiChange[] }
export type AiFixRun = { refused: 'dirty'; dirty: AiChange[] }
| { refused?: undefined; runId: string; outcome: { code: number | null; timed_out: boolean; cancelled: boolean; summary: string }; changes: AiChange[]; diff: string };
let jobSeq = 0; let jobSeq = 0;
/** A fresh job id for one background run (progress events and cancel refer to it). */ /** A fresh job id for one background run (progress events and cancel refer to it). */
export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`; export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`;
@ -84,6 +94,10 @@ export const api = {
invoke<CacheEntry<T> | null>('cache_get', { method, projectId, args }), invoke<CacheEntry<T> | null>('cache_get', { method, projectId, args }),
clearCache: () => invoke<void>('cache_clear'), clearCache: () => invoke<void>('cache_clear'),
cancel: (job: string) => invoke<boolean>('job_cancel', { job }), cancel: (job: string) => invoke<boolean>('job_cancel', { job }),
/** Fix with AI (src-tauri/src/ai_fix.rs): claude installed?, the prompt (also for Copy for AI), the dirty files. */
aiFixInfo: (projectId: string, finding: AiFinding) => invoke<AiFixInfo>('ai_fix_info', { projectId, finding }),
aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke<AiFixRun>('ai_fix_run', { projectId, finding, job }),
aiFixUndo: (runId: string) => invoke<string[]>('ai_fix_undo', { runId }),
/** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */ /** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */
writeWithConfirm: (method: string, projectId: string, args: Record<string, unknown> = {}, job?: string) => writeWithConfirm: (method: string, projectId: string, args: Record<string, unknown> = {}, job?: string) =>
invoke<WriteOutcome>('write_with_confirm', { method, projectId, args, job: job ?? null }), invoke<WriteOutcome>('write_with_confirm', { method, projectId, args, job: job ?? null }),
@ -97,6 +111,7 @@ export const api = {
addAppleAccount: (label: string, keyId: string, issuerId: string, studio: string | null = null) => invoke<Account | null>('account_add_apple', { label, keyId, issuerId, studio }), addAppleAccount: (label: string, keyId: string, issuerId: string, studio: string | null = null) => invoke<Account | null>('account_add_apple', { label, keyId, issuerId, studio }),
removeAccount: (id: string) => invoke<void>('account_remove', { id }), removeAccount: (id: string) => invoke<void>('account_remove', { id }),
updateAccount: (id: string, label: string, studio: string | null) => invoke<Account>('account_update', { id, label, studio }), updateAccount: (id: string, label: string, studio: string | null) => invoke<Account>('account_update', { id, label, studio }),
setPlayDeveloper: (id: string, developerId: string | null) => invoke<Account>('account_set_play_developer', { id, developerId }),
/** Does the key work, can it see apps (live, not cached). */ /** Does the key work, can it see apps (live, not cached). */
checkAccount: (id: string, job?: string) => invoke<AccountCheck>('account_check', { id, job: job ?? null }), checkAccount: (id: string, job?: string) => invoke<AccountCheck>('account_check', { id, job: job ?? null }),
/** Reads the apps an account can see from its store and keeps the list. */ /** Reads the apps an account can see from its store and keeps the list. */
@ -110,7 +125,10 @@ export const api = {
setSettings: (settings: Settings) => invoke<Settings>('settings_set', { settings }), setSettings: (settings: Settings) => invoke<Settings>('settings_set', { settings }),
cacheInfo: () => invoke<CacheInfo>('cache_info'), cacheInfo: () => invoke<CacheInfo>('cache_info'),
revealData: () => invoke<void>('data_reveal'), revealData: () => invoke<void>('data_reveal'),
notify: (title: string, body: string) => invoke<void>('notify', { title, body }), /** target: a route (JSON) the window opens on when it is next shown from the tray (the notification plugin has no click callback on desktop). */
notify: (title: string, body: string, target?: string) => invoke<void>('notify', { title, body, target: target ?? null }),
/** Pushes the tray's tooltip, attention flag and app list after a read (lib/tray.ts). */
trayUpdate: (summary: TraySummary) => invoke<void>('tray_update', { summary }),
/** Native confirmation first; true when everything was forgotten. */ /** Native confirmation first; true when everything was forgotten. */
reset: () => invoke<boolean>('app_reset'), reset: () => invoke<boolean>('app_reset'),
/** Offline (folders) or live; live also reads the given store-only apps (store targets). */ /** Offline (folders) or live; live also reads the given store-only apps (store targets). */
@ -155,8 +173,14 @@ export const reads = {
export interface ProgressEvent extends ProgressParams { job?: string } export interface ProgressEvent extends ProgressParams { job?: string }
export const onProgress = (cb: (p: ProgressEvent) => void): Promise<UnlistenFn> => export const onProgress = (cb: (p: ProgressEvent) => void): Promise<UnlistenFn> =>
listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job })); listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job }));
/** One progress line of a Fix with AI run. */
export const onAiFix = (cb: (e: { job: string; line: string }) => void): Promise<UnlistenFn> => listen<{ job: string; line: string }>('ai-fix', cb);
export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb); export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb);
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb); export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
/** storebound://open?path=... selected or added an app folder (its project id). */ /** storebound://open?path=... selected or added an app folder (its project id). */
export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise<UnlistenFn> => listen<string>('deep-link-folder', cb); export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise<UnlistenFn> => listen<string>('deep-link-folder', cb);
/** A tray menu click (or the first window show after a notification) that opens a route (JSON, see parseRouteJson). */
export const onTrayRoute = (cb: (route: string) => void): Promise<UnlistenFn> => listen<string>('tray-route', cb);
/** A tray menu click that is not navigation: read the stores now, toggle mute. */
export const onTrayAction = (cb: (action: 'read' | 'mute-toggle') => void): Promise<UnlistenFn> => listen<'read' | 'mute-toggle'>('tray-action', cb);
export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise<UnlistenFn> => listen('update-progress', cb); export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise<UnlistenFn> => listen('update-progress', cb);

View file

@ -0,0 +1,178 @@
// The tray card: store status at a glance next to the tray icon. It only draws the main window's snapshot (card_get /
// `card-snapshot`) and sends its buttons back (card_action); Refresh is the main window's own Refresh all.
import { ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowsClockwise';
import { ArrowSquareOutIcon } from '@phosphor-icons/react/dist/csr/ArrowSquareOut';
import { BellSlashIcon } from '@phosphor-icons/react/dist/csr/BellSlash';
import { BellIcon } from '@phosphor-icons/react/dist/csr/Bell';
import { DotsThreeIcon } from '@phosphor-icons/react/dist/csr/DotsThree';
import { PowerIcon } from '@phosphor-icons/react/dist/csr/Power';
import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
import { WarningOctagonIcon } from '@phosphor-icons/react/dist/csr/WarningOctagon';
import { PulseIcon } from '@phosphor-icons/react/dist/csr/Pulse';
import { AppWindowIcon } from '@phosphor-icons/react/dist/csr/AppWindow';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import type { CardApp, CardRelease, CardSnapshot, CardStore } from './model';
import { ago, cardT, type CardKey, type CardLang } from './text';
export interface CardHost {
get: () => Promise<CardSnapshot | null>;
onSnapshot: (fn: (s: CardSnapshot) => void) => () => void;
onShown: (fn: () => void) => () => void;
action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size', arg?: string) => void;
}
const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab });
export function Card({ host, lang }: { host: CardHost; lang: CardLang }) {
const T = useCallback((k: CardKey, v?: Record<string, string | number>) => cardT(lang, k, v), [lang]);
const [s, setS] = useState<CardSnapshot | null>(null);
const [menu, setMenu] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [shown, setShown] = useState(0);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
host.get().then(x => { if (x) setS(x); }).catch(() => {});
const a = host.onSnapshot(setS);
const b = host.onShown(() => { setNow(Date.now()); setMenu(false); setShown(n => n + 1); });
host.action('ready');
const tm = setInterval(() => setNow(Date.now()), 30_000);
return () => { a(); b(); clearInterval(tm); };
}, [host]);
// the window is as tall as the card: report the height whenever it changes (the host re-anchors it above the icon)
useEffect(() => {
const el = root.current;
if (!el || typeof ResizeObserver === 'undefined') return;
let last = 0;
const ro = new ResizeObserver(() => {
const h = Math.ceil(el.getBoundingClientRect().height);
if (h > 0 && h !== last) { last = h; host.action('size', String(h)); }
});
ro.observe(el);
return () => ro.disconnect();
}, [host]);
// every show: fade in, focus the first control (keyboard users start at Refresh)
useEffect(() => { (root.current?.querySelector<HTMLElement>('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); if (menu) setMenu(false); else host.action('hide'); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const all = [...(root.current?.querySelectorAll<HTMLElement>('button:not([disabled])') ?? [])];
const i = all.indexOf(document.activeElement as HTMLElement);
const n = all[(i + (e.key === 'ArrowDown' ? 1 : -1) + all.length) % all.length];
if (n) { e.preventDefault(); n.focus(); }
}
};
const store = (st: CardStore) => T(st === 'ios' ? 'appStore' : 'playStore');
const phase = (r: CardRelease) => (r.phase === 'rollout' ? (r.fraction != null && r.fraction < 1 ? T('rolloutPct', { pct: Math.round(r.fraction * 100) }) : T('rollout')) : T(r.phase));
const link = (key: string): CardApp | undefined => s?.links.find(l => l.key === key);
const busy = !!s?.busy;
const calm = s && !s.rejections.length && !s.releases.length && !s.health.length && !s.reviews.low.length;
return (
<div className="tc" ref={root} onKeyDown={onKey} data-shown={shown} tabIndex={-1} role="dialog" aria-label={T('title')}>
<header className="tc-head">
<div className="tc-title">
<strong>{T('title')}</strong>
<span className="tc-meta">{s?.muted && <span className="tc-muted"><BellSlashIcon size={12} weight="bold" />{T('muted')}</span>}{T('lastRead', { ago: ago(lang, s?.at ?? null, now) })}</span>
</div>
<button className="tc-btn tc-primary" data-first onClick={() => host.action('refresh')} disabled={busy} aria-busy={busy} data-testid="card-refresh">
<ArrowsClockwiseIcon size={14} weight="bold" className={busy ? 'spin' : undefined} />{busy ? T('refreshing') : T('refresh')}
</button>
<button className="tc-icon" onClick={() => setMenu(m => !m)} aria-haspopup="menu" aria-expanded={menu} aria-label={T('more')} title={T('more')}><DotsThreeIcon size={18} weight="bold" /></button>
{menu && (
<div className="tc-menu" role="menu">
<button role="menuitem" onClick={() => host.action('open')}><AppWindowIcon size={15} />{T('openMain')}</button>
<button role="menuitem" onClick={() => { host.action('mute'); setMenu(false); }}>{s?.muted ? <BellIcon size={15} /> : <BellSlashIcon size={15} />}{s?.muted ? T('unmute') : T('mute')}</button>
<hr />
<button role="menuitem" onClick={() => host.action('quit')}><PowerIcon size={15} />{T('quit')}</button>
</div>
)}
</header>
<div className="tc-body">
{!s && <p className="tc-empty">{T('noData')}</p>}
{s && calm && <p className="tc-empty">{s.at ? T('calm') : T('noData')}</p>}
{!!s?.rejections.length && (
<Section title={T('rejections')} tone="bad">
{s.rejections.map(r => (
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone="bad" onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
line={<><WarningOctagonIcon size={13} weight="fill" />{T('rejected')} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>} sub={r.reason}
consoles={consoles(link(r.app), r.store, host, T)} />
))}
</Section>
)}
{!!s?.releases.length && (
<Section title={T('moving')}>
{s.releases.map(r => (
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone={r.phase === 'halted' ? 'bad' : r.phase === 'approved' ? 'ok' : r.phase === 'review' ? 'info' : 'accent'}
onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
line={<>{phase(r)} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>}
bar={r.phase === 'rollout' && r.fraction != null && r.fraction < 1 ? r.fraction : null}
consoles={consoles(link(r.app), r.store, host, T)} />
))}
</Section>
)}
{!!s?.health.length && (
<Section title={T('health')} tone="warn">
{s.health.map(h => (
<Row key={h.app} hue={h.hue} name={h.name} tone="warn" onOpen={() => host.action('route', route(h.app, 'health'))} openLabel={T('openApp', { name: h.name })}
line={<><PulseIcon size={13} weight="bold" />{h.kinds.length > 1 ? T('crashAnr') : T(h.kinds[0]!)}</>} consoles={consoles(link(h.app), 'android', host, T)} />
))}
</Section>
)}
{s && s.at && (
<Section title={T('reviews')} aside={s.reviews.recent ? T('recent', { n: s.reviews.recent }) : T('recentNone')}>
{s.reviews.low.map(r => (
<button key={`${r.app}:${r.date}:${r.text}`} className="tc-review" onClick={() => host.action('route', route(r.app, 'reviews'))}>
<span className="tc-stars" aria-label={T('stars', { n: r.rating })}>{[1, 2, 3, 4, 5].map(i => <StarIcon key={i} size={11} weight={i <= r.rating ? 'fill' : 'regular'} />)}</span>
<span className="tc-rname">{r.name}</span>
<span className="tc-rtext">{r.text}</span>
</button>
))}
</Section>
)}
</div>
{s?.build && <footer className="tc-foot" data-testid="card-build">{T('build', { version: s.build.version, channel: T(s.build.channel === 'dev' ? 'chDev' : s.build.channel === 'beta' ? 'chBeta' : 'chStable') })}</footer>}
</div>
);
}
function consoles(l: CardApp | undefined, st: CardStore, host: CardHost, T: (k: CardKey) => string): ReactNode {
const url = st === 'ios' ? l?.asc : l?.play;
if (!url) return null;
return <button className="tc-ext" onClick={e => { e.stopPropagation(); host.action('external', url); }} title={T(st === 'ios' ? 'asc' : 'play')} aria-label={T(st === 'ios' ? 'asc' : 'play')}><ArrowSquareOutIcon size={14} /></button>;
}
function Section({ title, tone, aside, children }: { title: string; tone?: 'bad' | 'warn'; aside?: string; children: ReactNode }) {
return (
<section className={`tc-sec${tone ? ` tc-${tone}` : ''}`}>
<h2><span>{title}</span>{aside && <em>{aside}</em>}</h2>
{children}
</section>
);
}
function Row({ hue, name, line, sub, tone, bar, onOpen, openLabel, consoles }: { hue: number; name: string; line: ReactNode; sub?: string | null; tone: string; bar?: number | null; onOpen: () => void; openLabel: string; consoles: ReactNode }) {
return (
<div className={`tc-row tone-${tone}`}>
<button className="tc-open" onClick={onOpen} aria-label={openLabel}>
<span className="tc-ic" style={{ '--tint-h': hue } as React.CSSProperties} aria-hidden>{initials(name)}</span>
<span className="tc-txt">
<span className="tc-name">{name}</span>
<span className="tc-line">{line}</span>
{sub && <span className="tc-sub">{sub}</span>}
{bar != null && <span className="tc-bar" aria-hidden><i style={{ width: `${Math.max(2, Math.round(bar * 100))}%` }} /></span>}
</span>
</button>
{consoles}
</div>
);
}
const initials = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]!.toUpperCase()).join('') || '?';

View file

@ -0,0 +1,66 @@
// Main window side of the tray card: turns what this window already knows (live releases, the last cached reviews and
// health per app, the shared read state of Refresh all, mute) into a CardSnapshot and hands it to the host
// (card_snapshot), which keeps it for the card. No reads of its own: reviews and health come from the cache that the
// window's reads fill, and the card's Refresh arrives as the tray's `tray-action` read (store.tsx), i.e. Refresh all.
import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api';
import type { HealthSnapshot, Review } from '../lib/model';
import { hueOf } from '../lib/model';
import { useData, useReadState } from '../store';
import { playUrlFor } from '../ui/app-menu';
import { cardApi, inTauri as hasHost } from './card-api';
import { cardSnapshot, type CardInput } from './model';
const inTauri = hasHost();
const ascUrl = (id: string | null | undefined) => (id ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(id)}/distribution` : 'https://appstoreconnect.apple.com/apps');
export function TrayCardBridge() {
const d = useData();
const all = useReadState({ tags: ['all'] });
const [extra, setExtra] = useState<Record<string, { reviews: Review[] | null; health: HealthSnapshot | null }>>({});
const [ask, setAsk] = useState(0);
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
// the card opened (or a read pass ended): the last cached reviews and health of every app
useEffect(() => {
if (!inTauri) return;
return cardApi.onRequest(() => setAsk(n => n + 1));
}, []);
const wasBusy = useRef(all.busy);
useEffect(() => { if (wasBusy.current && !all.busy) setAsk(n => n + 1); wasBusy.current = all.busy; }, [all.busy]);
useEffect(() => {
if (!inTauri || !d.ready) return;
let live = true;
Promise.all(visible.map(async a => {
const [r, h] = await Promise.all([
api.cached<{ data?: Review[] }>('reviews.list', a.target, { days: 30 }).catch(() => null),
api.cached<{ data?: HealthSnapshot }>('health', a.target, {}).catch(() => null),
]);
return [a.key, { reviews: Array.isArray(r?.value.data) ? r.value.data : null, health: (h?.value.data as HealthSnapshot | undefined) ?? null }] as const;
})).then(rows => { if (live) setExtra(Object.fromEntries(rows)); }).catch(() => {});
return () => { live = false; };
}, [ask, d.ready, visible]);
const snap = useMemo(() => {
const inputs: CardInput[] = visible.map(a => {
const st = d.liveFor(a) ?? d.statusFor(a);
const ios = st?.app?.platforms.ios ?? (a.ios ? { ascAppId: a.ios.app.id } : null);
return {
key: a.key, name: a.name, hue: hueOf(a.name), status: st,
reviews: extra[a.key]?.reviews ?? null, health: extra[a.key]?.health ?? null,
asc: ios ? ascUrl(ios.ascAppId) : null, play: st?.app?.platforms.android || a.android ? playUrlFor(d, a) : null,
};
});
return cardSnapshot(inputs, { at: d.live?.value.at ?? d.portfolio?.at ?? null, busy: all.busy, muted: d.muteUntil > Date.now(), build: d.info ? { version: d.info.version, channel: d.settings.channel } : null });
}, [visible, d, extra, all.busy]);
const last = useRef('');
useEffect(() => {
if (!inTauri || !d.ready) return;
const json = JSON.stringify(snap);
if (json === last.current) return;
last.current = json;
cardApi.push(snap).catch(() => {});
}, [snap, d.ready]);
return null;
}

View file

@ -0,0 +1,25 @@
// The tray card's host calls (the card page's counterpart of src/api.ts, kept apart so the card bundle stays small).
// Main window: card_snapshot + the `card-request` event. Card window: card_get, card_action and its two events.
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { CardSnapshot } from './model';
export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size';
export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
function sub<T>(ev: string, fn: (p: T) => void): () => void {
const un = listen<T>(ev, e => fn(e.payload));
return () => { un.then(f => f()).catch(() => {}); };
}
export const cardApi = {
/** Main window: hands the host the card's snapshot (the host forwards it as `card-snapshot`). */
push: (snapshot: CardSnapshot) => invoke<void>('card_snapshot', { snapshot }),
/** Main window: the card opened; refresh what is kept per app. */
onRequest: (fn: () => void) => sub<null>('card-request', () => fn()),
get: () => invoke<CardSnapshot | null>('card_get'),
onSnapshot: (fn: (s: CardSnapshot) => void) => sub<CardSnapshot>('card-snapshot', fn),
onShown: (fn: () => void) => sub<null>('card-shown', () => fn()),
action: (action: CardAction, arg?: string) => { invoke('card_action', { action, arg: arg ?? null }).catch(() => {}); },
};

View file

@ -0,0 +1,59 @@
/* The tray card (card.html): v2 language, 360 wide and as tall as its content (at most 480: the body scrolls), rounded, its own surface; fades in over 120 ms on every show. */
html:has(body.tc-page), body.tc-page { margin: 0; height: 100%; background: transparent; overflow: hidden; }
body.tc-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
body.tc-page #card { height: auto; }
body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; }
:root[data-motion="reduce"] body.tc-page.tc-in .tc { animation: none; }
@keyframes tc-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.tc { box-sizing: border-box; height: auto; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg);
border: 1px solid var(--ds-stroke); background: radial-gradient(120% 60% at 100% 0%, color-mix(in srgb, var(--accent-solid) 10%, transparent), transparent 60%), var(--ds-raised); overflow: hidden; outline: none; }
.tc-head { position: relative; display: flex; align-items: center; gap: 8px; padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--ds-stroke); }
.tc-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tc-title strong { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); letter-spacing: -.01em; }
.tc-meta { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--ds-t-micro); }
.tc-muted { display: inline-flex; align-items: center; gap: 3px; color: var(--warn-ink); background: var(--warn-bg); border-radius: var(--ds-r-pill); padding: 0 6px; }
.tc button { font: inherit; color: inherit; cursor: pointer; }
.tc button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.tc-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: var(--ds-r-pill); border: 0; font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); }
.tc .tc-primary { background: var(--ds-grad); color: #fff; box-shadow: var(--ds-e1); }
.tc-primary[disabled] { opacity: .7; cursor: default; }
.tc-icon { display: grid; place-items: center; width: 30px; height: 30px; border-radius: var(--ds-r-pill); border: 1px solid var(--ds-stroke); background: var(--ds-surface); }
.tc-icon:hover, .tc-icon[aria-expanded="true"] { background: var(--hover); }
.tc-menu { position: absolute; z-index: 3; right: 12px; top: 50px; min-width: 230px; padding: 5px; border-radius: var(--ds-r); background: var(--ds-raised); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e3); }
.tc-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; background: none; border-radius: var(--ds-r-xs); text-align: left; font-size: var(--ds-t-meta); }
.tc-menu button:hover, .tc-menu button:focus-visible { background: var(--hover); outline: none; }
.tc-menu hr { border: 0; border-top: 1px solid var(--ds-stroke); margin: 4px 2px; }
.tc-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; }
.tc-empty { margin: 28px 16px; text-align: center; color: var(--muted); }
.tc-sec { margin-top: 8px; }
.tc-sec h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 6px 6px 4px; font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
.tc-sec h2 em { font-style: normal; text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--faint); }
.tc-sec.tc-bad { background: var(--bad-bg); border: 1px solid color-mix(in srgb, var(--bad-solid) 30%, transparent); border-radius: var(--ds-r); padding: 2px 4px 4px; }
.tc-sec.tc-bad h2 { color: var(--bad-ink); }
.tc-row { display: flex; align-items: center; gap: 2px; border-radius: var(--ds-r-sm); }
.tc-row:hover { background: color-mix(in srgb, var(--hover) 80%, transparent); }
.tc-open { flex: 1; min-width: 0; display: flex; gap: 10px; align-items: flex-start; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); }
.tc-ic { --tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l)); flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; font-size: 10px; font-weight: 700; color: #fff; background: linear-gradient(135deg, var(--tint), hsl(calc(var(--tint-h) + 30) var(--ds-tint-s) 45%)); }
.tc-txt { min-width: 0; display: flex; flex-direction: column; gap: 1px; flex: 1; }
.tc-name { font-weight: var(--ds-w-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tc-line { display: inline-flex; align-items: center; gap: 4px; font-size: var(--ds-t-meta); color: var(--ink-2); }
.tone-bad .tc-line { color: var(--bad-ink); font-weight: var(--ds-w-strong); }
.tone-ok .tc-line { color: var(--ok-ink); }
.tone-info .tc-line { color: var(--info-ink); }
.tone-warn .tc-line { color: var(--warn-ink); }
.tone-accent .tc-line { color: var(--accent); }
.tc-sub { font-size: var(--ds-t-micro); color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.tc-bar { margin-top: 4px; height: 4px; border-radius: 4px; background: var(--ds-inset); overflow: hidden; }
.tc-bar i { display: block; height: 100%; background: var(--ds-grad); border-radius: 4px; }
.tc-ext { flex: none; display: grid; place-items: center; width: 28px; height: 28px; margin-right: 4px; border: 0; background: none; color: var(--muted); border-radius: var(--ds-r-xs); }
.tc-ext:hover { background: var(--hover); color: var(--ink); }
.tc-review { display: grid; grid-template-columns: auto 1fr; gap: 1px 8px; width: 100%; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); }
.tc-review:hover { background: var(--hover); }
.tc-stars { display: inline-flex; gap: 1px; align-items: center; color: var(--warn-solid); }
.tc-rname { font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tc-rtext { grid-column: 1 / -1; font-size: var(--ds-t-meta); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tc-foot { padding: 6px 12px 8px; color: var(--muted); font-size: var(--ds-t-micro); border-top: 1px solid var(--ds-stroke); font-variant-numeric: tabular-nums; }

View file

@ -0,0 +1,25 @@
// Dev only: a card snapshot for the plain browser (no Tauri host), so the card can be looked at in `vite` alone.
import type { CardSnapshot } from './model';
const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
export const CARD_FIXTURE: CardSnapshot = {
at: ago(4), busy: false, muted: false, apps: 6,
rejections: [{ app: 'a1', name: 'Prism Puddle', hue: 280, store: 'ios', version: '2.4.0', reason: 'Guideline 2.1 - Performance - App Completeness: the app crashed on launch on iPad Air (5th generation).' }],
releases: [
{ app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'ios', version: '1.8.2', phase: 'review', fraction: null },
{ app: 'a3', name: 'Moss Runner', hue: 30, store: 'ios', version: '3.0.0', phase: 'approved', fraction: null },
{ app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'android', version: '1.8.1', phase: 'rollout', fraction: 0.2 },
],
reviews: { recent: 14, low: [
{ app: 'a2', name: 'Tidy Tiles', store: 'play', rating: 1, text: 'Lost my progress · After the update every level is locked again.', date: ago(90) },
{ app: 'a3', name: 'Moss Runner', store: 'apple', rating: 2, text: 'Too many ads between runs', date: ago(600) },
] },
health: [{ app: 'a4', name: 'Sample Blocks', hue: 210, kinds: ['crash'] }],
links: [
{ key: 'a1', name: 'Prism Puddle', hue: 280, asc: 'https://appstoreconnect.apple.com/apps', play: null },
{ key: 'a2', name: 'Tidy Tiles', hue: 160, asc: 'https://appstoreconnect.apple.com/apps', play: 'https://play.google.com/console' },
{ key: 'a3', name: 'Moss Runner', hue: 30, asc: 'https://appstoreconnect.apple.com/apps', play: null },
{ key: 'a4', name: 'Sample Blocks', hue: 210, asc: null, play: 'https://play.google.com/console' },
],
};

View file

@ -0,0 +1,53 @@
// The tray card window's entry (card.html): theme and language from the same prefs as the main window, the host
// through Tauri (or a fixture in a plain dev browser).
import { createRoot } from 'react-dom/client';
import { applyPrefs, loadPrefs, resolveLang } from '../prefs';
import { Card, type CardHost } from './Card';
import { cardApi, inTauri } from './card-api';
import type { CardSnapshot } from './model';
import '../styles.css';
import '../design.css';
import './card.css';
const sys = () => (typeof navigator !== 'undefined' ? navigator.language : 'en');
function prefs() {
const p = loadPrefs();
if (import.meta.env.DEV) {
const q = new URLSearchParams(location.search);
const th = q.get('theme'), lg = q.get('lang');
if (th === 'light' || th === 'dark') p.theme = th;
if (lg === 'en' || lg === 'tr') p.lang = lg;
}
applyPrefs(p, document.documentElement, sys());
return resolveLang(p.lang, sys());
}
async function devHost(): Promise<CardHost> {
const { CARD_FIXTURE } = await import('./fixture');
const q = new URLSearchParams(location.search);
const snap: CardSnapshot = q.get('empty')
? { ...CARD_FIXTURE, rejections: [], releases: [], health: [], reviews: { recent: 0, low: [] } }
: { ...CARD_FIXTURE, busy: !!q.get('busy') };
return { get: async () => snap, onSnapshot: () => () => {}, onShown: () => () => {}, action: (a, arg) => console.info('card action', a, arg ?? '') };
}
const tauriHost: CardHost = cardApi;
void (async () => {
let lang = prefs();
const host = inTauri() ? tauriHost : await devHost();
const root = createRoot(document.getElementById('card')!);
const render = () => root.render(<Card key={lang} host={host} lang={lang} />);
render();
const relang = () => { const l = prefs(); if (l !== lang) { lang = l; render(); } };
// theme or language changed in the main window (same origin: shared localStorage)
window.addEventListener('storage', relang);
host.onShown(() => {
relang();
document.body.classList.remove('tc-in');
void document.body.offsetWidth;
document.body.classList.add('tc-in');
});
document.body.classList.add('tc-in');
})();

View file

@ -0,0 +1,89 @@
// The tray card's data: what the main window already knows, boiled down to the few rows the card shows. Pure (the
// main window's bridge calls it, tests/card-model.test.ts pins it); the card itself only draws a CardSnapshot.
import type { AppStatus } from '../api';
import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from '../lib/model';
export type CardStore = 'ios' | 'android';
/** A release that moves or waits: in review, rolling out (with its share), approved and waiting for release, halted. */
export interface CardRelease { app: string; name: string; hue: number; store: CardStore; version: string | null; phase: 'review' | 'rollout' | 'approved' | 'halted'; fraction: number | null }
export interface CardRejection { app: string; name: string; hue: number; store: CardStore; version: string | null; reason: string | null }
export interface CardLowReview { app: string; name: string; store: 'apple' | 'play'; rating: number; text: string; date: string }
export interface CardHealth { app: string; name: string; hue: number; kinds: ('crash' | 'anr')[] }
export interface CardApp { key: string; name: string; hue: number; asc: string | null; play: string | null }
export interface CardSnapshot {
/** Last store read of the main window (ISO), null before any. */
at: string | null;
/** The main window's Refresh all is running (its shared read state). */
busy: boolean;
muted: boolean;
apps: number;
rejections: CardRejection[];
releases: CardRelease[];
reviews: { recent: number; low: CardLowReview[] };
health: CardHealth[];
/** Apps with something on the card, for the quick actions (console links). */
links: CardApp[];
/** This app's version and update channel (Settings > Updates), shown at the card's foot. */
build?: { version: string; channel: string } | null;
}
export interface CardInput {
key: string; name: string; hue: number; status: AppStatus | undefined;
reviews?: Review[] | null; health?: HealthSnapshot | null;
asc?: string | null; play?: string | null;
}
export const RECENT_DAYS = 7;
export const LOW_DAYS = 14;
export const MAX_ROWS = 6;
export const MAX_LOW = 3;
const DAY = 86_400_000;
const one = (s: string, max: number): string => { const x = s.replace(/\s+/g, ' ').trim(); return x.length > max ? `${x.slice(0, max - 1)}…` : x; };
/** Crash / ANR rates over the Play bad behavior threshold right now. */
export function healthAlerts(h: HealthSnapshot | null | undefined): ('crash' | 'anr')[] {
if (!h?.play || isError(h.play)) return [];
const out: ('crash' | 'anr')[] = [];
if (healthSeries(h.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash).tone === 'bad') out.push('crash');
if (healthSeries(h.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr).tone === 'bad') out.push('anr');
return out;
}
export function cardSnapshot(apps: CardInput[], o: { at: string | null; busy: boolean; muted: boolean; now?: number; build?: { version: string; channel: string } | null }): CardSnapshot {
const now = o.now ?? Date.now();
const rejections: CardRejection[] = [], releases: CardRelease[] = [], low: CardLowReview[] = [], health: CardHealth[] = [];
const touched = new Set<string>();
let recent = 0;
for (const a of apps) {
for (const r of a.status?.release.all ?? []) {
if (r.phase === 'rejected') { rejections.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, reason: r.reason ? one(r.reason, 140) : null }); touched.add(a.key); }
else if (r.phase === 'review' || r.phase === 'sent' || r.phase === 'rollout' || r.phase === 'approved' || r.phase === 'halted') {
releases.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, phase: r.phase === 'sent' ? 'review' : r.phase, fraction: r.phase === 'rollout' ? r.fraction : null });
touched.add(a.key);
}
}
for (const rv of a.reviews ?? []) {
const age = now - Date.parse(rv.date);
if (!(age > -DAY)) continue; // unreadable date, or far in the future
if (age <= RECENT_DAYS * DAY) recent++;
if (rv.rating <= 2 && age <= LOW_DAYS * DAY) {
low.push({ app: a.key, name: a.name, store: rv.store, rating: rv.rating, text: one([rv.title, rv.body].filter(Boolean).join(' · '), 110), date: rv.date });
touched.add(a.key);
}
}
const kinds = healthAlerts(a.health);
if (kinds.length) { health.push({ app: a.key, name: a.name, hue: a.hue, kinds }); touched.add(a.key); }
}
// halted first (it needs the owner), then review, waiting for release, rolling out
const order = { halted: 0, review: 1, approved: 2, rollout: 3 } as const;
releases.sort((x, y) => order[x.phase] - order[y.phase] || x.name.localeCompare(y.name));
low.sort((x, y) => y.date.localeCompare(x.date));
return {
at: o.at, busy: o.busy, muted: o.muted, apps: apps.length,
rejections: rejections.slice(0, MAX_ROWS), releases: releases.slice(0, MAX_ROWS),
reviews: { recent, low: low.slice(0, MAX_LOW) }, health: health.slice(0, MAX_ROWS),
links: apps.filter(a => touched.has(a.key)).map(a => ({ key: a.key, name: a.name, hue: a.hue, asc: a.asc ?? null, play: a.play ?? null })),
build: o.build ?? null,
};
}

View file

@ -0,0 +1,111 @@
// The tray card's words in every language the app has (en, tr; same keys, tr typed against en). Kept apart from the
// main catalogs so the card bundle stays small.
export const en = {
title: 'Store status',
rejected: 'Rejected',
rejections: 'Needs you',
moving: 'Releases on the move',
review: 'In review',
rollout: 'Rolling out',
rolloutPct: 'Rolling out {pct}%',
approved: 'Waiting for release',
halted: 'Rollout halted',
reviews: 'Reviews',
recent: '{n} new in the last 7 days',
recentNone: 'No new reviews in the last 7 days',
health: 'Crash and ANR alerts',
crash: 'Crash rate over the bad behavior threshold',
anr: 'ANR rate over the bad behavior threshold',
crashAnr: 'Crash and ANR rates over the bad behavior threshold',
calm: 'All quiet: nothing in review, rolling out or waiting on you.',
noData: 'No store read yet. Refresh reads every app.',
lastRead: 'Read {ago}',
never: 'never',
justNow: 'just now',
min: '{n} min ago',
hour: '{n} h ago',
day: '{n} d ago',
refresh: 'Refresh',
refreshing: 'Reading…',
openMain: 'Open storebound',
build: 'storebound {version} · {channel} channel',
chStable: 'stable',
chDev: 'dev',
chBeta: 'beta',
more: 'More',
mute: 'Mute notifications for 1 hour',
unmute: 'Unmute notifications',
muted: 'Muted',
quit: 'Quit storebound',
openApp: 'Open {name}',
asc: 'App Store Connect',
play: 'Play Console',
appStore: 'App Store',
playStore: 'Play',
stars: '{n} of 5 stars',
};
export type CardKey = keyof typeof en;
export const tr: Record<CardKey, string> = {
title: 'Mağaza durumu',
rejected: 'Reddedildi',
rejections: 'Seni bekliyor',
moving: 'İlerleyen sürümler',
review: 'İncelemede',
rollout: 'Yayılıyor',
rolloutPct: '%{pct} yayılıyor',
approved: 'Yayın bekliyor',
halted: 'Yayılım durduruldu',
reviews: 'Yorumlar',
recent: 'Son 7 günde {n} yeni',
recentNone: 'Son 7 günde yeni yorum yok',
health: 'Çökme ve ANR uyarıları',
crash: 'Çökme oranı kötü davranış eşiğinin üstünde',
anr: 'ANR oranı kötü davranış eşiğinin üstünde',
crashAnr: 'Çökme ve ANR oranları kötü davranış eşiğinin üstünde',
calm: 'Her şey sakin: incelemede, yayılan ya da seni bekleyen bir şey yok.',
noData: 'Henüz mağaza okunmadı. Yenile her uygulamayı okur.',
lastRead: '{ago} okundu',
never: 'hiç',
justNow: 'az önce',
min: '{n} dk önce',
hour: '{n} sa önce',
day: '{n} gün önce',
refresh: 'Yenile',
refreshing: 'Okunuyor…',
openMain: 'storebound\'u aç',
build: 'storebound {version} · {channel} kanalı',
chStable: 'kararlı',
chDev: 'geliştirme',
chBeta: 'beta',
more: 'Diğer',
mute: 'Bildirimleri 1 saat sustur',
unmute: 'Bildirimleri aç',
muted: 'Sessiz',
quit: 'storebound\'dan çık',
openApp: '{name} uygulamasını aç',
asc: 'App Store Connect',
play: 'Play Console',
appStore: 'App Store',
playStore: 'Play',
stars: '5 üzerinden {n} yıldız',
};
export const CARD_TEXT = { en, tr } as const;
export type CardLang = keyof typeof CARD_TEXT;
export function cardT(lang: CardLang, key: CardKey, vars?: Record<string, string | number>): string {
const s = CARD_TEXT[lang][key] ?? en[key];
return vars ? s.replace(/\{(\w+)\}/g, (m, k: string) => (vars[k] === undefined ? m : String(vars[k]))) : s;
}
/** "3 min ago" style age of the last read. */
export function ago(lang: CardLang, at: string | null, now: number): string {
if (!at) return cardT(lang, 'never');
const m = Math.floor((now - Date.parse(at)) / 60_000);
if (!Number.isFinite(m)) return cardT(lang, 'never');
if (m < 1) return cardT(lang, 'justNow');
if (m < 60) return cardT(lang, 'min', { n: m });
if (m < 48 * 60) return cardT(lang, 'hour', { n: Math.floor(m / 60) });
return cardT(lang, 'day', { n: Math.floor(m / 1440) });
}

302
apps/desktop/src/design.css Normal file
View file

@ -0,0 +1,302 @@
/* Design system v2 (components in src/ui/ds.tsx, guide in docs/drafts/desktop-v2/SYSTEM.md).
The store-like language of the portfolio home for every screen: tinted surfaces, big type, pill tabs, calm tables.
Loaded once after styles.css (main.tsx). Screens keep their own CSS in src/styles/<screen>.css. */
/* ---------- tokens ---------- */
:root {
/* surfaces and elevation */
--ds-page: var(--bg);
--ds-surface: var(--surface);
--ds-raised: #ffffff;
--ds-inset: var(--bg-sunken);
--ds-stroke: var(--line);
--ds-stroke-strong: var(--line-2);
--ds-e1: var(--shadow-1);
--ds-e2: var(--shadow-2);
--ds-e3: var(--shadow-3);
/* radii */
--ds-r-xs: 6px; --ds-r-sm: 10px; --ds-r: 14px; --ds-r-lg: 18px; --ds-r-xl: 24px; --ds-r-pill: 999px;
/* accent gradient (buttons, active pills, focus glow) */
--ds-grad: linear-gradient(135deg, #5b55e8, #8a5cf6 55%, #c264e8);
--ds-grad-soft: linear-gradient(135deg, color-mix(in srgb, #5b55e8 14%, transparent), color-mix(in srgb, #c264e8 10%, transparent));
/* per-app tint: set --tint-h (0-360) on an ancestor (tintStyle(hue) in ui/ds.tsx) */
--tint-h: 245;
--ds-tint-s: 78%; --ds-tint-l: 62%; --ds-tint-a: .16; --ds-tint-a2: .07;
/* typography scale */
--ds-t-display: 34px; --ds-t-h1: 28px; --ds-t-h2: 18px; --ds-t-h3: 15px; --ds-t-body: 13.5px; --ds-t-meta: 12px; --ds-t-micro: 11px;
--ds-t-stat: 32px;
--ds-w-display: 780; --ds-w-title: 700; --ds-w-strong: 650;
}
:root[data-theme="dark"] { --ds-raised: #151d2e; --ds-tint-l: 58%; --ds-tint-a: .26; --ds-tint-a2: .10; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ds-raised: #151d2e; --ds-tint-l: 58%; --ds-tint-a: .26; --ds-tint-a2: .10; } }
/* Anything below a tint: --tint is the app colour, .ds-tinted paints the soft gradient surface. */
[style*="--tint-h"] { --tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l)); }
.ds-tinted {
background:
radial-gradient(120% 80% at 100% 0%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a)), transparent 60%),
linear-gradient(180deg, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent 55%), var(--ds-surface);
border: 1px solid color-mix(in srgb, var(--tint, var(--accent-solid)) 22%, var(--ds-stroke));
}
/* ---------- shell: sidebar ---------- */
.side { background: linear-gradient(180deg, color-mix(in srgb, var(--bg-sunken) 92%, var(--accent-solid)) 0%, var(--bg-sunken) 260px); }
.side .nav { height: 34px; border-radius: 10px; }
.side .nav[aria-current="page"] { background: var(--ds-raised); box-shadow: var(--ds-e1), inset 0 0 0 1px var(--ds-stroke); }
.side .nav[aria-current="page"]::before { content: ""; position: absolute; left: 0; width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--ds-grad); }
.side .nav { position: relative; }
.side .nav-app { height: 36px; gap: 11px; }
.ds-side-ic { position: relative; display: inline-grid; flex: none; }
.side .nav-app .app-icon { width: 24px; height: 24px; border-radius: 7px; font-size: 9px; box-shadow: 0 2px 6px -2px rgba(0, 0, 0, .35), inset 0 0 0 1px rgba(255, 255, 255, .08); }
.ds-sdot { position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--bg-sunken); background: var(--neutral-solid); }
.ds-sdot.ok { background: var(--ok-solid); } .ds-sdot.warn { background: var(--warn-solid); } .ds-sdot.bad { background: var(--bad-solid); } .ds-sdot.info { background: var(--info-solid); }
.side-group-head { border-radius: 10px; }
.side-label { letter-spacing: .09em; }
/* ---------- shell: top bar ---------- */
.topbar { height: 56px; background: color-mix(in srgb, var(--bg) 70%, transparent); backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); position: relative; z-index: 5; }
.topbar .btn { border-radius: 10px; }
.crumbs .here { font-weight: 650; }
/* ---------- PageHeader ---------- */
.ds-ph { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.ds-ph-t { min-width: 0; display: grid; gap: 4px; }
.ds-eyebrow { font-size: var(--t-section); font-weight: 600; color: var(--accent); }
.ds-h1 { font-size: var(--ds-t-h1); line-height: 1.15; letter-spacing: -.025em; font-weight: var(--ds-w-display); display: flex; align-items: center; gap: 12px; }
.ds-ph-ic { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--ds-grad); color: #fff; box-shadow: 0 8px 20px -10px #5b55e8; }
.ds-ph-ic svg { width: 22px; height: 22px; }
.ds-sub { color: var(--muted); max-width: 80ch; }
/* ---------- Hero (app page) ---------- */
.ds-hero {
--tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l));
position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; border-radius: var(--ds-r-xl); overflow: hidden; min-height: 240px;
background:
radial-gradient(90% 120% at 100% 0%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / calc(var(--ds-tint-a) * 1.6)), transparent 62%),
radial-gradient(60% 90% at 0% 100%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent 70%), var(--ds-surface);
border: 1px solid color-mix(in srgb, var(--tint) 26%, var(--ds-stroke)); box-shadow: var(--ds-e2);
}
.ds-hero-main { display: grid; gap: 22px; padding: 26px 28px; align-content: space-between; min-width: 0; }
.ds-hero-id { display: flex; gap: 20px; align-items: center; min-width: 0; }
.ds-hero .ds-hero-icon { width: 88px; height: 88px; border-radius: 22px; font-size: 28px; box-shadow: 0 16px 36px -14px hsl(var(--tint-h) 70% 30% / .75), inset 0 0 0 1px rgba(255, 255, 255, .12); }
.ds-hero-t { display: grid; gap: 8px; min-width: 0; }
.ds-hero-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ds-hero-name { font-size: var(--ds-t-h1); font-weight: var(--ds-w-display); letter-spacing: -.025em; line-height: 1.1; }
.ds-hero-ids { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.ds-hero-ids .store-badge { height: 30px; padding-right: 4px; background: color-mix(in srgb, var(--ds-surface) 70%, transparent); }
.ds-hero-ids .store-badge .chip { height: 22px; }
.ds-hero-links { display: flex; gap: 6px; flex-wrap: wrap; }
.ds-hero-links .btn { height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--ds-surface) 60%, transparent); }
.ds-hero-pipe { display: grid; grid-template-columns: minmax(0, 560px) auto; gap: 28px; align-items: end; padding: 18px 20px; border-radius: var(--ds-r); background: color-mix(in srgb, var(--ds-surface) 55%, transparent); border: 1px solid color-mix(in srgb, var(--tint) 16%, var(--ds-stroke)); }
.ds-pipe-lg { gap: 9px; }
.ds-pipe-lg .pf-segs b { height: 10px; }
.ds-hero-foot { display: grid; gap: 10px; justify-items: end; }
.ds-hero-next { display: grid; justify-items: end; line-height: 1.1; }
.ds-hero-next-v { font-size: 26px; font-weight: var(--ds-w-display); letter-spacing: -.02em; }
.ds-hero-shots { position: relative; display: flex; gap: 12px; height: 100%; max-height: 300px; padding: 22px 22px 0; align-items: flex-start; overflow: hidden; width: min(44vw, 520px);
background: linear-gradient(160deg, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .30), transparent 80%);
-webkit-mask-image: linear-gradient(180deg, #000 78%, transparent), linear-gradient(90deg, transparent, #000 10%); -webkit-mask-composite: source-in; mask-image: linear-gradient(180deg, #000 78%, transparent); }
.ds-hero-shots img { height: 280px; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 16px; box-shadow: 0 18px 36px -14px rgba(0, 0, 0, .55); flex: none; transition: transform var(--speed) var(--ease); }
.ds-hero-shots img:nth-child(even) { margin-top: 26px; }
.ds-hero:hover .ds-hero-shots img { transform: translateY(-3px); }
/* compact hero: every app tab but Releases (one slim row, the tab content starts above the fold) */
.ds-hero.compact { grid-template-columns: minmax(0, 1fr); min-height: 0; border-radius: var(--ds-r-lg); }
.ds-hero.compact .ds-hero-main { padding: 14px 20px; gap: 0; }
.ds-hero.compact .ds-hero-icon { width: 52px; height: 52px; border-radius: 14px; font-size: 18px; }
.ds-hero.compact .ds-hero-id { gap: 14px; }
.ds-hero.compact .ds-hero-t { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
.ds-hero.compact .ds-hero-row { flex-basis: 100%; }
.ds-hero.compact .ds-hero-name { font-size: 20px; }
.ds-hero.compact .ds-hero-ids .store-badge { height: 26px; }
.ds-hero-cta { margin-left: auto; flex: none; }
@media (max-width: 1500px) { .ds-hero { grid-template-columns: minmax(0, 1fr); } .ds-hero-shots { display: none; } }
@media (max-width: 900px) { .ds-hero-pipe { grid-template-columns: minmax(0, 1fr); } .ds-hero-foot { justify-items: start; } }
/* ---------- app page shell ---------- */
.ds-app { gap: 18px; }
.ds-app .app-controls { padding: 0 4px; }
.ds-app-sticky { position: sticky; top: -1px; z-index: 4; margin: 0 -28px; padding: 8px 28px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
/* ---------- Tabs v2 (pill) ---------- */
.ds-tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); flex-wrap: nowrap; overflow: hidden; width: max-content; max-width: 100%; box-sizing: border-box; }
.ds-tabs::-webkit-scrollbar { display: none; }
.ds-tab { all: unset; flex: none; display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; transition: background var(--speed) var(--ease), color var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
.ds-tab svg { width: 16px; height: 16px; }
.ds-tab:hover { color: var(--ink); background: color-mix(in srgb, var(--ds-raised) 60%, transparent); }
.ds-tab[aria-selected="true"] { color: var(--ink); background: var(--ds-raised); box-shadow: var(--ds-e1), inset 0 0 0 1px var(--ds-stroke); }
.ds-tab[aria-selected="true"] svg { color: var(--tint, var(--accent-solid)); }
.ds-tab:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.ds-tab-n { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 99px; display: inline-grid; place-items: center; font-size: 10.5px; font-weight: 700; background: var(--bad-solid); color: #fff; }
/* ---------- Section / Card v2 ---------- */
.ds-sec { background: var(--ds-surface); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r-lg); box-shadow: var(--ds-e1); min-width: 0; }
.ds-sec.plain { background: none; border: 0; box-shadow: none; }
.ds-sec-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px 0; }
.ds-sec.plain .ds-sec-h { padding: 0 2px; }
.ds-sec-h h2 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); letter-spacing: -.015em; display: flex; align-items: center; gap: 10px; }
.ds-sec-ic { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 16%, transparent); color: var(--tint, var(--accent-solid)); }
.ds-sec-ic svg { width: 16px; height: 16px; }
.ds-sec-sub { color: var(--muted); font-size: var(--ds-t-meta); margin-top: 3px; }
.ds-sec-b { padding: 16px 22px 20px; }
.ds-sec.plain .ds-sec-b { padding: 12px 0 0; }
.ds-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(var(--ds-min, 220px), 1fr)); }
/* ---------- StatTile ---------- */
.ds-stat { all: unset; box-sizing: border-box; display: grid; gap: 6px; align-content: start; padding: 18px 20px; border-radius: 16px; background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e1); position: relative; overflow: hidden; min-width: 0; }
.ds-stat::after { content: ""; position: absolute; inset: 0 0 auto; height: 3px; background: var(--tint, transparent); opacity: .8; }
.ds-stat.clickable { cursor: pointer; transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
.ds-stat.clickable:hover { transform: translateY(-1px); box-shadow: var(--ds-e2); }
.ds-stat:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.ds-stat-l { display: flex; align-items: center; gap: 7px; font-size: var(--ds-t-meta); font-weight: 600; color: var(--muted); }
.ds-stat-l svg { width: 15px; height: 15px; }
.ds-stat-v { font-size: var(--ds-t-stat); font-weight: var(--ds-w-display); letter-spacing: -.03em; line-height: 1.05; color: var(--ink); }
.ds-stat-s { display: flex; gap: 8px; align-items: center; font-size: var(--ds-t-meta); color: var(--muted); min-width: 0; }
.ds-stat-s > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ds-stat.tone-bad { border-left: 3px solid var(--bad-solid); } .ds-stat.tone-warn { border-left: 3px solid var(--warn-solid); } .ds-stat.tone-ok { border-left: 3px solid var(--ok-solid); }
.ds-delta { font-weight: 700; font-variant-numeric: tabular-nums; padding: 1px 6px; border-radius: 6px; background: var(--neutral-bg); color: var(--neutral-ink); }
.ds-delta.up { background: var(--ok-bg); color: var(--ok-ink); } .ds-delta.down { background: var(--bad-bg); color: var(--bad-ink); }
/* ---------- StatusPill ---------- */
.ds-pill { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; border-radius: var(--ds-r-pill); font-size: var(--ds-t-micro); font-weight: 650; white-space: nowrap; background: var(--neutral-bg); color: var(--neutral-ink); }
.ds-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--neutral-solid); }
.ds-pill svg { width: 13px; height: 13px; }
.ds-pill.ok { background: var(--ok-bg); color: var(--ok-ink); } .ds-pill.ok i { background: var(--ok-solid); }
.ds-pill.warn { background: var(--warn-bg); color: var(--warn-ink); } .ds-pill.warn i { background: var(--warn-solid); }
.ds-pill.bad { background: var(--bad-bg); color: var(--bad-ink); } .ds-pill.bad i { background: var(--bad-solid); }
.ds-pill.info { background: var(--info-bg); color: var(--info-ink); } .ds-pill.info i { background: var(--info-solid); }
.ds-pill.accent { background: var(--accent-soft); color: var(--accent); } .ds-pill.accent i { background: var(--accent-solid); }
.ds-pill.solid { color: #fff; } .ds-pill.solid i { background: #fff; }
.ds-pill.solid.ok { background: var(--ok-solid); } .ds-pill.solid.warn { background: var(--warn-solid); color: #1b1203; } .ds-pill.solid.bad { background: var(--bad-solid); } .ds-pill.solid.info { background: var(--info-solid); } .ds-pill.solid.accent { background: var(--ds-grad); }
/* ---------- EmptyState ---------- */
.ds-empty { display: grid; justify-items: center; gap: 12px; padding: 36px 24px; text-align: center; color: var(--muted); }
.ds-empty-ic { width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 14%, transparent); color: var(--tint, var(--accent-solid)); }
.ds-empty-ic svg { width: 26px; height: 26px; }
.ds-empty.tone-ok .ds-empty-ic { background: var(--ok-bg); color: var(--ok-solid); }
.ds-empty.tone-bad .ds-empty-ic { background: var(--bad-bg); color: var(--bad-solid); }
.ds-empty p { font-size: var(--t-em); color: var(--ink-2); max-width: 52ch; }
/* ---------- Toolbar ---------- */
.ds-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px; border-radius: var(--ds-r); background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e1); }
.ds-toolbar .grow { flex: 1; }
.ds-toolbar .input, .ds-toolbar .btn { border-radius: 10px; }
/* ---------- Table v2 (add .v2 to .table, wrap in .ds-table-wrap) ---------- */
.ds-table-wrap { overflow: auto; max-height: var(--ds-table-max, none); border-radius: var(--ds-r-lg); border: 1px solid var(--ds-stroke); background: var(--ds-surface); box-shadow: var(--ds-e1); }
.table.v2 th { position: sticky; top: 0; z-index: 1; background: color-mix(in srgb, var(--ds-surface) 92%, var(--ds-inset)); backdrop-filter: blur(8px); text-transform: none; letter-spacing: 0; font-size: var(--ds-t-meta); font-weight: 600; padding-top: 12px; padding-bottom: 12px; }
.table.v2 th:first-child, .table.v2 th:last-child { border-radius: 0; }
.table.v2 td { padding-top: 14px; padding-bottom: 14px; border-bottom-color: color-mix(in srgb, var(--ds-stroke) 65%, transparent); }
.table.v2 tbody tr { transition: background-color var(--speed) var(--ease); }
.table.v2 tbody tr:hover td { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--hover)); }
.table.v2 tbody tr:focus-within td { background: var(--hover); }
.table.v2 .num, .table.v2 td.right { font-variant-numeric: tabular-nums; }
/* ---------- Dialog v2 ---------- */
.ds-scrim { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 24px; background: var(--scrim); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ds-fade var(--speed) var(--ease); }
.ds-dialog { width: min(560px, 100%); max-height: calc(100vh - 48px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border-radius: var(--ds-r-xl); background: var(--ds-raised); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e3); animation: ds-pop var(--speed) var(--ease); }
.ds-dialog.wide { width: min(880px, 100%); }
.ds-dialog:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.ds-dialog-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 22px 8px; }
.ds-dialog-h h2 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); display: flex; align-items: center; gap: 10px; }
.ds-dialog-b { padding: 8px 22px 18px; overflow: auto; }
.ds-dialog-f { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--ds-stroke); background: color-mix(in srgb, var(--ds-inset) 50%, transparent); border-radius: 0 0 var(--ds-r-xl) var(--ds-r-xl); }
@keyframes ds-fade { from { opacity: 0; } }
@keyframes ds-pop { from { opacity: 0; transform: translateY(6px) scale(.985); } }
/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) { .ds-hero-shots img, .ds-stat.clickable, .ds-scrim, .ds-dialog { transform: none !important; transition: none; animation: none; } }
:root[data-motion="reduce"] .ds-hero-shots img, :root[data-motion="reduce"] .ds-stat.clickable, :root[data-motion="reduce"] .ds-scrim, :root[data-motion="reduce"] .ds-dialog { transform: none !important; transition: none; animation: none; }
/* ---------- context menu (ui/ContextMenu.tsx) ---------- */
.ds-menu { position: fixed; z-index: 1000; min-width: 200px; max-width: 320px; max-height: calc(100vh - 16px); overflow: auto; padding: 5px;
border-radius: 12px; background: color-mix(in srgb, var(--ds-raised) 94%, transparent); border: 1px solid var(--ds-stroke-strong);
box-shadow: 0 1px 0 rgba(255, 255, 255, .05) inset, 0 14px 38px -10px rgba(10, 14, 30, .38), 0 3px 10px -4px rgba(10, 14, 30, .22);
-webkit-backdrop-filter: blur(18px) saturate(1.4); backdrop-filter: blur(18px) saturate(1.4);
font-size: var(--ds-t-body); color: var(--ink); outline: none; user-select: none;
opacity: 0; transform: scale(.97); transform-origin: top left; transition: opacity 120ms var(--ease), transform 120ms var(--ease); }
.ds-menu.in { opacity: 1; transform: none; }
.ds-menu-item { display: flex; align-items: center; gap: 9px; height: 30px; padding: 0 10px 0 8px; border-radius: 7px; cursor: default; white-space: nowrap; }
.ds-menu-item.active { background: var(--accent-soft); color: var(--ink); }
.ds-menu-item.active .ds-menu-ic { color: var(--accent); }
.ds-menu-item.danger { color: var(--bad-solid); }
.ds-menu-item.danger.active { background: var(--bad-bg); color: var(--bad-ink); }
.ds-menu-item.danger .ds-menu-ic, .ds-menu-item.danger.active .ds-menu-ic { color: inherit; }
.ds-menu-item.disabled { opacity: .45; }
.ds-menu-ic { width: 16px; height: 16px; flex: none; display: grid; place-items: center; color: var(--muted); }
.ds-menu-ic svg { width: 15px; height: 15px; }
.ds-menu-l { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.ds-menu-k { max-width: 150px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--faint); font-size: var(--ds-t-micro); letter-spacing: .02em; font-variant-numeric: tabular-nums; }
.ds-menu-sub { width: 12px; height: 12px; color: var(--faint); margin-left: 10px; }
.ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); }
/* menu header: the pin button (a normal menu or submenu), a pinned panel's title, unpin, close and drag handle */
.ds-menu-head { display: flex; align-items: center; gap: 2px; height: 24px; padding: 0 0 0 9px; margin-bottom: 1px; }
.ds-menu-head.pinned { cursor: grab; touch-action: none; }
.ds-menu-head.pinned:active { cursor: grabbing; }
.ds-menu-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ds-t-micro);
font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--faint); }
.ds-menu-btn { flex: none; width: 22px; height: 22px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
background: transparent; color: var(--faint); cursor: pointer; }
.ds-menu-btn svg { width: 13px; height: 13px; }
.ds-menu-btn:hover, .ds-menu-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; }
.ds-menu-btn.on { color: var(--accent); }
.ds-menu.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); transition: opacity 120ms var(--ease); }
.ds-menu-gone { max-width: 240px; padding: 6px 10px 8px; color: var(--muted); font-size: var(--ds-t-micro); white-space: normal; }
@media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } }
:root[data-motion="reduce"] .ds-menu { transition: none; transform: none; }
/* headings focused by code (dialog / wizard step titles) for screen readers: no ring */
[tabindex="-1"]:focus:not(:focus-visible), h1[tabindex="-1"]:focus, h2[tabindex="-1"]:focus { outline: none; }
.ds-tab[hidden] { display: none; }
.ds-tab-more svg:last-child { width: 12px; height: 12px; }
@media (max-width: 1560px) { .ds-tabs .ds-tab { padding: 0 10px; gap: 6px; } .ds-tabs .ds-tab svg { width: 15px; height: 15px; } }
.dr-cmd { display: block; margin: 10px 0 8px; padding: 6px 10px; border-radius: var(--ds-r-xs); background: var(--ds-inset); overflow-wrap: anywhere; }
.dr-out { max-height: 320px; overflow: auto; margin: 0; padding: 10px 12px; border-radius: var(--ds-r-sm); background: var(--ds-inset); border: 1px solid var(--ds-stroke); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre-wrap; }
/* wide tables: the first column (the row's name) stays in view while the rest scrolls sideways */
.table-wrap .table tr > :first-child, .ds-table-wrap .table tr > :first-child { position: sticky; left: 0; z-index: 1; background: var(--ds-surface, var(--surface)); }
.table-wrap .table thead tr > :first-child, .ds-table-wrap .table thead tr > :first-child { z-index: 3; }
.table-wrap .table tbody tr:hover > :first-child, .ds-table-wrap .table tbody tr:hover > :first-child { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--ds-surface, var(--surface))); }
/* Settings > Appearance > Tables: compact */
:root[data-density="compact"] .table th, :root[data-density="compact"] .table td { padding-top: 5px; padding-bottom: 5px; padding-left: 8px; padding-right: 8px; font-size: 12.5px; }
/* ---------- App sections (docs IA): section header, next step, sub views ---------- */
.ds-tab-k { font: 600 10px/1 var(--mono, ui-monospace, monospace); min-width: 16px; height: 16px; display: inline-grid; place-items: center; border-radius: 4px; color: var(--muted); border: 1px solid var(--ds-stroke); background: var(--ds-surface); margin-left: 2px; opacity: .75; }
.ds-tab[aria-selected="true"] .ds-tab-k { opacity: 1; }
.sec-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "t next" "subs subs"; gap: 10px 16px; align-items: center; padding: 2px 4px 4px; }
.sec-head-t { grid-area: t; min-width: 0; }
.sec-head-name { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--ds-t-h2, 18px); font-weight: 700; letter-spacing: -.01em; }
.sec-head-name svg { width: 18px; height: 18px; color: var(--tint, var(--accent-solid)); }
.sec-head-for { margin: 2px 0 0; color: var(--muted); font-size: var(--t-meta); }
.sec-next { grid-area: next; display: inline-flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; min-height: 32px; box-sizing: border-box; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-surface); font-weight: 600; font-size: var(--t-meta); max-width: 560px; }
.sec-next > svg { width: 15px; height: 15px; flex: none; }
.sec-next-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sec-next:not(:has(.btn)) { padding-right: 12px; }
.sec-next.tone-bad { background: var(--bad-bg); color: var(--bad-ink); border-color: transparent; }
.sec-next.tone-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; }
.sec-next.tone-ok > svg { color: var(--ok-solid); }
.sec-next.tone-info > svg { color: var(--accent-solid); }
.sec-next .btn { flex: none; }
.sec-subs { grid-area: subs; justify-self: start; }
.sec-subs .ed-dot { margin-left: 2px; }
@media (max-width: 1100px) { .sec-head { grid-template-columns: 1fr; grid-template-areas: "t" "next" "subs"; } .sec-next { justify-self: start; } }
/* ---------- Sidebar: attention groups, one status line per app, workspace footer ---------- */
.nav-app.has-line { height: auto; min-height: 40px; padding-top: 4px; padding-bottom: 4px; align-items: center; }
.nav-app .side-t { display: grid; min-width: 0; line-height: 1.2; }
.nav-app .side-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-app .side-s { font-size: 11px; font-weight: 500; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-app .side-s.bad { color: var(--bad-ink); } .nav-app .side-s.warn { color: var(--warn-ink); } .nav-app .side-s.ok { color: var(--ok-ink); }
:root[data-theme="dark"] .nav-app .side-s.bad { color: color-mix(in srgb, var(--bad-solid) 70%, white); }
:root[data-theme="dark"] .nav-app .side-s.warn { color: color-mix(in srgb, var(--warn-solid) 70%, white); }
:root[data-theme="dark"] .nav-app .side-s.ok { color: color-mix(in srgb, var(--ok-solid) 70%, white); }
.side-studios { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 6px 2px; }
.side-studios button { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 99px; font-size: 11px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); }
.side-studios button[aria-pressed="true"] { color: var(--ink); background: var(--surface); box-shadow: var(--shadow-1); }
.side-studios button:focus-visible { outline: 2px solid var(--focus); }
.side-ws { display: flex; gap: 2px; padding: 6px 8px; border-top: 1px solid var(--line); }
.side-ws button { all: unset; cursor: pointer; position: relative; flex: 1; height: 30px; display: grid; place-items: center; border-radius: 7px; color: var(--muted); }
.side-ws button:hover { background: var(--hover); color: var(--ink); }
.side-ws button[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1); }
.side-ws button:focus-visible { outline: 2px solid var(--focus); }
.side-ws svg { width: 17px; height: 17px; }
.side-ws .ws-n { position: absolute; top: 2px; right: 4px; font-size: 9.5px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }

View file

@ -83,7 +83,7 @@ export const DECLARATIONS: Record<string, DeclarationsResult> = {
review: { signInRequired: false, accounts: false, contact: 's***@example.com', demoAccount: null, notes: 'Offline puzzle game, no sign-in. Remove Ads is restored from Settings > Restore purchases. The daily drop unlocks at midnight local time.', notesBytes: 148, notesMax: 4000 }, review: { signInRequired: false, accounts: false, contact: 's***@example.com', demoAccount: null, notes: 'Offline puzzle game, no sign-in. Remove Ads is restored from Settings > Restore purchases. The daily drop unlocks at midnight local time.', notesBytes: 148, notesMax: 4000 },
ask: [ ask: [
{ area: 'appInfo', id: 'ai-age', text: 'No age rating answers: storebound would answer every content question "none".', fix: 'Confirm that is true (ads, user-generated content, chat, loot boxes, web access count), or set apple.info.ageRating overrides.' }, { area: 'appInfo', id: 'ai-age', text: 'No age rating answers: storebound would answer every content question "none".', fix: 'Confirm that is true (ads, user-generated content, chat, loot boxes, web access count), or set apple.info.ageRating overrides.' },
{ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy for each type below (tracking also needs App Tracking Transparency in the app).' }, { area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy: https://appstoreconnect.apple.com/apps/1000000001/distribution/privacy for each type below (tracking also needs App Tracking Transparency in the app).', url: 'https://appstoreconnect.apple.com/apps/1000000001/distribution/privacy' },
], ],
}, },
'p-tidy': { 'p-tidy': {

View file

@ -5,11 +5,21 @@
// not contain it (tests/webview.test.ts checks the built files for FIXTURE_MARKER). // not contain it (tests/webview.test.ts checks the built files for FIXTURE_MARKER).
import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleForm, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, type DownloadEvent, type DraftsResult, type ListingField, type ListingsResult, import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleForm, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, type DownloadEvent, type DraftsResult, type ListingField, type ListingsResult,
type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api'; type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api';
import { buildReport } from '../../../../cli/lib/report';
import { buildApps, type Overrides } from '../lib/identity'; import { buildApps, type Overrides } from '../lib/identity';
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model'; import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
import { editableFixture, liveFixture } from './editor-fixtures'; import { editableFixture, liveFixture } from './editor-fixtures';
import { serviceDisabledMessage } from './error-fixtures';
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures'; import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more'; import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
import { TOOLBOX } from '../../../../cli/lib/desktop-coverage';
/** system.methods for the More tools tab: the toolbox methods with a small schema each (reads end in a read verb). */
const toolMethods = () => TOOLBOX.flatMap(g => g.methods).map(m => {
const read = /status|list|devices|check|diff|next_|app_id|info|wait|plan|verify|rank|competitors|suggest|scan|manifest|triage|originality|aso/.test(m);
return { method: m, title: m.replace('.', ' ').replace(/_/g, ' '), description: `Fixture descriptor of ${m}.`, kind: read ? 'read' : 'write', network: m.startsWith('apple.') || m.startsWith('play.'), destructive: false, json: false,
args: { type: 'object', additionalProperties: false, properties: m === 'play.upload' ? { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, name: { type: 'string', description: 'Release name' } } : m === 'agent.install' ? { agent: { type: 'string', enum: ['claude', 'codex'] }, no_mcp: { type: 'boolean' } } : {}, ...(m === 'agent.install' ? { required: ['agent'] } : {}) } };
});
export const FIXTURE_MARKER = 'storebound-dev-fixtures'; export const FIXTURE_MARKER = 'storebound-dev-fixtures';
@ -92,7 +102,7 @@ let overrides: Overrides = {
}; };
if (import.meta.env.VITE_FIXTURES === 'empty') overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] }; if (import.meta.env.VITE_FIXTURES === 'empty') overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] };
let settings: Settings = { ...DEFAULT_SETTINGS, refresh: { ...DEFAULT_SETTINGS.refresh, autoMinutes: 60 } }; let settings: Settings = { ...DEFAULT_SETTINGS, refresh: { ...DEFAULT_SETTINGS.refresh, autoMinutes: 60 } };
const PLAY_INVITE = (who: string) => `Play Console > Users and permissions > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`; const PLAY_INVITE = (who: string) => `Play Console > Users and permissions: https://play.google.com/console > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`;
const CHECKS: Record<string, AccountCheck> = { const CHECKS: Record<string, AccountCheck> = {
[P1]: { store: 'android', status: 'ok', principal: 'play-bot@example.com', apps: 8, message: 'The service account can see 8 apps.', fix: null, docs: null }, [P1]: { store: 'android', status: 'ok', principal: 'play-bot@example.com', apps: 8, message: 'The service account can see 8 apps.', fix: null, docs: null },
[P2]: { store: 'android', status: 'permission', principal: 'sample-labs-bot@example.com', apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: PLAY_INVITE('sample-labs-bot@example.com'), docs: 'https://play.google.com/console/developers/users-and-permissions' }, [P2]: { store: 'android', status: 'permission', principal: 'sample-labs-bot@example.com', apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: PLAY_INVITE('sample-labs-bot@example.com'), docs: 'https://play.google.com/console/developers/users-and-permissions' },
@ -339,9 +349,12 @@ const CATALOG: Record<string, CatalogDiff> = {
const VALIDATE: Record<string, ValidateResult> = { const VALIDATE: Record<string, ValidateResult> = {
'p-prism': { 'p-prism': {
blockers: 1, findings: [ blockers: 1, findings: [
{ store: 'play', level: 'blocker', code: 'listing', msg: 'ja-JP: missing shortDescription', fix: 'storebound play listing', action: { method: 'play.listing', args: {} } },
{ store: 'play', level: 'blocker', code: 'product-missing', msg: 'starter_pack is in the config but not on Google Play', fix: 'storebound play products --only starter_pack (dry run first)' }, { store: 'play', level: 'blocker', code: 'product-missing', msg: 'starter_pack is in the config but not on Google Play', fix: 'storebound play products --only starter_pack (dry run first)' },
{ store: 'apple', level: 'warn', code: 'screenshots-ipad', msg: 'ar-SA has no 13" iPad screenshots', fix: 'storebound shots render --lang ar, then storebound apple screenshots --only ar-SA' }, { store: 'apple', level: 'warn', code: 'screenshots-ipad', msg: 'ar-SA has no 13" iPad screenshots', fix: 'storebound shots render --lang ar, then storebound apple screenshots --only ar-SA' },
{ store: 'apple', level: 'warn', code: 'whats-new', msg: '2.4.0 has no "What\'s New" for ja', fix: 'storebound whats-new --since v2.3.0' }, { store: 'apple', level: 'warn', code: 'whats-new', msg: '2.4.0 has no "What\'s New" for ja', fix: 'storebound whats-new --since v2.3.0' },
// a long unbreakable path (a merged manifest of a local build) must wrap inside the card
{ store: 'play', level: 'warn', code: 'scan', msg: 'Play: Foreground service types: FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (apps/native/android/app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml:74)', fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill Play Console > App content > Foreground service permissions: https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services', url: 'https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services' },
], ],
}, },
'p-tidy': { blockers: 0, findings: [] }, 'p-tidy': { blockers: 0, findings: [] },
@ -356,12 +369,19 @@ const SCAN_PRISM: ScanResult['findings'] = [
{ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'Tracking-capable code without App Tracking Transparency (AppLovin MAX, Unity Ads)', { rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'Tracking-capable code without App Tracking Transparency (AppLovin MAX, Unity Ads)',
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Add NSUserTrackingUsageDescription and request ATT before the SDKs start, or configure non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks', ref: 'https://developer.apple.com/app-store/review/guidelines/' }, evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Add NSUserTrackingUsageDescription and request ATT before the SDKs start, or configure non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
{ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: 'targetSdk 35 is below 36, which new apps and updates need since 2026-08-31', { rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: 'targetSdk 35 is below 36, which new apps and updates need since 2026-08-31',
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' }, evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk', aiFix: true },
{ rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found', { rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' }, evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
{ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review', { rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review',
evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' }, evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' },
// code changes: Fix with AI (the owner's Claude Code) or Copy for AI
{ rule: 'ats', guideline: '2.5.1 / ATS', store: 'apple', severity: 'medium', title: 'NSAllowsArbitraryLoads is true: App Review asks for a reason',
evidence: [{ file: 'ios/Unity-iPhone/Info.plist', line: 48, text: '<key>NSAllowsArbitraryLoads</key><true/>' }], fix: 'Remove NSAllowsArbitraryLoads (or limit it with NSExceptionDomains) unless the app must load arbitrary http content.', skill: 'review-rejections', ref: 'https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity', aiFix: true },
// a deterministic fix (scan.write: the detected answers into the config)
{ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: 'Data safety likely misses PSL_DEVICE_ID: an SDK collects it, dataSafety does not declare it',
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', action: { method: 'scan.write', args: {} } },
]; ];
const aiRuns = new Map<string, ScanResult['findings'][number]>();
const SCAN: Record<string, ScanResult> = { const SCAN: Record<string, ScanResult> = {
'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }], 'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }],
findings: SCAN_PRISM, counts: scanCounts(SCAN_PRISM), findings: SCAN_PRISM, counts: scanCounts(SCAN_PRISM),
@ -404,6 +424,23 @@ const TOOLS = [
]; ];
let chromeInstalled = false; let chromeInstalled = false;
// games status (cli/games-status.ts) of the game demo: Game Center on, one leaderboard to create; PGS project set
const GAMES_PRISM = {
apple: { capability: true, enabled: true, version: '1.2.0', versionEnabled: true, leaderboards: 1, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: ['leaderboard com.example.prismpuddle.daily: create'] },
play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] },
findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }],
links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }],
items: [
{ kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true,
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } },
{ kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true,
apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } },
...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true,
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })),
],
localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] },
playApiDisabled: null,
};
const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data }); const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data });
const STORE_REVIEWS: Review[] = [ const STORE_REVIEWS: Review[] = [
@ -428,7 +465,17 @@ export function fixtureRead(method: string, id: string, args: Record<string, unk
return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r; return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r;
} }
/** ?blocked: every Play health read fails with Google's SERVICE_DISABLED (the portfolio's "Check again" notice). */
const blockedSwitch = (): boolean => typeof location !== 'undefined' && new URLSearchParams(location.search).has('blocked');
function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult { function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
const r = read0(method, id, args);
const h = r.data as HealthSnapshot | undefined;
if (method !== 'health' || !blockedSwitch() || !h?.play) return r;
return { ...r, data: { ...h, play: { error: serviceDisabledMessage('com.example.app') } } };
}
function read0(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args)); if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args));
if (id.startsWith('store:')) { if (id.startsWith('store:')) {
const play = /android:/.test(id); const play = /android:/.test(id);
@ -451,6 +498,12 @@ function read(method: string, id: string, args: Record<string, unknown> = {}): T
case 'apple.testflight_status': return ok(method, TF[id]); case 'apple.testflight_status': return ok(method, TF[id]);
case 'catalog.diff': return ok(method, CATALOG[id]); case 'catalog.diff': return ok(method, CATALOG[id]);
case 'validate': return ok(method, VALIDATE[id]); case 'validate': return ok(method, VALIDATE[id]);
case 'report': {
const sum = SUMMARY[id];
const r = buildReport({ app: { name: sum?.name ?? id }, stores: (['apple', 'play'] as const).filter(x => !!sum?.[x]), storeRead: { apple: true, play: 'no Play service account' },
generatedAt: new Date().toISOString(), storebound: 'fixture', validate: VALIDATE[id] as never, scan: (SCAN[id] ?? SCAN_EMPTY) as never });
return ok(method, { file: `/fixture/${id}/.storebound/reports/readiness.html`, path: '.storebound/reports/readiness.html', ...r });
}
case 'scan': return ok(method, SCAN[id] ?? SCAN_EMPTY); case 'scan': return ok(method, SCAN[id] ?? SCAN_EMPTY);
case 'project.declarations': return ok(method, DECLARATIONS[id]); case 'project.declarations': return ok(method, DECLARATIONS[id]);
case 'project.listing_text': return ok(method, LISTING_TEXT[id]); case 'project.listing_text': return ok(method, LISTING_TEXT[id]);
@ -466,6 +519,7 @@ function read(method: string, id: string, args: Record<string, unknown> = {}): T
// send for review: Prism Puddle is ready to submit, Tidy Lists is back from App Review (UNRESOLVED_ISSUES) // send for review: Prism Puddle is ready to submit, Tidy Lists is back from App Review (UNRESOLVED_ISSUES)
case 'apple.status': return ok(method, id === 'p-tidy' ? APPLE_STATUS_UNRESOLVED : APPLE_STATUS_READY); case 'apple.status': return ok(method, id === 'p-tidy' ? APPLE_STATUS_UNRESOLVED : APPLE_STATUS_READY);
case 'play.status': return ok(method, PLAY_STATUS); case 'play.status': return ok(method, PLAY_STATUS);
case 'games.status': return ok(method, id === 'p-prism' ? GAMES_PRISM : { apple: null, play: null, findings: [], links: [] });
case 'console.plan': return ok(method, consolePlan(String(args.form))); case 'console.plan': return ok(method, consolePlan(String(args.form)));
case 'console.verify': return ok(method, consoleVerify(String(args.form))); case 'console.verify': return ok(method, consoleVerify(String(args.form)));
default: default:
@ -495,13 +549,18 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
}; };
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 }; const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']); const NETWORK = new Set(['games.status', 'health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'report', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
async function progressLines(job: string | null, lines: string[]) { async function progressLines(job: string | null, lines: string[]) {
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); } for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
} }
/** Every fake-host call, for checks that count them (window.__fakeCalls: "cmd method target args"). */
const fakeCalls: string[] = [];
if (typeof window !== 'undefined') (window as unknown as { __fakeCalls: string[] }).__fakeCalls = fakeCalls;
export async function mockInvoke<T>(cmd: string, a: Record<string, unknown>): Promise<T> { export async function mockInvoke<T>(cmd: string, a: Record<string, unknown>): Promise<T> {
fakeCalls.push([cmd, a.method ?? '', a.projectId ?? a.id ?? '', JSON.stringify((a.params as { args?: unknown } | null)?.args ?? a.live ?? '')].join(' '));
const r = await answer(cmd, a); const r = await answer(cmd, a);
return r as T; return r as T;
} }
@ -532,11 +591,12 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
} }
case 'studio_remove': studios = studios.filter(s => s.id !== a.id); accounts = accounts.map(x => (x.studio === a.id ? { ...x, studio: null } : x)); return null; case 'studio_remove': studios = studios.filter(s => s.id !== a.id); accounts = accounts.map(x => (x.studio === a.id ? { ...x, studio: null } : x)); return null;
case 'studio_logo': return null; case 'studio_logo': return null;
case 'account_set_play_developer': { const x = accounts.find(y => y.id === a.id)!; const v = (a.developerId as string | null) ?? null; if (v && !/^\d{1,25}$/.test(v)) throw new Error('the Play developer id is digits'); (x as { developerId?: string | null }).developerId = v; return x; }
case 'account_update': { const x = accounts.find(y => y.id === a.id)!; x.label = a.label as string; x.studio = (a.studio as string | null) ?? null; return x; } case 'account_update': { const x = accounts.find(y => y.id === a.id)!; x.label = a.label as string; x.studio = (a.studio as string | null) ?? null; return x; }
case 'overrides_set': overrides = a.overrides as Overrides; return overrides; case 'overrides_set': overrides = a.overrides as Overrides; return overrides;
case 'settings_set': settings = a.settings as Settings; return settings; case 'settings_set': settings = a.settings as Settings; return settings;
case 'cache_info': return { rows: 214, bytes: 3_412_000, oldest: iso(60 * 24 * 26), file_bytes: 4_718_592 }; case 'cache_info': return { rows: 214, bytes: 3_412_000, oldest: iso(60 * 24 * 26), file_bytes: 4_718_592 };
case 'data_reveal': case 'notify': return null; case 'data_reveal': case 'notify': case 'tray_update': return null;
case 'app_reset': return false; case 'app_reset': return false;
case 'update_check': await sleep(300); return { current: '0.1.0', channel: settings.channel, available: null, notes: null, date: null }; case 'update_check': await sleep(300); return { current: '0.1.0', channel: settings.channel, available: null, notes: null, date: null };
case 'update_install': return false; case 'update_install': return false;
@ -564,6 +624,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
case 'cache_clear': return null; case 'cache_clear': return null;
case 'sidecar_call': { case 'sidecar_call': {
const method = a.method as string; const method = a.method as string;
if (method === 'system.methods') return { methods: toolMethods() };
if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools, androidSdk: { path: `${HOME}/Library/Android/sdk`, source: 'default', checked: ['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', `Android Studio default folder: ${HOME}/Library/Android/sdk`] } }; if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools, androidSdk: { path: `${HOME}/Library/Android/sdk`, source: 'default', checked: ['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', `Android Studio default folder: ${HOME}/Library/Android/sdk`] } };
const id = a.projectId as string; const id = a.projectId as string;
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]); if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
@ -572,6 +633,29 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {}); return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
} }
case 'job_cancel': cancelled.add(a.job as string); return true; case 'job_cancel': cancelled.add(a.job as string); return true;
case 'ai_fix_info': {
const f = a.finding as { rule: string; evidence: { file: string; line: number }[] };
return { claude: !new URLSearchParams(location.search).has('noclaude'), git: true, gitError: null, dirty: new URLSearchParams(location.search).has('dirty') ? [{ path: 'ios/Unity-iPhone/Info.plist', status: 'M' }] : [],
prompt: `Fix one App Store rejection risk that \`storebound scan\` found in this app folder.\n\nFinding (${f.rule}): ...\nEvidence:\n${f.evidence.map(e => `- ${e.file}:${e.line}`).join('\n')}\n` };
}
case 'ai_fix_run': {
const job = a.job as string, f = a.finding as { rule: string; title: string; evidence: { file: string }[] };
for (const line of ['Read ios/Unity-iPhone/Info.plist', 'Grep NSAllowsArbitraryLoads', 'Edit ios/Unity-iPhone/Info.plist', 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.']) {
await sleep(400); if (cancelled.has(job)) break; emit('ai-fix', { job, line });
}
if (cancelled.has(job)) return { runId: job, outcome: { code: null, timed_out: false, cancelled: true, summary: '' }, changes: [], diff: '' };
const file = f.evidence[0]?.file ?? 'ios/Unity-iPhone/Info.plist';
const i = SCAN_PRISM.findIndex(x => x.rule === f.rule && x.title === f.title);
const removed = i >= 0 ? SCAN_PRISM.splice(i, 1)[0] : undefined;
if (removed) { aiRuns.set(job, removed); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
return { runId: job, outcome: { code: 0, timed_out: false, cancelled: false, summary: 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.' }, changes: [{ path: file, status: 'M' }],
diff: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -45,8 +45,6 @@\n <key>NSAppTransportSecurity</key>\n <dict>\n- <key>NSAllowsArbitraryLoads</key>\n- <true/>\n </dict>\n` };
}
case 'ai_fix_undo': {
const r = aiRuns.get(a.runId as string);
if (r) { SCAN_PRISM.push(r); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); aiRuns.delete(a.runId as string); }
return ['ios/Unity-iPhone/Info.plist'];
}
case 'write_with_confirm': { case 'write_with_confirm': {
const method = a.method as string, args = (a.args ?? {}) as Record<string, unknown>; const method = a.method as string, args = (a.args ?? {}) as Record<string, unknown>;
if (method === 'console.fill') { if (method === 'console.fill') {
@ -591,6 +675,9 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
if (method === 'reviews.approve' && d) d.approved = !args.undo; if (method === 'reviews.approve' && d) d.approved = !args.undo;
if (method === 'reviews.reply' && d) { d.reply = String(args.text); d.approved = false; d.issues = []; } if (method === 'reviews.reply' && d) { d.reply = String(args.text); d.approved = false; d.issues = []; }
if (method === 'owner.reply_send' && d) d.sent = iso(0); if (method === 'owner.reply_send' && d) d.sent = iso(0);
// a Fix button's write: the finding it fixed is gone on the next read
if (method === 'scan.write') { const i = SCAN_PRISM.findIndex(f => f.rule === 'privacy-labels'); if (i >= 0) SCAN_PRISM.splice(i, 1); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
if (method === 'play.listing') { const v = VALIDATE[a.projectId as string]; if (v) v.findings = v.findings.filter(f => f.code !== 'listing'); }
const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')}`, output: `[${method}] done (fixture)` }; const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')}`, output: `[${method}] done (fixture)` };
return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true }, result: res }; return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true }, result: res };
} }

View file

@ -59,6 +59,36 @@ export const en = {
'palette.gApps': 'Apps', 'palette.gApps': 'Apps',
'palette.gGo': 'Go to', 'palette.gGo': 'Go to',
'palette.gTabs': 'App tabs', 'palette.gTabs': 'App tabs',
'palette.gApp': 'This app: {name}',
'palette.nextApp': 'Next app',
'palette.prevApp': 'Previous app',
'settings.scApps': 'Previous / next app',
'settings.scMenu': 'Context menu of the focused item',
'reader.backoffShort': "Slowed down by the store",
'reader.appleQuotaShort': "App Store {left} / {limit} per hour",
'kpi.more': "+{count} more",
'pf.noShots': "No screenshots in the app folder yet",
'pf.noShotsStore': "Store only: no app folder with screenshots",
'tabs.more': "{count} more tabs",
'tabs.moreShort': "More",
'reviews.keysHint': "Keyboard: J / K next and previous, R reply, C copy, O open in the console",
'tools.filter': "Find a command",
'tools.search': "Search {count} commands (name, method, what it does)",
'tools.searchAria': "Search commands",
'tools.density': "Layout",
'tools.list': "List",
'tools.results': "{count} matching commands",
'tools.noHits': "No command matches. Try a store word (listing, prices, testflight...).",
'set.search': "Search settings",
'set.noHits': "No setting matches",
'preview.continue': "Continue to confirm",
'preview.lead': "Dry run: nothing is changed yet. Continue opens the confirmation, which runs the dry run once more and then applies it.",
'preview.more': "… {count} more lines",
'preview.nothing': "The dry run reports no change.",
'settings.density': "Tables",
'settings.comfortable': "Comfortable",
'settings.compact': "Compact",
'action.toggleDensity': "Compact / comfortable tables",
'palette.gActions': 'Actions', 'palette.gActions': 'Actions',
'palette.openApp': 'Open', 'palette.openApp': 'Open',
@ -123,7 +153,10 @@ export const en = {
'phase.releasedUnknown': "Couldn't check the store page", 'phase.releasedUnknown': "Couldn't check the store page",
'phase.pageUnknown': "Couldn't check the store page", 'phase.pageUnknown': "Couldn't check the store page",
'phase.pageStale': "couldn't check the store page, as of {when}", 'phase.pageStale': "couldn't check the store page, as of {when}",
'phase.reviewLive': 'In review (live: {version})', 'phase.reviewLive': 'Not live yet (live: {version}): in review or blocked before review',
'phase.playPending': 'Not live yet: in review or blocked before review',
'roll.publishReady': 'If the release is approved, publish it in Publishing overview ("Publish N changes"). The agent prepares it with `{cmd}`; the final press is for the owner.',
'roll.publishOpen': 'Open Publishing overview',
'phase.reviewManaged': 'In review / ready to publish (managed publishing)', 'phase.reviewManaged': 'In review / ready to publish (managed publishing)',
'phase.rejected': 'Rejected', 'phase.rejected': 'Rejected',
'phase.testing': 'Testing', 'phase.testing': 'Testing',
@ -366,6 +399,7 @@ export const en = {
'reviews.shown': '{count} shown', 'reviews.shown': '{count} shown',
'reviews.noMatch': 'No review matches the filter', 'reviews.noMatch': 'No review matches the filter',
'reviews.pick': 'Pick a review', 'reviews.pick': 'Pick a review',
'reviews.lagNote': "Read from the stores' APIs. Apple's API can trail the App Store Connect page; Google Play returns only the last 7 days.",
'reviews.answered': 'Answered', 'reviews.answered': 'Answered',
'reviews.drafted': 'Draft', 'reviews.drafted': 'Draft',
'reviews.approved': 'Approved', 'reviews.approved': 'Approved',
@ -422,6 +456,14 @@ export const en = {
'checks.problem': 'Problem', 'checks.problem': 'Problem',
'checks.warning': 'Warning', 'checks.warning': 'Warning',
'checks.validate': 'Store readiness', 'checks.validate': 'Store readiness',
'checks.report': 'Shareable report',
'checks.reportSub': 'One page for both stores with every finding, fix and console link, and the questions only you can answer. Opens in your browser; nothing is sent to a store.',
'checks.reportOpen': 'Report',
'checks.reportFailed': 'The report was not written',
'checks.reportBlockers': '{count} blocker(s)',
'checks.reportNotChecked': 'not checked (no store key)',
'checks.reportNotSetUp': 'not set up',
'checks.reportReady': 'ready to submit',
'checks.validateSub': 'Read-only report from both stores, blockers in order', 'checks.validateSub': 'Read-only report from both stores, blockers in order',
'checks.run': 'Run the report', 'checks.run': 'Run the report',
'checks.validateEmpty': 'Readiness not checked yet', 'checks.validateEmpty': 'Readiness not checked yet',
@ -437,6 +479,33 @@ export const en = {
'checks.scanAsk': '{count} declaration answers only you can give', 'checks.scanAsk': '{count} declaration answers only you can give',
'checks.scanAsk_one': '1 declaration answer only you can give', 'checks.scanAsk_one': '1 declaration answer only you can give',
'checks.scanWrite': 'Detected Data safety / App Privacy answers differ from the config: storebound scan --write adds them', 'checks.scanWrite': 'Detected Data safety / App Privacy answers differ from the config: storebound scan --write adds them',
'checks.fix': 'Fix',
'checks.fixing': 'Fix: {what}',
'checks.fixed': 'Fixed. Checking again',
'checks.fixHint.scan.write': 'Writes the detected Data safety / App Privacy answers into the config. You see the diff before anything is written.',
'checks.fixHint.play.listing': 'Pushes the listing file to Google Play (text only, nothing is published). You see the dry run first.',
'checks.fixHint.apple.listing': 'Pushes the listing file and copyright to the App Store version (text only, nothing is submitted). You see the dry run first.',
'checks.fixHint.apple.review_details': 'Sends the App Review contact and notes from the config (nothing is submitted). You see the dry run first.',
'ai.run': 'Fix with AI',
'ai.runHint': 'Runs Claude Code on this machine in the app folder with file tools only (read and edit; no commands, no network, no git push). You see the diff and can undo it.',
'ai.copy': 'Copy for AI',
'ai.copyHint': 'Claude Code (claude) is not installed: copies a prompt for this finding to paste into your AI agent.',
'ai.starting': 'Starting Claude Code...',
'ai.running': 'Claude Code is fixing this finding (at most 10 minutes).',
'ai.dirty': 'Uncommitted files in the app folder ({count}): commit or stash them first, so Undo can restore exactly what the AI changes.',
'ai.noGit': 'The app folder is not in a git repository: Fix with AI needs git for the diff and Undo.',
'ai.cancelled': 'Cancelled',
'ai.signedOut': 'Claude Code is not signed in: run claude once in a terminal and sign in with /login, or copy the prompt for your AI agent',
'ai.timedOut': 'Stopped after 10 minutes',
'ai.noChanges': 'No files changed',
'ai.changed': 'Changed files: {count}',
'ai.stillThere': 'the scan still reports it',
'ai.gone': 'the scan no longer reports it',
'ai.diff': 'Changes',
'ai.keep': 'Keep',
'ai.undo': 'Undo',
'ai.undone': 'Undone. Restored files: {count}',
'ai.failed': 'Fix with AI failed: {msg}',
'checks.accepted': 'Accepted: {reason}', 'checks.accepted': 'Accepted: {reason}',
'checks.sev.blocker': 'Blocker', 'checks.sev.blocker': 'Blocker',
'checks.sev.high': 'High', 'checks.sev.high': 'High',
@ -508,6 +577,7 @@ export const en = {
'deps.tools': 'Tools on this computer', 'deps.tools': 'Tools on this computer',
'deps.found': '{found} of {total} found', 'deps.found': '{found} of {total} found',
'deps.missing': 'Missing', 'deps.missing': 'Missing',
'deps.outdated': 'Too old (needs {version})',
'deps.appCopy': 'App copy', 'deps.appCopy': 'App copy',
'deps.installed': 'Installed', 'deps.installed': 'Installed',
'deps.download': 'Download', 'deps.download': 'Download',
@ -700,6 +770,16 @@ export const en = {
'app.unpair': 'Unpair', 'app.unpair': 'Unpair',
'app.ignore': 'Ignore', 'app.ignore': 'Ignore',
'app.ignoreHint': 'Leave this app out of the portfolio and store reads (Ignored lists it to restore)', 'app.ignoreHint': 'Leave this app out of the portfolio and store reads (Ignored lists it to restore)',
'app.unignore': 'Stop ignoring',
'app.unignoreHint': 'Bring this app back into the portfolio and store reads',
'app.ignoreSide': 'Ignore {store}',
'app.unignoreSide': 'Stop ignoring {store}',
'app.ignoreSideHint': 'Leave only the {store} side of this app out of reads and notifications. Storebound only: the store itself is untouched',
'app.unignoreSideHint': 'Read the {store} side of this app again',
'ignored.show': '{count} ignored · show',
'ignored.hide': '{count} ignored · hide',
'ignored.rowHint': 'Ignored: not read, not watched',
'ignored.sideHint': 'Ignored on {stores}',
'app.studioHint': 'Open the studio', 'app.studioHint': 'Open the studio',
'app.storeOnlyTitle': 'Store only.', 'app.storeOnlyTitle': 'Store only.',
'app.storeOnlyText': 'This app is on your store accounts but has no app folder: releases, reviews, health and TestFlight are shown read-only. Link its folder, or start one, to manage listings, products and checks.', 'app.storeOnlyText': 'This app is on your store accounts but has no app folder: releases, reviews, health and TestFlight are shown read-only. Link its folder, or start one, to manage listings, products and checks.',
@ -746,6 +826,54 @@ export const en = {
'notify.lowTitle': '{name}: {count} new low ratings', 'notify.lowTitle': '{name}: {count} new low ratings',
'notify.lowTitle_one': '{name}: a new low rating', 'notify.lowTitle_one': '{name}: a new low rating',
'notify.crashTitle': '{name}: over the Play threshold', 'notify.crashTitle': '{name}: over the Play threshold',
'notify.rejectedTitle': '{name}: rejected by {store}',
'notify.rejectedBody': 'Version {version} was rejected.',
'notify.approvedTitle': '{name}: approved',
'notify.approvedBody': '{store} {version} passed review and is ready for release.',
'notify.liveTitle': '{name}: live',
'notify.liveBody': '{store} {version} is live.',
'notify.haltedTitle': '{name}: rollout halted',
'notify.haltedBody': '{store} {version} was halted.',
'tray.open': 'Open storebound',
'tray.readNow': 'Read the stores now',
'tray.apps': 'Apps',
'tray.noApps': 'No apps yet',
'tray.noState': 'no release read yet',
'tray.mute': 'Mute notifications for 1 hour',
'tray.unmute': 'Unmute notifications',
'tray.update': 'Check for updates',
'tray.settings': 'Settings',
'tray.quit': 'Quit storebound',
'tray.tipReview': '{count} in review',
'tray.tipAttention': '{count} needs you',
'tray.mutedTip': 'muted',
'set.nRejected': 'Rejected',
'set.nRejectedHint': 'A version the store rejected, with its reason when the store gives one',
'set.nApproved': 'Approved and ready for release',
'set.nApprovedHint': 'A version passed review',
'set.nLive': 'Live',
'set.nLiveHint': 'A version reached the store page or a rollout',
'set.quiet': 'Quiet hours',
'set.quietHint': 'No notifications in this window (they are dropped, not saved for later)',
'set.quietFrom': 'From',
'set.quietTo': 'To',
'set.muteHour': 'Mute for 1 hour',
'set.unmute': 'Unmute ({until})',
'set.muted': 'Muted until {until}',
'set.clickNote': "Clicking a notification does not open the app on desktop; the next time you open the window from the tray, it goes to that app's Releases tab.",
'v2cfg.workspace': 'Workspace',
'v2cfg.studios': 'Studios',
'v2cfg.accounts': 'Store accounts',
'v2cfg.perStore': '{play} Google Play · {apple} App Store',
'v2cfg.keysWork': 'Keys working',
'v2cfg.keysFailing': 'Keys failing',
'v2cfg.depsFound': 'Tools found',
'v2cfg.depsNeeds': 'Need attention',
'v2cfg.depsInApp': 'In app data',
'v2cfg.depsInAppSub': 'downloaded by storebound',
'v2cfg.needs': 'needs {version}',
'v2cfg.stepOf': 'Step {n} of {total}',
'v2cfg.progress': 'Setup progress',
'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.', 'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.',
'set.sections': 'Settings sections', 'set.sections': 'Settings sections',
'set.studios': 'Studios & accounts', 'set.studios': 'Studios & accounts',
@ -792,6 +920,9 @@ export const en = {
'read.couldNotRefresh': "couldn't refresh: {reason}", 'read.couldNotRefresh': "couldn't refresh: {reason}",
'read.refresh': "Refresh", 'read.refresh': "Refresh",
'read.waiting': "Reading…", 'read.waiting': "Reading…",
'read.justNow': "read just now",
'read.paused': "store limit: waits until {time}",
'read.busyShared': "Already reading: this joins the read in progress",
'reader.progress': "Reading {done} of {total}", 'reader.progress': "Reading {done} of {total}",
'reader.upToDate': "Everything read", 'reader.upToDate': "Everything read",
'reader.off': "Auto-read is off", 'reader.off': "Auto-read is off",
@ -818,11 +949,11 @@ export const en = {
'set.always': 'Always', 'set.always': 'Always',
'set.notifications': 'Notifications', 'set.notifications': 'Notifications',
'set.notificationsSub': 'After a read, when something changed since the previous one', 'set.notificationsSub': 'After a read, when something changed since the previous one',
'set.nReview': 'Review and release state changes', 'set.nReview': 'Other release state changes',
'set.nReviewHint': 'In review, approved, rejected, live, halted', 'set.nReviewHint': 'Submitted, in review',
'set.nLow': 'New 1 or 2 star reviews', 'set.nLow': 'New 1 or 2 star reviews',
'set.nLowHint': 'When the Reviews tab reads new ones', 'set.nLowHint': 'When the Reviews tab reads new ones',
'set.nCrash': 'Crash or ANR rate over the threshold', 'set.nCrash': 'Rollout halted, or crash / ANR over the threshold',
'set.nCrashHint': 'Google Play bad behavior thresholds', 'set.nCrashHint': 'Google Play bad behavior thresholds',
'set.testNotify': 'Send a test notification', 'set.testNotify': 'Send a test notification',
'set.testTitle': 'storebound', 'set.testTitle': 'storebound',
@ -846,7 +977,9 @@ export const en = {
'set.channel': 'Channel', 'set.channel': 'Channel',
'set.stable': 'Stable', 'set.stable': 'Stable',
'set.beta': 'Beta', 'set.beta': 'Beta',
'set.updatesText': 'Updates come from storebound.dev and are signed; the app checks the signature before it replaces anything, and installs only when you press Install. Nothing is checked in the background.', 'set.dev': 'Dev (every change)',
'set.devToStable': 'This is the dev build {version}: it stays until a stable release is newer (no downgrade).',
'set.updatesText': 'Updates come from storebound.dev and are signed; the app checks the signature before it replaces anything. It looks for a newer version of the chosen channel at start, every 15 minutes and after sleep, and installs it in the background (not while you work in this window). Stable follows the npm releases; Dev gets every change on main.',
'set.updateCheck': 'Check for updates', 'set.updateCheck': 'Check for updates',
'set.updateChecking': 'Checking…', 'set.updateChecking': 'Checking…',
'set.updateNone': 'storebound {version} is the newest version on the {channel} channel.', 'set.updateNone': 'storebound {version} is the newest version on the {channel} channel.',
@ -1092,6 +1225,14 @@ export const en = {
'art.render': 'Render {lang}', 'art.render': 'Render {lang}',
'art.notThere': 'Not there yet', 'art.notThere': 'Not there yet',
'art.count': '{count} files', 'art.count': '{count} files',
'v2lst.preview': 'Store preview',
'v2lst.noSub': 'No subtitle yet',
'v2lst.noDesc': 'No description yet',
'v2lst.more': 'more',
'v2lst.ready': 'Ready',
'v2lst.toFix': '{count} to fix',
'v2lst.issues': '{count} issues',
'v2lst.issues_one': '1 issue',
'art.count_one': '1 file', 'art.count_one': '1 file',
'art.open': 'Open {name}', 'art.open': 'Open {name}',
'art.tooBig': 'too big to preview', 'art.tooBig': 'too big to preview',
@ -1443,6 +1584,8 @@ export const en = {
'cp.failed': 'Could not make the plan for {form}', 'cp.failed': 'Could not make the plan for {form}',
'cp.agentFirst': 'Agent-first: your AI agent drives the page with this plan; storebound never clicks the final button', 'cp.agentFirst': 'Agent-first: your AI agent drives the page with this plan; storebound never clicks the final button',
'cp.openPage': 'Open the page', 'cp.openPage': 'Open the page',
'link.open': 'Open',
'link.openFailed': 'Could not open the page',
'cp.openFailed': 'Could not open the page', 'cp.openFailed': 'Could not open the page',
'cp.steps': '{count} steps', 'cp.steps': '{count} steps',
'cp.steps_one': '1 step', 'cp.steps_one': '1 step',
@ -1768,4 +1911,325 @@ export const en = {
'ed.targetAudience': 'Target audience and ads (Play)', 'ed.targetAudience': 'Target audience and ads (Play)',
'ed.appContent': 'App content declarations (Play)', 'ed.appContent': 'App content declarations (Play)',
'ed.appContentHint': 'Government, financial and health features, advertising ID. No API: the console plan step uses them.', 'ed.appContentHint': 'Government, financial and health features, advertising ID. No API: the console plan step uses them.',
'tab.tools': 'More tools',
'tools.lead': 'Every CLI command without a tab of its own, as a form with the command\'s own options. Reads run here; writes show the dry run and ask in a native dialog first. Owner steps stay yours: copy the terminal command.',
'tools.g.build': 'Build and upload',
'tools.g.adhoc': 'Ad-hoc devices',
'tools.g.store': 'Store features',
'tools.g.text': 'Text and ASO',
'tools.g.capture': 'Captures',
'tools.g.quality': 'Quality',
'tools.g.ads': 'Apple Ads',
'tools.g.flows': 'Workflows and AI tools',
'tools.count': '{count} commands',
'v2ins.hStartup': 'Launch time',
'v2ins.hStartupSub': 'App Store diagnostics · version {version}',
'v2ins.rvOpen': 'Unanswered',
'v2ins.gFunnel': 'Store funnel',
'v2ins.gVsPrev': '% vs the {count} days before',
'v2ins.gPts': 'points vs the {count} days before',
'v2ins.cScore': 'Store readiness',
'v2ins.cScoreSub': '{ok} of {total} checks pass',
'v2ins.cReady': 'Ready to submit',
'v2ins.cBlocked': '{count} blockers to fix',
'v2ins.cNotRun': 'Not checked yet',
'v2ins.cBlockers': 'Blockers',
'v2ins.cWarnings': 'Warnings',
'v2ins.cPassedN': 'Passed',
'v2ins.cFixFirst': 'Fix first',
'v2ins.cPassed': 'Passed checks ({count})',
'v2ins.cAccepted': 'Accepted risks ({count})',
'v2ins.aiSheet': 'Store info',
'v2ins.aiOpen': 'Open questions',
'v2ins.aiNone': 'None',
'tools.count_one': '1 command',
'tools.show': 'Show',
'tools.hide': 'Hide',
'tools.read': 'read-only',
'tools.write': 'write (dry run first)',
'tools.network': 'store',
'tools.run': 'Run',
'tools.dryRun': 'Dry run, then apply',
'tools.done': '{title}: done',
'tools.default': 'default: {value}',
'tools.exit': 'exit {code}',
'tools.copy': 'Copy',
'tools.copied': 'Copied',
'tools.owner': 'Owner steps (terminal)',
'tools.ownerSub': 'Public or price changes only you decide; run them in a terminal in the app folder',
'tools.o.offer_codes': 'App Store offer codes for one-time purchases',
'tools.o.win_back': 'Win-back offers for lapsed subscribers',
'tools.o.promoted': 'Turn on promoted in-app purchases',
'tools.o.preorder': 'Enable or end an App Store pre-order',
'tools.o.cpp_submit': 'Submit a custom product page (then make it visible)',
'tools.o.events_submit': 'Submit an in-app event',
'tools.o.tf_expire': 'Expire old TestFlight builds',
'tools.o.ads_change': 'Apple Ads bid, budget or pause',
'tools.o.rc_sync': 'Sync the catalog to RevenueCat',
'tools.o.shots_login': 'Sign in to the demo account for screenshots (by hand, Enter saves)',
'cp.playGames': 'Play Games Services setup',
'cp.playGamesText': 'Games project, Android credential with the app signing SHA-1, web OAuth client; you press Publish.',
'gs.title': 'Game services',
'gs.sub': 'Game Center and Play Games Services: switches, ids, leaderboards and achievements from the config.',
'gs.gameCenter': 'Game Center',
'gs.playGames': 'Play Games Services',
'gs.enabled': 'On for the app',
'gs.capability': 'App ID capability',
'gs.version': 'Version {v}',
'gs.items': 'Leaderboards / achievements',
'gs.counts': '{lb} of {lbWant} leaderboards · {ach} of {achWant} achievements',
'gs.pending': '{n} change(s) to write',
'gs.projectId': 'Project id',
'gs.resource': 'games_APP_ID resource',
'gs.notSet': 'Not set',
'gs.yes': 'Yes',
'gs.no': 'No',
'gs.unknown': 'Unknown',
'gs.syncApple': 'Sync Game Center',
'gs.syncPlay': 'Sync Play Games',
'gs.syncHint': 'Dry run first, then you confirm; nothing is submitted or published.',
'gs.synced': 'Game services in step',
'gs.setup': 'Setup plan',
'pf.morning': "Good morning.",
'pf.afternoon': "Good afternoon.",
'pf.evening': "Good evening.",
'pf.moving': "{count} releases are moving",
'pf.moving_one': "1 release is moving",
'pf.movingNone': "Nothing is moving right now",
'pf.notMovingUnknown': "Read the stores to see what is moving",
'pf.readAgo': "stores read {when}",
'pf.layout': "Layout",
'pf.cards': "Cards",
'pf.table': "Table",
'pf.next': "next",
'pf.crash': "crash",
'pf.ctaFix': "Fix {count} blockers",
'pf.ctaFix_one': "Fix 1 blocker",
'pf.ctaRollout': "Roll out (now {share})",
'pf.ctaRelease': "Release {version}",
'pf.ctaSubmit': "Submit {version}",
'pf.ctaReview': "Follow review of {version}",
'pf.ctaWarnings': "Look at {count} warnings",
'pf.ctaWarnings_one': "Look at 1 warning",
'pf.ctaUpload': "Upload a build",
'pf.ctaOpen': "Open releases",
'pf.feed': "What changed",
'pf.feedSub': "Releases, health and folders, newest first",
'pf.feedEmpty': "Nothing changed yet",
'pf.feedEmptyText': "Read the stores to see releases and health here.",
'pf.evLive': "{version} is live on {store}",
'pf.evRollout': "{version} is rolling out to {share} on {store}",
'pf.evReview': "{version} went into review on {store}",
'pf.evApproved': "{version} was approved on {store}",
'pf.evRejected': "{version} was rejected on {store}",
'pf.evHalted': "{version} rollout halted on {store}",
'pf.evTesting': "{version} is in testing on {store}",
'pf.evDraft': "{version} draft on {store}",
'pf.evCrash': "crash rate {rate} on Google Play",
'pf.evAdded': "folder added to the workspace",
'pf.more': "Show {count} more",
'pf.less': "Show less",
'pf.fixFirst': "Fix first",
'pf.fixFirstSub': "{count} items, blockers first",
'pf.fixFirstSub_one': "1 item",
'pf.blocker': "Blocker",
'pf.warning': "Warning",
'pf.note': "Note",
'pf.fixIn': "Fix in {tab}",
'pf.openTab': "Open {tab}",
'v2rel.version': "Version",
'v2rel.stages': "{store} release stages",
'v2rel.ofUsers': "of users get this version",
'v2rel.versions': "{count} versions",
'v2rel.ready': "Ready",
'v2rel.blocked': "Blocked",
'v2rel.todo': "To do",
'v2rel.attention': "Check",
'v2rel.readyCount': "{done} of {total} steps ready",
'v2rel.steps': "Steps to send for review",
'v2rel.stepRead': "Store state",
'v2rel.stepNoOpen': "Nothing else in review",
'v2rel.stepBuild': "Pick the build",
'v2rel.stepVersion': "Version to send",
'v2rel.stepSend': "Submit for review",
'v2rel.stepCode': "Pick the versionCode",
'v2rel.tfGroups': "Beta groups",
'v2rel.tfInGroups': "in {count} groups",
'v2rel.tfSplit': "{internal} internal · {external} external",
'v2rel.tfUsable': "Ready to test",
// context menus (ui/ContextMenu.tsx)
'cm.cut': "Cut",
'cm.pin': "Pin: keeps this menu open",
'cm.unpin': "Unpin",
'cm.closePanel': "Close",
'cm.gone': "Gone: what this menu was for is not on screen now.",
'cm.menu': "Menu",
'cm.tearOff': "Ctrl+click or middle-click: pin this submenu",
'cm.copy': "Copy",
'cm.paste': "Paste",
'cm.selectAll': "Select all",
'cm.copied': "Copied",
'cm.open': "Open",
'cm.openTab': "Open tab",
'cm.openConsole': "Open in console",
'cm.openIn': "Open in {place}",
'cm.openFolder': "Open folder",
'cm.folderFailed': "Could not open the folder",
'cm.copyName': "Name",
'cm.copyBundleId': "Bundle id",
'cm.copyPackage': "Package name",
'cm.copyAscId': "App Store Connect app id",
'cm.copyPath': "Copy path",
'cm.refreshReads': "Read again from the stores",
'cm.refreshing': "Reading {name} again",
'cm.ignoredNoReads': "Ignored apps are not read",
'cm.ignore': "Ignore",
'acct.devId': 'Play developer id {id}',
'acct.viaBroker': 'tokens from the credential broker',
'acct.viaFile': 'local key file',
'acct.grantsTitle': 'Play permissions on the credential broker',
'acct.grantsOk': 'everything storebound needs',
'acct.grantsMissing': 'missing: {list}',
'acct.grantsNone': 'no permission data',
'acct.notRegistered': 'not registered on the broker',
'acct.noDevId': 'no Play developer id',
'acct.devIdLabel': 'Play developer account id',
'acct.devIdHint': "The digits after /developers/ in the Play Console address. With it, Play Console links open each app's own page; the CLI uses it too.",
'checks.openGuide': 'Open guide',
'checks.openGuideHint': 'Open the {skill} guide (SKILL.md)',
'cm.more': "More actions",
'cm.showIgnored': "Show ignored apps",
'cm.copyVersion': "Copy version",
'cm.copyBuild': "Copy build",
'cm.copyVersionCode': "Copy version code",
'cm.preview': "Preview",
'cm.openFile': "Open file",
'cm.reveal': "Show in folder",
'cm.readAgain': "Read again",
'cm.reading': "Reading…",
'cm.openDraft': "Show reply draft",
'cm.copyReview': "Copy review",
'cm.copyReply': "Copy the published reply",
'cm.copyDraft': "Copy the draft",
'cm.revealEvidence': "Show the file",
'cm.copyTitle': "Copy title",
'cm.copyFix': "Copy the fix",
'cm.copySkill': "Copy skill name",
'cm.copyEvidence': "Copy evidence lines",
'cm.copyCode': "Copy check code",
'cm.copyValue': "Copy value",
'cm.notChanged': "Not changed",
'cm.copyProductId': "Copy product id",
'cm.showPrices': "Show prices",
'cm.copyRow': "Copy row",
'cm.showPerRegion': "Show per region",
'cm.copyPriceOf': "Copy {store} price",
'cm.copyRegion': "Copy region code",
'cm.busy': "Another action is running",
'cm.copyPrincipal': "Service account / key",
'cm.copyProfile': "Profile name",
'cm.openAccounts': "Open Studios & accounts",
"sec.release": "Release",
"sec.store": "Store page",
"sec.revenue": "Revenue",
"sec.players": "Players",
"sec.health": "Health",
"sec.release.for": "Get a version to players on both stores: builds, TestFlight, review and rollout.",
"sec.store.for": "What people see on the store: texts per language, screenshots and app details.",
"sec.revenue.for": "What the app sells and for how much: in-app products, subscriptions and prices.",
"sec.players.for": "What players say, and for games what they can earn: reviews, Game Center, Play Games.",
"sec.health.for": "Is the app well and ready: crashes and ANRs, growth, checks to fix first, more tools.",
"sec.views": "{section} views",
"tab.games": "Game services",
"next.open": "Open {where}",
"next.rejected": "{version} was rejected on {store}",
"next.halted": "{version} is halted on {store}",
"next.blockers": "{count} blockers before submit",
"next.blockers_one": "1 blocker before submit",
"next.rollout": "{version} is rolling out at {share}",
"next.approved": "{version} is approved: release it",
"next.submit": "Submit {version} for review",
"next.review": "{version} is in review",
"next.managed": "{version} waits in Publishing overview (managed publishing)",
"next.upload": "Upload a first build",
"next.live": "Live: {version}",
"next.notRead": "Release state shows once the stores are read",
"next.storeLive": "The live store page, read from the stores",
"next.listingProblems": "{count} listing problems",
"next.listingProblems_one": "1 listing problem",
"next.unsaved": "{count} unsaved edits",
"next.unsaved_one": "1 unsaved edit",
"next.listingsUnread": "Listings not checked yet",
"next.storeOk": "Store page in step with the config",
"next.catalogDiff": "{count} products differ between the stores",
"next.catalogDiff_one": "1 product differs between the stores",
"next.noProducts": "No products yet: add one",
"next.products": "{products} products · {subs} subscriptions",
"next.reviews": "{count} reviews without a reply",
"next.reviews_one": "1 review without a reply",
"next.reviewsUnread": "See what players say",
"next.reviewsOk": "All reviews answered",
"next.gamesApiOff": "Play Games Services API is off",
"next.gamesBlockers": "{count} game services blockers",
"next.gamesBlockers_one": "1 game services blocker",
"next.gamesSync": "Game services: {count} changes to sync",
"next.gamesSync_one": "Game services: 1 change to sync",
"next.fixFirst": "{count} problems to fix first",
"next.fixFirst_one": "1 problem to fix first",
"next.warnings": "{count} warnings to look at",
"next.warnings_one": "1 warning to look at",
"next.healthOk": "No problems found",
"side.gAttention": "Needs attention",
"side.gProgress": "In progress",
"side.gLive": "Live",
"side.gDraft": "Not on a store yet",
"side.missing": "Folder missing",
"side.rejected": "Rejected · {what}",
"side.halted": "Halted · {what}",
"side.blockers": "{count} blockers",
"side.blockers_one": "1 blocker",
"side.rollout": "Rollout {share} · {what}",
"side.review": "In review · {what}",
"side.approved": "Approved · {what}",
"side.draft": "Draft · {what}",
"side.live": "Live · {version}",
"side.liveWarn": "Live · {version} · {count} warnings",
"side.liveWarn_one": "Live · {version} · 1 warning",
"side.storeOnly": "Store only · not read yet",
"side.notRead": "Not read yet",
"side.warnings": "{count} warnings",
"side.warnings_one": "1 warning",
"side.notOnStore": "No store version yet",
"side.ignored": "Ignored",
"side.studioFilter": "Show apps of a studio",
"side.allStudios": "All",
"games.keep": "In step",
"games.update": "Differs",
"games.create": "Missing",
"games.notOnStore": "Not on this store",
"games.notListed": "Not planned",
"games.notRead": "Not read",
"games.item": "Item",
"games.name": "Name",
"games.points": "Points",
"games.languages": "Languages",
"games.leaderboard": "Leaderboard",
"games.achievement": "Achievement",
"games.hidden": "hidden until earned",
"games.localeChanges": "Game Center languages: {list}",
"games.checkAgain": "Check again",
"games.apiOffTitle": "Play Games Services API is off.",
"games.apiOffText": "Turn on {api} in the service account's Google Cloud project, wait a minute, then check again.",
"games.itemsTitle": "Leaderboards and achievements",
"games.itemsSub": "One list in the config, mapped to both stores: each store's state against the config.",
"games.refresh": "Read again",
"games.noItems": "No leaderboards or achievements in the config yet (games.leaderboards, games.achievements).",
"games.notReadYet": "Reading game services…",
"games.images": "Images of leaderboards and achievements are added in the consoles; on Play the existing icon is kept on every sync.",
"games.localeMap": "Game Center locale map",
"games.localeMapSub": "Config language to the locale the live Game Center uses (games.localeMap).",
"games.mapSet": "Set",
"games.mapSuggested": "Suggested",
"games.mapHint": "Add to games.localeMap: {entries}",
} as const; } as const;

View file

@ -61,6 +61,36 @@ export const tr: Record<keyof typeof en, string> = {
'palette.gApps': 'Uygulamalar', 'palette.gApps': 'Uygulamalar',
'palette.gGo': 'Git', 'palette.gGo': 'Git',
'palette.gTabs': 'Uygulama sekmeleri', 'palette.gTabs': 'Uygulama sekmeleri',
'palette.gApp': 'Bu uygulama: {name}',
'palette.nextApp': 'Sonraki uygulama',
'palette.prevApp': 'Önceki uygulama',
'settings.scApps': 'Önceki / sonraki uygulama',
'settings.scMenu': 'Odaktaki öğenin bağlam menüsü',
'reader.backoffShort': "Mağaza yavaşlattı",
'reader.appleQuotaShort': "App Store saatte {left} / {limit}",
'kpi.more': "+{count} tane daha",
'pf.noShots': "Uygulama klasöründe henüz ekran görüntüsü yok",
'pf.noShotsStore': "Yalnız mağazada: ekran görüntülü uygulama klasörü yok",
'tabs.more': "{count} sekme daha",
'tabs.moreShort': "Daha fazla",
'reviews.keysHint': "Klavye: J / K sonraki ve önceki, R yanıtla, C kopyala, O konsolda aç",
'tools.filter': "Komut bul",
'tools.search': "{count} komutta ara (ad, yöntem, ne yaptığı)",
'tools.searchAria': "Komut ara",
'tools.density': "Görünüm",
'tools.list': "Liste",
'tools.results': "{count} eşleşen komut",
'tools.noHits': "Eşleşen komut yok. Bir mağaza kelimesi deneyin (listing, prices, testflight...).",
'set.search': "Ayarlarda ara",
'set.noHits': "Eşleşen ayar yok",
'preview.continue': "Onaya geç",
'preview.lead': "Deneme çalıştırması: henüz hiçbir şey değişmedi. Devam onay penceresini açar; deneme bir kez daha çalışır, sonra uygulanır.",
'preview.more': "… {count} satır daha",
'preview.nothing': "Deneme çalıştırması bir değişiklik bildirmiyor.",
'settings.density': "Tablolar",
'settings.comfortable': "Rahat",
'settings.compact': "Sıkı",
'action.toggleDensity': "Sıkı / rahat tablolar",
'palette.gActions': 'İşlemler', 'palette.gActions': 'İşlemler',
'palette.openApp': 'Aç', 'palette.openApp': 'Aç',
@ -125,7 +155,10 @@ export const tr: Record<keyof typeof en, string> = {
'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi', 'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi',
'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi', 'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi',
'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla', 'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla',
'phase.reviewLive': 'İncelemede (yayında: {version})', 'phase.reviewLive': 'Henüz yayında değil (yayında: {version}): incelemede ya da incelemeden önce takıldı',
'phase.playPending': 'Henüz yayında değil: incelemede ya da incelemeden önce takıldı',
'roll.publishReady': 'Sürüm onaylandıysa Yayınlama özetinde yayınlayın ("Publish N changes"). Ajan `{cmd}` ile hazırlar; son basış sahibine aittir.',
'roll.publishOpen': 'Yayınlama özetini aç',
'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)', 'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)',
'phase.rejected': 'Reddedildi', 'phase.rejected': 'Reddedildi',
'phase.testing': 'Testte', 'phase.testing': 'Testte',
@ -368,6 +401,7 @@ export const tr: Record<keyof typeof en, string> = {
'reviews.shown': '{count} gösteriliyor', 'reviews.shown': '{count} gösteriliyor',
'reviews.noMatch': 'Süzgece uyan yorum yok', 'reviews.noMatch': 'Süzgece uyan yorum yok',
'reviews.pick': 'Bir yorum seçin', 'reviews.pick': 'Bir yorum seçin',
'reviews.lagNote': "Mağaza API'lerinden okunur. Apple'ın API'si App Store Connect sayfasından geride kalabilir; Google Play yalnızca son 7 günü verir.",
'reviews.answered': 'Yanıtlandı', 'reviews.answered': 'Yanıtlandı',
'reviews.drafted': 'Taslak', 'reviews.drafted': 'Taslak',
'reviews.approved': 'Onaylı', 'reviews.approved': 'Onaylı',
@ -424,6 +458,14 @@ export const tr: Record<keyof typeof en, string> = {
'checks.problem': 'Sorun', 'checks.problem': 'Sorun',
'checks.warning': 'Uyarı', 'checks.warning': 'Uyarı',
'checks.validate': 'Mağaza hazırlığı', 'checks.validate': 'Mağaza hazırlığı',
'checks.report': 'Paylaşılabilir rapor',
'checks.reportSub': 'İki mağaza için tek sayfa: her bulgu, düzeltme ve konsol bağlantısı, yalnızca senin yanıtlayabileceğin sorular. Tarayıcında açılır; mağazaya hiçbir şey gönderilmez.',
'checks.reportOpen': 'Rapor',
'checks.reportFailed': 'Rapor yazılamadı',
'checks.reportBlockers': '{count} engel',
'checks.reportNotChecked': 'kontrol edilmedi (mağaza anahtarı yok)',
'checks.reportNotSetUp': 'kurulmamış',
'checks.reportReady': 'gönderime hazır',
'checks.validateSub': 'İki mağazadan salt okunur rapor, engeller sırayla', 'checks.validateSub': 'İki mağazadan salt okunur rapor, engeller sırayla',
'checks.run': 'Raporu çalıştır', 'checks.run': 'Raporu çalıştır',
'checks.validateEmpty': 'Hazırlık henüz kontrol edilmedi', 'checks.validateEmpty': 'Hazırlık henüz kontrol edilmedi',
@ -439,6 +481,33 @@ export const tr: Record<keyof typeof en, string> = {
'checks.scanAsk': 'Yalnızca senin cevaplayabileceğin {count} beyan sorusu', 'checks.scanAsk': 'Yalnızca senin cevaplayabileceğin {count} beyan sorusu',
'checks.scanAsk_one': 'Yalnızca senin cevaplayabileceğin 1 beyan sorusu', 'checks.scanAsk_one': 'Yalnızca senin cevaplayabileceğin 1 beyan sorusu',
'checks.scanWrite': 'Bulunan Data safety / App Privacy cevapları ayardan farklı: storebound scan --write ekler', 'checks.scanWrite': 'Bulunan Data safety / App Privacy cevapları ayardan farklı: storebound scan --write ekler',
'checks.fix': 'Düzelt',
'checks.fixing': 'Düzelt: {what}',
'checks.fixed': 'Düzeltildi. Yeniden kontrol ediliyor',
'checks.fixHint.scan.write': 'Bulunan Data safety / App Privacy cevaplarını ayara yazar. Bir şey yazılmadan önce farkı görürsün.',
'checks.fixHint.play.listing': "Mağaza metni dosyasını Google Play'e gönderir (yalnız metin, yayınlanmaz). Önce deneme çalıştırmasını görürsün.",
'checks.fixHint.apple.listing': 'Mağaza metnini ve telif hakkını App Store sürümüne gönderir (yalnız metin, incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
'checks.fixHint.apple.review_details': 'App Review iletişim bilgisini ve notları ayardan gönderir (incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
'ai.run': 'Yapay zekâyla düzelt',
'ai.runHint': "Claude Code'u bu bilgisayarda, uygulama klasöründe yalnız dosya araçlarıyla çalıştırır (okuma ve düzenleme; komut, ağ, git push yok). Farkı görür, geri alabilirsin.",
'ai.copy': 'Yapay zekâ için kopyala',
'ai.copyHint': 'Claude Code (claude) kurulu değil: bu bulgu için bir istemi yapay zekâ ajanına yapıştırmak üzere kopyalar.',
'ai.starting': 'Claude Code başlıyor...',
'ai.running': 'Claude Code bu bulguyu düzeltiyor (en çok 10 dakika).',
'ai.dirty': 'Uygulama klasöründe commit edilmemiş {count} dosya var: önce commit et ya da stash yap ki Geri al tam olarak yapay zekânın değiştirdiğini geri yüklesin.',
'ai.noGit': 'Uygulama klasörü bir git deposunda değil: Yapay zekâyla düzelt fark ve Geri al için git ister.',
'ai.cancelled': 'İptal edildi',
'ai.signedOut': 'Claude Code oturum açmamış: bir terminalde claude çalıştırıp /login ile giriş yapın ya da istemi yapay zekâ ajanınız için kopyalayın',
'ai.timedOut': '10 dakika sonra durduruldu',
'ai.noChanges': 'Hiçbir dosya değişmedi',
'ai.changed': 'Değişen dosya: {count}',
'ai.stillThere': 'tarama hâlâ bildiriyor',
'ai.gone': 'tarama artık bildirmiyor',
'ai.diff': 'Değişiklikler',
'ai.keep': 'Tut',
'ai.undo': 'Geri al',
'ai.undone': 'Geri alındı. Geri yüklenen dosya: {count}',
'ai.failed': 'Yapay zekâyla düzeltme başarısız: {msg}',
'checks.accepted': 'Kabul edildi: {reason}', 'checks.accepted': 'Kabul edildi: {reason}',
'checks.sev.blocker': 'Engel', 'checks.sev.blocker': 'Engel',
'checks.sev.high': 'Yüksek', 'checks.sev.high': 'Yüksek',
@ -510,6 +579,7 @@ export const tr: Record<keyof typeof en, string> = {
'deps.tools': 'Bu bilgisayardaki araçlar', 'deps.tools': 'Bu bilgisayardaki araçlar',
'deps.found': '{total} araçtan {found} tanesi var', 'deps.found': '{total} araçtan {found} tanesi var',
'deps.missing': 'Eksik', 'deps.missing': 'Eksik',
'deps.outdated': 'Eski (gereken {version})',
'deps.appCopy': 'Uygulamanın kopyası', 'deps.appCopy': 'Uygulamanın kopyası',
'deps.installed': 'Kurulu', 'deps.installed': 'Kurulu',
'deps.download': 'İndir', 'deps.download': 'İndir',
@ -701,6 +771,16 @@ export const tr: Record<keyof typeof en, string> = {
'app.pick': 'Bir uygulama seçin', 'app.pick': 'Bir uygulama seçin',
'app.unpair': 'Eşleşmeyi ayır', 'app.unpair': 'Eşleşmeyi ayır',
'app.ignore': 'Yok say', 'app.ignore': 'Yok say',
'app.unignore': 'Yok saymayı bırak',
'app.unignoreHint': 'Bu uygulamayı portföye ve mağaza okumalarına geri al',
'app.ignoreSide': '{store} tarafını yok say',
'app.unignoreSide': '{store} tarafını geri al',
'app.ignoreSideHint': 'Uygulamanın yalnızca {store} tarafını okumalardan ve bildirimlerden çıkarır. Yalnızca storebound: mağazaya dokunulmaz',
'app.unignoreSideHint': 'Uygulamanın {store} tarafını yeniden oku',
'ignored.show': '{count} yok sayılan · göster',
'ignored.hide': '{count} yok sayılan · gizle',
'ignored.rowHint': 'Yok sayılıyor: okunmaz, izlenmez',
'ignored.sideHint': '{stores} tarafı yok sayılıyor',
'app.ignoreHint': 'Bu uygulamayı portföy ve mağaza okumalarının dışında bırak (Yok sayılanlar ekranından geri getirilir)', 'app.ignoreHint': 'Bu uygulamayı portföy ve mağaza okumalarının dışında bırak (Yok sayılanlar ekranından geri getirilir)',
'app.studioHint': 'Stüdyoyu aç', 'app.studioHint': 'Stüdyoyu aç',
'app.storeOnlyTitle': 'Yalnızca mağaza.', 'app.storeOnlyTitle': 'Yalnızca mağaza.',
@ -748,6 +828,54 @@ export const tr: Record<keyof typeof en, string> = {
'notify.lowTitle': '{name}: {count} yeni düşük puan', 'notify.lowTitle': '{name}: {count} yeni düşük puan',
'notify.lowTitle_one': '{name}: yeni bir düşük puan', 'notify.lowTitle_one': '{name}: yeni bir düşük puan',
'notify.crashTitle': '{name}: Play eşiğinin üstünde', 'notify.crashTitle': '{name}: Play eşiğinin üstünde',
'notify.rejectedTitle': '{name}: {store} reddetti',
'notify.rejectedBody': '{version} sürümü reddedildi.',
'notify.approvedTitle': '{name}: onaylandı',
'notify.approvedBody': '{store} {version} incelemeyi geçti, yayına hazır.',
'notify.liveTitle': '{name}: yayında',
'notify.liveBody': '{store} {version} yayında.',
'notify.haltedTitle': '{name}: kademeli yayın durduruldu',
'notify.haltedBody': '{store} {version} durduruldu.',
'tray.open': "storebound'u aç",
'tray.readNow': 'Mağazaları şimdi oku',
'tray.apps': 'Uygulamalar',
'tray.noApps': 'Henüz uygulama yok',
'tray.noState': 'henüz okunmadı',
'tray.mute': 'Bildirimleri 1 saat sustur',
'tray.unmute': 'Bildirimlerin sesini aç',
'tray.update': 'Güncellemeleri denetle',
'tray.settings': 'Ayarlar',
'tray.quit': "storebound'dan çık",
'tray.tipReview': '{count} incelemede',
'tray.tipAttention': '{count} sizi bekliyor',
'tray.mutedTip': 'sessiz',
'set.nRejected': 'Reddedildi',
'set.nRejectedHint': 'Mağazanın reddettiği sürüm; mağaza verirse gerekçesiyle',
'set.nApproved': 'Onaylandı, yayına hazır',
'set.nApprovedHint': 'Bir sürüm incelemeyi geçti',
'set.nLive': 'Yayında',
'set.nLiveHint': 'Bir sürüm mağaza sayfasına ya da kademeli yayına ulaştı',
'set.quiet': 'Sessiz saatler',
'set.quietHint': 'Bu aralıkta bildirim gelmez (sonraya saklanmaz, atılır)',
'set.quietFrom': 'Başlangıç',
'set.quietTo': 'Bitiş',
'set.muteHour': '1 saat sustur',
'set.unmute': 'Sesi aç ({until})',
'set.muted': '{until} saatine kadar sessiz',
'set.clickNote': 'Masaüstünde bildirime tıklamak uygulamayı açmaz; pencereyi bir sonraki sefer tepsiden açtığınızda o uygulamanın Yayınlar sekmesine gider.',
'v2cfg.workspace': 'Çalışma alanı',
'v2cfg.studios': 'Stüdyolar',
'v2cfg.accounts': 'Mağaza hesapları',
'v2cfg.perStore': '{play} Google Play · {apple} App Store',
'v2cfg.keysWork': 'Çalışan anahtarlar',
'v2cfg.keysFailing': 'Sorunlu anahtarlar',
'v2cfg.depsFound': 'Bulunan araçlar',
'v2cfg.depsNeeds': 'İlgi bekleyen',
'v2cfg.depsInApp': 'Uygulama verisinde',
'v2cfg.depsInAppSub': 'storebound indirdi',
'v2cfg.needs': '{version} gerekli',
'v2cfg.stepOf': 'Adım {n} / {total}',
'v2cfg.progress': 'Kurulum ilerlemesi',
'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.', 'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.',
'set.sections': 'Ayar bölümleri', 'set.sections': 'Ayar bölümleri',
'set.studios': 'Stüdyolar ve hesaplar', 'set.studios': 'Stüdyolar ve hesaplar',
@ -794,6 +922,9 @@ export const tr: Record<keyof typeof en, string> = {
'read.couldNotRefresh': "yenilenemedi: {reason}", 'read.couldNotRefresh': "yenilenemedi: {reason}",
'read.refresh': "Yenile", 'read.refresh': "Yenile",
'read.waiting': "Okunuyor…", 'read.waiting': "Okunuyor…",
'read.justNow': "az önce okundu",
'read.paused': "mağaza sınırı: {time} saatine kadar bekliyor",
'read.busyShared': "Zaten okunuyor: bu, süren okumaya katılır",
'reader.progress': "{total} okumadan {done} tamam", 'reader.progress': "{total} okumadan {done} tamam",
'reader.upToDate': "Her şey okundu", 'reader.upToDate': "Her şey okundu",
'reader.off': "Otomatik okuma kapalı", 'reader.off': "Otomatik okuma kapalı",
@ -820,11 +951,11 @@ export const tr: Record<keyof typeof en, string> = {
'set.always': 'Her zaman', 'set.always': 'Her zaman',
'set.notifications': 'Bildirimler', 'set.notifications': 'Bildirimler',
'set.notificationsSub': 'Bir okumadan sonra, öncekinden beri bir şey değiştiyse', 'set.notificationsSub': 'Bir okumadan sonra, öncekinden beri bir şey değiştiyse',
'set.nReview': 'İnceleme ve yayın durumu değişiklikleri', 'set.nReview': 'Diğer yayın durumu değişiklikleri',
'set.nReviewHint': 'İncelemede, onaylandı, reddedildi, yayında, durduruldu', 'set.nReviewHint': 'Gönderildi, incelemede',
'set.nLow': 'Yeni 1 ya da 2 yıldızlı yorumlar', 'set.nLow': 'Yeni 1 ya da 2 yıldızlı yorumlar',
'set.nLowHint': 'Yorumlar sekmesi yenilerini okuduğunda', 'set.nLowHint': 'Yorumlar sekmesi yenilerini okuduğunda',
'set.nCrash': 'Çökme ya da ANR oranı eşiğin üstünde', 'set.nCrash': 'Kademeli yayın durdu, ya da çökme / ANR eşiğin üstünde',
'set.nCrashHint': 'Google Play kötü davranış eşikleri', 'set.nCrashHint': 'Google Play kötü davranış eşikleri',
'set.testNotify': 'Deneme bildirimi gönder', 'set.testNotify': 'Deneme bildirimi gönder',
'set.testTitle': 'storebound', 'set.testTitle': 'storebound',
@ -848,7 +979,9 @@ export const tr: Record<keyof typeof en, string> = {
'set.channel': 'Kanal', 'set.channel': 'Kanal',
'set.stable': 'Kararlı', 'set.stable': 'Kararlı',
'set.beta': 'Beta', 'set.beta': 'Beta',
'set.updatesText': 'Güncellemeler storebound.dev\'den imzalı gelir; uygulama bir şeyi değiştirmeden önce imzayı denetler ve yalnızca siz Yükle\'ye bastığınızda yükler. Arka planda denetim yapılmaz.', 'set.dev': 'Geliştirme (her değişiklik)',
'set.devToStable': 'Bu, geliştirme sürümü {version}: daha yeni bir kararlı sürüm çıkana kadar kalır (eski sürüme dönülmez).',
'set.updatesText': 'Güncellemeler storebound.dev\'den imzalı gelir; uygulama bir şeyi değiştirmeden önce imzayı denetler. Seçili kanalda daha yeni bir sürümü açılışta, 15 dakikada bir ve uykudan sonra arar, arka planda yükler (siz bu pencerede çalışırken değil). Kararlı, npm sürümlerini izler; Geliştirme, main\'deki her değişikliği alır.',
'set.updateCheck': 'Güncellemeleri denetle', 'set.updateCheck': 'Güncellemeleri denetle',
'set.updateChecking': 'Denetleniyor…', 'set.updateChecking': 'Denetleniyor…',
'set.updateNone': 'storebound {version}, {channel} kanalındaki en yeni sürüm.', 'set.updateNone': 'storebound {version}, {channel} kanalındaki en yeni sürüm.',
@ -1094,6 +1227,14 @@ export const tr: Record<keyof typeof en, string> = {
'art.render': '{lang} oluştur', 'art.render': '{lang} oluştur',
'art.notThere': 'Henüz yok', 'art.notThere': 'Henüz yok',
'art.count': '{count} dosya', 'art.count': '{count} dosya',
'v2lst.preview': 'Mağaza önizlemesi',
'v2lst.noSub': 'Henüz alt başlık yok',
'v2lst.noDesc': 'Henüz açıklama yok',
'v2lst.more': 'devamı',
'v2lst.ready': 'Hazır',
'v2lst.toFix': '{count} düzeltilecek',
'v2lst.issues': '{count} sorun',
'v2lst.issues_one': '1 sorun',
'art.count_one': '1 dosya', 'art.count_one': '1 dosya',
'art.open': '{name} aç', 'art.open': '{name} aç',
'art.tooBig': 'önizleme için çok büyük', 'art.tooBig': 'önizleme için çok büyük',
@ -1445,6 +1586,8 @@ export const tr: Record<keyof typeof en, string> = {
'cp.failed': '{form} için plan oluşturulamadı', 'cp.failed': '{form} için plan oluşturulamadı',
'cp.agentFirst': 'Önce ajan: yapay zekâ aracınız sayfayı bu planla yönetir; storebound son düğmeye asla basmaz', 'cp.agentFirst': 'Önce ajan: yapay zekâ aracınız sayfayı bu planla yönetir; storebound son düğmeye asla basmaz',
'cp.openPage': 'Sayfayı aç', 'cp.openPage': 'Sayfayı aç',
'link.open': 'Aç',
'link.openFailed': 'Sayfa açılamadı',
'cp.openFailed': 'Sayfa açılamadı', 'cp.openFailed': 'Sayfa açılamadı',
'cp.steps': '{count} adım', 'cp.steps': '{count} adım',
'cp.steps_one': '1 adım', 'cp.steps_one': '1 adım',
@ -1770,4 +1913,325 @@ export const tr: Record<keyof typeof en, string> = {
'ed.targetAudience': 'Hedef kitle ve reklamlar (Play)', 'ed.targetAudience': 'Hedef kitle ve reklamlar (Play)',
'ed.appContent': 'Uygulama içeriği beyanları (Play)', 'ed.appContent': 'Uygulama içeriği beyanları (Play)',
'ed.appContentHint': 'Devlet, finans ve sağlık özellikleri, reklam kimliği. API yok: konsol planı adımı bunları kullanır.', 'ed.appContentHint': 'Devlet, finans ve sağlık özellikleri, reklam kimliği. API yok: konsol planı adımı bunları kullanır.',
'tab.tools': 'Diğer araçlar',
'tools.lead': 'Kendi sekmesi olmayan her CLI komutu, komutun kendi seçenekleriyle bir form olarak. Okumalar burada çalışır; yazmalar önce prova sonucunu gösterir ve yerel bir pencerede onay ister. Sahip adımları sizindir: terminal komutunu kopyalayın.',
'tools.g.build': 'Derleme ve yükleme',
'tools.g.adhoc': 'Ad-hoc cihazlar',
'tools.g.store': 'Mağaza özellikleri',
'tools.g.text': 'Metin ve ASO',
'tools.g.capture': 'Çekimler',
'tools.g.quality': 'Kalite',
'tools.g.ads': 'Apple Ads',
'tools.g.flows': 'İş akışları ve yapay zekâ araçları',
'tools.count': '{count} komut',
'v2ins.hStartup': 'Açılış süresi',
'v2ins.hStartupSub': 'App Store tanılamaları · sürüm {version}',
'v2ins.rvOpen': 'Yanıtsız',
'v2ins.gFunnel': 'Mağaza hunisi',
'v2ins.gVsPrev': 'önceki {count} güne göre %',
'v2ins.gPts': 'önceki {count} güne göre puan',
'v2ins.cScore': 'Mağaza hazırlığı',
'v2ins.cScoreSub': '{total} kontrolden {ok} tanesi geçiyor',
'v2ins.cReady': 'Göndermeye hazır',
'v2ins.cBlocked': 'Düzeltilecek {count} engel',
'v2ins.cNotRun': 'Henüz kontrol edilmedi',
'v2ins.cBlockers': 'Engeller',
'v2ins.cWarnings': 'Uyarılar',
'v2ins.cPassedN': 'Geçti',
'v2ins.cFixFirst': 'Önce bunları düzelt',
'v2ins.cPassed': 'Geçen kontroller ({count})',
'v2ins.cAccepted': 'Kabul edilen riskler ({count})',
'v2ins.aiSheet': 'Mağaza bilgileri',
'v2ins.aiOpen': 'Açık sorular',
'v2ins.aiNone': 'Yok',
'tools.count_one': '1 komut',
'tools.show': 'Göster',
'tools.hide': 'Gizle',
'tools.read': 'salt okunur',
'tools.write': 'yazma (önce prova)',
'tools.network': 'mağaza',
'tools.run': 'Çalıştır',
'tools.dryRun': 'Prova, sonra uygula',
'tools.done': '{title}: tamam',
'tools.default': 'varsayılan: {value}',
'tools.exit': 'çıkış {code}',
'tools.copy': 'Kopyala',
'tools.copied': 'Kopyalandı',
'tools.owner': 'Sahip adımları (terminal)',
'tools.ownerSub': 'Yalnızca sizin karar verdiğiniz herkese açık veya fiyat değişiklikleri; uygulama klasöründe bir terminalde çalıştırın',
'tools.o.offer_codes': 'Tek seferlik satın almalar için App Store teklif kodları',
'tools.o.win_back': 'Ayrılan aboneler için geri kazanma teklifleri',
'tools.o.promoted': 'Öne çıkan uygulama içi satın almaları aç',
'tools.o.preorder': 'App Store ön siparişini aç veya bitir',
'tools.o.cpp_submit': 'Özel ürün sayfasını gönder (sonra görünür yap)',
'tools.o.events_submit': 'Uygulama içi etkinliği gönder',
'tools.o.tf_expire': 'Eski TestFlight derlemelerinin süresini bitir',
'tools.o.ads_change': 'Apple Ads teklif, bütçe veya duraklatma',
'tools.o.rc_sync': 'Kataloğu RevenueCat ile eşitle',
'tools.o.shots_login': 'Ekran görüntüleri için demo hesaba giriş (elle, Enter kaydeder)',
'cp.playGames': 'Play Games Hizmetleri kurulumu',
'cp.playGamesText': 'Oyun projesi, uygulama imzalama SHA-1 ile Android kimliği, web OAuth istemcisi; Yayınla düğmesine sen basarsın.',
'gs.title': 'Oyun hizmetleri',
'gs.sub': 'Game Center ve Play Games Hizmetleri: anahtarlar, kimlikler, yapılandırmadaki skor tabloları ve başarımlar.',
'gs.gameCenter': 'Game Center',
'gs.playGames': 'Play Games Hizmetleri',
'gs.enabled': 'Uygulamada açık',
'gs.capability': 'App ID yeteneği',
'gs.version': 'Sürüm {v}',
'gs.items': 'Skor tabloları / başarımlar',
'gs.counts': '{lbWant} skor tablosundan {lb} · {achWant} başarımdan {ach}',
'gs.pending': 'Yazılacak {n} değişiklik',
'gs.projectId': 'Proje kimliği',
'gs.resource': 'games_APP_ID kaynağı',
'gs.notSet': 'Ayarlı değil',
'gs.yes': 'Evet',
'gs.no': 'Hayır',
'gs.unknown': 'Bilinmiyor',
'gs.syncApple': 'Game Center eşitle',
'gs.syncPlay': 'Play Games eşitle',
'gs.syncHint': 'Önce deneme çalıştırması, sonra sen onaylarsın; hiçbir şey gönderilmez ya da yayınlanmaz.',
'gs.synced': 'Oyun hizmetleri eşit',
'gs.setup': 'Kurulum planı',
'pf.morning': "Günaydın.",
'pf.afternoon': "İyi günler.",
'pf.evening': "İyi akşamlar.",
'pf.moving': "{count} sürüm ilerliyor",
'pf.moving_one': "1 sürüm ilerliyor",
'pf.movingNone': "Şu an ilerleyen sürüm yok",
'pf.notMovingUnknown': "Neyin ilerlediğini görmek için mağazaları oku",
'pf.readAgo': "mağazalar {when} okundu",
'pf.layout': "Görünüm",
'pf.cards': "Kartlar",
'pf.table': "Tablo",
'pf.next': "sıradaki",
'pf.crash': "çökme",
'pf.ctaFix': "{count} engeli düzelt",
'pf.ctaFix_one': "1 engeli düzelt",
'pf.ctaRollout': "Dağıtımı sürdür (şu an {share})",
'pf.ctaRelease': "{version} sürümünü yayınla",
'pf.ctaSubmit': "{version} sürümünü gönder",
'pf.ctaReview': "{version} incelemesini izle",
'pf.ctaWarnings': "{count} uyarıya bak",
'pf.ctaWarnings_one': "1 uyarıya bak",
'pf.ctaUpload': "Derleme yükle",
'pf.ctaOpen': "Sürümleri aç",
'pf.feed': "Neler değişti",
'pf.feedSub': "Sürümler, sağlık ve klasörler, en yenisi üstte",
'pf.feedEmpty': "Henüz değişiklik yok",
'pf.feedEmptyText': "Sürümleri ve sağlığı burada görmek için mağazaları oku.",
'pf.evLive': "{version}, {store} üzerinde yayında",
'pf.evRollout': "{version}, {store} üzerinde {share} kullanıcıya dağıtılıyor",
'pf.evReview': "{version}, {store} incelemesine girdi",
'pf.evApproved': "{version}, {store} tarafından onaylandı",
'pf.evRejected': "{version}, {store} tarafından reddedildi",
'pf.evHalted': "{version} dağıtımı {store} üzerinde durduruldu",
'pf.evTesting': "{version}, {store} üzerinde testte",
'pf.evDraft': "{version}, {store} üzerinde taslak",
'pf.evCrash': "Google Play çökme oranı {rate}",
'pf.evAdded': "klasör çalışma alanına eklendi",
'pf.more': "{count} tane daha göster",
'pf.less': "Daha az göster",
'pf.fixFirst': "Önce bunları düzelt",
'pf.fixFirstSub': "{count} madde, önce engeller",
'pf.fixFirstSub_one': "1 madde",
'pf.blocker': "Engel",
'pf.warning': "Uyarı",
'pf.note': "Not",
'pf.fixIn': "{tab} sekmesinde düzelt",
'pf.openTab': "{tab} sekmesini aç",
'v2rel.version': "Sürüm",
'v2rel.stages': "{store} yayın aşamaları",
'v2rel.ofUsers': "kullanıcı bu sürümü alıyor",
'v2rel.versions': "{count} sürüm",
'v2rel.ready': "Hazır",
'v2rel.blocked': "Engelli",
'v2rel.todo': "Yapılacak",
'v2rel.attention': "Kontrol et",
'v2rel.readyCount': "{total} adımın {done} tanesi hazır",
'v2rel.steps': "İncelemeye gönderme adımları",
'v2rel.stepRead': "Mağaza durumu",
'v2rel.stepNoOpen': "İncelemede başka gönderim yok",
'v2rel.stepBuild': "Build'i seç",
'v2rel.stepVersion': "Gönderilecek sürüm",
'v2rel.stepSend': "İncelemeye gönder",
'v2rel.stepCode': "versionCode'u seç",
'v2rel.tfGroups': "Beta grupları",
'v2rel.tfInGroups': "{count} grupta",
'v2rel.tfSplit': "{internal} dahili · {external} harici",
'v2rel.tfUsable': "Teste hazır",
// context menus (ui/ContextMenu.tsx)
'cm.cut': "Kes",
'cm.pin': "Sabitle: menü açık kalır",
'cm.unpin': "Sabitlemeyi kaldır",
'cm.closePanel': "Kapat",
'cm.gone': "Yok: bu menünün ait olduğu şey şu an ekranda değil.",
'cm.menu': "Menü",
'cm.tearOff': "Ctrl+tık veya orta tık: bu alt menüyü sabitle",
'cm.copy': "Kopyala",
'cm.paste': "Yapıştır",
'cm.selectAll': "Tümünü seç",
'cm.copied': "Kopyalandı",
'cm.open': "Aç",
'cm.openTab': "Sekme aç",
'cm.openConsole': "Konsolda aç",
'cm.openIn': "{place} içinde aç",
'cm.openFolder': "Klasörü aç",
'cm.folderFailed': "Klasör açılamadı",
'cm.copyName': "Ad",
'cm.copyBundleId': "Bundle id",
'cm.copyPackage': "Paket adı",
'cm.copyAscId': "App Store Connect uygulama kimliği",
'cm.copyPath': "Yolu kopyala",
'cm.refreshReads': "Mağazalardan yeniden oku",
'cm.refreshing': "{name} yeniden okunuyor",
'cm.ignoredNoReads': "Yok sayılan uygulamalar okunmaz",
'cm.ignore': "Yok say",
'acct.devId': 'Play geliştirici kimliği {id}',
'acct.viaBroker': 'jetonlar kimlik aracısından',
'acct.viaFile': 'yerel anahtar dosyası',
'acct.grantsTitle': 'Kimlik aracısındaki Play izinleri',
'acct.grantsOk': 'storebound için gereken her şey var',
'acct.grantsMissing': 'eksik: {list}',
'acct.grantsNone': 'izin bilgisi yok',
'acct.notRegistered': 'aracıda kayıtlı değil',
'acct.noDevId': 'Play geliştirici kimliği yok',
'acct.devIdLabel': 'Play geliştirici hesabı kimliği',
'acct.devIdHint': 'Play Console adresinde /developers/ sonrasındaki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.',
'checks.openGuide': 'Kılavuzu aç',
'checks.openGuideHint': '{skill} kılavuzunu aç (SKILL.md)',
'cm.more': "Diğer işlemler",
'cm.showIgnored': "Yok sayılanları göster",
'cm.copyVersion': "Sürümü kopyala",
'cm.copyBuild': "Build'i kopyala",
'cm.copyVersionCode': "Sürüm kodunu kopyala",
'cm.preview': "Önizle",
'cm.openFile': "Dosyayı aç",
'cm.reveal': "Klasörde göster",
'cm.readAgain': "Yeniden oku",
'cm.reading': "Okunuyor…",
'cm.openDraft': "Yanıt taslağını göster",
'cm.copyReview': "Yorumu kopyala",
'cm.copyReply': "Yayındaki yanıtı kopyala",
'cm.copyDraft': "Taslağı kopyala",
'cm.revealEvidence': "Dosyayı göster",
'cm.copyTitle': "Başlığı kopyala",
'cm.copyFix': "Düzeltmeyi kopyala",
'cm.copySkill': "Skill adını kopyala",
'cm.copyEvidence': "Kanıt satırlarını kopyala",
'cm.copyCode': "Kontrol kodunu kopyala",
'cm.copyValue': "Değeri kopyala",
'cm.notChanged': "Değişmedi",
'cm.copyProductId': "Ürün kimliğini kopyala",
'cm.showPrices': "Fiyatları göster",
'cm.copyRow': "Satırı kopyala",
'cm.showPerRegion': "Bölge bölge göster",
'cm.copyPriceOf': "{store} fiyatını kopyala",
'cm.copyRegion': "Bölge kodunu kopyala",
'cm.busy': "Başka bir işlem sürüyor",
'cm.copyPrincipal': "Hizmet hesabı / anahtar",
'cm.copyProfile': "Profil adı",
'cm.openAccounts': "Stüdyolar ve hesapları aç",
"sec.release": "Yayın",
"sec.store": "Mağaza sayfası",
"sec.revenue": "Gelir",
"sec.players": "Oyuncular",
"sec.health": "Sağlık",
"sec.release.for": "Bir sürümü iki mağazada oyunculara ulaştır: build, TestFlight, inceleme ve dağıtım.",
"sec.store.for": "Mağazada insanların gördüğü: dile göre metinler, ekran görüntüleri ve uygulama bilgileri.",
"sec.revenue.for": "Uygulamanın sattıkları ve fiyatları: uygulama içi ürünler, abonelikler ve fiyatlar.",
"sec.players.for": "Oyuncuların söyledikleri ve oyunlarda kazanabilecekleri: yorumlar, Game Center, Play Games.",
"sec.health.for": "Uygulama sağlıklı ve hazır mı: çökmeler ve ANR, büyüme, önce düzeltilecek kontroller, diğer araçlar.",
"sec.views": "{section} görünümleri",
"tab.games": "Oyun servisleri",
"next.open": "{where} aç",
"next.rejected": "{version}, {store} tarafından reddedildi",
"next.halted": "{version}, {store} üzerinde durduruldu",
"next.blockers": "Göndermeden önce {count} engel",
"next.blockers_one": "Göndermeden önce 1 engel",
"next.rollout": "{version} dağıtımı {share} seviyesinde",
"next.approved": "{version} onaylandı: yayınla",
"next.submit": "{version} sürümünü incelemeye gönder",
"next.review": "{version} incelemede",
"next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)",
"next.upload": "İlk build'i yükle",
"next.live": "Yayında: {version}",
"next.notRead": "Yayın durumu mağazalar okununca görünür",
"next.storeLive": "Mağazalardan okunan canlı mağaza sayfası",
"next.listingProblems": "{count} mağaza metni sorunu",
"next.listingProblems_one": "1 mağaza metni sorunu",
"next.unsaved": "{count} kaydedilmemiş düzenleme",
"next.unsaved_one": "1 kaydedilmemiş düzenleme",
"next.listingsUnread": "Mağaza metinleri henüz kontrol edilmedi",
"next.storeOk": "Mağaza sayfası yapılandırmayla uyumlu",
"next.catalogDiff": "{count} ürün mağazalar arasında farklı",
"next.catalogDiff_one": "1 ürün mağazalar arasında farklı",
"next.noProducts": "Henüz ürün yok: bir tane ekle",
"next.products": "{products} ürün · {subs} abonelik",
"next.reviews": "{count} yorum yanıtsız",
"next.reviews_one": "1 yorum yanıtsız",
"next.reviewsUnread": "Oyuncuların söylediklerine bak",
"next.reviewsOk": "Tüm yorumlar yanıtlandı",
"next.gamesApiOff": "Play Games Services API kapalı",
"next.gamesBlockers": "{count} oyun servisi engeli",
"next.gamesBlockers_one": "1 oyun servisi engeli",
"next.gamesSync": "Oyun servisleri: eşitlenecek {count} değişiklik",
"next.gamesSync_one": "Oyun servisleri: eşitlenecek 1 değişiklik",
"next.fixFirst": "Önce düzeltilecek {count} sorun",
"next.fixFirst_one": "Önce düzeltilecek 1 sorun",
"next.warnings": "Bakılacak {count} uyarı",
"next.warnings_one": "Bakılacak 1 uyarı",
"next.healthOk": "Sorun bulunmadı",
"side.gAttention": "İlgi bekliyor",
"side.gProgress": "Sürüyor",
"side.gLive": "Yayında",
"side.gDraft": "Henüz mağazada değil",
"side.missing": "Klasör bulunamadı",
"side.rejected": "Reddedildi · {what}",
"side.halted": "Durduruldu · {what}",
"side.blockers": "{count} engel",
"side.blockers_one": "1 engel",
"side.rollout": "Dağıtım {share} · {what}",
"side.review": "İncelemede · {what}",
"side.approved": "Onaylandı · {what}",
"side.draft": "Taslak · {what}",
"side.live": "Yayında · {version}",
"side.liveWarn": "Yayında · {version} · {count} uyarı",
"side.liveWarn_one": "Yayında · {version} · 1 uyarı",
"side.storeOnly": "Yalnız mağaza · henüz okunmadı",
"side.notRead": "Henüz okunmadı",
"side.warnings": "{count} uyarı",
"side.warnings_one": "1 uyarı",
"side.notOnStore": "Henüz mağaza sürümü yok",
"side.ignored": "Yok sayıldı",
"side.studioFilter": "Bir stüdyonun uygulamalarını göster",
"side.allStudios": "Tümü",
"games.keep": "Uyumlu",
"games.update": "Farklı",
"games.create": "Eksik",
"games.notOnStore": "Bu mağazada değil",
"games.notListed": "Planlanmadı",
"games.notRead": "Okunmadı",
"games.item": "Öğe",
"games.name": "Ad",
"games.points": "Puan",
"games.languages": "Diller",
"games.leaderboard": "Skor tablosu",
"games.achievement": "Başarım",
"games.hidden": "kazanılana kadar gizli",
"games.localeChanges": "Game Center dilleri: {list}",
"games.checkAgain": "Yeniden kontrol et",
"games.apiOffTitle": "Play Games Services API kapalı.",
"games.apiOffText": "Hizmet hesabının Google Cloud projesinde {api} açın, bir dakika bekleyin, sonra yeniden kontrol edin.",
"games.itemsTitle": "Skor tabloları ve başarımlar",
"games.itemsSub": "Yapılandırmada tek liste, iki mağazaya eşlenir: her mağazanın yapılandırmaya göre durumu.",
"games.refresh": "Yeniden oku",
"games.noItems": "Yapılandırmada henüz skor tablosu veya başarım yok (games.leaderboards, games.achievements).",
"games.notReadYet": "Oyun servisleri okunuyor…",
"games.images": "Skor tablosu ve başarım görselleri konsollarda eklenir; Play'de mevcut simge her eşitlemede korunur.",
"games.localeMap": "Game Center dil eşlemesi",
"games.localeMapSub": "Yapılandırma dilinden canlı Game Center'ın kullandığı dile (games.localeMap).",
"games.mapSet": "Ayarlı",
"games.mapSuggested": "Önerilen",
"games.mapHint": "games.localeMap'e ekleyin: {entries}",
}; };

View file

@ -4,6 +4,7 @@
import type { AnalyticsCompare, AppStatus, DeclarationsResult, ListingText, PricesCurrent } from '../api'; import type { AnalyticsCompare, AppStatus, DeclarationsResult, ListingText, PricesCurrent } from '../api';
import type { CatalogDiff, HealthSnapshot, Review, ValidateResult } from './model'; import type { CatalogDiff, HealthSnapshot, Review, ValidateResult } from './model';
import { isError } from './model'; import { isError } from './model';
import { buildReport, renderMarkdown } from '../../../../cli/lib/report';
type Cell = string | number | boolean | null | undefined; type Cell = string | number | boolean | null | undefined;
@ -62,11 +63,12 @@ export function healthMd(app: string, h: HealthSnapshot): string {
return out.join('\n'); return out.join('\n');
} }
export function validateMd(app: string, v: ValidateResult, at: string | null): string { /** The store readiness export: the same report and Markdown renderer as `storebound report --md` (cli/lib/report.ts),
const lvl = (l: string) => (l === 'blocker' ? 'Blocker' : l === 'warn' ? 'Warning' : 'OK'); * from what the Checks tab read (validate, the offline check, the scan when it ran). */
return [`# ${app}: store readiness`, '', `Read ${at ?? 'now'} · ${v.blockers} blocker(s), ${v.findings.filter(f => f.level === 'warn').length} warning(s)`, '', export function validateMd(app: string, v: ValidateResult, at: string | null, more: { check?: { problems: string[]; warnings: string[] } | null; scan?: unknown; version?: string; stores?: ('apple' | 'play')[] } = {}): string {
mdTable(['store', 'level', 'check', 'finding', 'fix'], v.findings.map(f => [f.store === 'apple' ? 'App Store' : 'Google Play', lvl(f.level), f.code, f.msg, f.fix ?? ''])), const r = buildReport({ app: { name: app }, ...(more.stores ? { stores: more.stores } : {}), generatedAt: at ?? new Date().toISOString(), storebound: more.version ?? 'desktop',
].join('\n'); validate: v as never, check: more.check ?? null, scan: (more.scan ?? null) as never, storeRead: { apple: true, play: true } });
return renderMarkdown(r);
} }
export function catalogMd(app: string, c: CatalogDiff): string { export function catalogMd(app: string, c: CatalogDiff): string {

View file

@ -5,7 +5,7 @@ import type { UnifiedApp as CoreApp } from '../../../../cli/lib/identity';
export { export {
buildApps, type BuildInput, NO_OVERRIDES, normId, normName, type Overrides, type Pair, pairCandidates, type Pairing, refKey, type StoreRef, storeTarget, buildApps, type BuildInput, NO_OVERRIDES, normId, normName, type Overrides, type Pair, pairCandidates, type Pairing, refKey, type StoreRef, storeTarget,
withIgnored, withLink, withPair, withStudio, withUnpair, withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair,
} from '../../../../cli/lib/identity'; } from '../../../../cli/lib/identity';
export type UnifiedApp = CoreApp<Project>; export type UnifiedApp = CoreApp<Project>;

Some files were not shown because too many files have changed in this diff Show more