Compare commits

..

618 commits

Author SHA1 Message Date
CRT
a5acf20730 storebound 0.8.77: version in package.json and every copy, CHANGELOG 0.8.77 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 3m24s
ci / windows (push) Successful in 7m27s
ci / linux (push) Successful in 5m43s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 27s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / dev (push) Successful in 1m48s
ci / record (push) Successful in 10s
release / promote (push) Successful in 14m28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m27s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m23s
2026-11-09 06:37:51 +07:00
CRT
1a66c50d56 iPhone Duo: limit from Apple's reference data instead of a cut to 10, --duo-only; shared asset library helpers in their own module
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m13s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-09 06:35:00 +07:00
CRT
4b6c0e4030 storebound 0.8.76: version in package.json and every copy, CHANGELOG 0.8.76 section
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m58s
ci / windows (push) Successful in 7m3s
ci / linux (push) Successful in 13m20s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 38s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m37s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m39s
ci / dev (push) Successful in 2m9s
ci / record (push) Successful in 11s
release / promote (push) Successful in 22m53s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m17s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m51s
2026-11-08 18:24:15 +07:00
CRT
0995e8093c ci: AppImage tools from the Forgejo mirror (sha256-checked) into ~/.cache/tauri; GitHub release assets fail too often
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 3m0s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 18:20:36 +07:00
CRT
7c8b6d2fb9 storebound 0.8.75: version in package.json and every copy, CHANGELOG 0.8.75 section
Some checks failed
ci / gate (push) Successful in 2s
ci / windows (push) Successful in 6m28s
ci / ci (push) Successful in 8m55s
ci / linux (push) Failing after 8m0s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / dev (push) Has been skipped
release / promote (push) Failing after 17m6s
ci / record (push) Has been skipped
2026-11-08 17:40:26 +07:00
CRT
9dfdb7d741 CHANGELOG: 0.8.74 was not published (GitHub 504)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m59s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 17:37:34 +07:00
CRT
b702f7574f storebound 0.8.74: version in package.json and every copy, CHANGELOG 0.8.74 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m51s
ci / windows (push) Successful in 6m53s
ci / linux (push) Failing after 6m19s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
release / promote (push) Failing after 9m17s
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / dev (push) Has been skipped
ci / record (push) Has been skipped
2026-11-08 16:58:18 +07:00
CRT
79101e0bf6 ci: cache ~/.cache/tauri and retry the Linux bundle (GitHub release 504s); CHANGELOG note for 0.8.73
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m14s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 16:55:29 +07:00
CRT
4a94892db0 storebound 0.8.73: version in package.json and every copy, CHANGELOG 0.8.73 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 3m18s
ci / linux (push) Failing after 2m48s
release / promote (push) Failing after 6m12s
ci / windows (push) Successful in 7m1s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / dev (push) Has been skipped
ci / record (push) Has been skipped
2026-11-08 16:26:03 +07:00
CRT
e9adc079b7 ci: install bun from the npm registry (GitHub release assets answer 504 now and then), bun.sh with retries as fallback
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m39s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 16:23:15 +07:00
CRT
c20ed04799 ci: re-run (GitHub download outage)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 24s
ci / linux (push) Failing after 23s
ci / windows (push) Successful in 6m30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / dev (push) Has been skipped
ci / record (push) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
2026-11-08 16:17:34 +07:00
CRT
9ac3f9bb30 ci: re-run (bun download 504)
Some checks failed
ci / gate (push) Successful in 2s
ci / linux (push) Failing after 40s
ci / ci (push) Failing after 42s
ci / windows (push) Successful in 6m43s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / dev (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / record (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
2026-11-08 15:50:43 +07:00
CRT
e3065963ff iPhone Duo: accept platform IPHONE_APP_STORE from appAssetLibraryRefData; a Duo failure warns, other sets continue
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 40s
ci / linux (push) Failing after 39s
ci / windows (push) Successful in 6m21s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / dev (push) Has been skipped
ci / record (push) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
2026-11-08 15:49:17 +07:00
CRT
e814ddb9d7 storebound 0.8.72: version in package.json and every copy, CHANGELOG 0.8.72 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 56s
ci / linux (push) Successful in 6m13s
ci / windows (push) Successful in 7m9s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 36s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / dev (push) Successful in 2m10s
ci / record (push) Successful in 11s
release / promote (push) Successful in 20m58s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m41s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m51s
2026-11-08 15:20:10 +07:00
CRT
a0af8cddd9 flags.json: regenerated
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m10s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 15:18:12 +07:00
CRT
77ccba0bbd api tables: refData and asset library images now wrapped by apple screenshots
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m14s
ci / linux (push) Successful in 6m9s
ci / windows (push) Successful in 7m13s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 27s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 35s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m12s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / dev (push) Successful in 2m30s
ci / record (push) Successful in 11s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m36s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m20s
2026-11-08 15:15:57 +07:00
CRT
a25718d6d8 apple screenshots: iPhone Duo through the App Asset Library (IPHONE_DUO placement group, APP_SCREENSHOT), pitfall 130
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m14s
ci / linux (push) Successful in 6m47s
ci / windows (push) Successful in 7m30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 38s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / record (push) Successful in 11s
ci / dev (push) Successful in 1m50s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m2s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m47s
2026-11-08 14:19:12 +07:00
CRT
e11fd43fa5 storebound 0.8.71: version in package.json and every copy, CHANGELOG 0.8.71 section
All checks were successful
ci / gate (push) Successful in 2s
ci / windows (push) Successful in 6m25s
ci / ci (push) Successful in 2m30s
ci / linux (push) Successful in 5m43s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / dev (push) Successful in 30s
ci / record (push) Successful in 12s
release / promote (push) Successful in 37m9s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m15s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m51s
2026-11-08 03:33:21 +07:00
CRT
63369353af CHANGELOG: 0.8.71 summary (0.8.69 and 0.8.70 were not published)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m5s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 03:30:40 +07:00
CRT
dfc6362335 boot check: the portfolio with a Play page 404 counts no live version
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m59s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 03:26:22 +07:00
CRT
84f1b7127a desktop Portfolio: the Play phase uses the same label as Releases (Not live yet ...) 2026-11-08 03:26:22 +07:00
CRT
a21db7b460 storebound 0.8.70: version in package.json and every copy, CHANGELOG 0.8.70 section
Some checks failed
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 2m45s
ci / windows (push) Successful in 7m22s
ci / linux (push) Successful in 5m46s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
release / promote (push) Failing after 40m12s
2026-11-08 03:17:41 +07:00
CRT
e74fa1c0aa public MCP: a browser on the bare host goes to the landing page
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m4s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 03:14:37 +07:00
CRT
98e123f403 public MCP: the bare host answers MCP (POST /) and names the connect URL (GET /) 2026-11-08 03:14:37 +07:00
CRT
425a99b801 storebound 0.8.69: version in package.json and every copy, CHANGELOG 0.8.69 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m3s
ci / linux (push) Successful in 6m3s
ci / windows (push) Successful in 6m51s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / dev (push) Successful in 51s
ci / record (push) Successful in 12s
release / promote (push) Successful in 13m21s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m9s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m57s
2026-11-08 02:24:27 +07:00
CRT
f3edfccbcf 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m50s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-08 02:18:24 +07:00
CRT
e786f43a15 storebound 0.8.68: version in package.json and every copy, CHANGELOG 0.8.68 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 3m3s
ci / windows (push) Successful in 8m22s
ci / linux (push) Successful in 6m30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m33s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / dev (push) Successful in 53s
ci / record (push) Successful in 15s
release / promote (push) Successful in 14m27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 7m34s
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
2026-11-08 02:01:11 +07:00
CRT
bbf29ab569 apple submit/resubmit --platform macos: apple.macos.noIap leaves IAPs out, no Game Center switch without apple.macos.gameCenter; pitfalls #128
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m14s
ci / linux (push) Successful in 6m23s
ci / windows (push) Successful in 7m24s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m31s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / record (push) Successful in 13s
ci / dev (push) Successful in 1m51s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m44s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m52s
2026-11-06 21:07:22 +07:00
CRT
537809865d tests: placeholder home folders in the launcher test (scrub) 2026-11-06 21:04:14 +07:00
CRT
19942368a0 desktop: the app's own CLI on the terminal PATH (launcher rewritten on every start; npm installs stay first)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m11s
ci / linux (push) Successful in 7m9s
ci / windows (push) Successful in 8m0s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m20s
ci / dev (push) Successful in 1m12s
ci / record (push) Successful in 11s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 42s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m10s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m50s
2026-11-06 21:01:19 +07:00
CRT
46f37dec22 storebound 0.8.67: version in package.json and every copy, CHANGELOG 0.8.67 section
All checks were successful
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 3m34s
ci / windows (push) Successful in 7m40s
ci / linux (push) Successful in 6m18s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m33s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / dev (push) Successful in 1m53s
ci / record (push) Successful in 14s
release / promote (push) Successful in 15m50s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m39s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m5s
2026-11-04 00:20:45 +07:00
CRT
3e91e2464d apple build --platform macos: apple.macos.prebuild, not the iOS project's build.prebuild
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m34s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-04 00:17:04 +07:00
CRT
a2bf1c78c1 storebound 0.8.66: version in package.json and every copy, CHANGELOG 0.8.66 section
All checks were successful
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 3m24s
ci / windows (push) Successful in 7m38s
ci / linux (push) Successful in 6m6s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / dev (push) Successful in 54s
ci / record (push) Successful in 15s
release / promote (push) Successful in 14m17s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m27s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m19s
2026-11-03 23:43:46 +07:00
CRT
891ff8f074 changelog: macOS entitlements, MCP review-details platform
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m19s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-03 23:40:51 +07:00
CRT
f56cf58559 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
c2afa256c8 mcp: apple_review_details takes platform (macOS version, apple.macos.reviewNotes) 2026-11-03 23:40:25 +07:00
CRT
37b9d03f56 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
a1249b445d storebound 0.8.65: version in package.json and every copy, CHANGELOG 0.8.65 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m58s
ci / windows (push) Successful in 7m23s
ci / linux (push) Successful in 5m57s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 35s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m32s
ci / dev (push) Successful in 1m12s
ci / record (push) Successful in 14s
release / promote (push) Successful in 14m8s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m37s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m2s
2026-11-03 23:17:34 +07:00
CRT
83f1d3f826 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
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m8s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-03 23:14:34 +07:00
CRT
638bae6645 storebound 0.8.64: version in package.json and every copy, CHANGELOG 0.8.64 section
All checks were successful
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 2m27s
ci / windows (push) Successful in 7m7s
ci / linux (push) Successful in 5m58s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / dev (push) Successful in 2m13s
ci / record (push) Successful in 13s
release / promote (push) Successful in 20m26s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m36s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m45s
2026-11-02 18:22:39 +07:00
CRT
81cfee1e40 flags: regenerate for the new apple build / flow options
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 1m39s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-02 18:19:50 +07:00
CRT
38c72e4afa 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m46s
ci / linux (push) Successful in 6m34s
ci / windows (push) Successful in 7m27s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / dev (push) Successful in 1m32s
ci / record (push) Successful in 14s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m25s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m19s
2026-11-02 18:16:25 +07:00
CRT
b966a93812 storebound 0.8.63: version in package.json and every copy, CHANGELOG 0.8.63 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m9s
ci / windows (push) Successful in 7m59s
ci / linux (push) Successful in 7m13s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 37s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / dev (push) Failing after 3s
ci / record (push) Successful in 13s
release / promote (push) Successful in 13m22s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m9s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m41s
2026-11-02 16:43:58 +07:00
CRT
4580c6d9d3 desktop: a second --hidden start stays in the tray (single-instance no longer opens the window)
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 3m33s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-11-02 16:39:15 +07:00
CRT
20a870c80b storebound 0.8.62: version in package.json and every copy, CHANGELOG 0.8.62 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 3m43s
ci / windows (push) Successful in 7m1s
ci / linux (push) Successful in 5m45s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / dev (push) Successful in 1m11s
ci / record (push) Successful in 11s
release / promote (push) Successful in 14m35s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m15s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m48s
2026-10-30 00:29:52 +07:00
CRT
fb41775f4f doctor: warn when storebound runs from a folder macOS guards (a launchd job hangs opening it)
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m36s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-30 00:26:07 +07:00
CRT
6bf3f42e3e 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
1f620f248c storebound 0.8.61: version in package.json and every copy, CHANGELOG 0.8.61 section
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 3m36s
ci / windows (push) Successful in 7m10s
ci / linux (push) Successful in 5m58s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 36s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / dev (push) Successful in 2m10s
ci / record (push) Successful in 13s
release / promote (push) Successful in 21m6s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m50s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m47s
2026-10-29 22:40:00 +07:00
CRT
a655eff0c9 regenerate the api coverage tables
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m11s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-29 22:37:03 +07:00
CRT
b6169d7b29 testflight: description keys by language id or App Store locale, unknown keys are a config error, primary-locale check before Beta App Review
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 2m22s
ci / linux (push) Successful in 6m28s
ci / windows (push) Successful in 7m22s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 37s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / dev (push) Successful in 51s
ci / record (push) Successful in 12s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m36s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m27s
2026-10-29 22:33:59 +07:00
CRT
ece1ab9f6a storebound 0.8.60: version in package.json and every copy, CHANGELOG 0.8.60 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 3m12s
ci / windows (push) Successful in 7m5s
ci / linux (push) Successful in 5m52s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m35s
ci / dev (push) Successful in 1m36s
ci / record (push) Successful in 13s
release / promote (push) Successful in 14m52s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 7m34s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m57s
2026-10-29 20:24:54 +07:00
CRT
82d6ec00e3 gradle: comment without a home path
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m14s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-29 20:21:52 +07:00
CRT
f5f67a5074 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
b0a5d0de4f 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
6318f4fe78 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
e668f9b82c play build: absolute --dir stays absolute; gradlew without exec bit is made executable 2026-10-29 20:19:16 +07:00
CRT
15a76cbb20 release --resume: a fixed tools/desktop-release.ts may be uncommitted too 2026-10-29 20:18:52 +07:00
CRT
fed3d50657 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
78bc066d59 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
54ae1f4960 storebound 0.8.59: version in package.json and every copy, CHANGELOG 0.8.59 section
All checks were successful
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 5m50s
ci / windows (push) Successful in 6m42s
ci / linux (push) Successful in 8m21s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 35s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 39s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m39s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / dev (push) Successful in 30s
ci / record (push) Successful in 12s
release / promote (push) Successful in 18m59s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m27s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m48s
2026-10-29 19:10:28 +07:00
CRT
3d25c34127 docs: SSE keepalive for long tool calls
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 3m0s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-29 19:06:22 +07:00
CRT
24d551ba13 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
7f8be3f4c7 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
6506fbe46b storebound 0.8.58: version in package.json and every copy, CHANGELOG 0.8.58 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m29s
ci / windows (push) Successful in 6m59s
ci / linux (push) Successful in 6m7s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 35s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 50s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / dev (push) Successful in 50s
ci / record (push) Successful in 11s
release / promote (push) Successful in 13m29s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m5s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m40s
2026-10-29 18:41:35 +07:00
CRT
376f3560f8 mcp --http: --broker-profiles (store keys from the broker vault, memory only), --read-only, project by folder name under the roots
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m49s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-29 18:38:11 +07:00
CRT
82710194f2 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
7413df037b mcp --http: no request timeout (tool calls can run for many minutes) 2026-10-29 16:31:41 +07:00
CRT
a09bbaf005 docs: mcp --http (shared server, project argument, token and introspection auth) 2026-10-29 16:21:50 +07:00
CRT
bdcc79173f 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
87fbce4487 storebound 0.8.57: version in package.json and every copy, CHANGELOG 0.8.57 section
All checks were successful
ci / gate (push) Successful in 6s
ci / ci (push) Successful in 2m18s
ci / windows (push) Successful in 6m59s
ci / linux (push) Successful in 5m43s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / dev (push) Successful in 1m13s
ci / record (push) Successful in 14s
release / promote (push) Successful in 13m13s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m21s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m51s
2026-10-27 19:01:14 +07:00
CRT
a417dde2d0 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)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m35s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-27 18:58:48 +07:00
CRT
777e513061 storebound 0.8.56: version in package.json and every copy, CHANGELOG 0.8.56 section
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m49s
ci / windows (push) Successful in 7m24s
ci / linux (push) Successful in 6m22s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 36s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / dev (push) Successful in 2m30s
ci / record (push) Successful in 11s
release / promote (push) Successful in 15m34s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m15s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m50s
2026-10-27 17:17:39 +07:00
CRT
6d79a22697 regenerate the api coverage tables
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 1m32s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-27 17:13:34 +07:00
CRT
9159dfeedf regenerate embedded skills 2026-10-27 17:10:59 +07:00
CRT
34d053d19b docs: escape the table pipes 2026-10-27 17:08:39 +07:00
CRT
9ef107d1ca docs: apple release type flags and config template line 2026-10-27 17:07:53 +07:00
CRT
ca8dad1663 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
fd5028a042 storebound 0.8.55: version in package.json and every copy, CHANGELOG 0.8.55 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 5m10s
ci / linux (push) Successful in 7m3s
ci / windows (push) Successful in 6m19s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / dev (push) Successful in 51s
ci / record (push) Successful in 13s
release / promote (push) Successful in 23m41s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m21s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m59s
2026-10-26 20:21:17 +07:00
CRT
03fbbd5f99 scrub: /Users/Shared is the system shared folder, not a home path
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m33s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-26 20:17:46 +07:00
CRT
3916575bdc path-free: a shared build folder outside home is not a leak (only home and user-name paths are private)
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 2m47s
ci / linux (push) Successful in 6m27s
ci / windows (push) Successful in 7m28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m32s
ci / record (push) Successful in 12s
ci / dev (push) Successful in 1m11s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 7m34s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m21s
2026-10-26 20:13:11 +07:00
CRT
65f975ecff storebound 0.8.54: version in package.json and every copy, CHANGELOG 0.8.54 section
All checks were successful
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 3m57s
ci / windows (push) Successful in 7m24s
ci / linux (push) Successful in 5m57s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 31s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / dev (push) Successful in 1m11s
ci / record (push) Successful in 13s
release / promote (push) Successful in 15m1s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m29s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m4s
2026-10-26 19:16:46 +07:00
CRT
8154d64f31 apple build: --derived-data (default <out>/DerivedData with --out), --path-free (Swift/clang prefix maps, no serialized debug options) and an archive path scan
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m58s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-26 19:12:21 +07:00
CRT
569b419fb5 storebound 0.8.53: version in package.json and every copy, CHANGELOG 0.8.53 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 3m51s
ci / windows (push) Successful in 7m39s
ci / linux (push) Successful in 5m45s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m34s
ci / dev (push) Successful in 2m15s
ci / record (push) Successful in 14s
release / promote (push) Successful in 15m49s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 7m19s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m46s
2026-10-26 18:17:15 +07:00
CRT
0e39750b8f 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
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m29s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-26 18:12:19 +07:00
CRT
97d1eca0a7 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
517582af78 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)
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m29s
ci / linux (push) Successful in 6m10s
ci / windows (push) Successful in 7m12s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 37s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 37s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / record (push) Successful in 13s
ci / dev (push) Successful in 2m11s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 7m11s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m17s
2026-10-26 16:52:29 +07:00
CRT
8a96f38784 storebound 0.8.52: version in package.json and every copy, CHANGELOG 0.8.52 section
Some checks failed
ci / gate (push) Successful in 7s
ci / ci (push) Successful in 2m56s
ci / windows (push) Successful in 6m32s
ci / linux (push) Successful in 5m31s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 38s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 40s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m43s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m42s
ci / dev (push) Failing after 5s
ci / record (push) Successful in 12s
release / promote (push) Successful in 13m9s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 11m6s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 9m32s
2026-10-26 11:08:21 +07:00
CRT
0c1157688e CHANGELOG: 0.8.51 was not published
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m23s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-26 11:05:27 +07:00
CRT
e52edba4bc release: the dev-channel job never gates a release; desktop-dev retries the live dev latest.json on 5xx
Some checks failed
ci / gate (push) Successful in 3s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-26 11:05:08 +07:00
CRT
a4f43187ad storebound 0.8.51: version in package.json and every copy, CHANGELOG 0.8.51 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 3m14s
ci / windows (push) Successful in 8m32s
ci / linux (push) Successful in 6m12s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m20s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m25s
ci / dev (push) Failing after 10s
release / promote (push) Failing after 13m2s
ci / record (push) Successful in 13s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 7m27s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 9m14s
2026-10-26 10:27:48 +07:00
CRT
564e885517 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m40s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-26 10:20:27 +07:00
CRT
6521abae59 storebound 0.8.50: version in package.json and every copy, CHANGELOG 0.8.50 section
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m38s
ci / windows (push) Successful in 7m14s
ci / linux (push) Successful in 5m42s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / dev (push) Successful in 1m11s
ci / record (push) Successful in 13s
release / promote (push) Successful in 13m27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m53s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m14s
2026-10-25 23:18:12 +07:00
CRT
343e69dcbc 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
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m49s
ci / linux (push) Successful in 6m4s
ci / windows (push) Successful in 6m54s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m32s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m31s
ci / record (push) Successful in 13s
ci / dev (push) Successful in 51s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m6s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m46s
2026-10-25 22:51:32 +07:00
CRT
7d41a3a3eb 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
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m57s
ci / linux (push) Successful in 5m57s
ci / windows (push) Successful in 6m52s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m32s
ci / record (push) Successful in 14s
ci / dev (push) Successful in 51s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m6s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m51s
2026-10-25 22:35:36 +07:00
CRT
7922651fb4 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
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m2s
ci / linux (push) Successful in 6m5s
ci / windows (push) Successful in 6m53s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m31s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / dev (push) Successful in 31s
ci / record (push) Successful in 12s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m1s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m0s
2026-10-25 21:08:13 +07:00
CRT
2c23254a9a storebound 0.8.49: version in package.json and every copy, CHANGELOG 0.8.49 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m28s
ci / windows (push) Successful in 8m25s
ci / linux (push) Successful in 6m37s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 40s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / dev (push) Successful in 2m11s
ci / record (push) Successful in 12s
release / promote (push) Successful in 15m23s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m48s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 7m2s
2026-10-25 20:15:46 +07:00
CRT
1de68f0616 desktop: read StartupApproved through the registry API, no reg.exe (the security test allows no other programs)
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m24s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-25 21:11:31 +08:00
CRT
9791f066e4 desktop: autostart respects Windows Startup apps (turned off there stays off; the Run entry is no longer re-enabled on every start)
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m40s
ci / linux (push) Successful in 5m56s
ci / windows (push) Successful in 7m15s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 44s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 45s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / record (push) Successful in 15s
ci / dev (push) Successful in 1m52s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m49s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m31s
2026-10-24 21:13:10 +08:00
CRT
7940289272 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
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m33s
ci / linux (push) Successful in 7m3s
ci / windows (push) Successful in 7m48s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / record (push) Successful in 14s
ci / dev (push) Successful in 50s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m29s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m37s
2026-10-24 19:53:10 +08:00
CRT
b13d7d36f6 api tables: regenerate for the new Play listing calls
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m24s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-24 19:49:26 +08:00
CRT
3b08002f78 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
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m24s
ci / linux (push) Successful in 5m59s
ci / windows (push) Successful in 7m4s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m16s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m39s
ci / dev (push) Successful in 2m12s
ci / record (push) Successful in 9s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m55s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m39s
2026-10-24 19:46:57 +08:00
CRT
b944bbd835 storebound 0.8.48: version in package.json and every copy, CHANGELOG 0.8.48 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m49s
ci / windows (push) Successful in 6m53s
ci / linux (push) Successful in 5m32s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / dev (push) Successful in 1m10s
ci / record (push) Successful in 11s
release / promote (push) Successful in 13m18s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m55s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m29s
2026-10-24 18:17:14 +07:00
CRT
e569d79a63 tests: adhoc register --yes gets 20 s (timed out at 5 s on a busy CI runner)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m23s
ci / linux (push) Successful in 5m41s
ci / windows (push) Successful in 6m26s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 31s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-24 19:09:21 +08:00
CRT
4811ad5cf2 desktop: tray menu panels and pinnable context menus
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m20s
ci / linux (push) Successful in 5m48s
ci / windows (push) Successful in 6m35s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / record (push) Successful in 12s
ci / dev (push) Successful in 1m51s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m48s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m34s
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
84b15dc6fd apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m14s
ci / linux (push) Successful in 6m28s
ci / windows (push) Successful in 7m46s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 36s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 43s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m31s
ci / dev (push) Successful in 31s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / record (push) Successful in 12s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m3s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m39s
2026-10-24 13:07:58 +08:00
CRT
e100001017 desktop: boot check finds unsaved marks on the sub views; neutral next-step lines before a read
Some checks failed
ci / gate (push) Successful in 3s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-24 13:06:42 +08:00
CRT
eac6388705 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
f9577f4a8f embedded: regenerated
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m16s
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-24 13:02:02 +08:00
CRT
1e40e65580 flags: shots fonts --lang, capture --probe 2026-10-24 13:01:45 +08:00
CRT
4fea3b63a9 shots fonts usage line 2026-10-24 13:01:32 +08:00
CRT
ee711c32ca shots: feature rotate, per-language frame fallback, layout-aware capture selectors 2026-10-24 13:00:48 +08:00
CRT
35f094a758 mas: placeholders the scrub accepts
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m15s
ci / linux (push) Successful in 5m44s
ci / windows (push) Successful in 6m42s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 38s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / record (push) Successful in 11s
ci / dev (push) Successful in 1m30s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m6s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m50s
2026-10-24 12:23:53 +08:00
CRT
889d350486 generated API tables refreshed for the macOS build upload 2026-10-24 12:23:52 +08:00
CRT
2788654965 docs: build-and-sign Mac App Store Tauri section, pitfall 114, changelog 2026-10-24 12:23:36 +08:00
CRT
fc90a4fa5d path-free: no cargo trim-paths (nightly only) 2026-10-24 12:23:20 +08:00
CRT
c9428647bf mas build: cargo target folder is src-tauri/target 2026-10-24 12:23:20 +08:00
CRT
86b64ecdf2 apple build/upload: flags and help for the macOS Tauri path 2026-10-24 12:23:17 +08:00
CRT
efbd37e406 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
3642ec9a2b pitfalls spacing
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 1m10s
ci / linux (push) Successful in 5m43s
ci / windows (push) Successful in 6m37s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / record (push) Successful in 11s
ci / dev (push) Successful in 1m10s
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
2026-10-24 12:17:45 +08:00
CRT
7bef01aa54 merge fixups: changelog and pitfall numbering 2026-10-24 12:17:01 +08:00
CRT
d07bef5cfb desktop coverage test: shots copy is agent-edited 2026-10-24 12:16:49 +08:00
CRT
72696950d4 desktop coverage entry for shots copy 2026-10-24 12:16:49 +08:00
CRT
76ccca43d7 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
8bcf407527 play-publish-changes: a release is ready only when the store page shows an older version; unknown reads are not determined, never a mismatch
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m11s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-24 12:12:37 +08:00
CRT
e94f9bb9fb generated API tables refreshed for the play-publish-changes form
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m27s
ci / linux (push) Successful in 6m29s
ci / windows (push) Successful in 6m50s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / dev (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-24 12:01:37 +08:00
CRT
9e166bf9a3 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
6738ec3149 dev channel: Apache serves /desktop/dev/latest.json; the VDS publisher lists releases without the draft filter (it hid the dev drafts)
All checks were successful
ci / gate (pull_request) Successful in 2s
ci / windows (pull_request) Has been skipped
ci / linux (pull_request) Has been skipped
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (pull_request) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (pull_request) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (pull_request) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (pull_request) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (pull_request) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (pull_request) Has been skipped
ci / dev (pull_request) Has been skipped
ci / record (pull_request) Has been skipped
ci / ci (pull_request) Successful in 1m29s
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m58s
ci / linux (push) Successful in 6m24s
ci / windows (push) Successful in 7m21s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / record (push) Successful in 11s
ci / dev (push) Successful in 1m31s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m45s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m28s
2026-10-23 23:46:03 +08:00
CRT
a25b8bdb4d dev channel VDS publisher: send a User-Agent (Cloudflare refuses urllib's default with 403)
All checks were successful
ci / gate (pull_request) Successful in 3s
ci / windows (pull_request) Has been skipped
ci / linux (pull_request) Has been skipped
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (pull_request) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (pull_request) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (pull_request) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (pull_request) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (pull_request) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (pull_request) Has been skipped
ci / dev (pull_request) Has been skipped
ci / record (pull_request) Has been skipped
ci / ci (pull_request) Successful in 1m35s
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m3s
ci / linux (push) Successful in 5m55s
ci / windows (push) Successful in 6m38s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m31s
ci / record (push) Successful in 12s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m46s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m28s
ci / dev (push) Successful in 13m53s
2026-10-23 23:17:05 +08:00
CRT
9dccf357a9 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
8bdb801937 desktop open: Windows window check uses real top-level windows (the tray helper windows and MainWindowHandle also answer for a hidden app)
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m1s
ci / linux (push) Successful in 3m42s
ci / windows (push) Successful in 3m59s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m32s
ci / record (push) Successful in 13s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m41s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m29s
2026-10-23 22:53:30 +08:00
CRT
78a7598221 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
6d0238d9de shots: full-bleed layout (shots.layout bleed) with corner captions, per-size frames, and landscape orientation, on top of the landscape sizes
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m55s
ci / linux (push) Successful in 3m38s
ci / windows (push) Successful in 3m56s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m32s
ci / record (push) Successful in 13s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
2026-10-23 22:45:10 +08:00
CRT
67534f8df2 storebound 0.8.47: version in package.json and every copy, CHANGELOG 0.8.47 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m39s
ci / linux (push) Successful in 3m53s
ci / windows (push) Successful in 3m37s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / record (push) Successful in 12s
release / promote (push) Successful in 14m58s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m42s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m26s
2026-10-23 16:03:29 +07:00
CRT
4004050245 desktop install: stop the running app by exact path, check the installed exe's real version, verify it opened
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 1m55s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 15:57:32 +07:00
CRT
9c3f1756fa generated API tables and flags refreshed for the play products clamp (Forgejo ci failed on stale api tables)
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 15:56:36 +07:00
CRT
0ddb871b26 play products: clamp regional prices to Play's min/max, per-product results; apple iap: skip live localizations, per-product results
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m41s
ci / linux (push) Successful in 3m35s
ci / windows (push) Successful in 3m51s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 42s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m6s
ci / record (push) Successful in 6m51s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m11s
2026-10-23 16:53:25 +08:00
CRT
fe46ae6cd1 storebound 0.8.46: version in package.json and every copy, CHANGELOG 0.8.46 section
All checks were successful
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 1m57s
ci / windows (push) Successful in 3m54s
ci / linux (push) Successful in 3m20s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / record (push) Successful in 14s
release / promote (push) Successful in 8m57s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m38s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m15s
2026-10-23 14:44:51 +07:00
CRT
222befc097 Merge origin/main (0.8.45 tag, not published)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m42s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 15:40:56 +08:00
CRT
e1fc29139c watch: ingest sink (--notify ingest) posting events to a push ingest endpoint 2026-10-23 15:30:18 +08:00
CRT
c319ed8313 storebound 0.8.45: version in package.json and every copy, CHANGELOG 0.8.45 section
Some checks failed
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 2m59s
ci / linux (push) Successful in 4m11s
ci / windows (push) Successful in 3m30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / record (push) Failing after 2m25s
release / promote (push) Failing after 22m50s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m37s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m24s
2026-10-23 13:50:24 +07:00
CRT
eb2e8cbd58 CHANGELOG: 0.8.44 was not published
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 1m13s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 14:48:09 +08:00
CRT
eb0c71970e Merge origin/main (0.8.44 tag) 2026-10-23 14:48:03 +08:00
CRT
3f823eb2ef 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
17a42fc2c9 storebound 0.8.44: version in package.json and every copy, CHANGELOG 0.8.44 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 2m34s
ci / linux (push) Successful in 3m22s
ci / windows (push) Successful in 4m1s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / record (push) Failing after 2m23s
release / promote (push) Failing after 23m25s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m7s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m14s
2026-10-23 13:16:42 +07:00
CRT
776eb9ac4f desktop coverage: place for the status command
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 1m11s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 14:14:02 +08:00
CRT
be5a2d454a portfolio test: android identity carries developerId and appId
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m7s
ci / linux (push) Successful in 3m32s
ci / windows (push) Successful in 4m10s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 2m1s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / record (push) Failing after 2m23s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m21s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m35s
2026-10-23 14:11:38 +08:00
CRT
bfe44f2c15 tray card: per-icon Phosphor imports; teaser pins the current app-privacy step 5 text
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m12s
ci / linux (push) Successful in 4m4s
ci / windows (push) Successful in 5m28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 27s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m5s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m4s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 24s
ci / record (push) Failing after 2m24s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m37s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m51s
2026-10-23 14:07:44 +08:00
CRT
1e374a56b1 CHANGELOG: 0.8.43 was not published; notes move to Unreleased
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m15s
ci / linux (push) Successful in 3m29s
ci / windows (push) Successful in 3m54s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / record (push) Failing after 2m22s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m31s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m44s
2026-10-23 13:56:01 +08:00
CRT
86d8d8a147 Merge origin/main (0.8.43 tag) 2026-10-23 13:55:50 +08:00
CRT
f53deca39e 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
8fa9153f8f storebound 0.8.43: version in package.json and every copy, CHANGELOG 0.8.43 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 57s
ci / windows (push) Successful in 3m50s
ci / linux (push) Successful in 3m26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m48s
ci / record (push) Failing after 2m27s
release / promote (push) Failing after 18m50s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m38s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m31s
2026-10-23 12:20:06 +07:00
CRT
9e3a66420b Merge origin/main (0.8.42 tag, not published); API tables and flags regenerated
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m4s
ci / linux (push) Successful in 3m31s
ci / windows (push) Successful in 3m54s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / record (push) Failing after 2m24s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m43s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 7m8s
2026-10-23 13:16:13 +08:00
CRT
c70221acf7 Merge origin/main (0.8.42 tag, not published); API tables regenerated 2026-10-23 13:15:34 +08:00
CRT
7f63b8b6a6 Regenerate the API tables with forward slashes 2026-10-23 13:13:45 +08:00
CRT
d8afe954f7 docs/ship-step.md, pitfall 110 (pipeline exit 7 and double uploads), CHANGELOG Unreleased 2026-10-23 13:13:28 +08:00
CRT
d12348c33f 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
d70c4d39c2 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
1960649a26 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
f648f6b269 storebound 0.8.42: version in package.json and every copy, CHANGELOG 0.8.42 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m6s
ci / linux (push) Successful in 3m24s
ci / windows (push) Successful in 4m7s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / record (push) Failing after 2m24s
release / promote (push) Failing after 18m34s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m12s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m24s
2026-10-23 11:30:53 +07:00
CRT
ed9c4e7183 Regenerate the API tables (wrapped.gen.ts, api-coverage.md); 0.8.41 notes move to Unreleased
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m14s
ci / linux (push) Successful in 4m13s
ci / windows (push) Successful in 3m59s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 37s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m6s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m4s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / record (push) Failing after 2m21s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m40s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m29s
2026-10-23 12:29:24 +08:00
CRT
b08f28888a storebound 0.8.41: version in package.json and every copy, CHANGELOG 0.8.41 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m17s
ci / linux (push) Successful in 4m5s
ci / windows (push) Successful in 4m8s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 35s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m48s
release / promote (push) Failing after 40m10s
ci / record (push) Failing after 2m22s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m11s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m52s
2026-10-23 10:25:06 +07:00
CRT
0c34a823af 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m12s
ci / linux (push) Successful in 4m7s
ci / windows (push) Successful in 4m8s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 28s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m6s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m7s
ci / record (push) Failing after 2m23s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m14s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m22s
2026-10-23 11:22:26 +08:00
CRT
8a2f4832b4 CHANGELOG: reads registry and tray card
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m5s
ci / linux (push) Successful in 4m4s
ci / windows (push) Successful in 4m31s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m14s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m7s
ci / record (push) Failing after 2m22s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m40s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m22s
2026-10-23 11:16:12 +08:00
CRT
ecd00ef369 Desktop: generated permissions of the tray card commands 2026-10-23 11:15:40 +08:00
CRT
f72d8ef00b 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
0161b6daf5 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
9a01763e73 Desktop boot check: quota row match as plain text
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 59s
ci / linux (push) Successful in 4m3s
ci / windows (push) Successful in 3m38s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / record (push) Successful in 11s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m11s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m24s
2026-10-23 10:47:39 +08:00
CRT
d2202b930b Desktop boot check: the reader's quota row uses the short wording
Some checks are pending
ci / gate (push) Waiting to run
ci / ci (push) Blocked by required conditions
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Blocked by required conditions
ci / acceptance (bun, 22, windows, Windows, bun) (push) Blocked by required conditions
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
ci / acceptance (node20, 20, windows, Windows, node) (push) Blocked by required conditions
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
ci / acceptance (node22, 22, windows, Windows, node) (push) Blocked by required conditions
ci / linux (push) Blocked by required conditions
ci / windows (push) Blocked by required conditions
ci / record (push) Blocked by required conditions
2026-10-23 10:46:49 +08:00
CRT
f8c76c607e Desktop boot check: the dry-run preview is accepted whenever it shows, also during waits
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 57s
ci / linux (push) Successful in 3m47s
ci / windows (push) Successful in 4m14s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 41s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / record (push) Successful in 12s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m53s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m48s
2026-10-23 10:44:54 +08:00
CRT
170dc84cf1 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
8f3912d13e Desktop boot check: say why a tab could not be opened
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m9s
ci / linux (push) Successful in 4m11s
ci / windows (push) Successful in 4m19s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m16s
ci / record (push) Successful in 9s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m9s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m42s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m53s
2026-10-23 10:36:38 +08:00
CRT
addb4e8ccc 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m9s
ci / linux (push) Successful in 4m19s
ci / windows (push) Successful in 3m48s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m9s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m41s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m25s
2026-10-23 10:31:40 +08:00
CRT
409adb932e Merge origin/main (0.8.40 tag, not published)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m3s
ci / linux (push) Successful in 3m38s
ci / windows (push) Successful in 3m55s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m6s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m51s
2026-10-23 10:26:05 +08:00
CRT
8385db2dad Desktop boot check: accept the in-app dry-run preview before the native confirm 2026-10-23 10:25:26 +08:00
CRT
367ce3fbd7 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
731347e636 storebound 0.8.40: version in package.json and every copy, CHANGELOG 0.8.40 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m9s
ci / linux (push) Successful in 4m9s
ci / windows (push) Successful in 3m40s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m18s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / record (push) Successful in 10s
release / promote (push) Successful in 22m20s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m40s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m22s
2026-10-23 08:54:55 +07:00
CRT
8a6ad61996 Desktop: tab row reserves enough room for the More menu button
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 09:54:20 +08:00
CRT
5db5a127e8 CHANGELOG: 0.8.39 was not published; its notes move to Unreleased
Some checks failed
ci / gate (push) Successful in 2s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 09:53:32 +08:00
CRT
c8e5968237 Merge origin/main (0.8.39 tag) 2026-10-23 09:53:20 +08:00
CRT
a836b87a32 CHANGELOG: audit fixes 2026-10-23 09:52:54 +08:00
CRT
dfddc453fe Desktop: portfolio card ids show the full id as a tooltip 2026-10-23 09:50:11 +08:00
CRT
301df0ac67 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
5be831e04a 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
b3e15d5f1f Desktop: Settings search filters the sections (nav and body), Enter jumps to the first match 2026-10-23 09:47:29 +08:00
CRT
fa297983cb Desktop: More tools gets a command search across groups and a compact list layout 2026-10-23 09:46:55 +08:00
CRT
aac04fb39a 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
2e4e242251 Desktop: Prices sheet panel is one row (Export primary, Compare a sheet, rest in ...) 2026-10-23 09:45:19 +08:00
CRT
5dfb89443d 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
866089c8c4 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
721ac626ae storebound 0.8.39: version in package.json and every copy, CHANGELOG 0.8.39 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m40s
ci / windows (push) Successful in 4m27s
ci / linux (push) Successful in 3m55s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m15s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / record (push) Successful in 10s
release / promote (push) Successful in 15m19s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m23s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m18s
2026-10-23 08:39:22 +07:00
CRT
3f8456b9cb CHANGELOG: next release notes
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m3s
ci / windows (push) Successful in 4m0s
ci / linux (push) Successful in 4m21s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / record (push) Successful in 10s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m2s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m6s
2026-10-23 09:37:13 +08:00
CRT
03c3e207a9 Merge origin/main (0.8.38) 2026-10-23 09:37:04 +08:00
CRT
20278c4bac 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
0f09ff823d 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
2b4de14e59 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
9741a77e66 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
c7c97b34c1 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
8481199f67 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
8d7c4e5c91 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
1bef60590a 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
c3c43c7ab4 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
87a0f98ae3 Desktop: release builds on Windows turn off the WebView2 browser context menu and accelerator keys 2026-10-23 08:59:13 +08:00
CRT
ebe7b8faac storebound 0.8.38: version in package.json and every copy, CHANGELOG 0.8.38 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m1s
ci / windows (push) Successful in 3m46s
ci / linux (push) Successful in 3m28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 28s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m15s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m20s
ci / record (push) Successful in 11s
release / promote (push) Successful in 7m59s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m34s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m11s
2026-10-23 07:53:23 +07:00
CRT
ccbffbd176 CHANGELOG: move the signing and version-train notes to Unreleased
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 08:52:03 +08:00
CRT
101a5b2da8 Merge origin/main (0.8.37)
Some checks failed
ci / gate (push) Successful in 3s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-23 08:51:41 +08:00
CRT
b9e821ef00 play build: signing passwords only through the child environment, never argv 2026-10-23 08:51:07 +08:00
CRT
3112df31d4 Signed AAB guardrails, injected signing args, IPA version train check, Play notes lint at build 2026-10-23 08:49:43 +08:00
CRT
c6156584ca Desktop: app hero without screenshots no longer shows a cropped second copy of the icon 2026-10-23 08:39:51 +08:00
CRT
bb377802f0 storebound 0.8.37: version in package.json and every copy, CHANGELOG 0.8.37 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 54s
ci / windows (push) Successful in 4m4s
ci / linux (push) Successful in 3m55s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m18s
ci / record (push) Successful in 9s
release / promote (push) Successful in 14m20s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 24s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m2s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m12s
2026-10-23 01:38:18 +07:00
CRT
c37dfadfcc CHANGELOG: next release notes
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m9s
ci / linux (push) Successful in 3m52s
ci / windows (push) Successful in 3m54s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / record (push) Successful in 11s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m11s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m15s
2026-10-23 02:36:37 +08:00
CRT
ea5192c8fa Merge origin/main (0.8.36) 2026-10-23 02:36:17 +08:00
CRT
b54b780d64 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
32e5034612 storebound 0.8.36: version in package.json and every copy, CHANGELOG 0.8.36 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m2s
ci / windows (push) Successful in 3m45s
ci / linux (push) Successful in 3m32s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 35s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m18s
ci / record (push) Successful in 10s
release / promote (push) Successful in 14m9s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 25s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m1s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m32s
2026-10-23 01:08:33 +07:00
CRT
f58add10f1 watch: read a missing App Store bundle id from the API instead of skipping the app
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m3s
ci / linux (push) Successful in 3m37s
ci / windows (push) Successful in 3m46s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / record (push) Successful in 10s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m53s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m25s
2026-10-23 02:06:56 +08:00
CRT
e2ef09a848 Merge origin/main (0.8.35)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m18s
ci / linux (push) Successful in 3m41s
ci / windows (push) Successful in 3m45s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m31s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m17s
2026-10-23 01:46:03 +08:00
CRT
1729cb6cc8 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
ba036a0e83 storebound 0.8.35: version in package.json and every copy, CHANGELOG 0.8.35 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m2s
ci / windows (push) Successful in 3m46s
ci / linux (push) Successful in 3m26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / record (push) Successful in 10s
release / promote (push) Successful in 8m10s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m44s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m22s
2026-10-23 00:15:25 +07:00
CRT
b4084d0b16 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
cefa3580a0 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
9b68cc57a1 Merge origin/main (0.8.34)
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 50s
ci / linux (push) Successful in 3m42s
ci / windows (push) Successful in 3m50s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 42s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m31s
ci / record (push) Successful in 12s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m8s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m16s
2026-10-22 23:59:05 +08:00
CRT
68ac313114 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
0c749bf2d5 storebound 0.8.34: version in package.json and every copy, CHANGELOG 0.8.34 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m9s
ci / linux (push) Successful in 9m49s
ci / windows (push) Successful in 3m55s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / record (push) Successful in 11s
release / promote (push) Successful in 19m18s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m5s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m18s
2026-10-22 22:53:36 +07:00
CRT
33126d15a8 CHANGELOG: next release note
Some checks are pending
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Blocked by required conditions
ci / acceptance (bun, 22, windows, Windows, bun) (push) Blocked by required conditions
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
ci / acceptance (node20, 20, windows, Windows, node) (push) Blocked by required conditions
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
ci / acceptance (node22, 22, windows, Windows, node) (push) Blocked by required conditions
ci / record (push) Blocked by required conditions
ci / gate (push) Successful in 2s
ci / ci (push) Waiting to run
ci / linux (push) Waiting to run
ci / windows (push) Waiting to run
2026-10-22 23:48:14 +08:00
CRT
e3de4a1b9d Merge origin/main (0.8.33)
Some checks are pending
ci / gate (push) Waiting to run
ci / ci (push) Blocked by required conditions
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Blocked by required conditions
ci / acceptance (bun, 22, windows, Windows, bun) (push) Blocked by required conditions
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
ci / acceptance (node20, 20, windows, Windows, node) (push) Blocked by required conditions
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
ci / acceptance (node22, 22, windows, Windows, node) (push) Blocked by required conditions
ci / linux (push) Blocked by required conditions
ci / windows (push) Blocked by required conditions
ci / record (push) Blocked by required conditions
2026-10-22 23:47:47 +08:00
CRT
dd8090d41b 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
c6c8fd9b21 storebound 0.8.33: version in package.json and every copy, CHANGELOG 0.8.33 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m17s
ci / windows (push) Successful in 3m30s
ci / linux (push) Successful in 9m4s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 28s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / record (push) Successful in 10s
release / promote (push) Successful in 14m0s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m49s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m27s
2026-10-22 22:41:58 +07:00
CRT
0a8f2b7155 CHANGELOG: desktop v2 and tray notes for the next release
Some checks failed
ci / gate (push) Successful in 3s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 23:40:09 +08:00
CRT
b36619bc24 Merge remote-tracking branch 'origin/main'
Some checks failed
ci / gate (push) Successful in 3s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 23:36:34 +08:00
CRT
8adb84164a 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
89853583cb storebound 0.8.32: version in package.json and every copy, CHANGELOG 0.8.32 section
Some checks failed
ci / gate (push) Successful in 3s
ci / windows (push) Successful in 3m45s
ci / ci (push) Failing after 10m2s
ci / linux (push) Successful in 3m59s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / record (push) Successful in 10s
release / promote (push) Successful in 17m42s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m33s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m27s
2026-10-22 22:19:38 +07:00
CRT
1dc643aab7 Merge origin/main (0.8.31)
Some checks failed
ci / gate (push) Successful in 4s
ci / ci (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 23:18:57 +08:00
CRT
2accb961f6 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
9499a9211f 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
bf60001e4b storebound 0.8.31: version in package.json and every copy, CHANGELOG 0.8.31 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m24s
ci / windows (push) Successful in 3m40s
ci / linux (push) Successful in 21m28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 28s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / record (push) Successful in 10s
release / promote (push) Successful in 26m36s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m41s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m8s
2026-10-22 21:42:53 +07:00
CRT
2f5f962611 Merge origin/main (0.8.30)
Some checks failed
ci / gate (push) Successful in 2s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 22:41:09 +08:00
CRT
1ebafbd91a 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
ed82b5f2f9 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
a13114ffdd 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
a566cd3a92 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
d6775e0a1a storebound 0.8.30: version in package.json and every copy, CHANGELOG 0.8.30 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m11s
ci / windows (push) Successful in 3m44s
ci / linux (push) Successful in 3m35s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 39s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 42s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / record (push) Successful in 11s
release / promote (push) Successful in 8m41s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m1s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m25s
2026-10-22 20:39:57 +07:00
CRT
87fe790385 Merge origin/main (0.8.29)
Some checks failed
ci / gate (push) Successful in 3s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 21:36:15 +08:00
CRT
df4381c6f5 CHANGELOG: desktop home v2 2026-10-22 21:34:24 +08:00
CRT
3d45cabb12 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
4b37aeedc8 storebound 0.8.29: version in package.json and every copy, CHANGELOG 0.8.29 section
Some checks failed
ci / gate (push) Successful in 2s
ci / windows (push) Successful in 3m40s
ci / ci (push) Failing after 4m37s
ci / linux (push) Successful in 11m38s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / record (push) Successful in 10s
release / promote (push) Successful in 19m57s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m24s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m2s
2026-10-22 19:51:07 +07:00
CRT
ffe3b7d06a Merge origin/main (0.8.28)
Some checks failed
ci / gate (push) Successful in 3s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 20:50:09 +08:00
CRT
2cc92bf335 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
fff087e923 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
044e8bb9b2 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
26d7b3ff37 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
8e5dccbc43 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
d8cda61a33 storebound 0.8.28: version in package.json and every copy, CHANGELOG 0.8.28 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m34s
ci / windows (push) Successful in 3m45s
ci / linux (push) Successful in 3m57s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 28s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m18s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m20s
ci / record (push) Successful in 10s
release / promote (push) Successful in 9m2s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m21s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 6m5s
2026-10-22 15:03:45 +07:00
CRT
3deaf353be Entitlements reference check compares forward-slash paths on every OS (false 'not referenced by CODE_SIGN_ENTITLEMENTS' on Windows); test, pitfall 103
Some checks failed
ci / gate (push) Successful in 2s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 16:03:07 +08:00
CRT
9b2a6fc9ff Merge origin/main (0.8.27)
Some checks failed
ci / gate (push) Successful in 4s
ci / ci (push) Failing after 1m40s
ci / linux (push) Successful in 4m25s
ci / windows (push) Successful in 3m51s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 39s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m20s
ci / record (push) Successful in 11s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m23s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m25s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m2s
2026-10-22 06:22:26 +08:00
CRT
55862cca50 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
7660bd9dc7 storebound 0.8.27: version in package.json and every copy, CHANGELOG 0.8.27 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m34s
ci / windows (push) Successful in 3m44s
ci / linux (push) Successful in 4m46s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m19s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m19s
ci / record (push) Successful in 10s
release / promote (push) Successful in 9m50s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m28s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 6m13s
2026-10-22 05:05:48 +07:00
CRT
38aee7dafb 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
Some checks failed
ci / gate (push) Successful in 3s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 05:59:41 +08:00
CRT
4bddbe07dd 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)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 2m7s
ci / windows (push) Successful in 3m46s
ci / linux (push) Successful in 4m21s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m26s
ci / record (push) Successful in 11s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m24s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m6s
2026-10-22 05:24:58 +08:00
CRT
4db6cb34f7 doctor: at a terminal the used-for column is cut to the window width so rows never wrap; piped output stays whole
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m49s
ci / linux (push) Successful in 4m45s
ci / windows (push) Successful in 4m14s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m23s
ci / record (push) Successful in 11s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m25s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m7s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m20s
2026-10-22 03:45:25 +08:00
CRT
92922a333b 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
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 1m31s
ci / linux (push) Successful in 4m31s
ci / windows (push) Successful in 4m40s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 35s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m29s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m21s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 38s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m32s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m19s
2026-10-22 03:33:30 +08:00
CRT
d5877504ee 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m30s
ci / linux (push) Failing after 1m12s
ci / windows (push) Failing after 17m12s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 03:28:46 +08:00
CRT
ec1ecd567b 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)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m29s
ci / windows (push) Successful in 4m1s
ci / linux (push) Successful in 7m9s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 27s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m16s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 39s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m19s
ci / record (push) Failing after 13m52s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 15m8s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m59s
2026-10-22 03:22:23 +08:00
CRT
ba5f5334e6 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
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 2m38s
ci / linux (push) Successful in 4m42s
ci / windows (push) Successful in 3m57s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m35s
ci / record (push) Successful in 11s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 13m40s
2026-10-22 02:50:12 +08:00
CRT
3783d85a6d Merge origin/main (0.8.26)
Some checks failed
ci / gate (push) Successful in 3s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 02:49:33 +08:00
CRT
f30f6f5456 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
b2ebebc6ff storebound 0.8.26: version in package.json and every copy, CHANGELOG 0.8.26 section
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Failing after 2m46s
ci / windows (push) Successful in 4m45s
ci / linux (push) Successful in 4m17s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 31s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / record (push) Successful in 11s
release / promote (push) Successful in 10m47s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m51s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m44s
2026-10-22 01:33:30 +07:00
CRT
e02772881a Merge origin/main (0.8.25)
Some checks failed
ci / gate (push) Successful in 3s
ci / windows (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-22 02:28:42 +08:00
CRT
7e5bb5ed53 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
d8a293706b 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
ebc82f3615 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
44c746244b 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
dd6aa1e704 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
a434ddbf2e 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
6abd49e8fa 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
190553ea31 storebound 0.8.25: version in package.json and every copy, CHANGELOG 0.8.25 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m22s
ci / windows (push) Successful in 3m48s
ci / linux (push) Successful in 4m37s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / record (push) Successful in 15s
release / promote (push) Successful in 10m46s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m21s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m10s
2026-10-22 00:59:03 +07:00
CRT
ee290c993d 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
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m8s
ci / windows (push) Successful in 3m54s
ci / linux (push) Successful in 4m12s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 43s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m20s
ci / record (push) Successful in 12s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m19s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m6s
2026-10-22 01:39:14 +08:00
CRT
bdd007b0bf storebound 0.8.24: version in package.json and every copy, CHANGELOG 0.8.24 section
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 2m23s
ci / windows (push) Successful in 3m47s
ci / linux (push) Successful in 3m44s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m49s
ci / record (push) Successful in 12s
release / promote (push) Successful in 10m17s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m31s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m22s
2026-10-21 23:40:26 +07:00
CRT
b98eff3c5f 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
All checks were successful
ci / gate (push) Successful in 3s
ci / ci (push) Successful in 1m49s
ci / windows (push) Successful in 3m53s
ci / linux (push) Successful in 3m52s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m30s
ci / record (push) Successful in 11s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m18s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m6s
2026-10-21 22:37:51 +08:00
CRT
9241c8ada0 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
f1b8bcecdd 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
32a0045ef7 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
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m30s
ci / linux (push) Successful in 3m37s
ci / windows (push) Successful in 3m45s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 35s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m49s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m29s
2026-10-21 18:10:58 +07:00
CRT
9e1ce42fe5 shots: render refuses missing frames and fails on load errors; shots check errors on renders with failed loads (pitfall 93)
Some checks failed
ci / gate (push) Successful in 2s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-21 18:09:06 +07:00
CRT
55f8b7ca16 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
c711782c81 storebound 0.8.23: version in package.json and every copy, CHANGELOG 0.8.23 section
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m38s
ci / windows (push) Successful in 4m14s
ci / linux (push) Successful in 4m0s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / record (push) Successful in 10s
release / promote (push) Successful in 10m13s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m13s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m49s
2026-10-21 11:21:56 +07:00
CRT
4fecf2e0b1 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
Some checks failed
ci / gate (push) Successful in 2s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-21 11:19:50 +07:00
CRT
06433f3d3c storebound 0.8.22: version in package.json and every copy, CHANGELOG 0.8.22 section
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m43s
ci / linux (push) Successful in 3m25s
ci / windows (push) Successful in 3m55s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 28s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m15s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m18s
ci / record (push) Successful in 9s
release / promote (push) Successful in 12m47s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 22s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 4m44s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m48s
2026-10-21 05:42:04 +07:00
CRT
2c3b7e25a8 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
Some checks failed
ci / gate (push) Successful in 3s
ci / ci (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-21 05:39:45 +07:00
CRT
4e1076f411 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
afb1393a18 flags regenerated
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m50s
ci / linux (push) Successful in 4m4s
ci / windows (push) Successful in 3m51s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m2s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m2s
ci / record (push) Successful in 7s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 25s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m12s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m52s
2026-10-21 05:36:12 +07:00
CRT
63b1f2c585 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
7b8ceb81e5 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
6eff4b3ea3 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
2f157e1323 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
b627a18990 apple game-center: literal API paths (query guard reads them); api coverage regenerated 2026-10-21 05:29:08 +07:00
CRT
803d48dab8 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
b3a22259cf 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
9bf96c77a0 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
1494a7157f 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
ea52614b5e 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
d05e64612b 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
bc0e4e549d 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
d4c40e3fb7 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
c4f2875b3a 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
73d3d2b30d docs: desktop.md (coverage map, More tools tab), CHANGELOG Unreleased
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m54s
ci / linux (push) Successful in 4m1s
ci / windows (push) Successful in 3m28s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m17s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m20s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m13s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m36s
2026-10-21 05:14:24 +07:00
CRT
c56c45d7d4 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
ba62ecd0c5 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
8a32050821 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
5212c10766 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m45s
ci / linux (push) Successful in 3m40s
ci / windows (push) Successful in 3m48s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m14s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m21s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 4m59s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m19s
2026-10-21 05:03:02 +07:00
CRT
3419cdd93c storebound 0.8.21: version in package.json and every copy, CHANGELOG 0.8.21 section
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m7s
ci / windows (push) Successful in 3m50s
ci / linux (push) Successful in 3m26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 27s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / record (push) Successful in 10s
release / promote (push) Successful in 9m1s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m6s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m47s
2026-10-21 04:03:24 +07:00
CRT
186dacd3a5 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-21 04:01:41 +07:00
CRT
32ad9147e0 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
d2be1b7eaf storebound 0.8.20: version in package.json and every copy, CHANGELOG 0.8.20 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m16s
ci / linux (push) Successful in 4m6s
ci / windows (push) Successful in 3m29s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 25s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 27s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m18s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m19s
ci / record (push) Successful in 10s
release / promote (push) Successful in 14m36s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m3s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 4m46s
2026-10-21 03:16:27 +07:00
CRT
2c6bbb6453 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
Some checks failed
ci / gate (push) Successful in 2s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-21 03:14:53 +07:00
CRT
77d144d393 tests: the AAB fixture targets API 36 (Play's minimum since 2026-08-31; play upload now refuses 35)
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m44s
ci / linux (push) Successful in 3m45s
ci / windows (push) Successful in 4m1s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 29s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m33s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m23s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m47s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m2s
2026-10-21 03:08:43 +07:00
CRT
c77854f258 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
3fbe545e5f storebound 0.8.19: version in package.json and every copy, CHANGELOG 0.8.19 section
Some checks failed
ci / gate (push) Successful in 1s
ci / ci (push) Failing after 1m31s
ci / windows (push) Successful in 3m45s
ci / linux (push) Successful in 3m25s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 25s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 28s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m16s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m18s
ci / record (push) Successful in 10s
release / promote (push) Successful in 8m23s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m10s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 4m46s
2026-10-20 13:44:41 +07:00
CRT
d0af818154 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
Some checks failed
ci / gate (push) Successful in 2s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-20 13:44:22 +07:00
CRT
6cfabbcb9e storebound 0.8.18: version in package.json and every copy, CHANGELOG 0.8.18 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m30s
ci / windows (push) Successful in 3m42s
ci / linux (push) Successful in 3m30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 24s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 28s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m18s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m20s
ci / record (push) Successful in 9s
release / promote (push) Successful in 8m40s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m8s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 4m44s
2026-10-20 13:11:36 +07:00
CRT
086b49aa7a 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)
Some checks failed
ci / gate (push) Successful in 2s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-20 13:11:10 +07:00
CRT
af51acc195 storebound 0.8.17: version in package.json and every copy, CHANGELOG 0.8.17 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m21s
ci / windows (push) Successful in 3m43s
ci / linux (push) Successful in 3m24s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m15s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m17s
ci / record (push) Successful in 9s
release / promote (push) Successful in 14m38s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 23s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 4m40s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 4m51s
2026-10-20 12:44:41 +07:00
CRT
d82be973f4 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m26s
ci / linux (push) Successful in 3m40s
ci / windows (push) Successful in 3m44s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 23s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 2m13s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m18s
ci / record (push) Successful in 10s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m19s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m15s
2026-10-20 12:41:43 +07:00
CRT
3184f5077e storebound 0.8.16: version in package.json and every copy, CHANGELOG 0.8.16 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m28s
ci / windows (push) Successful in 3m39s
ci / linux (push) Successful in 3m31s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m12s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m19s
ci / record (push) Successful in 10s
release / promote (push) Successful in 13m46s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 26s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 4m35s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 4m44s
2026-10-20 12:16:54 +07:00
CRT
9c60336304 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m26s
ci / linux (push) Successful in 3m39s
ci / windows (push) Successful in 3m51s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m17s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m16s
ci / record (push) Successful in 10s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m20s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m5s
2026-10-20 12:15:00 +07:00
CRT
e6983cf41d storebound 0.8.15: version in package.json and every copy, CHANGELOG 0.8.15 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 2m45s
ci / windows (push) Successful in 4m4s
ci / linux (push) Successful in 3m23s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 26s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m16s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m18s
ci / record (push) Successful in 9s
release / promote (push) Successful in 9m43s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m1s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 4m42s
2026-10-20 03:31:36 +07:00
CRT
d6b97a362b 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)
Some checks failed
ci / gate (push) Successful in 4s
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-20 03:29:59 +07:00
CRT
bfe7c4b417 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m14s
ci / linux (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-20 03:29:19 +07:00
CRT
08ffd99518 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m6s
ci / windows (push) Successful in 3m39s
ci / linux (push) Successful in 3m17s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Failing after 26s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Failing after 30s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Failing after 1m13s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Failing after 1m19s
ci / record (push) Successful in 10s
release / promote (push) Successful in 7m52s
ci / acceptance (node20, 20, windows, Windows, node) (push) Failing after 5m5s
ci / acceptance (node22, 22, windows, Windows, node) (push) Failing after 5m3s
2026-10-20 03:16:23 +07:00
CRT
c0ab9f371c storebound 0.8.14: version in package.json and every copy, CHANGELOG 0.8.14 section
Some checks failed
ci / gate (push) Successful in 2s
ci / windows (push) Failing after 24s
release / promote (push) Failing after 35s
ci / ci (push) Failing after 1m17s
ci / linux (push) Failing after 1m12s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / record (push) Has been skipped
2026-10-20 03:09:33 +07:00
CRT
e4297e5731 api tables regenerated (the new creative-assets and platform commands wrap more operations)
Some checks failed
ci / gate (push) Successful in 2s
ci / windows (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-20 03:08:37 +07:00
CRT
787704a653 merge origin/main (cross-store text checker); macOS platform pitfall renumbered to 79
Some checks failed
ci / gate (push) Successful in 1s
ci / windows (push) Failing after 30s
ci / ci (push) Failing after 51s
ci / linux (push) Failing after 53s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / record (push) Has been skipped
2026-10-20 03:06:16 +07:00
CRT
872585c658 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
254e9c1d3f 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
83f870f9a1 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
Some checks failed
ci / gate (push) Successful in 2s
ci / windows (push) Failing after 28s
ci / ci (push) Failing after 59s
ci / linux (push) Failing after 59s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / record (push) Has been skipped
2026-10-20 02:57:25 +07:00
CRT
b33743ed96 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
699f61e4b7 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
2fc126fa4a creative-assets: the no-files message names the shared creative folder too 2026-10-19 05:47:54 +07:00
CRT
a599e5cb90 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
10768bc078 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
338def4fe3 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
664c694a09 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m14s
ci / linux (push) Successful in 3m30s
ci / windows (push) Successful in 3m37s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / record (push) Successful in 10s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m20s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 4m54s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m33s
2026-10-18 13:38:26 +07:00
CRT
2d67c9b1a7 storebound 0.8.13: version in package.json and every copy, CHANGELOG 0.8.13 section
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m11s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m3s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 59s
ci / linux (push) Successful in 3m34s
ci / windows (push) Successful in 3m22s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / record (push) Successful in 9s
release / promote (push) Successful in 16m31s
2026-10-18 13:13:16 +07:00
CRT
066b099e96 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
Some checks failed
ci / gate (push) Successful in 2s
ci / ci (push) Failing after 1m13s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m16s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m20s
ci / linux (push) Successful in 4m9s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 24s
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 13:10:20 +07:00
CRT
043fba6c0a 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
Some checks failed
ci / gate (push) Successful in 4s
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 13:09:06 +07:00
CRT
f9e7af11b4 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
Some checks failed
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 29s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 38s
ci / ci (push) Failing after 1m7s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m14s
ci / linux (push) Successful in 5m14s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m20s
ci / windows (push) Successful in 3m56s
ci / record (push) Successful in 10s
2026-10-18 13:07:15 +07:00
CRT
0bc308d53f 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
All checks were successful
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / ci (push) Successful in 1m51s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m20s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m46s
ci / linux (push) Successful in 3m55s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m52s
ci / windows (push) Successful in 3m21s
ci / record (push) Successful in 8s
release / promote (push) Successful in 14m49s
2026-10-18 02:43:59 +08:00
CRT
27eaf35779 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
Some checks failed
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 25s
ci / ci (push) Successful in 1m59s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m48s
ci / linux (push) Successful in 4m42s
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
release / promote (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 02:32:44 +08:00
CRT
abb4a183f0 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
Some checks failed
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 25s
ci / ci (push) Successful in 1m45s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m19s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m28s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m25s
ci / linux (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
release / promote (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 02:24:34 +08:00
CRT
f30547f0af desktop-release: a DMG notarization upload cut off before Apple had it (abortedUpload) is submitted once more
Some checks failed
ci / gate (push) Successful in 3s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / ci (push) Successful in 2m2s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m29s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m25s
ci / linux (push) Successful in 4m16s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m56s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m38s
ci / windows (push) Has been cancelled
release / promote (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 02:11:54 +08:00
CRT
b8516553e2 tests: report fixtures use example.com subdomains and the zero uuid (the repo scrub flagged corp.invalid emails and a real-looking issuer id)
Some checks failed
ci / gate (push) Successful in 4s
ci / ci (push) Successful in 2m49s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 35s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m12s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 47s
ci / linux (push) Successful in 3m38s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m14s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m42s
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
release / promote (push) Failing after 25m19s
2026-10-18 01:46:36 +08:00
CRT
ac0707888f 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)
Some checks failed
ci / record (push) Blocked by required conditions
ci / gate (push) Successful in 3s
ci / ci (push) Waiting to run
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Waiting to run
ci / acceptance (bun, 22, windows, Windows, bun) (push) Waiting to run
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Waiting to run
ci / acceptance (node20, 20, windows, Windows, node) (push) Waiting to run
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Waiting to run
ci / acceptance (node22, 22, windows, Windows, node) (push) Waiting to run
ci / linux (push) Waiting to run
ci / windows (push) Waiting to run
release / promote (push) Has been cancelled
2026-10-18 01:45:37 +08:00
CRT
c6836ef5fe storebound 0.8.12: version in package.json and every copy, CHANGELOG 0.8.12 section
Some checks failed
ci / gate (push) Successful in 1s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / ci (push) Failing after 1m45s
release / promote (push) Failing after 1m58s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m43s
ci / linux (push) Successful in 4m40s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 6m53s
ci / windows (push) Successful in 3m54s
ci / record (push) Successful in 11s
2026-10-18 01:43:07 +08:00
CRT
615122d22c 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
Some checks failed
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 30s
ci / ci (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 01:42:03 +08:00
CRT
a10f6f70ca agent rules: every check, rule, template line, message and desktop view covers both stores and every surface
Some checks failed
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 28s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 35s
ci / ci (push) Successful in 1m55s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m27s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m47s
ci / linux (push) Successful in 4m48s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 6m37s
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 01:31:17 +08:00
CRT
8fa3da9998 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)
Some checks failed
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 26s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 34s
ci / ci (push) Successful in 1m59s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / linux (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 01:29:36 +08:00
CRT
4032657695 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
Some checks failed
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 28s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m20s
ci / ci (push) Successful in 2m3s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m23s
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-18 01:26:32 +08:00
CRT
83639ce41e 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
All checks were successful
ci / gate (push) Successful in 2s
ci / ci (push) Successful in 1m47s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 36s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m14s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m10s
ci / linux (push) Successful in 3m59s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 4m24s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m34s
ci / windows (push) Successful in 3m22s
ci / record (push) Successful in 9s
2026-10-17 15:59:44 +08:00
CRT
c047ef7a6f storebound 0.8.11: version in package.json and every copy, CHANGELOG 0.8.11 section
All checks were successful
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 24s
ci / ci (push) Successful in 3m30s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m34s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m11s
ci / linux (push) Successful in 4m15s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 5m9s
ci / windows (push) Successful in 3m20s
ci / record (push) Successful in 9s
release / promote (push) Successful in 14m48s
2026-10-17 14:29:27 +08:00
CRT
241bf6a735 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
All checks were successful
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 30s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 33s
ci / ci (push) Successful in 1m32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m24s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m21s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m32s
ci / linux (push) Successful in 4m10s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m33s
ci / windows (push) Successful in 3m21s
ci / record (push) Successful in 9s
2026-10-17 12:50:06 +08:00
CRT
1b4ccb6527 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)
All checks were successful
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 27s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / ci (push) Successful in 1m31s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m26s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m22s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m25s
ci / linux (push) Successful in 4m4s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m26s
ci / windows (push) Successful in 3m26s
ci / record (push) Successful in 8s
2026-10-16 07:48:55 +08:00
CRT
2b5244a3ff 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
All checks were successful
ci / gate (push) Successful in 2s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Successful in 29s
ci / ci (push) Successful in 1m45s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Successful in 32s
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Successful in 1m17s
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Successful in 1m16s
ci / acceptance (node20, 20, windows, Windows, node) (push) Successful in 5m17s
ci / linux (push) Successful in 4m15s
ci / acceptance (node22, 22, windows, Windows, node) (push) Successful in 4m51s
ci / windows (push) Successful in 3m19s
ci / record (push) Successful in 8s
release / promote (push) Successful in 14m17s
2026-10-16 07:17:19 +08:00
CRT
7d4a2e4986 storebound 0.8.10: version in package.json and every copy, CHANGELOG 0.8.10 section
Some checks failed
ci / gate (push) Failing after 40m5s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / ci (push) Has been skipped
release / promote (push) Failing after 40m4s
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / windows (push) Has been skipped
ci / linux (push) Has been skipped
ci / record (push) Has been skipped
2026-10-16 06:35:47 +08:00
CRT
6ed8f3d544 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')
Some checks failed
ci / gate (push) Successful in 4s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / ci (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-16 06:34:51 +08:00
CRT
cfd957711d CHANGELOG: Unreleased for 0.8.10
Some checks failed
ci / gate (push) Successful in 4s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / ci (push) Has been skipped
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been skipped
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been skipped
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been skipped
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been skipped
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-16 06:33:40 +08:00
CRT
42835c94f1 neutral names in docs, tests and comments (no real app or project names)
Some checks failed
ci / gate (push) Successful in 4s
ci / acceptance (bun, 22, windows, Windows, bun) (push) Has been skipped
ci / ci (push) Has been cancelled
ci / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Has been cancelled
ci / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node20, 20, windows, Windows, node) (push) Has been cancelled
ci / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Has been cancelled
ci / acceptance (node22, 22, windows, Windows, node) (push) Has been cancelled
ci / linux (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / record (push) Has been cancelled
2026-10-16 06:14:02 +08:00
CRT
65cb5de5eb 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'
Some checks are pending
release / promote (push) Waiting to run
2026-10-16 05:54:14 +08:00
CRT
748da4fb49 play: changesNotSentForReview goes only on edits.commit (validate answers 400 "Unknown name changesNotSentForReview: Cannot bind query parameter", found by a game on 0649abe'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
6ed862ac3c 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
0649abe5c4 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
5971075f59 storebound 0.8.8: version in package.json and every copy, CHANGELOG 0.8.8 section
Some checks are pending
release / promote (push) Waiting to run
2026-10-16 00:09:06 +08:00
CRT
69c0dd7e43 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
61c0c0e04a 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
7e2206e48f 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
5aae98365d 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
4f675b0612 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
Some checks are pending
release / promote (push) Waiting to run
2026-10-15 19:58:47 +08:00
CRT
145901ffb3 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
d029dadda1 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
992e09bb8d 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 8d3eced died of SIGTERM in the middle of these tests 2026-10-15 19:45:00 +08:00
CRT
8d3eced2a5 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
f6aac93762 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
66283b9e8b 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 34e37dc)
Some checks are pending
release / promote (push) Waiting to run
2026-10-15 06:36:24 +08:00
CRT
34e37dc4e0 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
e3b7decf7a 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 0c35750); build-node names the missing install instead of a bare ENOENT 2026-10-15 06:33:01 +08:00
CRT
0c3575043c 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
df8793b02c 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
3e4208e656 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
325554f813 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
f917f7e40d 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
8ee2819a7f 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
1cde3e6663 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)
Some checks are pending
release / promote (push) Waiting to run
2026-10-15 02:31:15 +08:00
CRT
63d901fa02 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
621ac3ed73 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
d95ee4d988 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
b74160a73d 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
68f0ec1a9b docs/pitfalls.md: rows 64-67 (the skill had them) 2026-10-15 01:21:13 +08:00
CRT
f6d995d3b3 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
683996e1e7 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
fa0fda46f9 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
38a621970f 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
023aaddc05 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
Some checks are pending
release / prepare (push) Waiting to run
release / ci (push) Blocked by required conditions
release / linux (push) Blocked by required conditions
release / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Blocked by required conditions
release / acceptance (bun, 22, windows, Windows, bun) (push) Blocked by required conditions
release / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
release / acceptance (node20, 20, windows, Windows, node) (push) Blocked by required conditions
release / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
release / acceptance (node22, 22, windows, Windows, node) (push) Blocked by required conditions
release / acceptance-report (push) Blocked by required conditions
release / windows (push) Blocked by required conditions
release / release (push) Blocked by required conditions
2026-10-14 23:56:57 +08:00
CRT
39e1d879ec 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
457ac9175f 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
bf131834bc 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
93cad15023 --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
5a521737ca 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
9ffb60fe8b 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
e0fb59324b 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
b6c9523037 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
608f9cea2f 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
1cceb0fc38 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
d8c3cc88c2 acceptance: macOS results of 0.8.3 from 94f61e4 (123/123 on Node 20, Node 22, Bun), docs/acceptance.md regenerated
Some checks are pending
release / prepare (push) Waiting to run
release / ci (push) Blocked by required conditions
release / linux (push) Blocked by required conditions
release / acceptance (bun, 22, ubuntu-22.04, Linux, bun) (push) Blocked by required conditions
release / acceptance (bun, 22, windows, Windows, bun) (push) Blocked by required conditions
release / acceptance (node20, 20, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
release / acceptance (node20, 20, windows, Windows, node) (push) Blocked by required conditions
release / acceptance (node22, 22, ubuntu-22.04, Linux, node) (push) Blocked by required conditions
release / acceptance (node22, 22, windows, Windows, node) (push) Blocked by required conditions
release / acceptance-report (push) Blocked by required conditions
release / windows (push) Blocked by required conditions
release / release (push) Blocked by required conditions
2026-10-14 08:51:14 +08:00
CRT
94f61e471c acceptance report expects all 9 runs (Linux, Windows, macOS x node20 / node22 / bun) 2026-10-14 08:45:32 +08:00
CRT
1b160a9bef 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
5f08e3ad7c 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
3aeba5aa33 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
42b68a5bf1 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
ccc2cb1f70 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
91aec822ba 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
1a25555552 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
7730385b0e 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
CRT
ca9d933257 storebound 0.8.2: version in package.json and every copy, CHANGELOG 0.8.2 section with the release summary; site teaser cache key v=7
Some checks are pending
release / prepare (push) Waiting to run
release / ci (push) Blocked by required conditions
release / linux (push) Blocked by required conditions
release / windows (push) Blocked by required conditions
release / release (push) Blocked by required conditions
2026-10-14 06:32:32 +08:00
CRT
94174870fa teaser v9 final with the new reject shot: 1920x1080 master, web version and posters from the end card; site teaser and poster (cache key v=6), README poster 2026-10-14 06:10:39 +08:00
CRT
f0cd8dc670 tests copy the demo app as git has it: the example's gitignored .storebound/, renders, frames and video of a local checkout stay out of the temp copy (edit.test.ts failed on a local .storebound/, the boot check's store art case opened a local ios69 render instead of its pulled Play shot) 2026-10-14 06:10:39 +08:00
CRT
f1eab75348 tests/teaser: the stamp check back in the safe-margin test (typecheck) 2026-10-14 05:49:15 +08:00
CRT
a52225d871 teaser: no text overflows anywhere: teaser.textCheck(t) checks every visible text node (scroll size within its element, its box inside its card, every card in frame as drawn), render.ts --animatic runs it at every frame of the whole teaser into animatic-checks.json and tests/teaser.test.ts requires zero problems; reject titles shortened ('No demo account', 'Manifest refused'); fixes it found: terminal and pile rows wrap instead of being cut with an ellipsis, the scan findings wrap, the pile shows only the rows that fit whole (no streaming past the edge), caption, edge word, labels and end card line heights fit their glyphs, builds and products push straight in so the terminal stays in frame 2026-10-14 05:47:42 +08:00
CRT
b593215b1d teaser: the reject shot redone (7-11 s, 4 s): the common rejections storebound catches as real scan finding cards landing one per beat (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT from tests/fixtures/scan/teaser; ITMS-91064 from the new teaser-manifest fixture), guideline + one plain line each, then our own REJECTED stamp hits the stack on the beat at 9.5 s, no shake (one 4 px 1-frame jolt), a 3% push, the picture darkens into the cut; caption 'Rejected. Again.'; later shots +1.5 s (42.5 s, every cut on a beat, music follows the scenes, a thud per card and a hit on the stamp); render.ts --animatic --range a,b and --sheet a,b,fps, and the reject cards' boxes per frame in animatic-checks.json; tests: real finding lines, beat timing, and every card inside 6% safe margins at every frame 2026-10-14 05:47:42 +08:00
CRT
16eeab0e74 desktop boot check: the Store art tab's live thumbnails are read on demand (art live), so its 'Live store art not read yet' is not a missed background read 2026-10-14 05:35:59 +08:00
CRT
381ce04afe teaser: render.ts --scale 2, the 3840x2160 master for 4K uploads (same page at device scale 2, desktop frames captured at @3x into out/desktop@3x, demo game renders at 2x into out/assets@2x, H.264 high about 40 Mbps, AAC 320 kbps, +faststart; no web version); desktop.ts captureDesktop takes a scale and an output folder 2026-10-14 05:31:31 +08:00
CRT
a9fa7786e6 onboard subscription-level test runs without the art step (--no-art, as the other onboard tests since art pull) 2026-10-14 05:31:02 +08:00
CRT
627d523a2f onboard never writes duplicate subscription levels: the App Store groupLevel when it is set and unique in the group, else a stable order (App Store level, higher price per month = higher level, longer period, id) with a note per derived level; assignLevels in cli/lib/onboard.ts, tests with a live-shape App Store group (groupLevel 2 and 1) and Play-only / tied cases 2026-10-14 05:30:15 +08:00
CRT
80b57b9795 pitfalls dry-run guard: cli/lib/git-ignore.ts's dry branch returns the planned .gitignore line (the plan, printed by its callers), allowed with the reason 2026-10-14 05:26:14 +08:00
CRT
388849116d pulled store art never goes into the app repo: art pull --write, init and onboard add the renders folder (relative to the repo root, e.g. /store/renders/) to the repo's .gitignore when git check-ignore says it is not ignored yet, with one line saying so; tests 2026-10-14 05:25:37 +08:00
CRT
866c8c67a2 tests: art_pull in the local-write tools, art_live / art_thumb in the store-only reads, onboard tests without the art step 2026-10-14 05:25:37 +08:00
CRT
b5758b3ebb store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59 2026-10-14 05:25:37 +08:00
CRT
cb5b02c21a bun run ci defers the browser suites and the desktop boot check to Forgejo CI when the 1-minute load is above 2x the cores (deferBrowser in tools/ci-plan.ts, said at the start and in the summary); CI=full forces them, Forgejo runs CI=full 2026-10-14 05:24:38 +08:00
CRT
0882522d15 CI green on a loaded shared machine: browser suites (now also glyph-ligatures and feature-fit, through the shared launch helper) in one dedicated shard started first at concurrency 2; timeouts scaled by load ÷ cores (tests/helpers/load.ts, max(1-, 5-min average), capped at 5x, logged) for browser tests, waits, launch/close, the other shards' default and the desktop boot check; one retry for a browser test that timed out while the factor was above 2, listed as 'retried under load' in the CI summary, never for an assertion failure; the six-type App Privacy walk no longer dumps every step (the --debug-dump check walks two types), wait timeouts print per-line times; play-edit-lock checks reads in flight instead of wall time; tests/load-retry.test.ts 2026-10-14 05:21:15 +08:00
CRT
675aee58fe onboard reads review access from the live App Store version (READY_FOR_SALE / READY_FOR_DISTRIBUTION, newest live first; the newest version only when nothing is live): a newer draft's empty review detail no longer hides the live demo account or turns signInRequired off; reviewVersion in cli/lib/onboard.ts, tests with a live version holding a demo account and a newer draft without 2026-10-14 02:08:39 +08:00
CRT
9836c10d15 teaser v9 final: music reworked for the tight cut (drives from frame 0, an accent on every cut, tension in the pile and the rejection, a short drop into the request, layers building through the montage, the stop on Submit, the resolve on Waiting for review, a confident end under the card fading over the last 1 s; -16 LUFS, true peak <= -1.5 dBTP); 1920x1080 master, web version and posters from the end card; site teaser and poster (cache key v=5), README poster 2026-10-14 01:05:24 +08:00
CRT
7f922168ab teaser v9 animatic: 41 s story cut tight (hard cuts on the beat, 2-4 s shots, frame 0 moving, every shot opens on its subject, captions <= 6 words held >= 2 s, end card with npx storebound init held 4 s); render.ts --animatic (960x540 draft with sound, contact sheet, shot list, end-card layout checks); tests for the v9 timing rules 2026-10-14 01:05:24 +08:00
CRT
feea9166f4 store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest 2026-10-14 00:54:56 +08:00
CRT
40f899cb16 desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case 2026-10-14 00:37:08 +08:00
CRT
186affd84f Play edits under a machine-wide per-package lock (parallel reads of one app no longer delete each other's edits; a read whose edit was deleted is redone once, writes are not); a store that failed to read is 'Couldn't read Google Play' and keeps its last good releases with 'couldn't refresh'; stores on different versions: neutral 'Current · Play x · App Store y' header and each lane its own state, 'Not on this store' only without a record; Play status decided from the public store page's live version (Live / In review / In review (live: x) / managed publishing / Rolling out / Halted / Draft; only a network failure says 'Couldn't check the store page', with the last known answer and its time); tests/play-edit-lock.test.ts, recorded page fixture, boot check auto-read cases (pitfall 58) 2026-10-14 00:26:17 +08:00
CRT
b051313495 docs/console-parity.md: audit of every App Store Connect and Play Console field storebound reads or writes against the desktop (view, edit in place, store write), with the gaps 2026-10-14 00:09:54 +08:00
CRT
ad408a305e auto-read after the rebase: results through usable / failureText, issuesError notice kept, scheduler 'accounts' (no credentials word in the webview), fixtures and generated tables for health_issue 2026-10-13 23:41:36 +08:00
CRT
961eeb5c17 desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case 2026-10-13 23:37:39 +08:00
CRT
4877d950fb Google API errors as typed findings, never raw JSON: cli/lib/google-errors.ts parses SERVICE_DISABLED (api-disabled: service, Cloud project, console page), PERMISSION_DENIED (no-app-access: which app, Users and permissions), 401 / invalid_grant (auth-revoked) and 429 (quota); health / analytics print one line with the URL, raw text in detail and the log; desktop Health tab: the line, 'Turn it on in Google Cloud' and 'Check again', raw text in a collapsed Details; Portfolio shows a Cloud project blocking several apps once. Findings exit codes are results: the sidecar reports outcome (ok / findings / declined / failed) and the command's own error line; scan / check / validate / health exit 1 with findings show and cache the findings; no bare 'Exit code N' anywhere. tests/google-errors.test.ts, boot check errors cases (api-disabled, scan-findings, scan-failed) (pitfall 57) 2026-10-13 23:20:04 +08:00
CRT
7e609eb5b6 tests: serve portfolio.status play fixture carries the public store page (200) 2026-10-13 20:48:01 +08:00
CRT
f309a5e57b tests: unique package names for the public page cache test (the temp-folder cache outlives a run) 2026-10-13 20:47:22 +08:00
CRT
5df8f1807a Play 'Live' only when the public store page proves it: play status checks https://play.google.com/store/apps/details?id=<pkg> (no auth, 4 s timeout, cached 5 min; publicPage in the JSON), a completed production release with a 404 page is 'Sent to Google (in review or not yet published)', 200 is live for the first release or when the page shows the versionName, otherwise 'Released to production (Google may still be reviewing)', offline is unknown; new phases sent / released in the desktop Releases tab, Portfolio, release status | plan; versions matched across the stores without trailing .0 parts (App Store 1.0 and Play 1.0.0 are one row and one train, each side shows its own string); tests/play-public-version-match.test.ts, desktop boot check release-state cases (page 404 and 200) (pitfall 56) 2026-10-13 20:46:48 +08:00
CRT
086e1c7935 site: the highlighted download card keeps its own text colour for the details line (grey on the accent fill was unreadable); style.css cache key 2026-10-13 10:12:12 +08:00
CRT
32f93fa5a0 storebound 0.8.1: version in package.json and every copy, CHANGELOG 0.8.1 section with the release summary
Some checks failed
release / prepare (push) Has been cancelled
release / ci (push) Has been cancelled
release / linux (push) Has been cancelled
release / windows (push) Has been cancelled
release / release (push) Has been cancelled
2026-10-13 09:59:18 +08:00
CRT
428c0268af Feature graphic fits the phone: shots.feature.fit 'contain' (default) sizes the device so its rotated box stays inside 1024x500 with a 24 px margin in both templates, 'bleed' keeps the crop, scale 0.5-1.5, config validated; shots render records measured device boxes (report.json devices) and shots check fails a feature graphic whose device leaves the canvas (warns for a screenshot's front device off a side or the top); tests/feature-fit.test.ts (rotated-box geometry, both templates rendered at 1024x500, bleeding fixture) (pitfall 55) 2026-10-13 09:50:15 +08:00
CRT
2204bc735a site: main.js cache key 20261013b so Cloudflare serves the per-OS signing notice 2026-10-13 09:48:04 +08:00
CRT
ad9474150d apple build exports each build into build/ios/export/<version>-<build>/ (cleaned first), runs xcodebuild with Apple's tool folders first on PATH (Homebrew rsync 3.x broke Xcode's IPA step: exportArchive Copy failed), shows the IDEDistribution log's real error lines and exits non-zero on any export failure; apple upload fails with exit 1 when the IPA's build number is already on App Store Connect (stale IPA after a failed build), --allow-existing re-checks, --build N picks that build's IPA, release and beta flows use both; doctor flags a non-Apple rsync first on PATH; desktop publish calls rsync by absolute path without --chmod (publishSteps); site download notice per OS (macOS signed and notarized, Windows unsigned for now, Linux unsigned); game-report credential tests hermetic against a machine default profile (pitfalls 53, 54) 2026-10-13 09:45:48 +08:00
CRT
e5d0f46a0c storebound 0.8.0: one version for the CLI and the desktop app (package.json, plugin manifests, the app's copies), CHANGELOG 0.8.0 section with the release summary, teaser v8 on the site and README poster (img/teaser.mp4, teaser-poster.webp, cache key v=4)
Some checks failed
release / prepare (push) Successful in 4s
release / ci (push) Has been cancelled
release / linux (push) Has been cancelled
release / windows (push) Has been cancelled
release / release (push) Has been cancelled
2026-10-13 08:17:16 +08:00
CRT
337c32fd73 Releases on git.crt.fyi from a workflow: a v* tag push runs .forgejo/workflows/release.yml (tag = package.json version and every version copy, the full CI, Linux AppImage + deb and Windows NSIS on the VDS runners, privacy scan with the private terms, one Forgejo release with short CHANGELOG-summary notes, the installers and SHA256SUMS, older release entries deleted, tags kept); workflow_dispatch dry run makes a draft test-<sha> and deletes it; desktop.yml folded in, ci.yml and release.yml share .forgejo/actions/linux-setup and ci-run; tools/forgejo-release.ts (notes, upsert, prune, delete; uploads over 100 MB go to the origin because Cloudflare refuses them); desktop-release.ts upload-forgejo adds the notarized DMG and updater bundle from the Mac; docs/desktop-release.md release order 2026-10-13 07:44:18 +08:00
CRT
94745c9f39 Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52) 2026-10-13 07:25:01 +08:00
CRT
3c9517f7fd One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00
CRT
05144a460a apple testflight distribute: a build's groups read per group (GET /v1/betaGroups/{id}/relationships/builds) instead of GET /v1/betaGroups?filter[builds]=, which App Store Connect refuses with 400 although the spec declares it; internal all-builds groups reported as already available and never sent the build; a re-run adds nothing; configured groups missing on App Store Connect point at groups sync, unknown ones list what exists; APPLE_LIVE_REJECTED (refused by storebound api too), tests/asc-query-guard.test.ts checks every App Store Connect GET with a query in cli/ against the spec's declared parameters, and the fake App Store Connect answers 400 to undeclared or live-refused parameters (pitfall 42) 2026-10-13 06:03:16 +08:00
CRT
dc2289fab4 console: Play App content card Saves are the agent's (declaration-save; Send changes for review, Submit, Publish, Restart review stay forbidden), one owner batch per plan with the IARC Terms tick before the run, console plan play-app-content --all, New App fields typed not set, play.appId from the Create app redirect (console verify --url --write) and storebound play app-id across app list pages 2026-10-13 05:44:29 +08:00
CRT
53d27ac999 scan, privacy answers and credentials from a second game's first release (lessons 35-37): review-prompt needs rating context, not a bare "rate"; account signals skip import lines and module paths, config-only accounts say "from config"; accounts.inApp without deletion code stays a medium 5.1.1(v) finding; privacy-manifest --propose builds NSPrivacyCollectedDataTypes from appPrivacy.types and check warns on an empty list; appPrivacy.types is authoritative in the App Privacy sheet (unlisted = not collected), ad / support / AR types default to not collected without tracking or ad SDKs; Play advertising ID from SDKs, the AD_ID permission, app-ads.txt or the flag, never from a Device ID alone; scan.ignore[].evidence and warnings for entries that match nothing; empty credentials template, placeholder values count as missing (check, doctor) 2026-10-13 05:39:09 +08:00
CRT
3e51b50d7a desktop-release add: evidence-linux / evidence-windows files (run, commit, sizes, hashes, scan) and a check that every artifact of a version comes from one commit (--commit against the folder's other evidence); docs: check the downloads against the run before add 2026-10-13 05:09:17 +08:00
CRT
a101c01cb8 teaser v8: a 58 s story instead of a feature list (the game is done; the stores pile up; one missing line; hand it to your AI; a montage of builds, screenshots, products, forms, consoles; the scan turns green; one button is yours; Waiting for review; the end card), one short line per scene, cuts on the 120 BPM beat; music rewritten for the arc (calm, tension, near silence, a driving build, a stop, a resolve), mastered with one gain instead of dynamic loudnorm; the pile shows real console plan, Data safety CSV and price sheet lines, the builds beat real apple / play build dry runs; desktop.ts captures submit-ready (Send for review before the press); tests check every line, the beat-aligned cuts and the caption times; two unused teaser icons dropped 2026-10-13 04:48:33 +08:00
CRT
02ed1247ca 0.7.1: README links and images absolute (npmjs.com rendered relative paths as 404s); test keeps README free of relative links 2026-10-13 04:37:25 +08:00
CRT
9f04ee0f00 0.7.0: version (package, plugin and extension manifests), changelog section with summary; check no longer counts owner-only App content steps (IARC Terms, demo account typing) as missing config answers; the check shot shows a ready project (init plus icons, apple.name, App content answers, demo account): all good 2026-10-13 04:19:39 +08:00
CRT
03865d24f3 CLI runs on Node 20+: npx storebound (bunx keeps working)
- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and
  "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot,
  config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it
- tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded
  (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci
- package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20
- bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help,
  init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/
- install writes npx MCP commands when run with Node; bin/storebound falls back to Node
- docs: npx storebound init (Node 20+) or bunx storebound init
2026-10-13 04:03:28 +08:00
CRT
7a2a11454a teaser v7: 62 s, two new scenes (consoles: the real console plan of apple-app-privacy, then the app's plan panel stopping at Publish; resubmit: back from App Review, Resubmit), submit renewed as the app's own send-for-review (dry-run plan with Continue, then Waiting for review); v6's desktop scene folded into it; console plan lines checked against real output, cuts on the beat, image decodes batched with retries 2026-10-13 03:49:42 +08:00
CRT
8765f6e79f README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
README is a short front page (about 120 lines) with one real visual per section; every former section moved
unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth,
studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one
page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight
screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools:
tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and
WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
2026-10-13 03:29:59 +08:00
CRT
a642104ca8 ci workflow: CI=full on the command itself (the runner's CI=true overrode the workflow env, so the desktop Rust tests and clippy were skipped on pushes that did not touch src-tauri) 2026-10-13 03:02:17 +08:00
CRT
6d7ebed604 ci workflow: Playwright browser cache keyed by playwright-core's browsers.json (the repo has no bun.lock, the key was empty) 2026-10-13 02:57:18 +08:00
CRT
308f2ea990 shots / console: resolve playwright only from an installed node_modules copy; in a project without playwright Bun auto-installed the newest one into its global cache, whose browsers were never downloaded (capture-web on Linux CI: 'headless shell: not installed'; macOS hid it with Google Chrome) 2026-10-13 02:55:12 +08:00
CRT
cb4d82b037 ci on Linux: --no-sandbox for the Chromium storebound starts itself as root on Linux or in a container (sandboxArgs; Chromium refuses root without it), never on macOS / Windows / a normal user; workflow caches Playwright's browsers; capture-web prints the capture's stderr when a run fails 2026-10-13 02:51:48 +08:00
CRT
7f92fe874a desktop-release: a DMG notarization without an answer (notarytool killed or timed out) says so instead of an empty reason 2026-10-13 02:32:23 +08:00
CRT
a9b54d7d9d desktop-release: architecture guard before signing (tauri build --no-sign, guard, then tauri bundle signs / notarizes): every Mach-O in the app must contain arm64, nothing Intel-only, Info.plist must not force Intel (LSRequiresNativeExecution false, LSArchitecturePriority x86_64 first); guard repeated on the signed app, the DMG and the updater bundle, recorded in evidence.json 2026-10-13 02:32:09 +08:00
CRT
3b1852bb5b ci speed: desktop boot check runs its cases in parallel (one browser, killed at the end; a page per case; condition waits instead of fixed sleeps: element, text or a quiet window of host calls and DOM); shots capture / render close the browser with a 3 s cap, then kill it; console engine waits injectable for tests (WAITS / engineWaits: poll rounds, a missing step ends once its loaded page stays quiet, team switch settle, field-miss grace), fillPage looks up each data type's dialog once per round and skips dialog sections when no dialog is open; browser tests run 4 at a time per file (testScope().concurrent with a scope per test, --max-concurrency in ci), 20 ms rounds; capture-web counts boots per test and runs its browser tests concurrently; console-session / -persist Chrome tests concurrent; recorded test timings smoothed (mergeTimings) so one loaded run does not skew the shard split 2026-10-13 02:22:25 +08:00
CRT
8e76c3403c docs desktop-release: the Linux job starts the AppImage and the deb under Xvfb 2026-10-13 02:09:58 +08:00
CRT
c616b70c73 desktop bundle: long description (the deb's description said '(none)') 2026-10-13 02:09:58 +08:00
CRT
3275a3cba1 desktop: the notification plugin's refused is_permission_granted probe at page load (macOS, Linux; the webview has no plugin API) is no longer logged as a start error in webview.log 2026-10-13 02:09:58 +08:00
CRT
6dbedb6d6e desktop (Linux): a missing update-desktop-database or xdg-mime no longer stops the app at start (the setup hook failed with 'No such file or directory'); storebound:// registration failure is a host.log warning 2026-10-13 02:09:58 +08:00
CRT
ac44ca7d34 desktop workflow: start the AppImage and the deb under Xvfb; the UI must reach the sidecar (host.log), no error lines 2026-10-13 02:09:58 +08:00
CRT
dc288b86eb desktop-release: never run the x86_64 slice on the build Mac (Rosetta makes macOS warn the owner that the app will not open in the next macOS); every Mach-O in the bundle checked with lipo (arm64 + x86_64), self-test and sidecar version run natively only; pitfall 34 2026-10-13 02:01:35 +08:00
CRT
7ed16d619f console verify App Privacy: real page text (category-only summary sections, ' | ' joined lines, Apple's commas); tracking label without 'data data'; Play Data safety page path confirmed 2026-10-13 01:38:50 +08:00
CRT
f60919b632 desktop: Releases > Send for review (App Store submission state, builds by number, Submit / Resubmit / in-app purchases; Play versionCodes by number, Send to production, managed publishing, console plans for countries and Send changes for review), plan panel before every owner dialog with streamed progress; App info console steps for every console plan form with verify; Catalog price-change owner actions and the declined state; Data safety deletion answer; TestFlight create missing groups; Android SDK in Dependencies; upload progress in job toasts; boot check send-for-review cases on D1 and D3 state 2026-10-12 12:08:43 +08:00
CRT
13295d10d8 apple submit --build <n> (first submission, the resubmit steps; plan table with --json under serve); desktop owner actions to send for review: owner.apple_submit, owner.apple_resubmit, owner.apple_iap_submit, owner.play_production, and the price gate owner actions (owner.prices_play_products / _play_subscriptions / _apple_subscriptions; owner.prices_import passes --allow-price-changes after the dialog); OWNER_ALLOWED_ARGS ties each forbidden flag to its one action, dry runs never carry owner flags, MCP still has no submit or production release; submit dialog text in the host; system.tools androidSdk, project.inspect playRelease, declarations dataDeletion 2026-10-12 12:08:07 +08:00
CRT
6a9a1df322 play build versionCode source check: follow a variable (def / ext. / val) to its definition; a variable read from the property is not flagged 2026-10-12 11:34:41 +08:00
CRT
9e922f5dd5 apple iap-submit: DEVELOPER_REJECTED IAP / subscription / group versions (left by a canceled submission) are submitted as they are, plan says 'add (developer rejected, re-submitted as is)', --new-version only for APPROVED / REJECTED, empty draft reused; fake ASC models cancel and re-add. play build / check: warn before Gradle when defaultConfig does not read -PversionCode / -PversionName, with the exact Groovy / Kotlin line (built with AGP 8.13); android.injected.version.code does not override bundleRelease; post-build versionCode mismatch is a hard stop. play-production-release plan: 'Do you want to restart your review?' dialog, Restart review is never-press. Pitfalls 31-33 2026-10-12 11:32:05 +08:00
CRT
19f22f6335 apple testflight groups: bare lists the groups and what the config would create (read-only); groups sync (or --yes) creates; flows: bare = read, sync = write; MCP tool runs groups sync 2026-10-12 10:48:59 +08:00
CRT
e060a1fcfc apple resubmit: REJECTED review submission items are marked resolved before the submit (PATCH reviewSubmissionItems resolved: true; the version item by default, IAP / subscription items with --resolve-iaps), plan table 'mark resolved' rows; on 409 'not ready yet' the items are re-read and a REJECTED item out of scope stops the run at once naming it and how to resolve it, backoff only when none is left; an empty READY_FOR_REVIEW draft is never canceled (409 'not in cancellable state'): left as is, no cancel command, apple status labels it harmless and treats the cancel 409 as such; fake ASC blocks submit on REJECTED items and refuses to cancel empty drafts. Pitfall 30 2026-10-12 10:45:05 +08:00
CRT
99f70dab20 apple resubmit after a rejection: iap-submit reuses the open review submission (UNRESOLVED_ISSUES gets only missing items and is resubmitted, a draft is reused, one waiting for or in review means nothing is sent), never a second draft; 409 ITEM_PART_OF_ANOTHER_SUBMISSION names the other submission instead of the list-lag message, a draft the run created is canceled and the named one used; submit retried with backoff and heartbeat while Apple says not ready yet; new apple resubmit --build <n> (build by number, plan table, --yes); apple status lists UNRESOLVED_ISSUES items and the resubmit command. Pitfall 30 2026-10-12 10:34:12 +08:00
CRT
0f534749bf ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in every privacy manifest (true needs domains, domains need true); check, scan and privacy-manifest fail on either mismatch with the exact ITMS text; App Privacy tracking no longer demands the app manifest's true: SDK manifests that declare tracking (Pods, frameworks, SwiftPM, Carthage, plugins, the built IPA) are info, known tracking SDKs info, else a warning; privacy-manifest --propose writes true only with appPrivacy.trackingDomains; apple upload checks every manifest in the IPA (XML or binary) before any network call, apple build after the export. Pitfall 29 2026-10-12 10:27:21 +08:00
CRT
e52f839880 Play console steps without an API as agent plans: play-store-settings (App category; play listing cannot set it), play-app-content (every card in order, one owner batch) and its cards (privacy policy, ads, sign in details, advertising ID, government, financial, health), play-production-countries and play-production-release; IARC plan with the Terms of Use agreement, follow-ups (console.contentRating.details, products[].randomRewards, social) and the Summary's final Save; config play.category / countries / managedPublishing, console.appContent; check, validate, play status and play release reminders; inPageLoop helper and material checkbox clicks; App Privacy published signal; first IAPs via apple iap-submit --with-version. Pitfalls 27, 28 2026-10-12 10:16:34 +08:00
CRT
e24287e390 play data-safety: PSL_SUPPORT_DATA_DELETION_BY_USER is the general 'can users request deletion?' question; a delete-account URL answers yes (with PSL_DATA_DELETION_URL), dataSafety.dataDeletion for apps without one; check flags a no next to a delete-account URL 2026-10-12 09:46:58 +08:00
CRT
16e30d6b06 price changes of existing products need --allow-price-changes (play products, play/apple subscriptions, prices import): --yes and flow run --apply --yes confirm creates, first prices and other updates only; without the flag the run writes the rest, names each held change and exits 3 (declined), dry runs mark them owner, flows refuse the flag; lib/price-gate.ts, tests/price-gate.test.ts. CLI wording: console fill leaves what it cannot open or fill to the calling agent's browser tool (console plan / verify) instead of 'yourself'; other 'by hand' output points at the config; do-it-dont-ask test scans cli/ output 2026-10-12 09:38:57 +08:00
CRT
e5abf3db18 play data-safety: PSL_SUPPORT_DATA_DELETION_BY_USER as Play's single choice (partialDeletion true | false | 'auto90'), drop the removed PSL_DATA_COLLECTION_USER_REQUEST_DELETE (deletionRequest deprecated, check notes it); ship Google's empty form (cli/data/play-data-safety-form.csv, exported 2026-10-12), take every Answer requirement from it and report pairs not in it (formPairIssues) in check, declarations and play data-safety. Pitfall 21 2026-10-12 09:34:12 +08:00
CRT
8597f94a77 agent rules: first rule "Do it, don't ask" (agents-core and every adapter, README, store-pitfalls lead, first-release, declarations and console skills); the agent runs the real command after the dry run, the owner presses only final public or irreversible buttons and answers compliance facts in one batch; handoff text removed from skills; tests/do-it-dont-ask.test.ts. Pitfall 26 2026-10-12 09:28:45 +08:00
CRT
12eed58f41 play build: find the Android SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir, default folder), pass it in the env, stop before Gradle when missing; doctor shows it. apple upload: size, per-part lines, 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, --timeout exits 7 with the resume hint. Pitfall 25 2026-10-12 08:59:45 +08:00
CRT
3de295c294 flow run: a declined confirmation stops the flow; --apply --yes confirms write steps
confirm() prints 'declined: <reason>' and exits 3 whenever the answer is no or nobody can answer (not a
terminal without --yes, serve without the owner's dialog), so no command reports success after declining.
flow run stops at such a step with 'step N declined: <reason>' (status declined), never ok. flow run
--apply --yes passes --yes to write steps only; --yes without --apply is refused. Pitfall 24.
2026-10-12 08:51:58 +08:00
CRT
af827be884 build.prebuild and a Capacitor web-asset staleness guard for apple build / play build
apple build and play build run build.prebuild (or apple.prebuild / play.prebuild) first, once per flow run;
--no-prebuild skips it, --dry only prints it. A Capacitor project's copied public/index.html must then be newer
than the last source commit and <webDir>/index.html, else the build stops (--allow-stale-web overrides). check
warns when stale without a prebuild; the build output and dry plan show the web bundle's sha256 and mtime.
The Capacitor project is also found in a monorepo (apps/native etc.); several ask for play.build.dir /
apple.build.*. Pitfall 23.
2026-10-12 08:51:38 +08:00
CRT
de511500d9 pitfall 22: AAB proto manifest field numbers 2026-10-12 08:46:03 +08:00
CRT
4c85495501 AAB manifest: read aapt2 Resources.proto field numbers (element name 3, attribute 4, child 5); real AGP bundles failed with 'AAB manifest without package / versionCode' 2026-10-12 08:45:43 +08:00
CRT
476cce7ad3 play images: same languages as play listing (drafts skipped unless --include-drafts); a language with no Play listing (404) is skipped with an info line, never fatal 2026-10-12 08:45:43 +08:00
CRT
540869d4f4 templates: app / subscription-app answer dataSafety.partialDeletion; teaser Data safety row count 766 2026-10-12 08:40:36 +08:00
CRT
cdd8502385 play data-safety: write the conditional required rows (PSL_ACCOUNT_DELETION_URL from accounts.deletionUrl, PSL_SUPPORT_DATA_DELETION_BY_USER from dataSafety.partialDeletion, PSL_DATA_DELETION_URL); a rule table checks every conditional row in check, declarations and --dry before Play answers 400 2026-10-12 08:40:36 +08:00
CRT
0c9b37039c console: agent-first mode (console plan / console verify), verified App Privacy and Play Create app facts
- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser
  itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner,
  curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log),
  verify instructions. All six forms.
- `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play
  Create app from input states, content rating / target audience summaries, App Store record and age rating from
  the API; exit 1 on a mismatch.
- MCP: console_plan, console_verify (read-only); console fill stays off MCP.
- App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text),
  "Definitions and Examples" never pressed; fixture and engine follow.
- Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check
  availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account
  "Add app" as an owner step.
- console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out),
  19; CHANGELOG; play-first-release.
2026-10-12 08:39:12 +08:00
CRT
4d8a64cbed play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune
A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
2026-10-12 08:21:03 +08:00
CRT
4a436077fa console fill: labels match straight and curly quotes (Developer’s Advertising, the user’s identity); an App Privacy dialog step with no inputs (the definitions page) is passed with Next 2026-10-12 07:52:13 +08:00
CRT
8cda5e28a1 console fill: never copy Google session cookies by default (device-bound); own profile in a Chrome started without automation switches
Google binds its session cookies to the device (Device Bound Session Credentials) and signs out
sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is
now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir,
--remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP.
--use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
2026-10-12 07:46:38 +08:00
CRT
dc8bfa8fb4 console fill apple-app-privacy: set up each data type from the real page ('Set Up <type>' is a plain text, clicked by its exact text in the Data Types section), fill its dialog step by step, dump and pause on an unknown step; '?' and Publish never clicked 2026-10-12 07:38:25 +08:00
CRT
376c4c8c54 console fill: sign-in only by the top-level URL, the main frame's form or an Apple sign-in frame
Google's account widget and cookie rotation frames (accounts.google.com /
ogs.google.com) on a signed-in Play Console no longer count as a sign-in
page. Each detection logs its rule and URL; heartbeats name the page; a
copied Chrome session that lands on a sign-in URL is said plainly; the
sign-in wait gets a watchdog dump, and --debug-dump up to 3 dumps.
Fixtures and tests for the chooser and home with the widget frames and a
top-level accounts.google.com page; pitfall 16.
2026-10-12 07:29:11 +08:00
CRT
eb2cba74d4 console fill: checkbox / radio ticks are gated on agreements only (help text like 'saved place in a game' no longer blocks Product Interaction); buttons keep the full word gate 2026-10-12 07:19:50 +08:00
CRT
e22c2101aa console fill apple-app-privacy: fit the Data Collection dialog to the real console
A real run (sanitized debug dump) showed why the walk stopped after Next:
the data types step is the same "Data Collection" dialog (URL unchanged),
and each checkbox's accessible name is its label run together with its
help text ("NameIncluding first or last name"). Some labels also differ
from Apple's help page ("Purchases", "Other Data").

- labelNames: the exact name, then the label as a prefix that ends where
  the help text starts (no lowercase letter follows), so "Name" never
  matches "Phone Number...", "Head" never "Health...", "Contact" never
  "Contacts...". Used for the data types and the purposes.
  TYPE_ALIASES maps Purchase History / Other Data Types to the console's
  labels.
- Dialog per step: a section's dialog is also found as the one visible
  dialog with a heading of that name.
- Every click and check is bounded (ACTION_MS, 10 s; the scan test now
  also rejects clicks without a timeout).
- The flow fixture mirrors the real dialog's structure (dialog/h2 title,
  group h3s, input name = Apple's enum, <label> with label + help <p>s,
  Save disabled until a type is ticked); no data from the dump. A test
  checks every Apple type matches exactly one real-style checkbox.
- Pitfall 15.

Publish is still never clicked; unknown steps still get the watchdog dump.
2026-10-12 07:17:15 +08:00
CRT
f714731c43 console fill play-app-record: read the real account chooser, match names loosely
With the copied Chrome profile, `console fill play-app-record` stopped with
"no developer account named "<name>" (play.developerAccount) on the
chooser" although the name was there: the real "Choose developer account"
rows are not plain /developers/<id> text links (nested spans, icon-font
glyphs, role=button rows with the id in a data attribute, an avatar row
whose id only its click reveals).

- readChooser: every visible clickable row in the page content (header
  and navigation left out); the name is the first visible line (icon
  fonts, images and aria-hidden parts skipped); the id comes from
  /developers/<id> in any attribute, else a data-*id attribute, else ''
  (the row is then opened through the account-switch gate and the id is
  read from the URL). "Create a new developer account" is not an account.
- matchAccount: exact spelling, then spaces and case aside (Turkish I/ı
  folded), then a unique prefix. A case pair (KAYNAC / Kaynac) needs the
  exact spelling; otherwise it stops and lists both with their ids.
- One developer account: picked (a different play.developerAccount is
  said). Not found: every visible account is listed with its id.
- The resolved developerId is remembered as before.

Fixtures play-chooser-real.html (the owner's chooser shape, placeholder
names) and play-chooser-one.html; tests for exact, case pair, prefix,
single account and not found.
2026-10-12 07:12:05 +08:00
CRT
81313b6fa4 console fill: no silent waits; an unknown page gets a debug dump and a bounded stop
A real `console fill apple-app-privacy` clicked Next onto a console page
the engine did not recognise and logged nothing for 8 hours.

- Step watchdog: after a click, or while the form is expected, no known
  page within --step-timeout seconds (default 30) -> "the page after
  "Next" is not a page storebound knows ... Buttons there: ...", a debug
  dump, and the manual steps. It keeps watching, but 20 min without
  progress closes the window and exits 1.
- A heartbeat line every minute while waiting (also during sign-in); the
  final review wait is bounded (2 h) with a reminder every 10 min.
- Debug dump (cli/console/debug.ts) in .storebound/console-debug/
  <form>-<time>/: page.json (URL without query, headings, dialogs, every
  visible control with role and name), page.html (rebuilt from a clone:
  no scripts, values, hidden inputs or non-whitelisted attributes) and a
  small screenshot. Emails, phone numbers and the header / account area
  are replaced; cookies, storage and tokens are never read.
  --debug-dump writes one at every step for bug reports.
- Guardrail tests: an unknown-page fixture (dump sanitized, bounded exit)
  and a scan of cli/console for while loops without a deadline and
  Playwright waits without a timeout (one waitForLoadState fixed).
- Pitfall 14 (docs/pitfalls.md, store-pitfalls skill); the
  console-autofill skill says how to send a dump.
2026-10-12 07:08:10 +08:00
CRT
ece6644522 console fill: the content rating's Save draft is clicked too (the owner agreed)
Play content rating (IARC): storebound now clicks Save draft, then Next,
on each page, like App Privacy's draft saves. Google keeps one draft per
app and IARC gets nothing before Submit, which stays the owner's click.
FORM_BUTTONS, the flow gate (AUTO_DRAFT_SAVE), the console-autofill
skill and the guardrail test are updated; the test answers the open
questions as the owner and checks that Submit on the Summary is never
clicked.
2026-10-12 06:57:26 +08:00
CRT
8ffc83bb39 console fill: walk the whole form; the owner only reviews and presses the final button
App Privacy stopped at "now click Get Started yourself" and every page
asked the owner to click Next. The owner should not click what
storebound can safely do.

- Every button of every console form is classified (navigation,
  draft-save, final-submit) in FORM_BUTTONS (cli/lib/console-forms.ts),
  citing Apple's and Google's help pages. A scoped flow gate
  (flowAllowed) lets navigation through; the generic click gate stays
  absolute for field clicks.
- App Privacy: Get Started, Edit next to Data Types (never the Privacy
  Policy Edit), each data type / "Set Up <type>", Next, and the Save
  inside the questionnaire dialogs (a draft: Apple publishes only after
  Publish, per "Manage app privacy"). Each type's dialog may be one page
  or purposes > Next > linked > Next > tracking > Save. It ends with
  "review the summary, then press Publish yourself". Publish is never
  clicked.
- Play content rating and target audience: Next by itself. Save draft,
  Submit, the final Save, Create and Create app stay the owner's.
- An uncertain answer stops the walk on its page (outlined, "answer
  needed on ...") until the owner has set it; then it goes on.

Fixture apple-app-privacy-flow.html (first time, ?existing=1, decoys);
tests/console-flow.test.ts with the guardrail that no log line asks the
owner to click a navigation control.
2026-10-12 06:51:01 +08:00
CRT
b39e44e6ab console fill: one app shown twice is one app; App Privacy opens by app id via the API
`console fill apple-app-privacy` without apple.appId stopped at "the app
in the list" with "more than one app matches: X | X" for an account with
one app: the Apps grid names each app twice (the icon link's aria-label
and the title link), both opening the same /apps/<id>/... page.

- pickApp tells hits apart by the app they open (/apps/<id>, else the
  link target), not by name. Two apps of one name with different targets
  still stop and are listed.
- Without apple.appId, but with App Store Connect API credentials, the
  app id is looked up from apple.bundleId (GET /v1/apps?filter[bundleId],
  as `apple status` does), cached in .storebound/console-app-ids.json, and
  App Privacy opens by deep link. Without credentials, or when the lookup
  fails, the Apps list by name is used, and the log says so.

Fixture asc-apps-card.html (a card with the name in the title and an
aria-label); tests for both paths.
2026-10-12 06:32:21 +08:00
CRT
de243cdbd9 tests: profile persistence runs in a real Chrome, in its own shard
The two "session cookie survives closing and relaunching storebound's own
profile" tests failed with Playwright's headless shell, which ignores
--restore-last-session. They now live in tests/console-persist.test.ts
and launch the same browser kind console fill uses for its profile (the
resolver's "visible" kind: Google Chrome, else the full Chromium; headless
here, so no window). They skip with the reason only when neither is
installed. All other browser tests keep the fast headless shell. The file
is HEAVY in tools/ci-plan.ts, so it never shares a shard with another
browser suite.
2026-10-12 06:27:42 +08:00
CRT
e21e274c2a browser: one launch resolver; console fill never reports a hidden error
`console fill --fresh-profile` failed with "Executable doesn't exist ...
chromium-1187" when only Playwright's headless shell was downloaded,
although Google Chrome was installed (also with STOREBOUND_CHROME set).
The Chrome launch did run, but failed because an earlier storebound run
still held the profile, and the fallback rethrew the FIRST error.

Every launch (renders, captures, console profile and copy modes, the
desktop sidecar) now goes through one resolver that checks the exact
executable before trying it:
- headless: STOREBOUND_CHROME, headless shell, full Chromium, Chrome
- visible / persistent: STOREBOUND_CHROME, Google Chrome, full Chromium
  (channel "chromium"); never the headless shell
- copy mode: Google Chrome only
A missing browser is skipped; an installed one that fails reports its own
error; nothing usable names what was looked for plus the doctor /
playwright install hint. A profile whose SingletonLock names a live
process is reported before launching.
2026-10-12 06:17:19 +08:00
CRT
db363be37b flags: regenerate (check reads the iOS project through the scan modules) 2026-10-12 05:27:03 +08:00
CRT
d531626836 Merge remote-tracking branch 'origin/main' into play-app-record
# Conflicts:
#	CHANGELOG.md
2026-10-12 05:26:34 +08:00
CRT
04fb0089b5 Merge remote-tracking branch 'origin/main' into play-app-record
# Conflicts:
#	CHANGELOG.md
#	docs/pitfalls.md
#	skills/store-pitfalls/SKILL.md
#	tests/console-fill.test.ts
2026-10-12 05:24:52 +08:00
CRT
7746a984ec Merge remote-tracking branch 'origin/main' into shots-liga
# Conflicts:
#	CHANGELOG.md
#	cli/index.ts
2026-10-12 05:24:19 +08:00
CRT
af08d7113a shots check: ligatures no longer read as system-font glyphs (probe spans shaped as one run); --help on every command and group, unknown flags rejected before anything runs
Root cause: the per-character font probes were inline spans side by side, so Chrome shaped f + i into the fi ligature across them and the lone i probe reported no glyph of ours. Each probe is now its own block. The Google Fonts text= subsets keep the GSUB ligatures; regression test with a real Montserrat text= subset.

--help / -h prints the command's usage and exits 0 before any config, render or network; unknown flags exit 2 with the usage (cli/data/flags.json from tools/gen-flags.ts). Command tables moved to cli/commands.ts; ads apple reachable again; shots render --scenes rejects unknown ids at once.
2026-10-12 05:23:37 +08:00
CRT
c51b689070 Merge remote-tracking branch 'origin/main' into fast-ci 2026-10-12 05:16:52 +08:00
CRT
29e1ec799e Merge remote-tracking branch 'origin/main' into flaky-console 2026-10-12 05:11:33 +08:00
CRT
d9515fa8f0 Merge remote-tracking branch 'origin/main' into fast-ci
# Conflicts:
#	CHANGELOG.md
#	tests/site-overflow.test.ts
2026-10-12 05:10:37 +08:00
CRT
c00bb0be61 ci: Chromium hint only when browser steps run 2026-10-12 05:10:27 +08:00
CRT
44db357b73 ci:quick: browser suites only for nearby imports (full ci runs them always) 2026-10-12 05:08:23 +08:00
CRT
2508d9031d Merge remote-tracking branch 'origin/main' into flaky-console 2026-10-12 05:06:36 +08:00
CRT
66bf1f82eb scan: TODO / TBD / FIXME / XXX count as placeholder markers only in capitals ('todo' is a real keyword for to-do apps) 2026-10-12 05:06:23 +08:00
CRT
5e1357116d fast ci: task graph runner, sharded tests balanced by recorded durations, ci:quick (typecheck + related tests + scrub), Rust only when src-tauri / sidecar build changed, event waits instead of sleeps 2026-10-12 05:05:26 +08:00
CRT
fd82db0d75 Merge remote-tracking branch 'origin/main' into aso-plus 2026-10-12 04:59:49 +08:00
CRT
a7569deb26 Merge remote-tracking branch 'origin/main' into flaky-console
# Conflicts:
#	tests/site-overflow.test.ts
2026-10-12 04:54:13 +08:00
CRT
a5295db539 browser tests: deterministic under load
The afterAll hook of tests/console-fill.test.ts timed out (30 s) in 2 of 3
runs with 10 CPU hogs. Cause: Google Chrome (the fallback when playwright's
Chromium is not downloaded) hangs in its own shutdown after loading http
pages until its 10 s teardown watchdog fires; under load that passed 30 s.

- tests/helpers/browser.ts: one shared launch per file as a browser server
  with a 60 s launch timeout, killed at the end (throwaway profile, nothing
  to flush); a launch timeout fails instead of silently skipping; contexts
  and engine runs tracked per test and closed in afterEach (capped);
  event-driven waits for engine log lines
- console-fill: explicit test timeouts, slow tests marked, waits expected
  to succeed get a long cap (they return at once), sign-in / watching
  navTimeoutMs 1.5 s -> 5 s, persistent-profile Chromium closed in finally,
  temp account dirs removed
- site-overflow, console-session: same plumbing (site-overflow's afterAll
  had bun's 5 s default)
- console session: launchTimeoutMs option (tests only)
2026-10-12 04:54:07 +08:00
CRT
bd15f790cc site overflow test: 60 s for closing Chromium (the 5 s hook default times out on a busy machine) 2026-10-12 04:50:36 +08:00
CRT
3fb3f2108a Merge remote-tracking branch 'origin/main' into aso-plus
# Conflicts:
#	CHANGELOG.md
2026-10-12 04:24:52 +08:00
CRT
96d1e6b62d Merge remote-tracking branch 'origin/main' into idem-listing 2026-10-12 04:24:49 +08:00
CRT
a2009fa7ac idempotency: apple listing findOrCreate / createOnce, play listing re-plan in the edit, data-safety hash, twice-run tests for the last open rows 2026-10-12 04:24:45 +08:00
CRT
e38f68fa33 Merge origin/main into local-fonts 2026-10-12 04:15:26 +08:00
CRT
354daba672 shots fonts: local font files (title / body / fallback) with cmap coverage, served in place, hash in fonts.json, license note 2026-10-12 04:15:19 +08:00
CRT
17f4413afa Merge remote-tracking branch 'origin/main' into aso-plus
# Conflicts:
#	CHANGELOG.md
#	docs/pitfalls.md
#	skills/store-pitfalls/SKILL.md
2026-10-12 04:13:48 +08:00
CRT
d68028caba Merge remote-tracking branch 'origin/main' into play-app-record
# Conflicts:
#	CHANGELOG.md
#	docs/pitfalls.md
#	skills/store-pitfalls/SKILL.md
2026-10-12 04:13:35 +08:00
CRT
124e3097ec listing check: App Store keywords shown and limited in UTF-8 bytes (96/100 B), other fields in characters 2026-10-12 04:10:28 +08:00
CRT
b65843828b Merge remote-tracking branch 'origin/main' into aso-plus
# Conflicts:
#	CHANGELOG.md
2026-10-12 04:09:46 +08:00
CRT
7e26941ffc aso: competitors, suggest and audit from official data; ASO card on the Growth tab
- aso competitors: top apps per keyword from the iTunes Search API (name, developer,
  rating count, release / update dates, localized genre), coverage gaps (words of 2+
  developers), new entrants vs the last snapshot (.storebound/aso/competitors/)
- aso suggest: candidates from own text, competitor names, localized category names and
  Apple Ads keyword suggestions (POST /v1/suggestions/keywords/query, relative 0-100
  popularity, only with credentials); 100-byte keyword field per locale as a diff,
  --write marks it a draft that check --aso and aso audit report
- aso audit: check --aso, free keyword room, rank trend arrows, gaps, Play short
  description / density, App Store conversion, PPO / Play experiment ideas, next 3 actions
- MCP: read-only aso_competitors, aso_suggest, aso_audit; desktop ASO card (visx rank chart)
- docs: README, aso-basics, store-pitfalls (Search API order, no Play search API), CHANGELOG;
  demo-app listing files; tests on recorded Search API and mocked Ads answers
2026-10-12 04:09:34 +08:00
CRT
4533e3e1c4 app privacy: one Apple purpose per Play purpose (advertising is Third-Party Advertising only; Developer's Advertising only from the owner), appPrivacy.types (owner answers per Apple type id: certain in console fill, proposed by scan without linked / tracking, compared by check with Info.plist and PrivacyInfo.xcprivacy) 2026-10-12 04:07:15 +08:00
CRT
d5bc7b8889 idempotency: docs table, pitfall 9, changelog; lock scan fix 2026-10-12 04:03:23 +08:00
CRT
8f41ed4c02 Merge origin/main into idempotency 2026-10-12 04:01:25 +08:00
CRT
00cf2ed341 idempotency: WIP 2026-10-12 04:00:58 +08:00
CRT
704231457f Merge remote-tracking branch 'origin/main' into play-app-record
# Conflicts:
#	CHANGELOG.md
2026-10-12 03:58:50 +08:00
CRT
1150d4e4e6 Merge origin/main into shots-speed
# Conflicts:
#	CHANGELOG.md
2026-10-12 03:49:58 +08:00
CRT
5cb5dc2c32 shots capture / render: --concurrency, per-frame timing, per-language page reuse, input-hash cache
Web capture runs frames in parallel browser contexts (default from the CPU count, lower for GPU-heavy capture),
logs n/total + ETA and goto / waitFor / eval / delay / screenshot per frame, writes .storebound/capture-report.json
with the slowest frames and steps. shots.capture.session 'per-language' boots the app once per language and device
and switches shots via pushState / popstate (navigate 'spa') or goto (navigate 'reload'), falling back to a fresh
page when a precondition fails. .storebound/capture-cache.json skips frames with unchanged inputs; --only-missing,
--force. shots render renders with --concurrency pages and async ffmpeg.
2026-10-12 03:49:53 +08:00
CRT
75d6f4cb71 console fill play-app-record: Play Console's Create app form (account picked by developerId / workspace / play.developerAccount, else stop and list; Create app opener allowlisted only on the app list, never in the form, EN + TR; name, default language, app or game, free or paid as an owner check; declarations never ticked; package name set by the first AAB upload) 2026-10-12 03:48:20 +08:00
CRT
143325c033 pitfalls: apple listing dry run prints a plan now, its allow entry removed 2026-10-12 03:42:45 +08:00
CRT
d466d4a3c2 Merge remote-tracking branch 'origin/main' into apple-name
# Conflicts:
#	CHANGELOG.md
2026-10-12 03:40:51 +08:00
CRT
578d3a5669 Merge remote-tracking branch 'origin/main' into pitfalls 2026-10-12 03:33:20 +08:00
CRT
e1e0b689d2 api tables: regenerate for apple iap-review's new reads 2026-10-12 03:33:16 +08:00
CRT
51e30a42b5 Merge remote-tracking branch 'origin/main' into apple-name 2026-10-12 03:32:51 +08:00
CRT
866aa4a041 Merge remote-tracking branch 'origin/main' into pitfalls
# Conflicts:
#	cli/apple/iap-review.ts
2026-10-12 03:30:34 +08:00
CRT
dc3ff364a4 api: refresh coverage tables (iap-review reads more operations) 2026-10-12 03:30:15 +08:00
CRT
6f561393f4 api tables regenerated after the merge 2026-10-12 03:30:08 +08:00
CRT
59e60960b0 pitfalls: store-pitfalls skill and docs/pitfalls.md, store list lag warnings (apple iap-review exits 1), dryPlan table and a scan for count-only dry runs 2026-10-12 03:29:53 +08:00
CRT
6c4f8a4551 Merge remote-tracking branch 'origin/main' into apple-name
# Conflicts:
#	CHANGELOG.md
2026-10-12 03:29:53 +08:00
CRT
b88648499e apple listing, play listing, apple appinfo: the dry run prints a real plan (version decision, old -> new per field, draft locales skipped); the real run asks and applies exactly it 2026-10-12 03:29:38 +08:00
CRT
221f08aaf6 apple iap-review: size check before upload, delivery state read back after (FAILED shown with Apple's reason), dry run names why it would upload, MISSING_METADATA explained per part; check flags non-screenshot sizes offline 2026-10-12 03:24:41 +08:00
CRT
5afe4613c4 apple.name: the App Store name when the plain name is taken; console fill, apple listing, pull, check (30-char error, same-name hint), scan use it 2026-10-12 03:23:43 +08:00
CRT
296aa6a6ae Merge origin/main into shots-glyphs 2026-10-12 03:13:05 +08:00
CRT
8bfa49807d Merge origin/main into shots-glyphs
# Conflicts:
#	CHANGELOG.md
2026-10-12 03:05:32 +08:00
CRT
d942275920 shots: glyph coverage check per language, automatic Noto fallbacks, check errors on fallback / system-font glyphs
Lilita One lacks most of Latin Extended-A (Turkish s-cedilla, g-breve, dotted I ...), so Turkish titles were drawn
with a silent browser fallback while shots check passed. shots fonts now reads the cmap of every face (pure TS,
TTF/OTF/WOFF/WOFF2), adds a Noto fallback per missing character or fails with language + characters; render
measures which face drew each character (CDP probes) and records it in report.json; check errors on fallbacks the
config did not choose (shots.fonts.fallback) and on system-font glyphs. Default game title font: Paytone One.
2026-10-12 03:05:23 +08:00
CRT
8186b25575 console fill: User Access radios found under a plain heading (real App Store Connect), a lasting partial miss fills every field found and leaves only the missing ones to the owner 2026-10-12 03:05:11 +08:00
CRT
9e15cf43e3 Merge remote-tracking branch 'origin/main' into console-signin 2026-10-12 02:54:50 +08:00
CRT
21c0c2ed60 console fill: sign-in detected by URL, frame and text; keeps watching after the fallback; session kept across relaunches; one login across Play developer accounts and App Store Connect teams 2026-10-12 02:54:48 +08:00
CRT
930f960318 teaser v6: 55.5 s, v5 kept, its desktop part grown to four scenes
Portfolio (every app, both stores, rollout %, crash sparklines), the Growth and
Health charts with a real hover sweep (one captured frame per hovered day, the
pointer follows the recorded positions), the Prices matrix (both stores per
country, gaps marked), then v5's release timeline and 0 blockers. All frames are
real fixture-mode captures (desktop.ts). Tests: scenes 4-5 s, 50-60 s total,
captions >= 3 s, every desktop frame captured by desktop.ts and present.
2026-10-12 02:32:53 +08:00
CRT
e5f610a033 Merge origin/main into console-nav 2026-10-12 02:21:37 +08:00
CRT
ce69fbd600 console fill: opens the form itself and reuses the owner's Chrome sign-in
Navigation after sign-in (navPlan + navigate): App Store Connect Apps > + > New App
(EN/TR), App Privacy by /apps/<appId>/distribution/privacy or the app list, Play app
list by package name > Policy and programs > App content > Start / Manage. Every
navigation click goes through navClick: the click gate stays absolute, only a link
named exactly Start / Manage / Başla / Yönet passes. Each step is logged; a step not
found in time or two matching apps -> the printed steps and wait (no crash).
The TR gate now refuses "uygula" (apply) but not "uygulama" (app).

Browser (cli/console/session.ts): attach to a Chrome with remote debugging (--cdp or
DevToolsActivePort), else on macOS a temporary profile with only Local State and the
profile's Cookies filtered to the console hosts, opened in real Chrome and deleted at
the end, else storebound's own profile (--fresh-profile). Cookies are never printed.
Desktop: fresh_profile option, the dialog says it uses the Chrome sign-in.
Tests: navigation fixtures (ASC EN/TR, Play list, App content, missing step,
duplicates, deny-listed decoys), session file selection, host filter, cleanup, and a
real-Chrome copy-mode test on a throwaway profile and a local site.
2026-10-12 02:21:35 +08:00
CRT
bee5627ec0 aso: App Store keyword fill hint and limit both count UTF-8 bytes (non-Latin locales were warned to add characters that would break the 100-byte limit) 2026-10-12 02:14:27 +08:00
CRT
e92fda612c scan.ignore: optional match on the finding title, to accept one check of a rule with several (e.g. the ALL CAPS title in play-metadata) 2026-10-12 02:08:11 +08:00
CRT
3dba3682b3 Merge remote-tracking branch 'origin/main' into fix-scan-accounts 2026-10-12 02:03:08 +08:00
CRT
582ea909ca scan: accounts mean User ID, never email without evidence; accounts.signUp (username -> PSL_ACM_USER_ID_OTHER_AUTH); built bundles are low-trust evidence; privacy-manifest and scan find plugins in monorepo node_modules, workspaces and local SwiftPM packages 2026-10-12 02:03:07 +08:00
CRT
eecdcc4f83 teaser v5: 42 s 16:9 cut, same story and captions as v4
Nine scenes of 4-5 s (4.5 / 4.5 / 5 / 5 / 4.5 / 5 / 5 / 4.5 / 4), one camera move per scene with a quick quartic
ease-out that settles at 80% of the scene, 0.4 s dissolves on the cuts. Captions fade in and out faster and stay fully
on screen 3.25-4.25 s. Long terminal output streams faster instead of being cut: init types at 22 cps with 0.12 s
lines, the prompt at 40 cps, the plan at 0.13 s per line, products, declarations, form rows and findings closer
together.

tests/teaser.test.ts: scenes 4-5 s, 38-46 s in total, captions at least 3 s.
2026-10-12 01:59:29 +08:00
CRT
f07f3acbd8 teaser v4: calm 60 s 16:9 cut, from an empty folder to ready on both stores
Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top
(on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt
and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and
`catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning
green, the desktop release timeline and Store readiness "No blockers", Submit and the end card.

Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from
tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths,
caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB.
Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
2026-10-12 01:48:03 +08:00
CRT
179e4d3815 site: no sideways scroll at 450-1020 px (hero clips the demo glow), overflow test for 320-1440 px light and dark, css ?v bump 2026-10-12 01:38:42 +08:00
CRT
0547433c2f tests: all-zero placeholder profile uuid (scrub) 2026-10-12 01:28:32 +08:00
CRT
20f8d6a311 Merge origin/main into close-gaps 2026-10-12 01:26:37 +08:00
CRT
8a23bf7eec live read run fixes: Xcode-managed profiles in signing, TestFlight-only release trains, scan skips agent worktrees and takes an absolute --config, --all-pages paging meta, iap-submit json, report hints 2026-10-12 01:24:56 +08:00
CRT
549dfeefc8 changelog: the new work goes under Unreleased (0.6.0 shipped meanwhile) 2026-10-12 01:24:33 +08:00
CRT
d88635db67 Merge remote-tracking branch 'origin/main' into close-gaps 2026-10-12 01:24:22 +08:00
CRT
236977940e flows, listing translate, scan 2.2 + --originality, prices export with subscriptions, site sections
- storebound flow list|validate|run: repo-local workflows (store/workflows/*.yml, storebound.workflows.*), writes dry unless --apply, owner steps never run, --from / --resume; init templates for app and game; MCP flow_list + dry flow_run; release-flows skill
- storebound listing translate|check|reviewed: per-language listing drafts with store limits and keyword rules, agent or --translate hook, marked until reviewed; MCP listing_translate / listing_check; listing-localization skill
- scan: Apple 2.2 beta/demo/test wording rule; scan --originality [--against]
- prices export includes subscriptions; prices import skips their rows
- site: rejection scan section, Desktop app section with fixture-mode screenshots, Everything else grid, FAQ 4.3 answer
- README, CHANGELOG, adapters, API tables
2026-10-12 01:24:21 +08:00
CRT
d4140437f1 0.6.0 2026-10-12 01:15:56 +08:00
CRT
8fcc78bb52 teaser: CLI montage before the Submit card (api coverage, play build --dry, console fill --dry), real output checked in tests 2026-10-12 00:52:13 +08:00
CRT
657bfdc35e Merge growth-extras: PPP pricing, Apple Ads, web presence, ad-hoc and internal sharing, RevenueCat, console autofill 2026-10-12 00:21:32 +08:00
CRT
4a894a1937 growth extras: MCP safe tools, README and CHANGELOG, socialMedia age rating defaults, regenerated adapters and API coverage 2026-10-12 00:19:32 +08:00
CRT
f2d58d5b15 site: bump asset version 2026-10-12 00:17:59 +08:00
CRT
bd4579ebbf site: the pre-flight command strip is one line tall, matching the one line it shows 2026-10-12 00:17:59 +08:00
CRT
2d2fd9040d Merge remote-tracking branch 'origin/main' into growth-extras
# Conflicts:
#	cli/index.ts
#	cli/lib/config.ts
#	cli/lib/credentials.ts
#	docs/desktop.md
#	templates/credentials.example.json
#	tools/site-icons.ts
2026-10-12 00:14:36 +08:00
CRT
cea2dffdc3 growth extras: prices ppp, ads apple, web, apple adhoc, play share, revenuecat, console fill (+ desktop button) 2026-10-12 00:14:00 +08:00
CRT
97ab678691 Merge scan-play: Google Play scan rule parity
Conflict in cli/lib/config.ts (play section): kept play.build from main and the new targetAges, accountType, accountCreated and billingPrograms. Adapters and API tables regenerated (unchanged).
2026-10-12 00:09:22 +08:00
CRT
d972c36fa8 Merge store-ops: TestFlight wait / expire, IAP review submissions, pulls, offers, CPP, events, reports, triage, ASO rank, ads scan
Conflicts resolved for both sides:
- cli/lib/plist.ts, cli/lib/zip.ts: one implementation each. plist reads XML (text or
  bytes, attributes tolerated) and binary plists; ads scan now reads bytes, so a binary
  Info.plist works too. zip gains unzip() for the Play report exports on top of
  zipEntries / zipRead.
- apple upload waits with testflight wait's loop (waitForBuild) instead of its own copy;
  BUILD_DONE removed.
- config, credentials (profiles, keystore, ASC_VENDOR_NUMBER, now also a profile key),
  command table, MCP tools, credentials template, README, CHANGELOG: union.
- MCP: --write stays forbidden; the three pull tools are the listed local-file exception
  (LOCAL_WRITE_TOOLS), so scan --write remains unreachable.
- Adapters and API coverage tables regenerated.
2026-10-12 00:07:26 +08:00
CRT
84ef94253c storebound scan: Google Play parity
Play rules verified against the live Policy Center / Play Console Help pages
(checked 2026-10-11, cited in cli/lib/scan/play-rules.ts): restricted
permissions and their declarations (background location, photo / video, All
files access, AccessibilityService, REQUEST_INSTALL_PACKAGES, Health Connect,
full-screen intent, VpnService), prominent disclosure and the in-app privacy
policy, Data safety vs SDK disclosures, Families (certified ad SDKs, AD_ID,
location), AD_ID on targetSdk 33+, 16 KB page sizes (Unity, React Native, NDK,
AGP, prebuilt .so), testing for new personal accounts, Play metadata, ads at
launch, payments outside Play Billing, impersonation. Detection reads manifest
components, accessibility configs, strings.xml, native toolchain versions and a
local build's merged manifest. New fixtures and tests; README table per store.
2026-10-12 00:05:35 +08:00
CRT
b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks)
- apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app
  version (--with-version), --no-submit keeps the draft; read-only MCP plan
- apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten)
- whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits
- apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API)
- apple cpp and apple events: list, create, upload, submit (asks)
- reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV
- triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS
- aso rank: iTunes Search API ranks and competitors, rate limited
- ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks
- tests against recorded, sanitized answers; skills, README, CHANGELOG
2026-10-11 23:58:17 +08:00
CRT
0092a67fc7 Teaser remake: inbox hook, real scan and re-scan, screenshot wall, Data safety, three desktop scenes, Submit card
New cut on the approved storyboard (33.75 s, every cut on the 128 BPM beat): rejection mails in a generic inbox,
storebound scan typed on black with its real output (tests/fixtures/scan/teaser), the re-scan of the fixed copy
(new fixture teaser-fixed) flipping five reds to green, 200 store screenshots, 764 Data safety answers, the desktop
app's Portfolio, release timeline (rollout to 20%) and Checks tab, then the Submit card and the logo.
60 fps capture blended to 30 fps for motion blur, whip / mask / zoom transitions, sound design on every cut,
a 1280x720 web version. tests/teaser.test.ts checks the scan text, beat grid, caption length and holds.
2026-10-11 23:54:19 +08:00
CRT
2a91a791be api: refresh the coverage tables after the build and signing commands 2026-10-11 23:48:30 +08:00
CRT
217d42cc9a Merge build-sign: signing, Xcode/Gradle build and upload, originality check
# Conflicts:
#	CHANGELOG.md
#	cli/index.ts
#	cli/lib/credentials.ts
2026-10-11 23:48:20 +08:00
CRT
41319562d8 Signing, builds, uploads and an originality check
apple signing status|ensure: bundle id capabilities, a distribution certificate from a key made locally (kept in .storebound/signing, never printed), App Store profiles installed for Xcode; Developer ID 403 explained.
apple build / apple upload: xcodebuild archive + export with a generated ExportOptions.plist, next build number, App Store Connect API build upload (or altool), processing wait. Native, Capacitor, React Native, Flutter, Unity projects.
play build / play upload: Gradle bundleRelease with the upload key passed through the environment, next versionCode read back from the AAB, testing tracks only.
originality: offline Guideline 4.3 check (name, listing text, icon and screenshot perceptual hashes, project structure).
New skill build-and-sign; zero new dependencies.
2026-10-11 23:42:48 +08:00
CRT
f7fde413e0 Merge api-coverage: storebound api, every App Store Connect and Play endpoint
# Conflicts:
#	.cursor/rules/storebound.mdc
#	.github/copilot-instructions.md
#	AGENTS.md
#	CHANGELOG.md
#	GEMINI.md
#	README.md
#	cli/index.ts
#	cli/serve.ts
#	tools/gen-adapters.ts
2026-10-11 23:40:55 +08:00
CRT
77aa09235e storebound api: every App Store Connect and Google Play operation
- specs/: Apple's App Store Connect OpenAPI 4.5.1 and the Google discovery docs for androidpublisher v3 and
  playdeveloperreporting v1beta1, pinned with URL, date and SHA-256 (specs/SOURCES.json)
- tools/gen-api.ts: one row per operation (cli/api/apple.gen.ts 1303, cli/api/play.gen.ts 170), a scan of which
  operations the commands already call (cli/api/wrapped.gen.ts) and docs/api-coverage.md; CI checks freshness
- cli/api/client.ts + types.ts: generic URL / parameter building, retries with Retry-After, pagination, types derived
  from the rows; auth reuses ascJwt and the Play service account
- cli/api/gate.ts: GET runs, writes dry unless --yes in a TTY, owner-only operations need the id typed, nothing
  written under MCP or serve; MCP gets the read-only api_get
- storebound api list | coverage | apple|play <op|METHOD path>; skill store-api, README, CHANGELOG, tests
2026-10-11 23:37:09 +08:00
CRT
54dd012cfa Merge cli-workspace: multi-app workspace, credential profiles, release trains in the CLI and MCP
# Conflicts:
#	.cursor/rules/storebound.mdc
#	.github/copilot-instructions.md
#	AGENTS.md
#	CHANGELOG.md
#	GEMINI.md
#	docs/agents-core.md
2026-10-11 23:36:36 +08:00
CRT
2165322c02 CLI workspace for many apps and studios, credential profiles, release trains (release status / plan)
- storebound.workspace.json: app folders (globs), studios with accounts by credential profile, ignore list, the
  desktop's pairing overrides; ws init | list | status | check | validate | run | apps import | desktop export/import
- credential profiles in ~/.storebound/credentials/<profile>.json or env, --account <profile> on any command
- release status / release plan <version>, table or --json; MCP workspace_list, workspace_status, release_status,
  release_plan (read-only)
- pairing rules (cli/lib/identity.ts), release lanes (cli/lib/train.ts) and the per-app status read
  (cli/lib/app-status.ts) shared with the desktop instead of copied
- tests with sanitized fixtures, README, skills, CHANGELOG
2026-10-11 23:35:45 +08:00
CRT
ae58948abd storebound scan: offline rejection-risk scan of the project itself
Detects the stack (native iOS / Android, React Native, Expo, Capacitor, Cordova,
Flutter, Unity, Godot) and the SDKs from a maintained table with versions,
file:line and each vendor's disclosure page, then reports App Store and Google
Play rejection risks with guideline, evidence, fix, severity and skill: 2.1(a)
demo account and placeholder text, 5.1.1(v) account deletion, 4.8 login
services, 3.1.1 restore and outside payments, 3.1.2 subscription info, 5.1.2(i)
ATT, purpose strings, privacy link, privacy manifest, 2.3.10, 2.3.7, 2.3.8, 4.2
web wrappers, 2.5.1, 2.5.5, ATS, export compliance, 1.3 kids ads, review
prompts, privacy labels vs SDKs; Play target SDK 36, delete-account URL, App
access, foreground services, exact alarms, QUERY_ALL_PACKAGES, SMS / call log,
com.example package name.

Proposes Data safety / App Privacy / ATT / privacy manifest answers (detected
file:line or ask owner) as a diff against the config; --write adds only the
detected ones. scan.ignore records owner-accepted findings. MCP tool scan
(read-only), validate includes open findings, desktop Checks tab shows them.
Fixtures for iOS, Android, Capacitor, Unity, Flutter, clean and teaser.
2026-10-11 23:20:55 +08:00
CRT
346d80e07e site: teaser links ?v=3 (the CDN had cached the old mp4 under ?v=2) 2026-10-11 11:10:21 +08:00
CRT
cec0ac2ffe Teaser: Phosphor cursor and icons, a desktop app scene, new render for the site
- The mouse cursor and the blocker-list icons are Phosphor files (media/teaser/icons, copied unchanged by
  tools/site-icons.ts) shown as CSS masks; no hand-written SVG left in media/teaser.
- The no-handmade-svg guard now covers media/teaser: no inline SVG, every referenced icon is a vendored Phosphor file,
  byte-equal to the package.
- New 6-bar desktop scene before the Submit card: real frames of apps/desktop in fixture mode (media/teaser/desktop.ts,
  captured headlessly) in a CSS window: portfolio across studios, the release track of both stores, the Growth chart
  with its tooltip, the Cmd+K palette, a price-sheet dry-run diff, the confirm dialog, and Submit for review left to
  the console. Music and timeline follow (26 bars, 48.75 s).
- End card: "Desktop app for macOS, Windows, Linux".
- site/img/teaser.mp4 (1280x720 H.264, about 3 MB) and its poster re-rendered; ?v= bumped, captions say 49 s.
2026-10-11 10:59:32 +08:00
CRT
451ffd601c desktop settings: the section nav follows scrolling (scroll spy), clicks hold the highlight until the scroll settles 2026-10-11 10:39:08 +08:00
CRT
de555b7524 New logo from one master: brand/, tools/brand-assets.py makes every app icon, favicon, header mark and og image; no hand-written SVG left 2026-10-11 10:13:13 +08:00
CRT
f9a53f508e tools: also look in the Android SDK folder Android Studio installs and in Homebrew, for apps started without a shell PATH 2026-10-11 09:51:22 +08:00
CRT
bd3ab20e1d Desktop: never a blank window (frozen-prototype fix, error screens, logs, state migration, boot check)
The signed release showed only its background colour and never started the sidecar. Tauri's freezePrototype
freezes Object.prototype before the bundle runs; d3-color and d3-format (under the visx charts) assign
`constructor` / `toString` on plain objects while loading, which throws on a frozen prototype, so the bundle
stopped before React drew anything. The saved state was not the cause.

- build/freeze-safe.ts: Vite plugin that rewrites those assignments in dependencies into a helper that defines
  the property when the frozen prototype would refuse it
- error boundaries for the window, each screen and each app tab (Copy details, Reload, Reset saved state with a
  backup); public/boot.js shows the same panel when the bundle fails or the window stays empty
- logs/host.log (start, saved state, sidecar start/stop, panics) and logs/webview.log (log_webview), rotated,
  home as ~, keys, tokens and e-mail addresses replaced
- state.json `version` 2 with an explicit 1 -> 2 migration (original kept as state.json.v1.bak), per-entry
  tolerant parsing, partial settings keep their fields, unreadable files kept aside
- folders that are gone: "Folder not found" with Locate... (project_locate) / Remove; portfolio reads skip them
- tools/desktop-boot-check.ts: the built window with the release CSP and the frozen prototype, a mocked host from
  the state fixtures (empty, D1 with folders gone, D3) and the real sidecar, through every screen; in bun run ci,
  the Forgejo CI workflow and the macOS release
2026-10-11 09:36:34 +08:00
CRT
ae2ffd8dbf Phosphor icons and the real store marks
Desktop icons move from lucide to Phosphor (@phosphor-icons/react, per-icon imports, regular weight; fill for the
active nav item, notices and rating stars). The site's icons and the fixture art use Phosphor's own files from
@phosphor-icons/core. App Store and Google Play are shown with their marks from simple-icons (CC0 data), vendored
unchanged by tools/site-icons.ts and rendered as a mask in the neutral text colour next to the store's name; the
trademark note is in docs/desktop.md and the site footer. lucide-react and lucide-static removed. The
no-hand-written-SVG guard allows only the vendored files (byte-equal to their packages) and per-icon Phosphor
imports. Site stylesheet ?v= bumped.
2026-10-11 09:00:46 +08:00
CRT
b76ccb20b8 Merge no-handmade-svg: visx charts, lucide icons, Mermaid diagram, CI guard 2026-10-11 08:50:13 +08:00
CRT
3af1b3e5f4 play products: price, region and activation changes of existing products ask first (diff, terminal confirmation or --yes; refused without a terminal and under serve unless a desktop owner action confirmed); new products and listing text unchanged; store-iap-setup skill and MCP description; tests 2026-10-11 08:47:28 +08:00
CRT
52d1e379e3 play products --dry: name what a run would change per product (listings added / changed / removed, regional prices old -> new, dropped regions, activation); checked live with convertRegionPrices allowed, store unchanged; test on a sanitized recorded product 2026-10-11 08:44:43 +08:00
CRT
b4d924b132 Guarded read-only run of the Play POST read paths: errorIssues:search in UTC, issues failure keeps the vitals, read-only listing / images dry runs
A live run where only the token exchange, edit insert / delete and Reporting :query were let through:
- health / validate: errorIssues:search answered 400 "Unsupported timezone" for America/Los_Angeles (it takes
  hour-aligned UTC only); the failure dropped the crash and ANR rates and the reason was cut off in the query string.
  Issues are asked in UTC; a failing issues search is a vitals-issues warning; Reporting errors leave out the query.
- play listing --dry / play images --dry wrote into the edit (PUT details and listings, image clears and uploads,
  :validate) before discarding it. Now one throwaway edit is read (details, listings, image sha256) and compared
  with the local copy, then deleted.
- play.read(): one helper for read-only edits (status, next-version-code), always deleted, warns if the delete fails;
  next-version-code counts distinct codes.
- tests on sanitized recorded answers (tests/play-post-reads.test.ts)
2026-10-11 08:41:55 +08:00
1200 changed files with 407960 additions and 7006 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.5.0", "version": "0.8.77",
"category": "development", "category": "development",
"tags": [ "tags": [
"mobile", "mobile",

View file

@ -1,6 +1,6 @@
{ {
"name": "storebound", "name": "storebound",
"version": "0.5.0", "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.5.0", "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

@ -7,20 +7,62 @@ alwaysApply: false
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), ## First rule: do it, don't ask
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping storebound plus you, the AI agent, do the work. The owner is not your pair of hands.
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. 1. **You do everything yourself:** API calls, uploads, builds, template and CSV downloads and exports, every console
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it navigation (open, Get Started, Edit, Next, Continue, Save draft), console forms, and the verification afterwards.
for real only after the owner agrees. Never tell the owner "run this yourself" or "click X yourself" for any of these.
3. **Read before you write.** `storebound check` (offline) and `storebound validate` (read-only, both stores) tell you 2. **The owner keeps only the final public or irreversible buttons:** Submit for review (App Review or Beta App
what is missing and in which order to fix it. Review), releasing a version or completing / resuming a production rollout, App Privacy Publish, accepting legal
4. **Credentials only from the environment or `.storebound/`** in the project (gitignored). Never ask for keys, agreements; plus deleting store data, changing live prices, sending review replies and inviting or removing
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
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,
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
are already in the config.
4. **Default loop:** plan, then you drive (the API first, your browser tool for consoles without an API), then verify
(read it back), then continue. No round trip to the owner in between.
5. **Catch rejections offline.** Before a write, `storebound check` and `--dry` must catch what the store would refuse
(live-shape fixtures, id and pair checks). A failure belongs in the dry run, not in a 400 at write time; when a
store refuses something the dry run passed, fix the check (pitfalls), not just the data.
Every hour of "please click this" is an hour the owner did not want to spend. If you catch yourself writing "you
need to ...", do it instead, unless it is one of the owner steps in point 2.
## The other rules
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner presses only the owner steps** (point 2 of the first rule). Prepare them completely, say what will
happen, and let the owner press the button, or run the command only after a clear yes in chat. storebound's MCP
server does not offer these at all. Everything else you run yourself.
2. **Dry run first, then run it yourself.** Every write command has `--dry` (MCP: `dry_run`, default true). Run the
dry run, check and show it (the rows that change, not just a count), then run the real command yourself (`--yes`
where it asks for a confirmation) and read the result back. Wait for the owner's yes only before an owner step.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
(`~/.storebound/credentials/<profile>.json`, named by `--account <profile>` or a workspace). Never ask for keys,
passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes
for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there. for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there.
5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating, 5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating,
target audience: ask the owner when the code does not prove the answer. A wrong "no" is worse than a slow "ask". target audience: ask the owner when the code does not prove the answer, all such questions in one batch at the
start (first rule, point 3), and write the answers into the config. A wrong "no" is worse than a slow "ask".
6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest". 6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest".
7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and 7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and
you report the progress lines, not guesses. you report the progress lines, not guesses.
@ -29,38 +71,62 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in 9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits, `.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
and never ask for tester emails in chat (storebound masks them in its output). and never ask for tester emails in chat (storebound masks them in its output).
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
that writes `window.__log` (one evaluate call times out around 45 s), stop only before the final button (Publish,
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.
`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
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`; outside the plugin: `npx storebound` on Node 20+ or `bunx storebound` on Bun); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound scan` (offline rejection risks in the project files), `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
- `skills/account-deletion/SKILL.md`: Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in - `skills/account-deletion/SKILL.md`: Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in
- `skills/adhoc-distribution/SKILL.md`: Get a test build of a mobile app or game onto specific devices without a store review
- `skills/ads-levelplay-checks/SKILL.md`: Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question - `skills/ads-levelplay-checks/SKILL.md`: Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question
- `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release - `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release
- `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork - `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork
- `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) - `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows)
- `skills/app-store-promotion/SKILL.md`: App Store promotion tools for a mobile app or game through the App Store Connect API
- `skills/apple-ads/SKILL.md`: Read Apple Ads (formerly Apple Search Ads) results for a mobile app or game and make small, owner-approved bid, daily budget and pause changes
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game - `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text - `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
- `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/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/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
- `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) - `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins)
- `skills/ppp-pricing/SKILL.md`: Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp`
- `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game - `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game
- `skills/privacy-policy/SKILL.md`: Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games - `skills/privacy-policy/SKILL.md`: Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games
- `skills/release-flows/SKILL.md`: Repo-local workflows for a mobile app or game
- `skills/release-management/SKILL.md`: Ship updates safely after the first release - `skills/release-management/SKILL.md`: Ship updates safely after the first release
- `skills/revenuecat-sync/SKILL.md`: Optional adapter that keeps a RevenueCat project's catalog in step with the storebound config
- `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in - `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in
- `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game - `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game
- `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way - `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way
- `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git - `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency
- `skills/store-api/SKILL.md`: Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely
- `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game - `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game
- `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores - `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores
- `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list
- `skills/store-pitfalls/SKILL.md`: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each
- `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game - `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game
- `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages - `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages
- `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config - `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config
- `skills/testflight/SKILL.md`: Run TestFlight for a mobile app or game through the App Store Connect API - `skills/testflight/SKILL.md`: Run TestFlight for a mobile app or game through the App Store Connect API
- `skills/web-presence/SKILL.md`: Set up the web side of a mobile app or game
- `skills/whats-new/SKILL.md`: Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag - `skills/whats-new/SKILL.md`: Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag

View file

@ -0,0 +1,31 @@
# `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.
name: ci-run
description: storebound's full CI (bun run ci) on the Linux runner
runs:
using: composite
steps:
- uses: actions/cache@v4
with:
path: apps/desktop/src-tauri/target
key: ci-target-${{ runner.os }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ci-target-${{ runner.os }}-
# recorded test durations (shard balance) and tsc's incremental build info
- uses: actions/cache@v4
with:
path: node_modules/.cache/storebound-ci
key: ci-timings-${{ runner.os }}-${{ github.sha }}
restore-keys: ci-timings-${{ runner.os }}-
- name: Install
shell: bash
run: |
bun --version && cargo --version
bun install --frozen-lockfile
# 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
with:
node-version: '20'
# no browser tests in CI (tests/browser/ runs only with `bun run test:browser`); the changes of this push (against
# its parent; the checkout has fetch-depth 2) decide the Rust steps, as on a laptop against origin/main
- name: bun run ci
shell: bash
run: STOREBOUND_CI_BASE=HEAD~1 bun run ci

View file

@ -0,0 +1,43 @@
# Shared setup of the Linux jobs (ci.yml; release.yml's ci and linux jobs) on the VDS runner (label ubuntu-22.04, the
# catthehacker act image): Tauri's system packages plus `packages`, the Bun / Cargo caches, Bun and Rust on PATH.
name: linux-setup
description: System packages, caches, Bun and Rust for storebound's Linux jobs
inputs:
packages:
description: Extra apt packages
default: ''
cache-key:
description: Cache key prefix (separate caches for CI and the installer build)
default: ci
rust-components:
description: Extra rustup components (for example clippy)
default: ''
runs:
using: composite
steps:
- name: System packages
shell: bash
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends libwebkit2gtk-4.1-dev libgtk-3-dev \
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libssl-dev libxdo-dev \
build-essential curl unzip pkg-config ${{ inputs.packages }} >/dev/null
- uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
~/.cache/tauri
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ inputs.cache-key }}-${{ runner.os }}-
- name: Bun and Rust
shell: bash
run: |
P="${FORGEJO_PATH:-$GITHUB_PATH}"
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "$P"
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$P"
for c in ${{ inputs.rust-components }}; do "$HOME/.cargo/bin/rustup" component add "$c"; done

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,11 +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 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
# Publishes nothing: site/ is still published by Jenkins. # in headless Chromium, desktop Rust tests and clippy, scrub); tools/ci.ts runs it as a task graph with
# parallel test shards on the one Linux runner; no browser test runs here (tests/browser/, `bun run
# test:browser` by hand). The Rust steps run when the push changed a Rust input (STOREBOUND_CI_BASE).
# 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:
@ -16,41 +29,400 @@ env:
CARGO_INCREMENTAL: '0' CARGO_INCREMENTAL: '0'
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
- name: System packages (Tauri on Linux) with:
fetch-depth: 2
- uses: ./.forgejo/actions/linux-setup
with:
cache-key: ci
rust-components: clippy
- 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: | run: |
sudo apt-get update -qq bash tools/ci-install-bun.sh
sudo apt-get install -y -qq --no-install-recommends libwebkit2gtk-4.1-dev libgtk-3-dev \ echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libssl-dev libxdo-dev \ - name: Acceptance matrix (Linux)
build-essential curl unzip pkg-config >/dev/null 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 - uses: actions/cache@v4
with: with:
path: | path: /tmp/storebound-target
~/.bun/install/cache key: desktop-target-${{ runner.os }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
~/.cargo/registry/index restore-keys: desktop-target-${{ runner.os }}-
~/.cargo/registry/cache - name: Install and check
~/.cargo/git/db
apps/desktop/src-tauri/target
key: ci-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ci-${{ runner.os }}-
- name: Bun and Rust
run: | run: |
P="${FORGEJO_PATH:-$GITHUB_PATH}"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "$P"
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy
echo "$HOME/.cargo/bin" >> "$P"
- name: Install
run: |
rustup component add clippy
bun --version && cargo --version
bun install --frozen-lockfile bun install --frozen-lockfile
- name: bun run ci bun tools/desktop-version.ts
run: bun run ci 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,135 +0,0 @@
# storebound desktop: Linux (AppImage + deb) and Windows (NSIS, per user, unsigned) installers.
# macOS is built, signed and notarized on the owner's Mac (tools/desktop-release.ts mac); the updater signing key
# never reaches a runner: the Mac signs these installers after downloading them (tools/desktop-release.ts add).
# Runner setup (Forgejo runners on the VDS: Linux in Docker, Windows in a KVM VM): docs/desktop-release.md.
# Publishes nothing: the installers are workflow artifacts only (14 days); `tools/desktop-release.ts add` on the Mac
# signs and stages them, and only `publish` puts anything on storebound.dev.
name: desktop
on:
workflow_dispatch:
inputs:
windows:
description: Build Windows too (needs a runner with the "windows" label)
type: boolean
default: false
push:
tags: ['desktop-v*']
env:
BUN_VERSION: '1.4.0'
# the path-free build keeps the target folder outside every home folder
CARGO_TARGET_DIR: /tmp/storebound-target
CARGO_TERM_COLOR: never
jobs:
linux:
# 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:
# 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
- name: System packages
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libssl-dev libxdo-dev patchelf file build-essential \
curl unzip squashfs-tools p7zip-full xz-utils >/dev/null
- uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: desktop-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: desktop-${{ runner.os }}-
- name: Bun and Rust
run: |
P="${FORGEJO_PATH:-$GITHUB_PATH}"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "$P"
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$P"
- 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: 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:
# only on a manual run with "windows" ticked (a tag push never sets it), so without a Windows runner it is skipped
if: ${{ github.event_name == 'workflow_dispatch' && 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
- 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

View file

@ -0,0 +1,78 @@
# 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 CHANGELOG section), waits
# for the commit's candidate (heartbeat; fails at once when the commit is red or has no ci run,
# with the run's link; times out after 40 min), then ONE release "storebound <version>" with the
# candidate's installers, the acceptance matrix, SHA256SUMS and short notes. Stable versions
# then delete the older release entries (tags stay). A version with a "-" (0.0.0-rehearsal.1)
# is a prerelease: never pruning, never the stable release.
# workflow_dispatch tag: the same for an existing tag; dry (default): only the plan.
#
# Not here: macOS. tools/release.ts on the owner's Mac builds, signs and notarizes the DMG in parallel and adds it to
# this release, then publishes storebound.dev (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
# Cloudflare). The private terms scan of the installers ran in ci.yml's build jobs (FORBIDDEN_TERMS).
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Version tag to release (v0.8.5)
type: string
default: ''
dry:
description: Dry run (only the plan)
type: boolean
default: true
env:
BUN_VERSION: '1.4.0'
jobs:
promote:
runs-on: ubuntu-22.04
timeout-minutes: 50
env:
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
FORGEJO_URL: ${{ github.server_url }}
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
steps:
- name: Mode
id: mode
env:
EVENT: ${{ github.event_name }}
IN_DRY: ${{ inputs.dry }}
run: |
[ -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) dry=false;; *) dry=true;; esac
[ "$EVENT" = push ] && dry=false
echo "dry=$dry" >> "$GITHUB_OUTPUT"; echo "tag=$TAG dry=$dry"
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.TAG }}
- name: Tag, package.json version, every version copy and the CHANGELOG agree
id: check
run: |
bash tools/ci-install-bun.sh
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
export PATH="$HOME/.bun/bin:$PATH"
bun tools/desktop-version.ts
V=$(bun tools/desktop-version.ts --print)
[ "$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; }
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
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Promote the commit's green candidate
run: bun tools/release-candidate.ts promote --tag "$TAG" --sha "${{ steps.check.outputs.sha }}" --wait 40 ${{ steps.mode.outputs.dry == 'false' && '--yes' || '' }}
- name: Keep only the newest release (stable versions; tags stay)
if: ${{ steps.mode.outputs.dry == 'false' && !contains(steps.check.outputs.version, '-') }}
run: bun tools/forgejo-release.ts prune --keep "$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

@ -7,20 +7,62 @@ format; `storebound install --agent copilot` copies them to `.github/skills/` of
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), ## First rule: do it, don't ask
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping storebound plus you, the AI agent, do the work. The owner is not your pair of hands.
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. 1. **You do everything yourself:** API calls, uploads, builds, template and CSV downloads and exports, every console
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it navigation (open, Get Started, Edit, Next, Continue, Save draft), console forms, and the verification afterwards.
for real only after the owner agrees. Never tell the owner "run this yourself" or "click X yourself" for any of these.
3. **Read before you write.** `storebound check` (offline) and `storebound validate` (read-only, both stores) tell you 2. **The owner keeps only the final public or irreversible buttons:** Submit for review (App Review or Beta App
what is missing and in which order to fix it. Review), releasing a version or completing / resuming a production rollout, App Privacy Publish, accepting legal
4. **Credentials only from the environment or `.storebound/`** in the project (gitignored). Never ask for keys, agreements; plus deleting store data, changing live prices, sending review replies and inviting or removing
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
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,
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
are already in the config.
4. **Default loop:** plan, then you drive (the API first, your browser tool for consoles without an API), then verify
(read it back), then continue. No round trip to the owner in between.
5. **Catch rejections offline.** Before a write, `storebound check` and `--dry` must catch what the store would refuse
(live-shape fixtures, id and pair checks). A failure belongs in the dry run, not in a 400 at write time; when a
store refuses something the dry run passed, fix the check (pitfalls), not just the data.
Every hour of "please click this" is an hour the owner did not want to spend. If you catch yourself writing "you
need to ...", do it instead, unless it is one of the owner steps in point 2.
## The other rules
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner presses only the owner steps** (point 2 of the first rule). Prepare them completely, say what will
happen, and let the owner press the button, or run the command only after a clear yes in chat. storebound's MCP
server does not offer these at all. Everything else you run yourself.
2. **Dry run first, then run it yourself.** Every write command has `--dry` (MCP: `dry_run`, default true). Run the
dry run, check and show it (the rows that change, not just a count), then run the real command yourself (`--yes`
where it asks for a confirmation) and read the result back. Wait for the owner's yes only before an owner step.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
(`~/.storebound/credentials/<profile>.json`, named by `--account <profile>` or a workspace). Never ask for keys,
passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes
for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there. for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there.
5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating, 5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating,
target audience: ask the owner when the code does not prove the answer. A wrong "no" is worse than a slow "ask". target audience: ask the owner when the code does not prove the answer, all such questions in one batch at the
start (first rule, point 3), and write the answers into the config. A wrong "no" is worse than a slow "ask".
6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest". 6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest".
7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and 7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and
you report the progress lines, not guesses. you report the progress lines, not guesses.
@ -29,40 +71,64 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in 9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits, `.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
and never ask for tester emails in chat (storebound masks them in its output). and never ask for tester emails in chat (storebound masks them in its output).
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
that writes `window.__log` (one evaluate call times out around 45 s), stop only before the final button (Publish,
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.
`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
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`; outside the plugin: `npx storebound` on Node 20+ or `bunx storebound` on Bun); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound scan` (offline rejection risks in the project files), `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
| skill | for | | skill | for |
|---|---| |---|---|
| `account-deletion` | Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in | | `account-deletion` | Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in |
| `adhoc-distribution` | Get a test build of a mobile app or game onto specific devices without a store review |
| `ads-levelplay-checks` | Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question | | `ads-levelplay-checks` | Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question |
| `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release | | `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release |
| `app-icons` | Generate every icon a mobile app or game needs from one artwork | | `app-icons` | Generate every icon a mobile app or game needs from one artwork |
| `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) | | `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) |
| `app-store-promotion` | App Store promotion tools for a mobile app or game through the App Store Connect API |
| `apple-ads` | Read Apple Ads (formerly Apple Search Ads) results for a mobile app or game and make small, owner-approved bid, daily budget and pause changes |
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game | | `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
| `aso-basics` | App store optimization basics for a mobile app's or game's text | | `aso-basics` | App store optimization basics for a mobile app's or game's text |
| `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 |
| `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 |
| `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 |
| `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) | | `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) |
| `ppp-pricing` | Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp` |
| `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game | | `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game |
| `privacy-policy` | Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games | | `privacy-policy` | Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games |
| `release-flows` | Repo-local workflows for a mobile app or game |
| `release-management` | Ship updates safely after the first release | | `release-management` | Ship updates safely after the first release |
| `revenuecat-sync` | Optional adapter that keeps a RevenueCat project's catalog in step with the storebound config |
| `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in | | `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in |
| `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game | | `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game |
| `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way | | `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way |
| `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git | | `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency |
| `store-api` | Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely |
| `store-declarations` | Answer the store compliance forms for a mobile app or game | | `store-declarations` | Answer the store compliance forms for a mobile app or game |
| `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores | | `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores |
| `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list | | `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list |
| `store-pitfalls` | Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each |
| `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game | | `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game |
| `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages | | `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages |
| `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config | | `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config |
| `testflight` | Run TestFlight for a mobile app or game through the App Store Connect API | | `testflight` | Run TestFlight for a mobile app or game through the App Store Connect API |
| `web-presence` | Set up the web side of a mobile app or game |
| `whats-new` | Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag | | `whats-new` | Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag |

6
.gitignore vendored
View file

@ -15,3 +15,9 @@ apps/desktop/src-tauri/binaries/
apps/desktop/src-tauri/gen/ apps/desktop/src-tauri/gen/
apps/desktop/.screens/ apps/desktop/.screens/
.scratch/ .scratch/
# scan fixtures: a monorepo whose Capacitor plugins live in apps/native/node_modules
!tests/fixtures/scan/monorepo/apps/native/node_modules/
# npm bundle (bun tools/build-node.ts; npm pack / publish build it)
/dist/

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

@ -8,20 +8,62 @@ file below and follow it step by step.
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), ## First rule: do it, don't ask
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping storebound plus you, the AI agent, do the work. The owner is not your pair of hands.
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. 1. **You do everything yourself:** API calls, uploads, builds, template and CSV downloads and exports, every console
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it navigation (open, Get Started, Edit, Next, Continue, Save draft), console forms, and the verification afterwards.
for real only after the owner agrees. Never tell the owner "run this yourself" or "click X yourself" for any of these.
3. **Read before you write.** `storebound check` (offline) and `storebound validate` (read-only, both stores) tell you 2. **The owner keeps only the final public or irreversible buttons:** Submit for review (App Review or Beta App
what is missing and in which order to fix it. Review), releasing a version or completing / resuming a production rollout, App Privacy Publish, accepting legal
4. **Credentials only from the environment or `.storebound/`** in the project (gitignored). Never ask for keys, agreements; plus deleting store data, changing live prices, sending review replies and inviting or removing
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
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,
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
are already in the config.
4. **Default loop:** plan, then you drive (the API first, your browser tool for consoles without an API), then verify
(read it back), then continue. No round trip to the owner in between.
5. **Catch rejections offline.** Before a write, `storebound check` and `--dry` must catch what the store would refuse
(live-shape fixtures, id and pair checks). A failure belongs in the dry run, not in a 400 at write time; when a
store refuses something the dry run passed, fix the check (pitfalls), not just the data.
Every hour of "please click this" is an hour the owner did not want to spend. If you catch yourself writing "you
need to ...", do it instead, unless it is one of the owner steps in point 2.
## The other rules
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner presses only the owner steps** (point 2 of the first rule). Prepare them completely, say what will
happen, and let the owner press the button, or run the command only after a clear yes in chat. storebound's MCP
server does not offer these at all. Everything else you run yourself.
2. **Dry run first, then run it yourself.** Every write command has `--dry` (MCP: `dry_run`, default true). Run the
dry run, check and show it (the rows that change, not just a count), then run the real command yourself (`--yes`
where it asks for a confirmation) and read the result back. Wait for the owner's yes only before an owner step.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
(`~/.storebound/credentials/<profile>.json`, named by `--account <profile>` or a workspace). Never ask for keys,
passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes
for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there. for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there.
5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating, 5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating,
target audience: ask the owner when the code does not prove the answer. A wrong "no" is worse than a slow "ask". target audience: ask the owner when the code does not prove the answer, all such questions in one batch at the
start (first rule, point 3), and write the answers into the config. A wrong "no" is worse than a slow "ask".
6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest". 6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest".
7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and 7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and
you report the progress lines, not guesses. you report the progress lines, not guesses.
@ -30,44 +72,72 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in 9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits, `.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
and never ask for tester emails in chat (storebound masks them in its output). and never ask for tester emails in chat (storebound masks them in its output).
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
that writes `window.__log` (one evaluate call times out around 45 s), stop only before the final button (Publish,
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.
`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
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`; outside the plugin: `npx storebound` on Node 20+ or `bunx storebound` on Bun); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound scan` (offline rejection risks in the project files), `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
- `skills/account-deletion/SKILL.md`: Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in - `skills/account-deletion/SKILL.md`: Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in
- `skills/adhoc-distribution/SKILL.md`: Get a test build of a mobile app or game onto specific devices without a store review
- `skills/ads-levelplay-checks/SKILL.md`: Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question - `skills/ads-levelplay-checks/SKILL.md`: Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question
- `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release - `skills/ads-mediation-checks/SKILL.md`: Diagnose and verify ad mediation in a mobile app or game before and after store release
- `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork - `skills/app-icons/SKILL.md`: Generate every icon a mobile app or game needs from one artwork
- `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) - `skills/app-previews/SKILL.md`: Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows)
- `skills/app-store-promotion/SKILL.md`: App Store promotion tools for a mobile app or game through the App Store Connect API
- `skills/apple-ads/SKILL.md`: Read Apple Ads (formerly Apple Search Ads) results for a mobile app or game and make small, owner-approved bid, daily budget and pause changes
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game - `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text - `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
- `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/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/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
- `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) - `skills/portfolio-originality/SKILL.md`: Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins)
- `skills/ppp-pricing/SKILL.md`: Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp`
- `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game - `skills/privacy-manifest/SKILL.md`: Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game
- `skills/privacy-policy/SKILL.md`: Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games - `skills/privacy-policy/SKILL.md`: Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games
- `skills/release-flows/SKILL.md`: Repo-local workflows for a mobile app or game
- `skills/release-management/SKILL.md`: Ship updates safely after the first release - `skills/release-management/SKILL.md`: Ship updates safely after the first release
- `skills/revenuecat-sync/SKILL.md`: Optional adapter that keeps a RevenueCat project's catalog in step with the storebound config
- `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in - `skills/review-access/SKILL.md`: Give App Store and Google Play reviewers working access to an app behind a sign-in
- `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game - `skills/review-rejections/SKILL.md`: Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game
- `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way - `skills/review-replies/SKILL.md`: Reply to App Store and Google Play reviews of a mobile app or game the safe way
- `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git - `skills/store-analytics/SKILL.md`: Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency
- `skills/store-api/SKILL.md`: Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely
- `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game - `skills/store-declarations/SKILL.md`: Answer the store compliance forms for a mobile app or game
- `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores - `skills/store-health/SKILL.md`: Watch a released mobile app's or game's crash and ANR health on both stores
- `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - `skills/store-iap-setup/SKILL.md`: Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list
- `skills/store-pitfalls/SKILL.md`: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each
- `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game - `skills/store-preflight/SKILL.md`: Run and read the storebound readiness checks before submitting a mobile app or game
- `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages - `skills/store-screenshots/SKILL.md`: Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages
- `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config - `skills/store-subscriptions/SKILL.md`: Set up auto-renewable subscriptions on the App Store and Google Play from one config
- `skills/testflight/SKILL.md`: Run TestFlight for a mobile app or game through the App Store Connect API - `skills/testflight/SKILL.md`: Run TestFlight for a mobile app or game through the App Store Connect API
- `skills/web-presence/SKILL.md`: Set up the web side of a mobile app or game
- `skills/whats-new/SKILL.md`: Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag - `skills/whats-new/SKILL.md`: Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag
## Working on storebound itself ## Working on storebound itself
- Bun + TypeScript strict, zero runtime dependencies. `bun run ci` must pass (adapters fresh, tests, typecheck, scrub). - Bun + TypeScript strict, zero runtime dependencies. `bun run ci` must pass (adapters fresh, tests, typecheck, scrub).
- 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.
- 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.

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,38 @@ but fixes, store rule updates and new engines / AI tools are very welcome.
```sh ```sh
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 # adapters fresh, tests, typecheck, secrets / personal data scrub 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,
# 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,
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`,
`bun.lock`, `bunfig.toml`, `tsconfig.json` or the test preload runs every test. Run the full `bun run ci` before every
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
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
`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
checkout. The desktop Rust host (`cargo test`, `clippy`) runs only when `apps/desktop/src-tauri/` or
`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).
@ -17,6 +46,12 @@ Try the screenshot and video pipeline on the demo game without any store account
- **Bun + TypeScript strict, zero runtime dependencies.** `fetch` against the official store APIs, `node:` built-ins, - **Bun + TypeScript strict, zero runtime dependencies.** `fetch` against the official store APIs, `node:` built-ins,
external tools (`ffmpeg`, `xcrun`, `adb`) only through argv builders. Playwright is resolved from the user's project. external tools (`ffmpeg`, `xcrun`, `adb`) only through argv builders. Playwright is resolved from the user's project.
- **Runs on Node 20+ and Bun.** Development, tests and CI use Bun; users run the npm bundle with Node (`npx`) or Bun
(`bunx`). The CLI never touches the `Bun` global, `bun:` modules, `import.meta.dir` or text imports: everything goes
through `cli/lib/rt.ts` (spawn, which, sleep, serve, glob, hashing, stdin, the package folder, config imports), and
text files the CLI ships are embedded by `bun tools/gen-embedded.ts` (templates/, `cli/shots/*.html`, Play's form
export: run it after changing one). `tests/node-compat.test.ts` fails on a direct Bun use; `bun run ci` builds the
bundle (`tools/build-node.ts`) and runs it with the real `node` (`tools/node-smoke.ts`).
- **Pure where possible.** Rules, argv builders, plans and parsers are pure functions with tests in `tests/`; the IO - **Pure where possible.** Rules, argv builders, plans and parsers are pure functions with tests in `tests/`; the IO
stays in the command's `run`. Tests never need a store account, a simulator or a network. stays in the command's `run`. Tests never need a store account, a simulator or a network.
- **Every write command takes `--dry`** and is idempotent. New MCP write tools default to `dry_run: true`. Nothing - **Every write command takes `--dry`** and is idempotent. New MCP write tools default to `dry_run: true`. Nothing
@ -27,16 +62,39 @@ Try the screenshot and video pipeline on the demo game without any store account
- **Skills are the single source.** Edit `skills/*/SKILL.md` or `docs/agents-core.md`, then run - **Skills are the single source.** Edit `skills/*/SKILL.md` or `docs/agents-core.md`, then run
`bun tools/gen-adapters.ts`; never edit `AGENTS.md`, `GEMINI.md`, the plugin manifests or the Cursor / Copilot files `bun tools/gen-adapters.ts`; never edit `AGENTS.md`, `GEMINI.md`, the plugin manifests or the Cursor / Copilot files
by hand. Frontmatter is `name` + `description` only. by hand. Frontmatter is `name` + `description` only.
- **New or renamed flags: run `bun tools/gen-flags.ts`.** The CLI rejects a flag a command does not read
(`cli/data/flags.json`, generated from the commands and their imports in `cli/commands.ts`); a flag read with a
computed name goes into `DYNAMIC` in that tool. `tests/cli-help.test.ts` fails on a stale table, on a command whose
`--help` does anything but print its usage, and on an MCP / desktop tool that passes a flag its command does not take.
- **No secrets, no real ids, no personal data** in code, tests, fixtures or examples: use `com.example.*`, - **No secrets, no real ids, no personal data** in code, tests, fixtures or examples: use `com.example.*`,
`example.com` and obviously fake values. `bun run ci` runs the scrub. `example.com` and obviously fake values. `bun run ci` runs the scrub.
- **No hand-written SVG.** Charts come from visx, icons from lucide (`lucide-react` in the desktop app; the site's - **No hand-written SVG.** Charts come from visx, icons from Phosphor (`@phosphor-icons/react` in the desktop app, one
icons are lucide's own files, vendored by `bun tools/site-icons.ts` into `site/img/icons/` with the licence), per-icon import each; the site's icons are Phosphor's own files from `@phosphor-icons/core`), the App Store and
Google Play marks from simple-icons (vendored with the licences by `bun tools/site-icons.ts` into `site/img/icons/`
and `apps/desktop/src/ui/store-marks/`, byte-equal to the packages),
diagrams from Mermaid (`docs/diagrams/*.mmd`, rendered by `bun tools/diagrams.ts`), the desktop fixtures' art from diagrams from Mermaid (`docs/diagrams/*.mmd`, rendered by `bun tools/diagrams.ts`), the desktop fixtures' art from
`bun tools/fixture-art.ts`. The brand mark and `site/favicon.svg` are brand assets. `tests/no-handmade-svg.test.ts` `bun tools/fixture-art.ts`. The logo files (app icons, favicons, header marks, `site/og.png`) are PNG / ICO / ICNS from
fails on new inline `<svg>` / `<path>` drawing in `apps/desktop/src` and `site/*.html`. These libraries are desktop `tools/brand-assets.py` (`brand/README.md`). `tests/no-handmade-svg.test.ts`
fails on new inline `<svg>` / `<path>` drawing in `apps/desktop/src` and `site/*.html` and on SVG files that are
not vendored or rendered. These libraries are desktop
dependencies (bundled by Vite) or devDependencies; the CLI package keeps zero runtime dependencies. dependencies (bundled by Vite) or devDependencies; the CLI package keeps zero runtime dependencies.
- **Every bug found in real use becomes a guardrail.** Add an entry to `docs/pitfalls.md` and to
`skills/store-pitfalls/SKILL.md` (symptom, cause, what to do, what guards it) and, where possible, a check or a test
with recorded store answers that fails without the fix. A `--dry` run prints the plan (`dryPlan` in
`cli/lib/dry.ts`), not only a count; `tests/pitfalls.test.ts` enforces it.
- Plain English in docs and messages, short sentences, no marketing words. - Plain English in docs and messages, short sentences, no marketing words.
## Releasing the npm package
`npm pack` and `npm publish` run `prepack`, which builds `dist/storebound.mjs` (the `bin`, `#!/usr/bin/env node`,
gitignored) with `bun tools/build-node.ts`. Before publishing: `bun run ci` green (it smoke-tests the bundle with
Node), `npm pack --dry-run` lists `dist/storebound.mjs`, the privacy scan on the tree and on the bundle, and a run of
the packed tarball in an empty folder (`npx --yes ./storebound-<version>.tgz --version`, then `init`).
The npm package and the desktop app are one release with one version (`bun tools/desktop-version.ts --set <version>`
writes it everywhere): publish the desktop artifacts of that version first (docs/desktop-release.md), then
`npm publish`, so `npx storebound desktop` of the new CLI finds its own app.
## Pull requests ## Pull requests
One topic per PR, `bun run ci` green, a line in `CHANGELOG.md` under an "Unreleased" heading, and for anything One topic per PR, `bun run ci` green, a line in `CHANGELOG.md` under an "Unreleased" heading, and for anything

View file

@ -6,20 +6,62 @@ This extension ships Agent Skills (skills/) and the storebound MCP server for mo
These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal). These rules apply to every AI tool that uses storebound (skills, MCP server or the CLI in a terminal).
1. **The owner decides public and irreversible steps.** Submitting for review (App Review or Beta App Review), ## First rule: do it, don't ask
releasing a version, resuming or completing a rollout, canceling a submission, deleting products or listings,
accepting agreements, sending review replies, inviting or removing testers, changing prices, starting or stopping storebound plus you, the AI agent, do the work. The owner is not your pair of hands.
a store experiment: explain what will happen and let the owner do it (or run the command only after a clear yes in
chat). storebound's MCP server does not offer these at all. 1. **You do everything yourself:** API calls, uploads, builds, template and CSV downloads and exports, every console
2. **Dry run first.** Every write command has `--dry` (MCP: `dry_run`, default true). Show the dry run, then run it navigation (open, Get Started, Edit, Next, Continue, Save draft), console forms, and the verification afterwards.
for real only after the owner agrees. Never tell the owner "run this yourself" or "click X yourself" for any of these.
3. **Read before you write.** `storebound check` (offline) and `storebound validate` (read-only, both stores) tell you 2. **The owner keeps only the final public or irreversible buttons:** Submit for review (App Review or Beta App
what is missing and in which order to fix it. Review), releasing a version or completing / resuming a production rollout, App Privacy Publish, accepting legal
4. **Credentials only from the environment or `.storebound/`** in the project (gitignored). Never ask for keys, agreements; plus deleting store data, changing live prices, sending review replies and inviting or removing
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
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,
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
are already in the config.
4. **Default loop:** plan, then you drive (the API first, your browser tool for consoles without an API), then verify
(read it back), then continue. No round trip to the owner in between.
5. **Catch rejections offline.** Before a write, `storebound check` and `--dry` must catch what the store would refuse
(live-shape fixtures, id and pair checks). A failure belongs in the dry run, not in a 400 at write time; when a
store refuses something the dry run passed, fix the check (pitfalls), not just the data.
Every hour of "please click this" is an hour the owner did not want to spend. If you catch yourself writing "you
need to ...", do it instead, unless it is one of the owner steps in point 2.
## The other rules
**Read `skills/store-pitfalls/SKILL.md` before any store task.** It lists the mistakes already made in real releases
(store lists that lag after a create, dry runs without a plan, taken App Store names, byte limits, ...) and what to do.
1. **The owner presses only the owner steps** (point 2 of the first rule). Prepare them completely, say what will
happen, and let the owner press the button, or run the command only after a clear yes in chat. storebound's MCP
server does not offer these at all. Everything else you run yourself.
2. **Dry run first, then run it yourself.** Every write command has `--dry` (MCP: `dry_run`, default true). Run the
dry run, check and show it (the rows that change, not just a count), then run the real command yourself (`--yes`
where it asks for a confirmation) and read the result back. Wait for the owner's yes only before an owner step.
3. **Read before you write.** `storebound scan` (offline: rejection risks in the project itself), `storebound check`
(offline) and `storebound validate` (read-only, both stores) tell you what is missing and in which order to fix it.
4. **Credentials only from the environment, `.storebound/`** in the project (gitignored) **or a credential profile**
(`~/.storebound/credentials/<profile>.json`, named by `--account <profile>` or a workspace). Never ask for keys,
passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes passwords or tokens in chat, never print them, never write them into the config, docs or commits. The same goes
for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there. for the App Review demo account (`reviewDemoAccount` in `.storebound/credentials.json`): the owner puts it there.
5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating, 5. **Never answer a compliance question you cannot know.** Export compliance, Data safety, App Privacy, age rating,
target audience: ask the owner when the code does not prove the answer. A wrong "no" is worse than a slow "ask". target audience: ask the owner when the code does not prove the answer, all such questions in one batch at the
start (first rule, point 3), and write the answers into the config. A wrong "no" is worse than a slow "ask".
6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest". 6. **Pick builds by explicit number.** Use the build number / versionCode you just uploaded, never "the latest".
7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and 7. **Long uploads run in the background with a log** (`storebound play images > .storebound/images.log 2>&1 &`) and
you report the progress lines, not guesses. you report the progress lines, not guesses.
@ -28,40 +70,64 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in 9. **Personal data stays local.** Tester lists, review reply drafts, health reports and analytics snapshots live in
`.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits, `.storebound/` (gitignored). Never put tester emails, reviewer names or crash logs into issues, docs or commits,
and never ask for tester emails in chat (storebound masks them in its output). and never ask for tester emails in chat (storebound masks them in its output).
10. **Console forms without an API: drive them yourself, agent-first.** `storebound console plan <form> --json` (MCP
`console_plan`) gives the start URL, steps, the answer sheet, the buttons you must never press and the verified UI
facts. Drive the page with your own browser tool, adapt to what it shows, run long flows as an in-page async loop
that writes `window.__log` (one evaluate call times out around 45 s), stop only before the final button (Publish,
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.
`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
- CLI: `storebound <command>` (`storebound help`); config `storebound.config.ts`, credentials in env or `.storebound/`. - CLI: `storebound <command>` (`storebound help`; outside the plugin: `npx storebound` on Node 20+ or `bunx storebound` on Bun); config `storebound.config.ts`, credentials in env or `.storebound/`.
- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed. - MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.
- Readiness: `storebound check` (offline), `storebound validate` (read-only, both stores). - Readiness: `storebound scan` (offline rejection risks in the project files), `storebound check` (offline), `storebound validate` (read-only, both stores).
- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.
## Skills ## Skills
| skill | for | | skill | for |
|---|---| |---|---|
| `account-deletion` | Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in | | `account-deletion` | Make account deletion pass App Store and Google Play review for an app with sign-up or sign-in |
| `adhoc-distribution` | Get a test build of a mobile app or game onto specific devices without a store review |
| `ads-levelplay-checks` | Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question | | `ads-levelplay-checks` | Renamed to ads-mediation-checks (AdMob, AppLovin MAX and Unity LevelPlay / ironSource). Kept so existing installs and links keep working. Use ads-mediation-checks for any ad mediation, app-ads.txt, SKAdNetwork or "no fill" question |
| `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release | | `ads-mediation-checks` | Diagnose and verify ad mediation in a mobile app or game before and after store release |
| `app-icons` | Generate every icon a mobile app or game needs from one artwork | | `app-icons` | Generate every icon a mobile app or game needs from one artwork |
| `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) | | `app-previews` | Make App Store app preview videos and a Google Play promo video for a mobile app or game from real footage (gameplay or app flows) |
| `app-store-promotion` | App Store promotion tools for a mobile app or game through the App Store Connect API |
| `apple-ads` | Read Apple Ads (formerly Apple Search Ads) results for a mobile app or game and make small, owner-approved bid, daily budget and pause changes |
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game | | `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
| `aso-basics` | App store optimization basics for a mobile app's or game's text | | `aso-basics` | App store optimization basics for a mobile app's or game's text |
| `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 |
| `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 |
| `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 |
| `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) | | `portfolio-originality` | Originality gate for studios and developers that ship many similar mobile apps or games (templates, white-label apps, reskins) |
| `ppp-pricing` | Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp` |
| `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game | | `privacy-manifest` | Add and check Apple privacy manifests (PrivacyInfo.xcprivacy) for an iOS app or game |
| `privacy-policy` | Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games | | `privacy-policy` | Write and host one studio-wide privacy policy page for all of a developer's mobile apps and games |
| `release-flows` | Repo-local workflows for a mobile app or game |
| `release-management` | Ship updates safely after the first release | | `release-management` | Ship updates safely after the first release |
| `revenuecat-sync` | Optional adapter that keeps a RevenueCat project's catalog in step with the storebound config |
| `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in | | `review-access` | Give App Store and Google Play reviewers working access to an app behind a sign-in |
| `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game | | `review-rejections` | Triage and answer an App Store or Google Play rejection, policy warning or strike for a mobile app or game |
| `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way | | `review-replies` | Reply to App Store and Google Play reviews of a mobile app or game the safe way |
| `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git | | `store-analytics` | Take store analytics snapshots for a mobile app or game (App Store impressions, product page views, downloads and conversion through the Analytics Reports API; Google Play store listing visitors, acquisitions and installs from the Play reports bucket) and compare them with listing, screenshot and experiment changes from git; download App Store sales / finance reports and Play earnings / sales reports with a summary per product and currency |
| `store-api` | Call any App Store Connect API or Google Play Developer API operation that storebound's commands do not wrap, safely |
| `store-declarations` | Answer the store compliance forms for a mobile app or game | | `store-declarations` | Answer the store compliance forms for a mobile app or game |
| `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores | | `store-health` | Watch a released mobile app's or game's crash and ANR health on both stores |
| `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list | | `store-iap-setup` | Create and price in-app purchases (consumables, non-consumables like "remove ads" or a Pro unlock, non-renewing passes, coin/gem packs, starter packs) on Google Play and App Store Connect from one product list |
| `store-pitfalls` | Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each |
| `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game | | `store-preflight` | Run and read the storebound readiness checks before submitting a mobile app or game |
| `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages | | `store-screenshots` | Produce store screenshots and the Play feature graphic for a mobile app or game in every required size and many languages |
| `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config | | `store-subscriptions` | Set up auto-renewable subscriptions on the App Store and Google Play from one config |
| `testflight` | Run TestFlight for a mobile app or game through the App Store Connect API | | `testflight` | Run TestFlight for a mobile app or game through the App Store Connect API |
| `web-presence` | Set up the web side of a mobile app or game |
| `whats-new` | Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag | | `whats-new` | Write user-facing release notes ("What's new") for a mobile app or game update from the git log since the last tag |

635
README.md
View file

@ -1,579 +1,118 @@
<p><img src="https://git.crt.fyi/CRT/storebound/raw/branch/main/brand/logo-mark-256.png" width="96" height="96" alt="storebound logo"></p>
# storebound # storebound
**Website: [storebound.dev](https://storebound.dev) · npm: [storebound](https://www.npmjs.com/package/storebound) · `bunx storebound init`** **From an empty folder to "Submit for review" on Google Play and the App Store.**
Your AI agent does all of it with storebound: builds, uploads, store text, screenshots in every language and size,
in-app products and prices, the compliance forms, even the consoles that have no API. You only press Submit.
[![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)](LICENSE) [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://git.crt.fyi/CRT/storebound/src/branch/main/LICENSE)
[![version 0.5.0](https://img.shields.io/badge/version-0.5.0-informational)](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: Bun](https://img.shields.io/badge/runtime-Bun%201.1%2B-f9f1e1)](https://bun.sh) [![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)](package.json) [![Agent Skills: 39](https://img.shields.io/badge/Agent%20Skills-39-8a63d2)](https://git.crt.fyi/CRT/storebound/src/branch/main/skills)
[![Agent Skills: 25](https://img.shields.io/badge/Agent%20Skills-25-8a63d2)](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)](#mcp-server)
**Take a mobile app or game from nothing to "Submit for review" on Google Play and the App Store: app records, **Website: [storebound.dev](https://storebound.dev) · Docs: [docs/](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/README.md) · `npx storebound init`**
listings in every language, in-app products and subscriptions with regional prices, every questionnaire, account
deletion and review access, privacy, icons, screenshots and previews, with one config and your AI coding agent.**
Hey, I'm CRTkafa. I built this while getting my own game through both stores and hit every wall on the way: draft [![The storebound teaser: from an empty folder to Submit for review in under a minute (opens the video on storebound.dev)](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/teaser-poster.webp)](https://storebound.dev/#teaser)
apps, edits that die when you touch the Console, the "first IAP must ship with a version" dance, Data safety CSVs that
silently need one more answer, an IARC questionnaire, target audience, App Privacy labels that are saved but not
published, screenshots in 7 sizes x 14 languages... I wrote each fix down as code or as a checklist. None of those
walls is game-only: a to-do app with accounts and a subscription hits the same ones, plus a few of its own (a demo
account for the reviewer, account deletion, subscription disclosures), so storebound covers apps and games alike. It
works for me, so I open-sourced it. Use it too.
No account, no sign-up, no telemetry. A small Bun CLI that talks to the official store APIs, <sub>Watch the teaser on [storebound.dev](https://storebound.dev/#teaser): the demo game from an empty folder to both stores, then the desktop app.</sub>
Agent Skills that walk your AI coding tool (or you) through the parts that are still console clicks, and an MCP server
so agents can call the CLI safely. **storebound never presses Submit, Release or Publish for you.**
Pick a profile and start from honest templates: `init --profile app` (utility / productivity app, optional account, ## Your agent does the whole job
analytics, no ads; the default), `--profile subscription-app` (accounts + auto-renewable subscriptions) or
`--profile game` (free-to-play with ads and IAP).
It keeps going after launch: TestFlight groups and testers, crash and ANR vitals against the store thresholds, review **Do it, don't ask.** You ask once ("get my app ready for both stores"); your agent and storebound do the work:
replies you approve before they go out, prices for many products and regions from one CSV, store analytics next to the app records, builds and uploads, listings in every language, screenshots and previews in every store size, icons,
your listing changes, and App Store product page tests from your screenshot variants. in-app products and subscriptions priced in 175 countries, Data safety, App Privacy, age rating and every other form,
and the console pages that have no API (the agent drives them from a `console plan`). Each step is planned first:
![The journey: config, app records, products, listings, questionnaires, privacy and icons, store art, validate, then you press Submit](https://git.crt.fyi/CRT/storebound/raw/branch/main/docs/img/journey.svg) ![storebound release plan 1.0.0: every step left on both stores for a brand-new app, in fix order, with the owner's next step named](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/term/release-plan.webp)
## The journey You press only the final public or irreversible buttons (Submit for review, releasing, App Privacy Publish, legal
agreements) and answer, once and in one batch, the facts no file can prove. The full rules: [AGENTS.md](https://git.crt.fyi/CRT/storebound/src/branch/main/AGENTS.md);
the whole journey step by step: [docs/journey.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/journey.md).
Everything an app or game needs before review, in the order I do it. For each step: what storebound does through the store ## You won't get rejected for the avoidable things
APIs (or on your machine), what the skill walks your agent through where the stores have no public API, and what stays
your own click.
- **storebound**: a CLI command (also an MCP tool). Every write has `--dry`; MCP write tools default to a dry run. `storebound scan` reads your project (Xcode and Gradle files, manifests, lockfiles, SDKs, your listings) and the store
- **skill + agent (UI)**: no public API exists for it. The named skill has the exact navigation and the prepared state, and names every App Review or Play policy problem it can prove before the stores do: the guideline, the
answers. An agent with browser tools (a Chrome extension, computer use, a Playwright MCP ...) can click through it evidence as `file:line`, and the fix.
while you watch; without one, you click with the skill as your checklist.
- **you**: public, legal or money steps. The skills tell every agent to stop and hand these to you.
| # | step | storebound (API / local) | skill + agent (UI) | you | ![storebound scan on a sign-in app: blockers for Guideline 2.1(a) (no demo account) and 5.1.1(v) (no account deletion), each with its evidence and fix](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/term/scan.webp)
|---|---|---|---|---|
| 1 | **Set up** | `init --profile app\|game\|subscription-app` (config, `store/*.md`, declarations, App Privacy and privacy templates per profile), `install --agent <tool>`, `check` | Play: service account + API access invite; App Store: API team key (`play-first-release`, `appstore-first-release`) | developer accounts; Paid Applications agreement, banking and tax; Play payments profile |
| 2 | **App records and bundle id** | `apple bundle` registers the bundle id, `apple status` finds the app record | App Store Connect > New App (no API); Play Console > Create app; the very first AAB upload when Play needs it from the Console | the declarations you accept when creating the Play app |
| 3 | **Builds and testers** | `play next-version-code`, `play release app.aab` (internal track), `apple next-build`, `xcodebuild` archive + upload with the API key (the skill has the commands), `apple encryption`, `apple testflight groups / what-to-test / info / distribute` (internal groups) | Play internal testers list, Apple sandbox testers (`play-first-release`, `testflight`) | `apple testflight testers` (Apple emails the invitations), external groups and Beta App Review (`distribute --submit-review`) |
| 4 | **In-app products and subscriptions** | `play products` (regional prices from USD + your overrides, activated), `apple iap` (consumable, non-consumable, non-renewing; localizations, price schedule, availability), `apple iap-review`, `apple subscriptions` (groups, levels, equalized prices, intro and promotional offers, offer codes), `play subscriptions` (base plans, regional prices, offers), `catalog diff` (both stores against one catalog) | Play license testers, Apple sandbox testers, "Add for Review" of the first IAPs and subscriptions into the version's submission, Play promo codes (`store-iap-setup`, `store-subscriptions`) | custom offer codes (`apple subscriptions --codes` asks first) |
| 5 | **Store listings, every language** | `play listing` (title, short / full description, contact email + website, promo video URL), `apple listing --version x --build N` (name, subtitle, description, keywords, promo text, URLs, privacy URL, manual release), `whats-new`, `check` (limits), `check --aso` | Play store settings: category and tags (`play-first-release`) | |
| 6 | **Play questionnaires** | `play data-safety`: the whole Data safety form from `dataSafety` in the config (CSV import through the API) | Policy and programs > App content, answers prepared in `store/declarations.md` (`store-declarations`): privacy policy, app access with the demo account (`review-access`), delete-account URL (`account-deletion`), ads, content rating (IARC), target audience and content, advertising ID, news, government, financial and health features; Data safety summary check | the answers themselves are your statements: the agent asks when the code does not prove them |
| 7 | **App Store questionnaires** | `apple appinfo` (categories, age rating declaration, content rights, Free price, availability), `apple encryption` (export compliance from `apple.usesNonExemptEncryption`, never guessed), `apple review-details` (contact, demo account from `.storebound/`, notes) | App Privacy nutrition labels from `store/app-privacy.md`, then **Publish** (`store-declarations`); encryption documentation when the answer is yes (`export-compliance`); the first availability record if the API refuses it | the export compliance answer (a legal statement, set once in the config) |
| 8 | **Privacy and accounts** | privacy policy page template (`init`), `privacy-manifest --ios <path>` (PrivacyInfo.xcprivacy, required-reason APIs, SDK manifests), `validate` checks the policy, delete-account and terms URLs answer | host the pages, link them in both consoles and inside the app; in-app account deletion (`privacy-policy`, `privacy-manifest`, `account-deletion`) | |
| 9 | **Icons** | `icons`: App Store 1024 without alpha, Play 512, adaptive + legacy Android launcher icons, web icons, Capacitor sources | | |
| 10 | **Screenshots and previews** | `shots capture / render / check / gallery`, `play images`, `apple screenshots`, `shots record / video`, `apple previews`, `apple ppo create / upload` (App Store product page tests from `shots` variants; see [Store art](#store-art)) | Play store listing experiments (optional, `store-screenshots`) | uploading the promo video to YouTube; `apple ppo start / stop` |
| 11 | **Validate** | `check` (offline) and `validate` (read-only on both stores: every blocker in fix order, plus the local renders) | the UI-only items `validate` cannot see (`store-preflight` lists them) | |
| 12 | **Submit** | `play release --version-code N --production draft`, `apple status` | Play: countries / regions, open the production draft and save it (`play-first-release`) | **Play: Send changes for review. App Store: Submit for Review.** |
| 13 | **After review** | `apple status --wait-review`, `apple release --phased on / pause`, `play rollout --halt`, reply drafts (`review-rejections`) | | releasing the approved version, resuming or completing a phased release or rollout (terminal commands that ask first, `release-management`) |
| 14 | **Live app** | `health [--weekly]` (crash / ANR vitals vs Play's thresholds, App Store diagnostics; also in `validate`), `reviews list / draft`, `analytics snapshot / compare`, `prices export`, `prices import --dry`, `apple ppo status` | `store-health`, `review-replies`, `store-analytics`; Play statistics come from the reports bucket (no API) | `reviews send` (approved drafts only), `prices import` (asks first) |
New personal Play developer accounts also need a closed test with 12+ testers for 14 days before production; the Every rule, what it proves and what only a reviewer can see: [docs/rejections.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/rejections.md).
`play-first-release` skill covers it.
### API or console, honestly ## The desktop app: every app, both stores, one window
- **Through the API** (storebound does it): bundle id, builds on tracks, versions and build selection, listings in Every app and game you ship on Google Play and the App Store side by side: releases, charts, catalog and prices,
every language, graphics, screenshots, app previews, in-app products and prices (also from one CSV), Play Data crash health, reviews, TestFlight and the rejection checks. Send for review from there too: a dry run first, then a
safety, Apple age rating, categories, content rights, price and availability, export compliance answer, App Review confirm, then your click. Same storebound underneath, same version number as the CLI, and the same store accounts
details with the demo account, subscriptions (Apple groups, levels, prices, introductory and promotional offers, and app folders as your terminal (`~/.storebound`).
offer codes; Play base plans, regional prices, offers), phased release and staged rollout, TestFlight groups / testers / What to Test / Test Information, crash and
ANR vitals and App Store diagnostics, review replies, App Store analytics reports, App Store Product Page
Optimization tests.
- **Console only** (no public API; the skill + your agent's browser, or you): creating the App Store Connect app record
and the Play app, Play App content (IARC content rating, target audience, ads, advertising ID, app access,
government / financial / health / news, app access with the demo account), Play category and tags, Play countries,
App Privacy labels and their Publish, adding first IAPs and subscriptions to the submission, Play promo codes, Play store listing experiments, test results of both stores' experiments.
Play store statistics have no API either: storebound reads the monthly exports from your Play reports bucket.
- **Only you**: agreements, banking and tax, and the final Send for review / Submit for Review. Releases and
rollouts after approval, Beta App Review, tester invitations, sending review replies, applying price changes and
starting / stopping a product page test too. None of these is an MCP tool; the terminal commands ask first.
## 5-minute quick start ![storebound desktop, Releases › Send for review: the dry run of the submission with the build picked by number and the confirm step](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/send-for-review-dark.webp)
You need [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video). <details>
<summary>More screens: portfolio, growth charts, prices, checks, TestFlight, console plan</summary>
![Portfolio: every app of every studio with its state on both stores](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/portfolio-dark.webp)
![Growth: store analytics chart with the day under the pointer](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/growth-dark.webp)
![Prices: products by key territory, both stores' prices per country, gaps marked](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/prices-dark.webp)
![Checks: rejection risks with evidence and the fix](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/checks-dark.webp)
![TestFlight: groups, testers and builds](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/testflight-dark.webp)
![Console plan: the steps, answer sheet and never-press list for a console form without an API](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/console-plan-dark.webp)
</details>
`npx storebound desktop` installs it for you (macOS, Windows, Linux; signature checked, no admin rights) and opens it on
the current app folder. Manual downloads: [storebound.dev/#download](https://storebound.dev/#download). Internals: [docs/desktop.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop.md).
## Works with your AI, safe by design
Skills, an MCP server and the CLI for Claude Code, Codex, Cursor, Gemini CLI, Copilot, Windsurf, OpenCode or
anything that reads `AGENTS.md`. Every write shows a dry run first; submit, release and delete are not MCP tools; keys
stay in env vars or the gitignored `.storebound/`.
![storebound play products --dry: two in-app products planned with regional prices in 174 Google Play regions, nothing written](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/term/products.webp)
Per-tool setup: [docs/getting-started.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/getting-started.md#install-for-your-ai-tool) · MCP tools:
[docs/mcp.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/mcp.md) · Safety model: [docs/safety.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/safety.md).
## Install and start
You need [Node](https://nodejs.org) 20+ or [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
```sh ```sh
cd my-app cd my-app
bun add -d storebound playwright # or: bun add -d git+https://git.crt.fyi/CRT/storebound.git playwright npx storebound init # storebound.config.ts + .storebound/ in this project (--profile app | subscription-app | game)
bunx storebound init # --profile app (default) | subscription-app | game: config, store/*.md, declarations, privacy policy, .storebound/ (gitignored) npx storebound desktop # installs the desktop app once (asks first) and opens it on this folder
# fill in storebound.config.ts and the store/ files
bunx storebound check # offline: config, copy limits, Data safety answers, products, files
``` ```
Add credentials (below), then walk the journey with dry runs first: One product, installed once per machine: the CLI (`npx` / `bunx`) and the desktop app have the same version, the same
store accounts (`~/.storebound/credentials`, `npx storebound accounts`) and the same list of app folders. Each project
has its `storebound.config.ts` and a gitignored `.storebound/` (upload keystore, review contact). Then
`npx storebound install --agent claude` (or codex, cursor, gemini, copilot, windsurf, opencode) and
`npx storebound check`. Every `npx storebound ...` works as `bunx storebound ...` too.
```sh Then ask your agent: "take my app to Google Play and the App Store". Claude Code plugin, credentials, coming from
bunx storebound apple bundle --dry fastlane: [docs/getting-started.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/getting-started.md).
bunx storebound play listing --dry
bunx storebound play products --dry && bunx storebound apple iap --dry
bunx storebound apple subscriptions --dry && bunx storebound play subscriptions --dry
bunx storebound apple review-details --dry # contact + demo account from .storebound/ (masked) + notes
bunx storebound play data-safety --dry --csv store/data-safety.csv
bunx storebound apple appinfo --dry
bunx storebound icons --dry
bunx storebound shots capture && bunx storebound shots render && bunx storebound shots gallery
bunx storebound validate # read-only: what still blocks a submission, per store, in fix order
bunx storebound catalog diff # read-only: products + subscriptions on both stores vs the config, prices per region
```
After launch: ## What it covers
```sh Store screenshots of the demo game, rendered by storebound in every size and language:
bunx storebound apple testflight what-to-test --build 42 --dry # then distribute --build 42 --group Team
bunx storebound health --weekly # vitals + diagnostics, .storebound/health/<date>.md
bunx storebound reviews draft # reply drafts in .storebound/replies/, then reviews send
bunx storebound prices export && bunx storebound prices import --dry
bunx storebound analytics snapshot && bunx storebound analytics compare
```
Or ask your agent: "take my app to Google Play and the App Store", "fill in the store questionnaires", "set up the ![Store screenshots rendered by storebound: four scenes in English plus the hero scene in Turkish, Japanese and Arabic](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/showcase.webp)
in-app purchases and a yearly subscription with a free trial on both stores", "do both stores sell the same
catalog", "the reviewer could not sign in", "make store screenshots in 5 languages", "is my app ready to submit", "why was my
update rejected", "put build 42 on TestFlight", "how are our crash rates", "answer the new reviews", "did the new
screenshots help". The matching skill takes over.
### Coming from fastlane - [The journey](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/journey.md): every step from setup to Submit, what is API, what the agent drives in a console, what stays yours
- [Rejection scan](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/rejections.md): every rule with its App Store guideline or Play policy
- [Commands](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/commands.md): the full command reference
- [Store art](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/store-art.md): screenshots, fonts, previews, variants
- [Growth extras and console forms](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/growth.md): PPP prices, Apple Ads, web presence, RevenueCat, `console plan` / `console fill`
- [Many apps, release trains, workflows](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/studios.md) · [Listings in more languages](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/listing-languages.md) · [Every API endpoint](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/api.md)
- [Skills](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/skills.md) · [MCP server](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/mcp.md) · [Safety model](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/safety.md) · [Idempotency](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/idempotency.md) · [Pitfalls](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/pitfalls.md)
- [Desktop app](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop.md) · [Desktop releases](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop-release.md) · [FAQ](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/faq.md) · [Roadmap, contributing, credits](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/project.md)
`storebound import fastlane` turns an existing fastlane setup into storebound files. It only reads (no Ruby is run), ## Contributing and license
never overwrites anything and prints what it could not map:
```sh Issues and pull requests are welcome; see [CONTRIBUTING.md](https://git.crt.fyi/CRT/storebound/src/branch/main/CONTRIBUTING.md) (`bun run ci` must be green).
bunx storebound import fastlane --dry # the plan + report; default --dir: fastlane/, ios/fastlane/, android/fastlane/ MIT, see [LICENSE](https://git.crt.fyi/CRT/storebound/src/branch/main/LICENSE). Copyright (c) 2026 CRTkafa. Not affiliated with Google or Apple.
bunx storebound import fastlane # writes the files below; an existing file gets a <name>.imported.<ext> copy
bunx storebound init # adds the remaining templates, keeps the imported files
bunx storebound check
```
| fastlane | storebound |
|---|---|
| Appfile `app_identifier`, `package_name` | `apple.bundleId`, `play.packageName` |
| deliver `metadata/<locale>/*.txt` (+ `default/`) | `store/appstore.md` per App Store locale; URLs, copyright, categories and review notes in the config |
| supply `metadata/android/<lang>/*.txt`, newest `changelogs/<versionCode>.txt` | `store/listing.md` per Play language |
| deliver and supply language folders | `languages[]` with both store codes (`tr` + `tr-TR`, `zh-Hans` + `zh-CN`, `he` + `iw-IL`) |
| `screenshots/<locale>/`, `images/phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames/<lang>/<phone\|tablet>/NN.png` with a `folder` capture source |
| supply `icon`, `featureGraphic` | `play.icon`, `store/renders/<lang>/feature/1024x500.*` |
Keys and accounts are never copied: `json_key_file`, the API key, team ids, the App Review contact and demo account are
named in the report with where they go (`.storebound/credentials.json`). Submission and release flags, `price_tier`,
the age rating config, lanes and signing (`match`) are listed with their storebound counterpart. The
`migrate-from-fastlane` skill walks an agent through it.
### Credentials (never in the repo)
Read from environment variables, or from `.storebound/credentials.json` in your project (gitignored by `init`):
| name | what |
|---|---|
| `PLAY_SERVICE_ACCOUNT` | path to the Google Cloud service account JSON key (or the JSON itself) with access to your app in Play Console |
| `ASC_KEY_ID`, `ASC_ISSUER_ID` | App Store Connect API team key (Users and Access > Integrations) |
| `ASC_P8` | path to `AuthKey_<id>.p8` (default `~/.appstoreconnect/private_keys/AuthKey_<id>.p8`) |
| `reviewContact` | (file only) App Review contact name / phone / email |
| `reviewDemoAccount` / `REVIEW_DEMO_USER`, `REVIEW_DEMO_PASSWORD` | demo account for App Review (`reviewAccess.signInRequired`); never in the config or the repo, masked in logs |
## Install for your AI tool
Every route gives the agent the same skills and the same MCP tools. Nothing here requires Claude: the skills are
plain Markdown and the CLI is a normal command.
| tool | install |
|---|---|
| Claude Code | `/plugin marketplace add https://git.crt.fyi/CRT/storebound.git` then `/plugin install storebound@crtkafa` (skills, MCP server, `storebound` on PATH). Or per project: `bunx storebound install --agent claude` |
| OpenAI Codex | `bunx storebound install --agent codex` (skills in `.agents/skills`, MCP in `.codex/config.toml`; Codex reads that file for trusted projects). The repo is also a Codex plugin (`.codex-plugin/plugin.json`, skills only) |
| Gemini CLI | `gemini extensions install https://git.crt.fyi/CRT/storebound.git` (skills, MCP server, `GEMINI.md`). Or `bunx storebound install --agent gemini` |
| Cursor | `bunx storebound install --agent cursor` (skills in `.agents/skills`, `.cursor/mcp.json`); the repo's `.cursor/rules/storebound.mdc` is an agent-requested rule you can copy |
| VS Code / GitHub Copilot | `bunx storebound install --agent copilot` (skills in `.github/skills`, `.vscode/mcp.json`) |
| Windsurf (Devin Desktop) | `bunx storebound install --agent windsurf` (skills in `.agents/skills`, MCP in your global `mcp_config.json`) |
| OpenCode | `bunx storebound install --agent opencode` (skills in `.opencode/skills`, `opencode.json`) |
| anything that reads `AGENTS.md` | copy or link `AGENTS.md` (rules + skill index) and use the CLI |
`install` takes several agents at once (`--agent codex,cursor`), `--dry` to preview, `--no-skills` / `--no-mcp`,
and `--command "<cmd>"` to choose how the MCP server starts. It only adds or updates the `storebound` entry and its
own skill folders; everything else in those files stays as it is, and re-running it is safe.
## Commands
Every write command takes `--dry`; re-runs are idempotent; output is one timestamped line per step, so long jobs can
run in the background with a log (`storebound shots render > .storebound/render.log 2>&1 &`).
<details>
<summary><b>Setup and checks</b></summary>
| command | kind | what |
|---|---|---|
| `init [--profile app\|game\|subscription-app]` | local | copy the templates of a profile (default app), gitignore `.storebound/` |
| `import fastlane [--dir fastlane] [--profile ...]` | local | config, `store/appstore.md`, `store/listing.md` and screenshot frames from a fastlane setup; never overwrites, reports what is not mapped |
| `install --agent <tool>` | local | skills + MCP entry for an AI tool |
| `mcp` | local | stdio MCP server wrapping the commands |
| `serve --stdio` | local | JSON-RPC session for the storebound desktop app (see [Desktop machine interface](#desktop-machine-interface)) |
| `inspect [--json]` | local | offline summary of the config: name, store ids, languages, products |
| `check` | local | config, copy limits, data safety, products, subscriptions, profile (delete-account URL, demo account, terms URL), files; warns on untranslated release notes |
| `catalog diff [--store] [--threshold 10]` | API, read-only | one catalog vs both stores: missing / extra / mismatched products and subscriptions, US and override prices, locales, offers, per-region price differences between the stores; exit 1 on mismatches |
| `check --aso` | local | keyword audit: App Store keyword field rules, Play stuffing / claims |
| `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left |
| `whats-new [--since <tag>]` | local | draft player-facing release notes from git log into both listing files |
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
| `desktop [--download]` | local | open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, `--download` saves it to ~/Downloads after checking its signature (never installs) |
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
</details>
<details>
<summary><b>Google Play</b></summary>
| command | kind | what |
|---|---|---|
| `play listing` | API | app details, title / short / full description per language, promo video URL (`play.video`) |
| `play images [--variant b]` | API | icon, feature graphic, phone / 7" / 10" screenshots, one edit per language |
| `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate |
| `play subscriptions [--csv]` | API | subscriptions: listings + benefits, one auto-renewing base plan, regional prices, intro and extra offers, activate (plan first, asks) |
| `play data-safety` | API | Data safety form (CSV import); `--csv` writes the file |
| `play release <aab>` | API | upload + tracks (`--internal completed --production draft`, `--production inProgress --fraction 0.05`), release notes |
| `play rollout` | API | `--fraction x`, `--halt`, `--resume`, `--complete` (asks first, except halt) |
| `play next-version-code` | API, read-only | next free versionCode |
| `play status` | API, read-only | tracks, listings, products |
</details>
<details>
<summary><b>App Store</b></summary>
| command | kind | what |
|---|---|---|
| `apple bundle` | API | register the bundle id, find the app record |
| `apple listing --version x.y.z [--build N]` | API | version (manual release), the build with exactly that number, texts per locale |
| `apple screenshots [--variant b]` | API | 6.9" + 6.5" (+ 13" iPad with `apple.ipad`, + 6.3" with `apple.iphoneMedium`) per locale, MD5-idempotent |
| `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame |
| `apple iap` / `apple iap-review` | API | IAPs (consumable, non-consumable, non-renewing), localizations, price schedules, availability / review screenshots |
| `apple subscriptions [--csv] [--codes]` | API | groups, subscriptions, levels, localizations, availability, equalized prices + overrides + CSV (scheduled changes), introductory and promotional offers, offer code campaigns, review screenshots (plan first, asks; custom codes only with `--codes`) |
| `apple appinfo` | API | categories, age rating, content rights, Free, territories |
| `apple review-details` | API | App Review contact, demo account (from `.storebound/`, masked), notes (+ sign-in steps, where account deletion is) |
| `apple encryption` | API | export compliance answer from `apple.usesNonExemptEncryption` (never guessed) |
| `apple release --phased on\|pause\|resume\|complete`, `--manual-release` | API | phased release, release an approved version (resume / complete / release ask first) |
| `apple next-build` | API, read-only | next safe build number |
| `apple status` | API | versions, builds, IAPs, submissions; `--wait-review` (exit 0 / 1 / 7); `--cancel-submission <id>` |
| `apple ppo status \| create --variant b \| upload --variant b` | API | Product Page Optimization test from `shots` variants: treatments, localizations, screenshot sets |
| `apple ppo start \| stop` | API | start / stop the test (asks first; a stopped test cannot be restarted) |
</details>
<details>
<summary><b>TestFlight</b></summary>
| command | kind | what |
|---|---|---|
| `apple testflight status` | API, read-only | groups, testers and builds per group, recent builds with internal / external beta states |
| `apple testflight groups` | API | create the groups of `apple.testflight.groups` that are missing |
| `apple testflight testers [--csv f] [--remove] [--invite]` | API | testers from a CSV in `.storebound/` into their groups (asks first; emails masked) |
| `apple testflight what-to-test --build N` | API | "What to Test" per locale from `store/appstore.md` (`whatToTest`, else `whatsNew`) |
| `apple testflight info` | API | Beta App Description, feedback email, URLs, Beta App Review contact and notes |
| `apple testflight distribute --build N --group a,b [--submit-review]` | API | the build to groups; external groups and Beta App Review ask first |
</details>
<details>
<summary><b>After launch</b></summary>
| command | kind | what |
|---|---|---|
| `health [--weekly] [--days 7]` | API, read-only | Play vitals (user-perceived crash / ANR vs 1.09 % / 0.47 %, devices, top issues), App Store metrics, diagnostics, TestFlight crashes; `--weekly` writes a Markdown report |
| `reviews list [--unanswered]` | API, read-only | recent reviews of both stores |
| `reviews draft` | API + local | one reply draft per unanswered review in `.storebound/replies/` |
| `reviews send` | API | sends drafts with `approved: true` (checks length and incentives, asks first) |
| `prices export` | API + local | current prices of every product, both stores, as `store/prices.csv` |
| `prices import [--reconvert]` | API | diff table, then (asks first) Apple price schedules incl. future-dated changes and Play regional prices |
| `analytics setup` | API | App Store analytics report request (once, Admin key) |
| `analytics snapshot [--days 28]` | API + local | impressions, page views, downloads (App Store), visitors, acquisitions, installs (Play reports bucket) -> `.storebound/analytics/` |
| `analytics compare` | local | snapshots vs the git history of the store files: before / after each listing change |
</details>
<details>
<summary><b>Screenshots and video</b></summary>
| command | kind | what |
|---|---|---|
| `shots capture [--source web\|ios-sim\|android-emu\|folder]` | local | raw frames from your running app or game |
| `shots fonts` | network | font subsets for your copy (Noto for CJK / Arabic / Cyrillic / Devanagari ...) |
| `shots render [--variant b \| --variants]` | local | every store size and language in the game or app style, feature graphic, contact sheet, overflow report |
| `shots jpeg` | local | JPEG q92 copies of the Play renders (about 4x faster uploads) |
| `shots check [--variants] [--json]` | local, read-only | store rules: sizes, alpha, format, bytes, counts, overflow, videos, folder frames |
| `shots gallery` | local | `<renders>/index.html` review page |
| `shots record [--source ...]` | local | app or gameplay clips: web (Chromium screencast), simulator, emulator, folder |
| `shots video [--target iphone,ipad,youtube]` | local | app previews + promo video with captions, poster, checks |
</details>
## Store art
Screenshots and previews are one step of the journey, but the one that takes the longest by hand, so storebound
does a lot here. Two looks: `shots.style: 'game'` (sunburst, sparkles, badges) and `shots.style: 'app'` (calm
gradient, a clean device frame, headline + subline). Everything below was made by storebound itself from two tiny
fictional demos: [a game](examples/demo-game) ("Prism Puddle", procedural art) and [an app](examples/demo-app)
("Tidy Lists", one static page with sample data): raw frames captured from the web build, captions in several
languages, every store size, a review gallery and an app preview video. No store account needed to try it.
![App style: four calm screenshots and the feature graphic of the demo list app, rendered by storebound](https://git.crt.fyi/CRT/storebound/raw/branch/main/docs/img/app-style.jpg)
![Store screenshots rendered by storebound: four scenes in English plus the hero scene in Turkish, Japanese and Arabic](https://git.crt.fyi/CRT/storebound/raw/branch/main/docs/img/showcase.jpg)
| Play feature graphic | App preview (App Store, 886 x 1920) |
|---|---|
| ![Play feature graphic, 1024 x 500](https://git.crt.fyi/CRT/storebound/raw/branch/main/docs/img/feature.jpg) | ![A few seconds of the app preview video](https://git.crt.fyi/CRT/storebound/raw/branch/main/docs/img/preview.gif) |
![The review gallery: every language, size, scene and variant on one local page, store check issues highlighted](https://git.crt.fyi/CRT/storebound/raw/branch/main/docs/img/gallery.jpg)
<details>
<summary>Contact sheet of one language (every size, packed into a grid)</summary>
![Contact sheet: iPhone 6.9" and 6.5", 13" iPad, Play phone, 7" and 10" tablets, feature graphic](https://git.crt.fyi/CRT/storebound/raw/branch/main/docs/img/sheet.jpg)
</details>
### How it works
1. **Frames.** Give your app or game a few switches (language, demo data or a fixed seed, no ads, jump to a screen,
freeze, autoplay) and
let `shots capture` take the frames: Playwright for a web build, `simctl` for anything that runs in the iOS
Simulator (Unity, Godot, Flutter, React Native, native, Capacitor), `adb` for an Android emulator or device, or bring a folder of
your own frames. The status bar is cropped off and redrawn clean (9:41, full battery).
2. **Copy.** Per language and scene: eyebrow, two-line title, subtitle, badges. Fonts are subset from Google Fonts to
exactly your characters, so Japanese, Chinese, Korean, Arabic (right to left) and Hindi just work.
3. **Render.** Every store size at exact pixels: iPhone 6.9" and 6.5", 13" iPad (if your app runs on iPad), Play
phone, 7" and 10" tablets, the 1024 x 500 feature graphic. 24-bit RGB, no alpha (both stores reject it).
4. **Check and review.** `shots check` applies the store rules locally (sources cited in `cli/shots/spec.ts`);
`shots gallery` puts everything on one page with problems outlined. `validate` repeats the checks next to the
store state.
5. **Variants.** Other first screenshot, order or copy as `shots.variants`, rendered side by side, for a Play store
listing experiment or an App Store Product Page Optimization test (set up in the consoles; storebound makes and
checks the images).
6. **Video.** `shots record` films the autoplay mode, `shots video` cuts 15-30 s App Store previews (886 x 1920,
1200 x 1600) and a 9:16 / 16:9 promo for YouTube with captions in every language, `apple previews` uploads them.
Play takes a YouTube URL in `play.video`.
Details: the [store-screenshots](skills/store-screenshots/SKILL.md) and [app-previews](skills/app-previews/SKILL.md)
skills.
## Skills
In journey order. Each one says which steps are API (storebound) and which are console clicks, with the exact
navigation.
| skill | for |
|---|---|
| `migrate-from-fastlane` | `storebound import fastlane`: ids, listings, languages and screenshots from fastlane; keys and accounts stay out |
| `play-first-release` | Google Play from zero to "In review" |
| `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key |
| `testflight` | beta groups, testers from a CSV that never enters git, What to Test, Test Information, Beta App Review |
| `store-iap-setup` | in-app products on both stores (consumable, non-consumable, non-renewing), prices and the price sheet CSV, catalog diff, testers, the first-IAP-with-version rule |
| `store-subscriptions` | subscription groups, levels, base plans, prices, intro and promotional offers, offer codes, disclosure and Terms (3.1.2), first submission |
| `review-access` | demo account for reviewers (App Review fields, Play App access), never committed |
| `account-deletion` | Apple 5.1.1(v) in-app deletion, Play delete-account web page and Data safety |
| `aso-basics` | keyword field, subtitle, cross-localization, Play placement without stuffing |
| `store-declarations` | every questionnaire: Play Data safety (API) and App content (IARC, target audience, ads, advertising ID, app access, government / financial / health), Apple App Privacy labels and age rating |
| `export-compliance` | encryption answer, Info.plist key, declarations; never exempt by default |
| `privacy-policy` | one policy page for all your apps with a per-app services table (templates per profile) |
| `privacy-manifest` | PrivacyInfo.xcprivacy, required-reason APIs, SDK manifests (ITMS-91053 / 91061) |
| `app-icons` | one artwork to every icon, adaptive / legacy launcher pitfalls |
| `store-screenshots` | capture from any engine, copy, fonts, render, checks, gallery, variants, App Store product page tests, Play experiments, upload |
| `app-previews` | app or gameplay clips, App Store previews, Play promo video, checks, upload |
| `store-preflight` | run and read `check`, `check --aso`, `validate`, `privacy-manifest` before submitting |
| `release-management` | updates: Apple phased + manual release, Play staged rollout, halt / pause (not a rollback), review wait |
| `store-health` | crash / ANR vitals vs Play's bad behavior thresholds, App Store diagnostics, weekly report |
| `review-replies` | reply drafts, tone, no incentives, store limits, the owner approves and sends |
| `store-analytics` | App Store analytics reports, Play reports bucket, before / after listing changes |
| `whats-new` | player-facing release notes from git, localized, within store limits |
| `review-rejections` | triage a rejection (fix / clarify / push back / appeal), reply template, the guidelines apps and games hit |
| `ads-mediation-checks` | AdMob, AppLovin MAX and LevelPlay: ids, debug tools, adapters, app-ads.txt, SKAdNetwork, no-fill (`ads-levelplay-checks` stays as a pointer) |
| `portfolio-originality` | Guideline 4.3 gate for developers shipping many similar apps or games |
## MCP server
`storebound mcp` speaks MCP over stdio, tools only: spec revision **2026-07-28** (stateless: `server/discover`,
per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same process, clients that still open with
`initialize` (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05). It runs each tool as a CLI call in the project directory
(the server's working directory, or `STOREBOUND_PROJECT`) and returns the output.
- One tool per command. Read-only: `check`, `check_aso`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`,
`analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`,
`play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`. Local writes: `import_fastlane`, `whats_new`, `icons`,
`health_report`, `reviews_draft`, `prices_export`, `analytics_snapshot`, `shots_capture`, `shots_render`,
`shots_gallery`, `shots_record`, `shots_video`. Store writes: `play_listing`, `play_images`, `play_products`,
`play_data_safety`, `play_subscriptions`, `play_release_testing`, `apple_bundle`, `apple_iap`, `apple_iap_review`,
`apple_subscriptions`, `apple_listing`,
`apple_screenshots`, `apple_previews`, `apple_appinfo`, `apple_review_details`, `apple_encryption`,
`apple_phased_release`, `apple_testflight_groups`, `apple_testflight_what_to_test`, `apple_testflight_info`,
`apple_testflight_distribute` (internal groups only), `apple_ppo_create`, `apple_ppo_upload`, `analytics_setup`.
- Annotations say what is read-only, destructive (replaces data), idempotent and network-bound.
- No MCP SDK: a small newline-delimited JSON-RPC server (server/discover, initialize, ping, tools/list, tools/call,
cancellation), so storebound keeps zero runtime dependencies.
Manual config for a tool that `install` does not know: command `bunx` with args `["storebound", "mcp"]` (or the
absolute path of `bin/storebound` plus `mcp`).
### Desktop machine interface
`storebound serve --stdio` is what the desktop app (`apps/desktop`, in progress) runs as its sidecar: newline-delimited
JSON-RPC on the same core as the MCP server (tool list, validation, forbidden flags, child-process runner), with typed
methods (`check`, `validate`, `apple.status`, `play.listing`, ... plus `project.inspect` and `portfolio.status` over
many project folders), structured results (the commands' `--json` output), progress notifications and cancellation.
The first request is `session.hello` with a per-session secret only the host knows. Write methods are dry runs unless
the call carries a single-use confirm token (HMAC over method, arguments and expiry) that the host mints after the
owner confirmed a native dialog showing that dry run. There is no method that submits, releases or publishes; Play
testing releases and phased release changes stay out of the desktop profile too. Types: `cli/lib/desktop-protocol.ts`.
`bun tools/build-sidecars.ts` compiles the CLI for macOS, Linux and Windows (`bun build --compile`). Design, safety
model and the Dependencies screen (tool sources and licenses): [docs/desktop.md](docs/desktop.md).
## Safety model
- **Credentials** live in env vars or the gitignored `.storebound/credentials.json`, never in the config or the repo.
storebound only talks to the official store APIs (plus Google Fonts, your own Play reports bucket and your own URLs);
see [SECURITY.md](SECURITY.md).
- **Dry run everywhere.** Every write command takes `--dry`; every MCP write tool has `dry_run`, **default true**.
- **Nothing public or irreversible through an agent.** Submitting for review (App Review and Beta App Review),
releasing a version, production releases, resuming or completing a rollout or phased release, canceling a
submission, deleting, inviting or removing testers, sending review replies, applying prices and starting or
stopping a product page test and creating custom offer codes are not MCP tools. Subscription syncs through MCP only
create what is new; price changes of live subscriptions wait for a terminal run. They stay terminal commands that ask for confirmation, and the skills tell every agent to ask the owner
first ([docs/agents-core.md](docs/agents-core.md)).
- **Personal data stays local.** Tester lists, the App Review demo account, reply drafts, health reports and analytics
snapshots live in the gitignored `.storebound/` (the demo account is masked in every log line); tester emails are masked in every log line, and the config refuses a tester CSV path
outside `.storebound/`.
- **Idempotent writes.** Re-running a command converges (MD5 checks for screenshots and previews, one Play edit per
language), so a failed upload is fixed by running it again.
- **Simulators and emulators**: storebound shuts down only a simulator it booted itself and never boots, wipes or
reboots an Android emulator or device.
## FAQ
**Is it only for games?** No. It started with my game, but every command is about store records, not engines or
genres. `init --profile app` and `--profile subscription-app` give you templates for a utility app with accounts and
for a subscription app (Data safety, App Privacy, declarations, listing and screenshot copy), the `app` screenshot
style, account deletion and demo account checks, and subscriptions on both stores. Game-only advice (IARC game
questions, gameplay footage, ad mediation) is marked as such in the skills.
**Do I need Claude (or any AI tool)?** No. The CLI is a normal command and the skills are Markdown checklists you can
read yourself. The AI integrations are optional adapters generated from the same files.
**Does it fill the questionnaires?** The ones with an API, yes: Play Data safety (the whole form from `dataSafety` in
the config; the import replaces the form, so the config is the single truth), Apple age rating, content rights,
categories, price and availability, the export compliance answer and the App Review details. The stores have no
public API for Play's App content cards (IARC content rating, target audience, ads, advertising ID, app access,
government / financial / health / news) or for Apple's App Privacy labels. For those, `init` gives you prepared
answers (`store/declarations.md`, `store/app-privacy.md`, per profile: a utility app with an account, a subscription
app, or a free-to-play game with ads and IAP),
and the `store-declarations` skill has the exact clicks: an agent with browser tools can fill them in while you
watch, or you copy them in yourself. Either way the answers are your statements to the stores: change what does not
match your app, and the agent asks you whenever the code does not prove an answer. `validate` cannot see these
forms, so `store-preflight` lists them as a manual check.
**My app or game is Unity / Godot / Flutter / React Native / native, not web.** Screenshots and clips come from the iOS Simulator
(`ios-sim`), an Android emulator or device (`android-emu`) or a folder you fill with any tool; only the rendering of
the store images uses a headless browser. The store commands do not care about the engine at all: they work on
your AAB / build numbers and listing files.
**Is my store account safe?** storebound uses the official APIs with keys you create and can revoke, never your
password. Store writes are dry runs until you say otherwise, and the public steps (submit, release) stay with you.
Use an App Store Connect key with the App Manager role and a Play service account limited to your app.
**Why Bun?** One fast runtime with TypeScript, `fetch`, a test runner and a static server built in: no build step and
zero runtime dependencies. It needs Bun installed (it does not run on plain Node).
**Does it upload my video to YouTube?** No. Play only takes a YouTube URL, and uploading to YouTube needs your Google
account; `shots video` makes the file, you upload it and paste the URL into `play.video`.
**Can it run store experiments for me?** On the App Store, mostly: `apple ppo create / upload` builds a Product Page
Optimization test from your `shots` variants (treatments, locales, screenshots) and you start it (`apple ppo start`
asks first). Results per treatment are only shown in App Store Connect. Play store listing experiments have no API:
storebound renders and checks the variant images, you set the experiment up in Play Console.
**Does it answer reviews by itself?** No. It drafts one file per review, your agent helps write the replies, and only
the drafts you mark `approved: true` are sent, by you, after a confirmation. Replies that offer something for a rating
are refused.
## Roadmap
Wave 2 (TestFlight, vitals, review replies, price sheets, analytics, MCP 2026-07-28, Product Page Optimization) shipped
in 0.4.0. 0.5.0 made storebound app-first (profiles, app screenshot style, account deletion, review access) and
brought the full in-app catalog: subscriptions on both stores with offers and offer codes, non-renewing products and
the cross-store `catalog diff`. Next, roughly in this order:
- Offer codes for one-time products (`inAppPurchaseOfferCodes`), Play prepaid base plans for `non_renewing`, win-back
offers, and a `prices export` that includes subscriptions.
- App Store custom product pages and in-app events from the same copy and renders.
- Play store listing experiments and their results, as soon as Google offers an API.
- App preview treatments and alternate icons in Product Page Optimization tests.
- The iPhone Duo screenshot classes Apple lists for apps built with the iOS 27.1 SDK (required from April 2027).
## Contributing
Issues and pull requests are welcome: store rule updates (with a source), new capture sources, adapters for more AI
tools. See [CONTRIBUTING.md](CONTRIBUTING.md); `bun run ci` must be green.
<details>
<summary><b>Agent support: formats and what is not verified</b></summary>
Checked against each tool's own docs in October 2026:
- **Agent Skills**: frontmatter is only `name` (lowercase-hyphen, equals the folder) and `description`; the generator
fails if a skill adds anything else.
- **Claude Code**: plugin manifest + root `.mcp.json` with `${CLAUDE_PLUGIN_ROOT}`; `claude plugin validate .` passes.
- **Codex**: `.codex-plugin/plugin.json` with `"skills": "./skills/"`. Not verified: whether Codex starts the root
`.mcp.json` from a plugin and expands `${CLAUDE_PLUGIN_ROOT}` there, so use `install --agent codex` for the MCP
server. OpenAI also documents a newer portable root `plugin.json`; not shipped yet.
- **Gemini CLI**: `gemini-extension.json` with `${extensionPath}${/}`, `GEMINI.md` as context, `skills/` inside the
extension. Google now points unpaid users to Antigravity CLI; not tested there. The `.gemini/skills` folder used by
`install --agent gemini` is not verified (the extension route is).
- **Windsurf**: the current docs name `~/.config/devin/mcp_config.json`; `install` keeps using
`~/.codeium/windsurf/mcp_config.json` when that file already exists (older installs).
- **MCP**: built for 2026-07-28 (final, modelcontextprotocol.io) and dual-era: `initialize` clients get 2025-11-25
(or 2025-06-18 / 2025-03-26 / 2024-11-05). Tested with both handshakes on one stdio process, not yet against every
client listed above.
- **Not verified against a live account yet** (no credentials during development; request builders are tested with
fixtures from the official docs): the scale of the Play Reporting API rates (read as fractions, 0.0109 = 1.09 %),
the App Store review reply limit (5970 characters is commonly reported; Apple documents none, storebound only
warns), the TestFlight "What to Test" limit (4000 assumed), whether an analytics segment checksum covers the gzip
file or its content (both are accepted), that a new App Store price schedule replaces the old one (Apple's example
implies it), stopping a Product Page Optimization test with `started: false`, and `APP_IPHONE_61` for the 6.3"
iPhone set. From 0.5.0 (request bodies follow Apple's and Google's published schemas, tested with fixtures, not
yet run against a live account): free promotional offers and offer codes sent with territories but no price point,
subscription prices and introductory offers created inline through `PATCH /v1/subscriptions/{id}`, Play
pay-as-you-go offers as a `relativeDiscount`, Play "developer determined" offers as offers without targeting plus an
offer tag, the ISO 8601 strings for Play billing periods (Google lists them only in words), the Play subscription
benefit length (40, from Play Console), the Data safety sign-up method ids (`PSL_ACM_*`, Google publishes none:
compare with a Console export) and the delete-account URL question id (use `dataSafety.extra`).
- **Apple IAP description**: 45 characters (App Store Connect Help, "In-app purchase information"); an older App Store
marketing page says 55. storebound checks 45.
</details>
<details>
<summary><b>Develop</b></summary>
```sh
bun install
bun run ci # adapters fresh (gen-adapters --check), tests, typecheck, secrets / personal data scrub
bun tools/gen-adapters.ts # after editing skills/*/SKILL.md or docs/agents-core.md
```
`skills/*/SKILL.md` and `docs/agents-core.md` are the single source; `AGENTS.md`, `GEMINI.md`, the plugin manifests,
`.github/copilot-instructions.md` and `.cursor/rules/storebound.mdc` are generated and committed, and CI fails when
they are stale.
</details>
## Thanks and idea sources
Some skills were sparked by other people's open work. I wrote my own text; the ideas deserve the credit:
- [rorkai/app-store-connect-cli-skills](https://github.com/rorkai/app-store-connect-cli-skills) (MIT): App Store Connect
workflow ideas.
- [rshankras/claude-code-apple-skills](https://github.com/rshankras/claude-code-apple-skills) (MIT): Apple platform and
App Store topic ideas.
Built with Bun and plain `fetch` against the Google Play Developer API v3, the Play Developer Reporting API and the App
Store Connect API. Screenshot
fonts come from Google Fonts at render time (Noto, Lilita One, Montserrat; SIL Open Font License 1.1). Not affiliated
with Google or Apple.
## License
MIT, see [LICENSE](LICENSE). Copyright (c) 2026 CRTkafa.

View file

@ -0,0 +1,68 @@
// Tauri's `freezePrototype: true` (tauri.conf.json) runs Object.freeze(Object.prototype) before any page script. In
// strict code, assigning a property that an object only inherits from the frozen Object.prototype (`constructor`,
// `toString`, ...) then throws "Cannot assign to read only property": the JavaScript "override mistake". d3-color and
// d3-format (under @visx/scale) do exactly that while their modules load, so the bundle stopped before React rendered
// anything and the release window showed only its background colour.
//
// This plugin rewrites every `x.constructor = v`, `x.toString = v`, ... in dependencies into a call that does the same
// assignment and, only when that assignment hits the frozen prototype, defines the property on `x` itself instead
// (Object.defineProperty is allowed there). The release boot check (tools/desktop-boot-check.ts) loads the built
// bundle with the prototype frozen, as Tauri does, and fails on any uncaught error.
import type { Plugin } from 'vite';
/** The writable data properties of Object.prototype that freezing turns read-only for every plain object. */
export const FROZEN_NAMES = ['constructor', 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable'] as const;
const QUICK = new RegExp(`\\.(?:${FROZEN_NAMES.join('|')})\\s*=(?!=)`);
export const HELPER = '__storeboundFreezeSafeSet';
// Appended (function declarations hoist), so a leading "use strict" directive stays first. Works in strict and sloppy
// code: when `k` is only inherited from a frozen (read-only) data property, define it on `o`; otherwise assign.
const HELPER_SRC = `\nfunction ${HELPER}(o, k, v) { if (o !== null && (typeof o === 'object' || typeof o === 'function') && !Object.prototype.hasOwnProperty.call(o, k)) { for (var p = Object.getPrototypeOf(o); p !== null; p = Object.getPrototypeOf(p)) { var d = Object.getOwnPropertyDescriptor(p, k); if (!d) continue; if ('value' in d && !d.writable) { Object.defineProperty(o, k, { value: v, writable: true, enumerable: true, configurable: true }); return v; } break; } } o[k] = v; return v; }\n`;
interface Node { type: string; start: number; end: number; [k: string]: unknown }
function walk(node: unknown, visit: (n: Node) => void): void {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { for (const n of node) walk(n, visit); return; }
const n = node as Node;
if (typeof n.type === 'string') visit(n);
for (const k of Object.keys(n)) if (k !== 'type' && k !== 'start' && k !== 'end') walk(n[k], visit);
}
/** Rewrites the assignments in one module's code; null when there is nothing to change. */
export function rewrite(code: string, parse: (code: string) => unknown): string | null {
if (!QUICK.test(code)) return null;
const edits: { start: number; end: number; text: (inner: (s: number, e: number) => string) => string }[] = [];
walk(parse(code), n => {
if (n.type !== 'AssignmentExpression' || n.operator !== '=') return;
const left = n.left as Node & { object: Node; property: Node & { name?: string }; computed: boolean; optional?: boolean };
if (left.type !== 'MemberExpression' || left.computed || left.object.type === 'Super') return;
if (!(FROZEN_NAMES as readonly string[]).includes(left.property.name ?? '')) return;
const right = n.right as Node;
edits.push({ start: n.start, end: n.end, text: inner => `${HELPER}(${inner(left.object.start, left.object.end)}, ${JSON.stringify(left.property.name)}, ${inner(right.start, right.end)})` });
});
if (!edits.length) return null;
// outermost edits only carry their inner text through `inner`, which applies nested edits recursively
edits.sort((a, b) => a.start - b.start || b.end - a.end);
const render = (s: number, e: number): string => {
let out = '', at = s;
for (const ed of edits) {
if (ed.start < at || ed.end > e || ed.start < s) continue;
out += code.slice(at, ed.start) + ed.text(render);
at = ed.end;
}
return out + code.slice(at, e);
};
return render(0, code.length) + HELPER_SRC;
}
export function freezeSafe(): Plugin {
return {
name: 'storebound-freeze-safe',
transform(code, id) {
const file = id.split('?')[0]!;
if (!/node_modules/.test(file) || !/\.(?:m?js|cjs)$/.test(file)) return null;
const out = rewrite(code, c => this.parse(c));
return out === null ? null : { code: out, map: null };
},
};
}

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

@ -6,6 +6,8 @@
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<title>storebound</title> <title>storebound</title>
<!-- before the bundle: an error panel instead of an empty window if the bundle fails (public/boot.js) -->
<script src="/boot.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,6 +1,6 @@
{ {
"name": "@storebound/desktop", "name": "@storebound/desktop",
"version": "0.1.0", "version": "0.8.77",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@ -16,6 +16,7 @@
"app:build:debug": "bun run sidecar && tauri build --debug --no-bundle" "app:build:debug": "bun run sidecar && tauri build --debug --no-bundle"
}, },
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@tauri-apps/api": "^2.12.0", "@tauri-apps/api": "^2.12.0",
"@visx/axis": "^4.0.0", "@visx/axis": "^4.0.0",
"@visx/curve": "^4.0.0", "@visx/curve": "^4.0.0",
@ -26,7 +27,6 @@
"@visx/scale": "^4.0.0", "@visx/scale": "^4.0.0",
"@visx/shape": "^4.0.0", "@visx/shape": "^4.0.0",
"@visx/tooltip": "^4.0.0", "@visx/tooltip": "^4.0.0",
"lucide-react": "^1.55.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },

136
apps/desktop/public/boot.js Normal file
View file

@ -0,0 +1,136 @@
// storebound desktop: the first script of the window (a plain script, loaded before the app bundle; CSP allows only
// files of the app). If the bundle fails to load or React never draws (or later unmounts after an error no boundary
// caught), the window would show nothing but its background colour. This shows a plain error panel instead, with
// Copy details, Reload and Reset saved state (keeps a backup), and sends the error to logs/webview.log through the
// app's `log_webview` command until src/lib/crash.ts takes over. No framework, no strings from the network.
(function () {
'use strict';
var errors = [];
var shown = false;
function invoke(cmd, args) {
try {
var i = window.__TAURI_INTERNALS__;
if (i && typeof i.invoke === 'function') return Promise.resolve(i.invoke(cmd, args || {})).catch(function () { return null; });
} catch (e) { /* no host */ }
return Promise.resolve(null);
}
function text(e) {
if (!e) return 'unknown error';
if (e instanceof Error) return e.name + ': ' + e.message;
if (typeof e === 'string') return e;
if (typeof e.message === 'string') return e.message;
try { return JSON.stringify(e); } catch (x) { return String(e); }
}
function root() { return document.getElementById('root'); }
function blank() { var r = root(); return !r || r.childElementCount === 0; }
function booted() { return document.documentElement.getAttribute('data-booted') === '1'; }
function record(where, e, stack) {
var msg = text(e);
var entry = where + ': ' + msg + (stack ? '\n' + stack : '');
errors.push(entry);
if (errors.length > 20) errors.shift();
// src/lib/crash.ts logs once the bundle is running; before that, this does
if (!window.__storeboundCrashHandlers) invoke('log_webview', { level: 'error', message: where + ': ' + msg, detail: stack || null });
// after start: at once; while starting, give React a moment (a non-fatal start error does not blank the window)
setTimeout(check, booted() ? 60 : 1500);
}
function el(tag, cls, txt) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (txt) n.textContent = txt;
return n;
}
function show(title, lead) {
if (shown) return;
shown = true;
var detail = 'storebound desktop · ' + (booted() ? 'after start' : 'during start') + ' · ' + new Date().toISOString() + '\n' +
(navigator.userAgent || '') + '\n\n' + (errors.length ? errors.join('\n\n') : 'No error was reported: the window stayed empty.');
var host = root() || document.body;
while (host.firstChild) host.removeChild(host.firstChild);
var wrap = el('div', 'crash crash-full');
wrap.id = 'boot-fallback';
wrap.setAttribute('role', 'alert');
wrap.setAttribute('data-crash', 'boot');
var box = el('div', 'crash-box');
box.appendChild(el('h2', '', title));
box.appendChild(el('p', '', lead));
box.appendChild(el('pre', 'crash-msg selectable', errors.length ? errors[errors.length - 1].split('\n')[0] : 'The window stayed empty.'));
var det = el('details');
det.appendChild(el('summary', '', 'Details'));
det.appendChild(el('pre', 'crash-detail selectable', detail));
box.appendChild(det);
var note = el('p', 'small muted');
note.setAttribute('role', 'status');
var actions = el('div', 'actions');
var copy = el('button', 'btn', 'Copy details');
copy.onclick = function () {
var done = function (ok) { note.textContent = ok ? 'Details copied.' : 'Could not copy: select the details and copy them by hand.'; };
try {
navigator.clipboard.writeText(detail).then(function () { done(true); }, function () { done(fallbackCopy(detail)); });
} catch (e) { done(fallbackCopy(detail)); }
};
var reload = el('button', 'btn btn-primary', 'Reload');
reload.onclick = function () { location.reload(); };
var reset = el('button', 'btn btn-ghost', 'Reset saved state (keeps a backup)');
reset.title = 'Starts with an empty list. The saved state is kept as a backup file, store keys stay in the keychain, app folders are not touched.';
reset.onclick = function () {
invoke('state_reset').then(function (backup) {
if (backup === null || backup === undefined) return;
note.textContent = 'Saved state reset. Backup: ' + (backup || '-') + '. Reloading…';
setTimeout(function () { location.reload(); }, 1200);
});
};
actions.appendChild(copy);
actions.appendChild(reload);
actions.appendChild(reset);
box.appendChild(actions);
box.appendChild(note);
box.appendChild(el('p', 'small muted', 'Also written to logs/webview.log in the app data folder (home folder shown as ~, no keys).'));
wrap.appendChild(box);
host.appendChild(wrap);
}
function fallbackCopy(s) {
try {
var ta = el('textarea', 'sr');
ta.value = s;
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
ta.remove();
return ok;
} catch (e) { return false; }
}
function check() {
if (shown || !blank() || !document.body) return;
if (booted()) show('storebound hit an error', 'The window was closed by an error. Your app folders, store keys and saved data are untouched. Reload usually fixes it; if it keeps happening, copy the details into a bug report.');
else if (errors.length) show('storebound could not start', 'The window failed while starting. Your app folders, store keys and saved data are untouched. Reload, or reset the saved state if it keeps happening, and copy the details into a bug report.');
}
window.addEventListener('error', function (ev) {
// a script or stylesheet that failed to load has no ev.error
var t = ev.target;
if (t && t !== window && (t.src || t.href)) { record('load', 'could not load ' + (t.src || t.href)); return; }
record('window.onerror', ev.error || ev.message, ev.error && ev.error.stack);
}, true);
// tauri-plugin-notification's init script asks for the notification permission at load (macOS, Linux); the webview
// has no plugin API by design (capabilities/main.json), so that call is refused: expected, not an error
function pluginProbe(e) { return /plugin:notification\|is_permission_granted not allowed/.test(text(e)); }
window.addEventListener('unhandledrejection', function (ev) {
if (pluginProbe(ev.reason)) { ev.preventDefault(); return; }
if (!booted()) record('unhandledrejection', ev.reason, ev.reason && ev.reason.stack);
});
// React's root reports an error no boundary caught (the tree is gone) here: src/main.tsx onUncaughtError
window.__storeboundBlank = function (message, stack) { record('react', message, stack); };
// nothing drawn and nothing reported after a while: still say so
window.addEventListener('load', function () {
setTimeout(function () {
if (!booted() && blank()) { errors.push('start: the window was still empty 15 s after loading'); invoke('log_webview', { level: 'error', message: 'start: the window was still empty 15 s after loading', detail: null }); show('storebound could not start', 'The window stayed empty while starting. Your app folders, store keys and saved data are untouched. Reload, or reset the saved state if it keeps happening, and copy the details into a bug report.'); }
}, 15000);
});
})();

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.1.0" 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.1.0" 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,12 +44,21 @@
"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",
"allow-project-import-fastlane", "allow-project-import-fastlane",
"allow-export-save", "allow-export-save",
"allow-price-sheet-pick", "allow-price-sheet-pick",
"allow-project-reveal" "allow-image-pick",
"allow-project-reveal",
"allow-project-locate",
"allow-state-reset",
"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: 5.3 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Before After
Before After

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#4338ca"/></linearGradient></defs><rect x="100" y="100" width="824" height="824" rx="185" fill="url(#g)"/><path d="M512 288v384M336 464 512 288l176 176M256 640v176h512V640" fill="none" stroke="#fff" stroke-width="88" stroke-linecap="round" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 461 B

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-image-pick"
description = "Enables the image_pick command without any pre-configured scope."
commands.allow = ["image_pick"]
[[permission]]
identifier = "deny-image-pick"
description = "Denies the image_pick command without any pre-configured scope."
commands.deny = ["image_pick"]

View file

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

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-project-locate"
description = "Enables the project_locate command without any pre-configured scope."
commands.allow = ["project_locate"]
[[permission]]
identifier = "deny-project-locate"
description = "Denies the project_locate command without any pre-configured scope."
commands.deny = ["project_locate"]

View file

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

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

@ -1,8 +1,10 @@
//! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer) //! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer)
//! goes into the OS credential store under the service `dev.storebound.desktop`; only a label, the kind and the //! lives in the machine's credential profiles, shared with the CLI (profiles.rs: ~/.storebound/credentials, files
//! creation time are kept in the app data folder. Secrets are never sent to the webview: they are read from the //! 0600); the app data folder keeps the label, the kind, the studio and the profile name. Versions before 0.8 kept
//! keychain right before a sidecar call and handed to it in that call's params (memory only), which passes them to //! the secret in the OS keychain (service `dev.storebound.desktop`): `Keychain` is still here to move those entries
//! the storebound command as environment variables. //! into profiles once (profiles::sync). Secrets are never sent to the webview: they are read right before a sidecar
//! call and handed to it in that call's params (memory only), which passes them to the command as environment
//! variables.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
@ -25,6 +27,14 @@ pub struct Account {
/// The studio this account belongs to (workspace::Studio id), if any. /// The studio this account belongs to (workspace::Studio id), if any.
#[serde(default)] #[serde(default)]
pub studio: Option<String>, pub studio: Option<String>,
/// The credential profile that holds the key (~/.storebound/credentials/<profile>.json, shared with the CLI).
/// None only for an account of an earlier version whose keychain entry has not moved yet.
#[serde(default)]
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).
@ -52,10 +62,13 @@ impl Secret {
Secret::Apple { key_id, issuer_id, p8 } => { Secret::Apple { key_id, issuer_id, p8 } => {
into.insert("ASC_KEY_ID".into(), Value::String(key_id.clone())); into.insert("ASC_KEY_ID".into(), Value::String(key_id.clone()));
into.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone())); into.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone()));
// a profile without ASC_P8: the CLI finds ~/.appstoreconnect/private_keys/AuthKey_<id>.p8 itself
if !p8.is_empty() {
into.insert("ASC_P8".into(), Value::String(p8.clone())); into.insert("ASC_P8".into(), Value::String(p8.clone()));
} }
} }
} }
}
} }
/// A Google service account JSON key: client_email and a PEM private key. /// A Google service account JSON key: client_email and a PEM private key.
@ -110,7 +123,7 @@ pub fn check_label(s: &str) -> Result<String, String> {
Ok(s.to_string()) Ok(s.to_string())
} }
/// Where secrets live. The OS keychain in the app; a map in tests. /// The OS keychain entries of earlier versions (read once to move them into profiles, then deleted); a map in tests.
pub trait SecretStore: Send + Sync { pub trait SecretStore: Send + Sync {
fn set(&self, id: &str, secret: &Secret) -> Result<(), String>; fn set(&self, id: &str, secret: &Secret) -> Result<(), String>;
fn get(&self, id: &str) -> Result<Secret, String>; fn get(&self, id: &str) -> Result<Secret, String>;

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

@ -1,6 +1,6 @@
//! App commands. Each one first checks the calling window and page (policy::require_main). Secrets never cross into //! App commands. Each one first checks the calling window and page (policy::require_main). Secrets never cross into
//! the webview: account files are picked in native dialogs and read here, and credentials go from the keychain //! the webview: account files are picked in native dialogs and read here, and credentials go from the machine's
//! straight into the sidecar call. //! credential profiles (profiles.rs, shared with the CLI) straight into the sidecar call.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
@ -12,6 +12,9 @@ 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::registry;
use crate::cache::{Cache, Entry}; use crate::cache::{Cache, Entry};
use crate::downloads; use crate::downloads;
use crate::policy::{self, Kind}; use crate::policy::{self, Kind};
@ -24,7 +27,12 @@ pub struct AppState {
pub sidecar: Sidecar, pub sidecar: Sidecar,
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub saved: Mutex<Saved>, pub saved: Mutex<Saved>,
pub secrets: Box<dyn SecretStore>, /// The machine's credential profiles (~/.storebound/credentials, shared with the CLI): where keys live.
pub profiles: ProfileStore,
/// Keychain entries of versions before 0.8, moved into profiles once by `sync_machine`.
pub legacy: Box<dyn SecretStore>,
/// The machine's app folder list (~/.storebound/projects.json, shared with the CLI).
pub registry: PathBuf,
pub cache: Cache, pub cache: Cache,
pub downloads: downloads::Jobs, pub downloads: downloads::Jobs,
} }
@ -36,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"))
} }
@ -44,6 +62,51 @@ fn persist(s: &AppState, st: &Saved) -> Res<()> {
store::save(&s.data_dir, st).map_err(Fail::host) store::save(&s.data_dir, st).map_err(Fail::host)
} }
/// Brings the state in line with the machine's shared stores: accounts with the credential profiles (and moves old
/// keychain entries into profiles), the folder list with the registry. Logged without values; saved when it changed.
pub fn sync_machine(s: &AppState) -> Res<()> {
let mut st = saved(s)?;
let report = profiles::sync(&mut st.accounts, &s.profiles, s.legacy.as_ref(), &store::now_iso());
for m in &report.migrated {
crate::logs::host("info", &format!("account moved from the keychain into a credential profile: {m}"));
}
for a in &report.added {
crate::logs::host("info", &format!("account from a credential profile: {a}"));
}
for n in &report.notes {
crate::logs::host("warn", n);
}
let folders = match registry::sync(&mut st, &s.registry) {
Ok(c) => c,
Err(e) => {
crate::logs::host("warn", &format!("app folder list not synced: {e}"));
false
}
};
let ignored = registry::pull_ignored(&mut st, &s.registry);
if report.changed() || folders || ignored {
persist(s, &st)?;
}
Ok(())
}
/// After the app changed its folder list: the shared registry gets it (keeping folders another tool added).
fn publish_folders(s: &AppState, st: &Saved, removed: &[String]) {
if let Err(e) = registry::publish(st, &s.registry, removed) {
crate::logs::host("warn", &format!("app folder list not written: {e}"));
}
}
/// One account's keys, read from its credential profile right now.
fn secret_of(s: &AppState, id: &str) -> Result<Secret, String> {
let (profile, kind) = {
let st = s.saved.lock().map_err(|_| "state poisoned".to_string())?;
let a = st.accounts.iter().find(|a| a.id == id).ok_or("unknown account")?;
(a.profile.clone().ok_or("the account's key has not moved from the keychain yet: restart storebound, or add the key again")?, a.kind)
};
s.profiles.secret(&profile, kind)
}
fn job_of(job: Option<String>) -> Res<Option<String>> { fn job_of(job: Option<String>) -> Res<Option<String>> {
match job { match job {
Some(j) if !valid_job(&j) => Err(Fail::host("bad job id")), Some(j) if !valid_job(&j) => Err(Fail::host("bad job id")),
@ -56,8 +119,8 @@ fn project_params(s: &AppState, project_id: &str) -> Res<(Project, Map<String, V
let p = saved(s)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?; let p = saved(s)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
let mut creds = Map::new(); let mut creds = Map::new();
for id in [&p.play_account, &p.apple_account].into_iter().flatten() { for id in [&p.play_account, &p.apple_account].into_iter().flatten() {
// A missing keychain entry is not fatal: the command says which credential it lacks. // A missing key is not fatal: the command says which credential it lacks.
if let Ok(secret) = s.secrets.get(id) { if let Ok(secret) = secret_of(s, id) {
secret.to_credentials(&mut creds); secret.to_credentials(&mut creds);
} }
} }
@ -81,7 +144,7 @@ fn target_params(s: &AppState, t: &StoreTarget) -> Res<Map<String, Value>> {
drop(st); drop(st);
let mut creds = Map::new(); let mut creds = Map::new();
for id in t.accounts() { for id in t.accounts() {
if let Ok(secret) = s.secrets.get(id) { if let Ok(secret) = secret_of(s, id) {
secret.to_credentials(&mut creds); secret.to_credentials(&mut creds);
} }
} }
@ -94,7 +157,7 @@ fn account_creds(s: &AppState, id: &str) -> Res<Map<String, Value>> {
return Err(Fail::host("unknown account")); return Err(Fail::host("unknown account"));
} }
let mut creds = Map::new(); let mut creds = Map::new();
s.secrets.get(id).map_err(Fail::host)?.to_credentials(&mut creds); secret_of(s, id).map_err(Fail::host)?.to_credentials(&mut creds);
Ok(creds) Ok(creds)
} }
@ -142,8 +205,8 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_
if matches!(kind, Some(Kind::Read | Kind::Write | Kind::Owner)) { if matches!(kind, Some(Kind::Read | Kind::Write | Kind::Owner)) {
let id = project_id.ok_or_else(|| Fail::host(format!("{method} needs a project")))?; let id = project_id.ok_or_else(|| Fail::host(format!("{method} needs a project")))?;
if id.starts_with("store:") { if id.starts_with("store:") {
// a store-only app: store reads only, through the sidecar's generated config // a store-only app: store reads, and the few writes that send only what the call carries (dry runs here)
if kind != Some(Kind::Read) { if kind != Some(Kind::Read) && !policy::store_only_write(&method, kind) {
return Err(Fail::host("store-only apps are read-only: link a folder to change anything")); return Err(Fail::host("store-only apps are read-only: link a folder to change anything"));
} }
let target = StoreTarget::parse(&id).map_err(Fail::host)?; let target = StoreTarget::parse(&id).map_err(Fail::host)?;
@ -154,7 +217,7 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_
o.insert("credentials".into(), Value::Object(creds)); o.insert("credentials".into(), Value::Object(creds));
} }
let res = r.call_job(&method, p, job.as_deref()).await?; let res = r.call_job(&method, p, job.as_deref()).await?;
if res["ok"] == Value::Bool(true) && !res["data"].is_null() { if kind == Some(Kind::Read) && readable(&res) {
let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso()); let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso());
} }
return Ok(res); return Ok(res);
@ -172,13 +235,19 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_
let res = r.call_job(&method, p, job.as_deref()).await?; let res = r.call_job(&method, p, job.as_deref()).await?;
if let Some(id) = cache_project { if let Some(id) = cache_project {
// only results that read something: a failed store read must not hide the last good one // only results that read something: a failed store read must not hide the last good one
if res["ok"] == Value::Bool(true) && !res["data"].is_null() { if readable(&res) {
let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso()); let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso());
} }
} }
Ok(res) Ok(res)
} }
/// A result worth caching: it carries data and is ok, or "findings" (scan / check / validate / health exit 1 with their
/// result printed: by design, not a failure). A failed read must not hide the last good one.
fn readable(res: &Value) -> bool {
!res["data"].is_null() && (res["ok"] == Value::Bool(true) || res["outcome"] == Value::String("findings".into()))
}
/// The last cached result of a read (`project_id` "*" for the portfolio), with its time; null when never read. /// The last cached result of a read (`project_id` "*" for the portfolio), with its time; null when never read.
#[tauri::command] #[tauri::command]
pub fn cache_get(window: WebviewWindow, state: State<'_, AppState>, method: String, project_id: String, args: Option<Value>) -> Res<Option<Entry>> { pub fn cache_get(window: WebviewWindow, state: State<'_, AppState>, method: String, project_id: String, args: Option<Value>) -> Res<Option<Entry>> {
@ -199,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;
@ -236,7 +305,7 @@ pub fn tilde(text: &str, home: Option<&str>) -> String {
/// Human summary shown above the dry run in the confirm dialog. /// Human summary shown above the dry run in the confirm dialog.
pub fn dialog_summary(title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String { pub fn dialog_summary(title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String {
let mut s = format!("{title} · {project}\n\n{description}\n\n"); let mut s = format!("{title} · {project}\n\n{description}\n\n");
s.push_str(if network { "This changes your store records (not public until you submit or release in the console)." } else { "This changes local files only." }); s.push_str(if network { "This changes your store records (not public until you submit or release it)." } else { "This changes local files only." });
if destructive { if destructive {
s.push_str("\nIt replaces what is there now."); s.push_str("\nIt replaces what is there now.");
} }
@ -248,6 +317,21 @@ pub fn owner_summary(title: &str, description: &str, project: &str) -> String {
format!("{title} · {project}\n\n{description}\n\nThis is your decision: it changes what users get on the store right away. storebound does it only because you press the button below.") format!("{title} · {project}\n\n{description}\n\nThis is your decision: it changes what users get on the store right away. storebound does it only because you press the button below.")
} }
/// Summary of an owner action that sends the app to review or production (policy::SUBMIT_METHODS): what goes out and
/// that it may go live by itself once approved.
pub fn submit_summary(title: &str, description: &str, project: &str) -> String {
format!("{title} · {project}\n\n{description}\n\nThis is your decision: it sends the app to the store's review. Once approved it can go live without another click (App Store: the version's release setting; Google Play: with managed publishing off). storebound does it only because you press the button below.")
}
/// The dialog text above the dry run of one write or owner action.
pub fn summary_for(method: &str, kind: Kind, title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String {
match kind {
Kind::Owner if policy::SUBMIT_METHODS.contains(&method) => submit_summary(title, description, project),
Kind::Owner => owner_summary(title, description, project),
_ => dialog_summary(title, description, project, network, destructive),
}
}
/// Dialog title and the confirm button of a write: owner actions name the action on the button. /// Dialog title and the confirm button of a write: owner actions name the action on the button.
pub fn dialog_labels(kind: Kind, title: &str) -> (String, String) { pub fn dialog_labels(kind: Kind, title: &str) -> (String, String) {
if kind == Kind::Owner { if kind == Kind::Owner {
@ -257,6 +341,39 @@ pub fn dialog_labels(kind: Kind, title: &str) -> (String, String) {
} }
} }
/// Dialog of `console.fill` (title, text, button): the answers of its dry run (`data` of `console fill --dry --json`),
/// the uncertain ones marked for the owner, and the note that the owner reviews and saves in the browser.
pub fn console_fill_dialog(project: &str, data: &Value) -> (String, String, String) {
let title = data["title"].as_str().unwrap_or("Console form");
let mut s = format!("{title} · {project}\n\nA browser window opens on the console page. It uses storebound's own browser profile: the first time, sign in there yourself (storebound never types passwords); the sign-in is kept for the next runs. Your own Chrome's cookies are never copied (Google ties them to your device and could sign you out). storebound opens the form, fills the fields below and marks the ones you answer.\n\n{}\n", policy::CONSOLE_FILL_NOTE);
let answers = data["answers"].as_array().cloned().unwrap_or_default();
let mut lines: Vec<String> = answers
.iter()
.map(|a| {
let label = a["label"].as_str().unwrap_or_default();
if a["certain"].as_bool() == Some(true) {
format!("- {label}: {}", a["value"].as_str().unwrap_or_default())
} else {
format!("- {label}: you answer this")
}
})
.collect();
if lines.is_empty() {
lines.push("- (no answers from the config: every field is yours)".into());
}
let mut list = String::new();
for (i, l) in lines.iter().enumerate() {
if list.chars().count() + l.chars().count() > DIALOG_CHARS {
list.push_str(&format!("({} more in the app)\n", lines.len() - i));
break;
}
list.push_str(l);
list.push('\n');
}
s.push_str(&format!("\nAnswers ({} for you):\n{list}", data["uncertain"].as_u64().unwrap_or(0)));
(format!("Fill in console: {title}"), s, "Open the browser".to_string())
}
/// Native Apply / Cancel dialog; true on Apply. /// Native Apply / Cancel dialog; true on Apply.
async fn confirm_dialog(window: &WebviewWindow, title: String, text: String) -> Res<bool> { async fn confirm_dialog(window: &WebviewWindow, title: String, text: String) -> Res<bool> {
confirm_dialog_with(window, title, text, "Apply".into()).await confirm_dialog_with(window, title, text, "Apply".into()).await
@ -295,6 +412,9 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St
Value::Object(o) => Value::Object(o), Value::Object(o) => Value::Object(o),
_ => return Err(Fail::host("args must be an object")), _ => return Err(Fail::host("args must be an object")),
}; };
if project_id.starts_with("store:") {
return store_write_with_confirm(&window, &app, &state, &r, &method, kind, &d, &project_id, args, job.as_deref()).await;
}
let (proj, creds) = project_params(&state, &project_id)?; let (proj, creds) = project_params(&state, &project_id)?;
let mut dry_params = json!({ "project": proj.path, "args": args, "dryRun": true }); let mut dry_params = json!({ "project": proj.path, "args": args, "dryRun": true });
if !creds.is_empty() { if !creds.is_empty() {
@ -304,10 +424,15 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St
if dry["ok"] != Value::Bool(true) { if dry["ok"] != Value::Bool(true) {
return Ok(json!({ "confirmed": false, "stage": "dry-run", "dryRun": dry })); return Ok(json!({ "confirmed": false, "stage": "dry-run", "dryRun": dry }));
} }
let summary = if kind == Kind::Owner { owner_summary(&d.title, &d.description, &proj.name) } else { dialog_summary(&d.title, &d.description, &proj.name, d.network, d.destructive) }; let (title, text, ok) = if method == policy::CONSOLE_FILL {
console_fill_dialog(&proj.name, &dry["data"])
} else {
let summary = summary_for(&method, kind, &d.title, &d.description, &proj.name, d.network, d.destructive);
let text = format!("{summary}\n\nDry run ({}):\n{}", dry["command"].as_str().unwrap_or_default(), dialog_excerpt(dry["output"].as_str().unwrap_or_default())); let text = format!("{summary}\n\nDry run ({}):\n{}", dry["command"].as_str().unwrap_or_default(), dialog_excerpt(dry["output"].as_str().unwrap_or_default()));
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
let (title, ok) = dialog_labels(kind, &d.title); let (title, ok) = dialog_labels(kind, &d.title);
(title, text, ok)
};
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
if !confirm_dialog_with(&window, title, tilde(&text, home.as_deref()), ok).await? { if !confirm_dialog_with(&window, title, tilde(&text, home.as_deref()), ok).await? {
return Ok(json!({ "confirmed": false, "stage": "dialog", "dryRun": dry })); return Ok(json!({ "confirmed": false, "stage": "dialog", "dryRun": dry }));
} }
@ -321,10 +446,100 @@ pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: St
Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result })) Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result }))
} }
/// `write_with_confirm` for a store-only app (no folder): only STORE_ONLY_WRITES, with the store ids (`virtual`) and the
/// accounts of the store target; the dry run, the same native dialog, then a token bound to `{ virtual, args }`.
#[allow(clippy::too_many_arguments)]
async fn store_write_with_confirm(window: &WebviewWindow, app: &AppHandle, state: &State<'_, AppState>, r: &std::sync::Arc<crate::sidecar::Running>, method: &str, kind: Kind, d: &crate::sidecar::Descriptor, project_id: &str, args: Value, job: Option<&str>) -> Res<Value> {
if !policy::store_only_write(method, Some(kind)) {
return Err(Fail::host("store-only apps are read-only for this: link a folder to change it"));
}
let target = StoreTarget::parse(project_id).map_err(Fail::host)?;
let creds = target_params(state, &target)?;
let virt = target.virtual_project();
let name = virt["name"].as_str().unwrap_or("store app").to_string();
let mut dry_params = json!({ "virtual": virt, "args": args, "dryRun": true });
if !creds.is_empty() {
dry_params["credentials"] = Value::Object(creds.clone());
}
let dry = r.call_job(method, dry_params, job).await?;
if dry["ok"] != Value::Bool(true) {
return Ok(json!({ "confirmed": false, "stage": "dry-run", "dryRun": dry }));
}
let summary = summary_for(method, kind, &d.title, &d.description, &name, d.network, d.destructive);
let text = format!("{summary}\n\nDry run ({}):\n{}", dry["command"].as_str().unwrap_or_default(), dialog_excerpt(dry["output"].as_str().unwrap_or_default()));
let (title, ok) = dialog_labels(kind, &d.title);
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
if !confirm_dialog_with(window, title, tilde(&text, home.as_deref()), ok).await? {
return Ok(json!({ "confirmed": false, "stage": "dialog", "dryRun": dry }));
}
let bound = json!({ "virtual": virt, "args": args });
let token = r.mint(method, &bound).map_err(Fail::host)?;
let mut real = json!({ "virtual": virt, "args": args, "dryRun": false, "confirmToken": token });
if !creds.is_empty() {
real["credentials"] = Value::Object(creds);
}
let result = r.call_job(method, real, job).await?;
Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result }))
}
/// An app folder as the window sees it: `missing` when the folder is gone (moved, renamed, deleted, or an old
/// worktree), so the app shows "Folder not found" with Locate / Remove instead of reading it.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectView {
#[serde(flatten)]
pub project: Project,
pub missing: bool,
/// The storebound version the folder's package.json pins when it is not this app's (its bundled CLI): the window
/// says "project uses storebound X, app is Y".
pub storebound_pin: Option<String>,
}
pub fn project_view(p: &Project) -> ProjectView {
let dir = Path::new(&p.path);
ProjectView { project: p.clone(), missing: !dir.is_dir(), storebound_pin: workspace::pinned_storebound(dir).filter(|pin| workspace::pin_differs(pin, env!("CARGO_PKG_VERSION"))) }
}
/// The app folders: the machine's shared list (registry.rs), with the app's accounts per folder.
#[tauri::command] #[tauri::command]
pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Project>> { pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<ProjectView>> {
guard(&window)?; guard(&window)?;
Ok(saved(&state)?.projects.clone()) sync_machine(&state)?;
Ok(saved(&state)?.projects.iter().map(project_view).collect())
}
/// "Locate…" for a folder that moved: native folder picker; the folder must hold a storebound.config.ts and not be in
/// the list already. Keeps the app's id, accounts, links and saved reads. None = cancelled.
#[tauri::command]
pub async fn project_locate(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String) -> Res<Option<ProjectView>> {
guard(&window)?;
if !saved(&state)?.projects.iter().any(|p| p.id == id) {
return Err(Fail::host("unknown project"));
}
let Some(dir) = pick_folder(&window, "Locate the app folder (with storebound.config.ts)").await? else { return Ok(None) };
if !has_config(&dir) {
return Err(Fail::host(format!("no storebound.config.ts in {}", dir.display())));
}
let path = dir.to_string_lossy().to_string();
// the app's name from its config when the sidecar can read it; the old name otherwise
let name = match state.sidecar.get(&app).await {
Ok(r) => r.call("project.inspect", json!({ "project": path })).await.ok().and_then(|v| v["name"].as_str().map(str::to_string)),
Err(_) => None,
};
let mut st = saved(&state)?;
if st.projects.iter().any(|p| p.path == path && p.id != id) {
return Err(Fail::host("this folder is already in the list"));
}
let p = st.projects.iter_mut().find(|p| p.id == id).ok_or_else(|| Fail::host("unknown project"))?;
p.path = path;
if let Some(n) = name.filter(|n| !n.trim().is_empty()) {
p.name = n;
}
let view = project_view(p);
persist(&state, &st)?;
publish_folders(&state, &st, &[]);
crate::logs::host("info", &format!("app folder located: {}", view.project.path));
Ok(Some(view))
} }
const CONFIG_NAMES: [&str; 3] = ["storebound.config.ts", "storebound.config.js", "storebound.config.json"]; const CONFIG_NAMES: [&str; 3] = ["storebound.config.ts", "storebound.config.js", "storebound.config.json"];
@ -362,6 +577,7 @@ async fn add_folder(app: &AppHandle, state: &AppState, dir: &Path) -> Res<Projec
let mut st = saved(state)?; let mut st = saved(state)?;
st.projects.push(project.clone()); st.projects.push(project.clone());
persist(state, &st)?; persist(state, &st)?;
publish_folders(state, &st, &[]);
Ok(project) Ok(project)
} }
@ -508,6 +724,25 @@ pub async fn price_sheet_pick(window: WebviewWindow, state: State<'_, AppState>,
Ok(Some(p.to_string_lossy().to_string())) Ok(Some(p.to_string_lossy().to_string()))
} }
/// An image (PNG / JPEG) to replace one store image with, picked in a native dialog. None = cancelled. The path goes
/// back to the window only as the `from` argument of art.replace (a write: dry run, dialog, then the copy into the app
/// folder).
#[tauri::command]
pub async fn image_pick(window: WebviewWindow, state: State<'_, AppState>, project_id: String) -> Res<Option<String>> {
guard(&window)?;
let start = saved(&state)?.projects.iter().find(|p| p.id == project_id).map(|p| PathBuf::from(&p.path)).ok_or_else(|| Fail::host("unknown project"))?;
let w = window.clone();
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title("Choose an image (PNG or JPEG)").add_filter("Image", &["png", "jpg", "jpeg"]).set_directory(start).set_parent(&w).blocking_pick_file())
.await
.map_err(|e| Fail::host(e.to_string()))?;
let Some(picked) = picked else { return Ok(None) };
let p = picked.into_path().map_err(|e| Fail::host(e.to_string()))?;
if !p.extension().is_some_and(|e| ["png", "jpg", "jpeg"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
return Err(Fail::host("pick a .png or .jpg file"));
}
Ok(Some(p.to_string_lossy().to_string()))
}
/// Shows a folder or file of an app folder in the file manager (`rel` empty: the app folder), or opens it with /// Shows a folder or file of an app folder in the file manager (`rel` empty: the app folder), or opens it with
/// `open` (a folder, or the gallery page storebound writes: <renders>/index.html in the default browser). /// `open` (a folder, or the gallery page storebound writes: <renders>/index.html in the default browser).
#[tauri::command] #[tauri::command]
@ -539,13 +774,16 @@ pub fn project_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, Ap
pub fn project_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> { pub fn project_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
guard(&window)?; guard(&window)?;
let mut st = saved(&state)?; let mut st = saved(&state)?;
let removed: Vec<String> = st.projects.iter().filter(|p| p.id == id).map(|p| p.path.clone()).collect();
st.projects.retain(|p| p.id != id); st.projects.retain(|p| p.id != id);
st.overrides.links.retain(|l| l.project != id); st.overrides.links.retain(|l| l.project != id);
let (ig, key) = (format!("project:{id}"), format!("project:{id}")); let (ig, key) = (format!("project:{id}"), format!("project:{id}"));
st.overrides.ignored.retain(|i| i != &ig); st.overrides.ignored.retain(|i| i != &ig);
st.overrides.app_studio.retain(|a| a.key != key); st.overrides.app_studio.retain(|a| a.key != key);
let _ = state.cache.forget_project(&id); let _ = state.cache.forget_project(&id);
persist(&state, &st) persist(&state, &st)?;
publish_folders(&state, &st, &removed);
Ok(())
} }
#[tauri::command] #[tauri::command]
@ -574,7 +812,12 @@ pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, i
#[tauri::command] #[tauri::command]
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)?;
Ok(saved(&state)?.accounts.clone()) sync_machine(&state)?;
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<()> {
@ -586,17 +829,28 @@ fn check_studio(state: &AppState, studio: &Option<String>) -> Res<()> {
Ok(()) Ok(())
} }
/// A new key goes into the machine's credential profiles (the profile that already has this key, else one named after
/// the label), so the CLI sees it too. The same key twice is one account.
fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<String>) -> Res<Account> { fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<String>) -> Res<Account> {
let label = accounts::check_label(label).map_err(Fail::host)?; let label = accounts::check_label(label).map_err(Fail::host)?;
check_studio(state, &studio)?; check_studio(state, &studio)?;
let acc = Account { id: store::new_id()?, label, kind: secret.kind(), created: store::now_iso(), studio }; let profile = state.profiles.place(&label, &secret);
state.secrets.set(&acc.id, &secret).map_err(Fail::host)?; state.profiles.save(&profile, &label, &secret).map_err(Fail::host)?;
let mut st = saved(state)?; let mut st = saved(state)?;
st.accounts.push(acc.clone()); if let Some(a) = st.accounts.iter_mut().find(|a| a.kind == secret.kind() && a.profile.as_deref() == Some(profile.as_str())) {
if let Err(e) = persist(state, &st) { a.label = label;
let _ = state.secrets.delete(&acc.id); if studio.is_some() {
return Err(e); a.studio = studio;
} }
let out = a.clone();
persist(state, &st)?;
return Ok(out);
}
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 acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile), developer_id: None };
st.accounts.push(acc.clone());
persist(state, &st)?;
Ok(acc) Ok(acc)
} }
@ -614,7 +868,7 @@ async fn pick_file(window: &WebviewWindow, title: &'static str, filter: (&'stati
std::fs::read_to_string(&path).map(Some).map_err(|e| Fail::host(e.to_string())) std::fs::read_to_string(&path).map(Some).map_err(|e| Fail::host(e.to_string()))
} }
/// Google Play: pick the service account JSON key in a native dialog; it goes to the keychain only. /// Google Play: pick the service account JSON key in a native dialog; it goes into a credential profile (0600).
#[tauri::command] #[tauri::command]
pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, label: String, studio: Option<String>) -> Res<Option<Account>> { pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, label: String, studio: Option<String>) -> Res<Option<Account>> {
guard(&window)?; guard(&window)?;
@ -625,7 +879,8 @@ pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>,
add_account(&state, &label, Secret::Play { service_account: sa }, studio).map(Some) add_account(&state, &label, Secret::Play { service_account: sa }, studio).map(Some)
} }
/// App Store Connect: key id + issuer id typed in the app, the .p8 picked in a native dialog; all to the keychain. /// App Store Connect: key id + issuer id typed in the app, the .p8 picked in a native dialog; all into a credential
/// profile (0600).
#[tauri::command] #[tauri::command]
pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>, label: String, key_id: String, issuer_id: String, studio: Option<String>) -> Res<Option<Account>> { pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>, label: String, key_id: String, issuer_id: String, studio: Option<String>) -> Res<Option<Account>> {
guard(&window)?; guard(&window)?;
@ -641,8 +896,18 @@ pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>
#[tauri::command] #[tauri::command]
pub fn account_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> { pub fn account_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
guard(&window)?; guard(&window)?;
state.secrets.delete(&id).map_err(Fail::host)?;
let mut st = saved(&state)?; let mut st = saved(&state)?;
let acc = st.accounts.iter().find(|a| a.id == id).cloned().ok_or_else(|| Fail::host("unknown account"))?;
// the key leaves the shared profile (moved to credentials/.removed/, never deleted outright), or the keychain
match &acc.profile {
Some(p) => {
if state.profiles.names().contains(p) && state.profiles.kinds(p).contains(&acc.kind) {
let stamp = store::now_iso().replace(['-', ':'], "");
state.profiles.remove(p, acc.kind, &stamp).map_err(Fail::host)?;
}
}
None => state.legacy.delete(&id).map_err(Fail::host)?,
}
st.accounts.retain(|a| a.id != id); st.accounts.retain(|a| a.id != id);
let ignored = format!("account:{id}"); let ignored = format!("account:{id}");
st.overrides.ignored.retain(|i| i != &ignored); st.overrides.ignored.retain(|i| i != &ignored);
@ -671,7 +936,9 @@ pub async fn portfolio_status(window: WebviewWindow, app: AppHandle, state: Stat
let (projects, parallel) = { let (projects, parallel) = {
let st = saved(&state)?; let st = saved(&state)?;
let ignored: Vec<&str> = st.overrides.ignored.iter().filter_map(|i| i.strip_prefix("project:")).collect(); let ignored: Vec<&str> = st.overrides.ignored.iter().filter_map(|i| i.strip_prefix("project:")).collect();
(st.projects.iter().filter(|p| !ignored.contains(&p.id.as_str())).cloned().collect::<Vec<_>>(), st.settings.refresh.parallel.clamp(1, 8) as usize) // folders that are gone are skipped (the window shows them as "Folder not found")
let projects = st.projects.iter().filter(|p| !ignored.contains(&p.id.as_str()) && Path::new(&p.path).is_dir()).cloned().collect::<Vec<_>>();
(projects, st.settings.refresh.parallel.clamp(1, 8) as usize)
}; };
// store-only apps (live reads only): validated targets with their accounts' credentials // store-only apps (live reads only): validated targets with their accounts' credentials
let mut targets = Vec::new(); let mut targets = Vec::new();
@ -867,7 +1134,7 @@ pub async fn studio_logo(window: WebviewWindow, state: State<'_, AppState>, id:
Ok(Some(out)) Ok(Some(out))
} }
/// Renames an account or moves it to another studio (the key in the keychain stays as it is). /// Renames an account or moves it to another studio (the key in its credential profile stays as it is).
#[tauri::command] #[tauri::command]
pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: String, label: String, studio: Option<String>) -> Res<Account> { pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: String, label: String, studio: Option<String>) -> Res<Account> {
guard(&window)?; guard(&window)?;
@ -882,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> {
@ -904,9 +1188,21 @@ pub async fn store_apps_refresh(window: WebviewWindow, app: AppHandle, state: St
let value = r.call_job("apps.list", json!({ "credentials": creds, "args": { "icons": icons } }), job.as_deref()).await?; let value = r.call_job("apps.list", json!({ "credentials": creds, "args": { "icons": icons } }), job.as_deref()).await?;
let at = store::now_iso(); let at = store::now_iso();
state.cache.put(&format!("acct:{id}"), "apps.list", &json!({}), &value, &at).map_err(Fail::host)?; state.cache.put(&format!("acct:{id}"), "apps.list", &json!({}), &value, &at).map_err(Fail::host)?;
let st = saved(&state)?;
let value = match st.accounts.iter().find(|a| a.id == id) {
Some(a) => store_only_into(&st.accounts, a, Some(value.clone()), &registry::read_store_apps(&state.registry), &at).unwrap_or(value),
None => value,
};
Ok(Entry { value, at }) Ok(Entry { value, at })
} }
/// One account's app list with the machine's store-only apps of its credential profile added (registry.rs).
fn store_only_into(all: &[Account], a: &Account, list: Option<Value>, apps: &[registry::StoreOnly], at: &str) -> Option<Value> {
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
let only = all.iter().filter(|x| x.kind == a.kind).count() == 1;
registry::with_store_only(list, store, a.profile.as_deref(), only, apps, at)
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct AccountApps { pub struct AccountApps {
account: String, account: String,
@ -917,11 +1213,19 @@ pub struct AccountApps {
#[tauri::command] #[tauri::command]
pub fn store_apps_cached(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<AccountApps>> { pub fn store_apps_cached(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<AccountApps>> {
guard(&window)?; guard(&window)?;
let ids: Vec<String> = saved(&state)?.accounts.iter().map(|a| a.id.clone()).collect(); let accounts = saved(&state)?.accounts.clone();
ids.into_iter() let store_only = registry::read_store_apps(&state.registry);
.map(|account| { accounts
let entry = state.cache.get(&format!("acct:{account}"), "apps.list", &json!({})).map_err(Fail::host)?; .iter()
Ok(AccountApps { account, entry }) .map(|a| {
let entry = state.cache.get(&format!("acct:{}", a.id), "apps.list", &json!({})).map_err(Fail::host)?;
// apps live on a store with no project on this machine (`storebound apps add --store … --app-id …`)
let at = entry.as_ref().map(|e| e.at.clone()).unwrap_or_else(store::now_iso);
let entry = match store_only_into(&accounts, a, entry.as_ref().map(|e| e.value.clone()), &store_only, &at) {
Some(value) => Some(Entry { value, at }),
None => entry,
};
Ok(AccountApps { account: a.id.clone(), entry })
}) })
.collect() .collect()
} }
@ -934,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)
} }
@ -943,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);
@ -987,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
@ -1003,7 +1394,7 @@ pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res
let yes = tauri::async_runtime::spawn_blocking(move || { let yes = tauri::async_runtime::spawn_blocking(move || {
w.app_handle() w.app_handle()
.dialog() .dialog()
.message("Removes every store key from the system keychain and forgets studios, accounts, app folders (the folders and their files stay), pairing, the ignore list, settings and saved store reads.\n\nDownloaded tools stay. This cannot be undone.") .message("Forgets studios, labels, pairing, the ignore list, settings and saved store reads of this app.\n\nThe store accounts in ~/.storebound/credentials and the app folder list in ~/.storebound/projects.json stay: the storebound CLI uses them too (remove an account in Accounts first to remove its key). Downloaded tools stay. This cannot be undone.")
.title("Reset storebound desktop?") .title("Reset storebound desktop?")
.kind(MessageDialogKind::Warning) .kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom("Reset".into(), "Cancel".into())) .buttons(MessageDialogButtons::OkCancelCustom("Reset".into(), "Cancel".into()))
@ -1016,16 +1407,78 @@ pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res
return Ok(false); return Ok(false);
} }
let mut st = saved(&state)?; let mut st = saved(&state)?;
for a in &st.accounts { // keys of earlier versions still in the keychain go; the shared credential profiles stay (the CLI uses them)
state.secrets.delete(&a.id).map_err(Fail::host)?; for a in st.accounts.iter().filter(|a| a.profile.is_none()) {
state.legacy.delete(&a.id).map_err(Fail::host)?;
} }
*st = Saved::default(); *st = Saved::default();
persist(&state, &st)?; persist(&state, &st)?;
drop(st); drop(st);
state.cache.clear().map_err(Fail::host)?; state.cache.clear().map_err(Fail::host)?;
sync_machine(&state)?;
Ok(true) Ok(true)
} }
/// "Reset saved state" of the error screen: after a native confirmation, state.json is moved aside as
/// `state.json.reset-<time>.bak` (nothing is deleted: store keys stay in the keychain, saved reads stay) and the app
/// starts with an empty list. The backup's path (~), or null when cancelled.
#[tauri::command]
pub async fn state_reset(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<Option<String>> {
guard(&window)?;
let w = window.clone();
let yes = tauri::async_runtime::spawn_blocking(move || {
w.app_handle()
.dialog()
.message("storebound forgets its studios, pairing and settings, and starts again from the machine's accounts and app folders.\n\nNothing is deleted: the saved state is kept as a backup file in the app data folder, store keys stay in ~/.storebound/credentials, and your app folders are not touched.")
.title("Reset saved state?")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom("Reset and keep a backup".into(), "Cancel".into()))
.parent(&w)
.blocking_show()
})
.await
.map_err(|e| Fail::host(e.to_string()))?;
if !yes {
return Ok(None);
}
let mut st = saved(&state)?;
let backup = store::reset(&state.data_dir).map_err(Fail::host)?;
*st = Saved::default();
persist(&state, &st)?;
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
let shown = backup.map(|b| tilde(&b.to_string_lossy(), home.as_deref())).unwrap_or_default();
crate::logs::host("warn", &format!("saved state reset from the error screen; backup: {shown}"));
Ok(Some(shown))
}
/// Uncaught errors of the window (window.onerror, unhandled rejections, React error boundaries) into
/// `<app data>/logs/webview.log`. Sanitized there (home as ~, no keys, tokens or e-mail addresses), size-capped, and
/// limited to WEBVIEW_LOG_BURST entries a minute so a render loop cannot fill the disk.
#[tauri::command]
pub fn log_webview(window: WebviewWindow, level: String, message: String, detail: Option<String>) -> Res<()> {
guard(&window)?;
static BUDGET: Mutex<(u64, u32)> = Mutex::new((0, 0));
let minute = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() / 60).unwrap_or(0);
{
let mut b = BUDGET.lock().map_err(|_| Fail::host("log state poisoned"))?;
if b.0 != minute {
*b = (minute, 0);
}
b.1 += 1;
if b.1 > WEBVIEW_LOG_BURST {
return Ok(());
}
}
let text = match detail.filter(|d| !d.trim().is_empty()) {
Some(d) => format!("{message}\n{d}"),
None => message,
};
crate::logs::webview(&level, &text);
Ok(())
}
pub const WEBVIEW_LOG_BURST: u32 = 60;
// --------------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------------
// Updates (Settings > Updates): signed bundles from storebound.dev, installed only after the owner's click. // Updates (Settings > Updates): signed bundles from storebound.dev, installed only after the owner's click.
@ -1040,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) {
@ -1051,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())))
@ -1140,6 +1595,8 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
))); )));
} }
let path = dir.to_string_lossy().to_string(); let path = dir.to_string_lossy().to_string();
// `storebound desktop` put the folder on the machine's list first: then it is selected without a question
sync_machine(&state)?;
let known = saved(&state)?.projects.iter().find(|p| p.path == path).map(|p| p.id.clone()); let known = saved(&state)?.projects.iter().find(|p| p.path == path).map(|p| p.id.clone());
let id = match known { let id = match known {
Some(id) => id, Some(id) => id,
@ -1154,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,11 +36,26 @@ 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",
"project_import_fastlane", "project_import_fastlane",
"export_save", "export_save",
"price_sheet_pick", "price_sheet_pick",
"image_pick",
"project_reveal", "project_reveal",
"project_locate",
"state_reset",
"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,15 +1,22 @@
//! 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 menu_panel;
pub mod policy; pub mod policy;
pub mod profiles;
pub mod registry;
pub mod selftest; 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");
@ -17,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
@ -30,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);
}
} }
} }
@ -125,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())
@ -139,22 +336,68 @@ 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::panic_hook();
logs::host("info", &format!("storebound desktop {} starting ({} {}{})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH, if cfg!(debug_assertions) { ", debug" } else { "" }));
dev_seed(&data_dir); dev_seed(&data_dir);
let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?; let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?;
let saved = store::load(&data_dir); let (saved, report) = store::load_report(&data_dir);
let home = app.path().home_dir().ok();
logs::host("info", &format!("saved state: {} app folders ({} not found), {} accounts, {} studios, format {}", saved.projects.len(), saved.projects.iter().filter(|p| !std::path::Path::new(&p.path).is_dir()).count(), saved.accounts.len(), saved.studios.len(), saved.version));
if let Some(from) = report.migrated_from {
logs::host("info", &format!("saved state migrated from format {from} to {}", store::VERSION));
}
for n in &report.notes {
logs::host("warn", &format!("saved state: {n}"));
}
if let Some(b) = &report.backup {
logs::host("info", &format!("saved state: original kept as {}", b.display()));
}
let keep_days = saved.settings.cache.keep_days; let keep_days = saved.settings.cache.keep_days;
let state = commands::AppState { let state = commands::AppState {
sidecar: sidecar::Sidecar::default(), sidecar: sidecar::Sidecar::default(),
saved: Mutex::new(saved), saved: Mutex::new(saved),
data_dir, data_dir,
secrets: Box::new(accounts::Keychain), profiles: profiles::ProfileStore::new(profiles::default_dir(home.as_deref()), home.clone()),
legacy: Box::new(accounts::Keychain),
registry: registry::default_file(home.as_deref()),
cache, cache,
downloads: downloads::Jobs::default(), downloads: downloads::Jobs::default(),
}; };
// Settings > Offline cache: drop saved reads older than the chosen number of days // Settings > Offline cache: drop saved reads older than the chosen number of days
commands::prune_cache(&state, keep_days); commands::prune_cache(&state, keep_days);
// the machine's shared accounts and app folders (CLI and app); old keychain accounts move into profiles once
if let Err(e) = commands::sync_machine(&state) {
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"))]
@ -164,8 +407,12 @@ pub fn run() {
show_main(app); show_main(app);
} }
}); });
// Linux: registering runs update-desktop-database and xdg-mime, which a minimal system may not have; a
// missing tool must not stop the app (it then only misses storebound:// links)
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))] #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all()?; if let Err(e) = app.deep_link().register_all() {
logs::host("warn", &format!("storebound:// links not registered: {e}"));
}
let handle = app.handle().clone(); let handle = app.handle().clone();
app.deep_link().on_open_url(move |e| handle_deep_link(&handle, &e.urls())); app.deep_link().on_open_url(move |e| handle_deep_link(&handle, &e.urls()));
if let Ok(Some(urls)) = app.deep_link().get_current() { if let Ok(Some(urls)) = app.deep_link().get_current() {
@ -174,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();
@ -210,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,
@ -218,13 +473,33 @@ 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,
commands::project_import_fastlane, commands::project_import_fastlane,
commands::export_save, commands::export_save,
commands::price_sheet_pick, commands::price_sheet_pick,
commands::image_pick,
commands::project_reveal, commands::project_reveal,
commands::project_locate,
commands::state_reset,
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");
@ -232,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,213 @@
//! Local diagnostics in `<app data>/logs`: `host.log` (this process: start, saved state, sidecar, panics) and
//! `webview.log` (uncaught errors of the window, sent through the `log_webview` command). Each file is rotated at
//! MAX_BYTES (one older `.1.log` kept). Every line goes through `sanitize`: the home folder shows as `~`, and keys,
//! tokens, long secrets and e-mail addresses are replaced, so a log can be attached to a bug report as it is.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub const DIR: &str = "logs";
pub const HOST: &str = "host.log";
pub const WEBVIEW: &str = "webview.log";
pub const MAX_BYTES: u64 = 512 * 1024;
/// Longest single entry kept (the rest is cut).
pub const MAX_ENTRY: usize = 16 * 1024;
struct Logs {
dir: PathBuf,
home: Option<String>,
lock: Mutex<()>,
}
static LOGS: OnceLock<Logs> = OnceLock::new();
/// Sets the log folder once (the first call wins).
pub fn init(data_dir: &Path, home: Option<String>) {
let _ = LOGS.set(Logs { dir: data_dir.join(DIR), home, lock: Mutex::new(()) });
}
pub fn dir() -> Option<PathBuf> {
LOGS.get().map(|l| l.dir.clone())
}
/// One line in host.log (no-op before `init`).
pub fn host(level: &str, message: &str) {
write(HOST, "host", level, message);
}
/// One entry in webview.log.
pub fn webview(level: &str, message: &str) {
write(WEBVIEW, "webview", level, message);
}
fn write(file: &str, source: &str, level: &str, message: &str) {
let Some(l) = LOGS.get() else { return };
let _g = l.lock.lock();
let level = match level {
"error" | "warn" | "info" | "debug" => level,
_ => "info",
};
let text = sanitize(message, l.home.as_deref());
let line = entry(&crate::store::now_iso(), source, level, &text);
let _ = append(&l.dir, file, &line);
}
/// `<time> <level> [<source>] <text>`; continuation lines are indented.
pub fn entry(at: &str, source: &str, level: &str, text: &str) -> String {
let mut t: String = text.chars().take(MAX_ENTRY).collect();
if t.len() < text.len() {
t.push_str(" [cut]");
}
format!("{at} {level:<5} [{source}] {}\n", t.trim_end().replace('\n', "\n "))
}
/// Appends, rotating first when the file would pass MAX_BYTES.
pub fn append(dir: &Path, file: &str, line: &str) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
let path = dir.join(file);
if fs::metadata(&path).map(|m| m.len() + line.len() as u64 > MAX_BYTES).unwrap_or(false) {
let old = dir.join(file.replace(".log", ".1.log"));
let _ = fs::remove_file(&old);
fs::rename(&path, &old)?;
}
OpenOptions::new().create(true).append(true).open(&path)?.write_all(line.as_bytes())
}
const SECRET_KEYS: &[&str] = &["secret", "token", "password", "passwd", "private_key", "privatekey", "apikey", "api_key", "authorization", "credential", "p8"];
/// The home folder as `~`, then keys, tokens and e-mail addresses replaced. Control characters other than newline and
/// tab are dropped.
pub fn sanitize(text: &str, home: Option<&str>) -> String {
let mut s: String = text.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').collect();
if let Some(h) = home.map(|h| h.trim_end_matches(['/', '\\'])).filter(|h| h.len() > 1) {
s = s.replace(h, "~");
}
s = redact_pem(&s);
s = redact_assignments(&s);
redact_words(&s)
}
fn redact_pem(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(i) = rest.find("-----BEGIN") {
out.push_str(&rest[..i]);
let after = &rest[i..];
match after.find("-----END").and_then(|e| after[e + 8..].find("-----").map(|k| e + 8 + k + 5)) {
Some(end) => {
out.push_str("[redacted key]");
rest = &after[end..];
}
None => {
out.push_str("[redacted key]");
rest = "";
}
}
}
out.push_str(rest);
out
}
/// `token=...`, `"password": "..."`, `Authorization: Bearer ...`: the value goes.
fn redact_assignments(s: &str) -> String {
let lower = s.to_ascii_lowercase();
let b = s.as_bytes();
let mut cut: Vec<(usize, usize)> = Vec::new();
for k in SECRET_KEYS {
let mut from = 0;
while let Some(i) = lower[from..].find(k) {
let mut j = i + from + k.len();
from = j;
// rest of the key name ("tokenId", "secret_key"), a closing quote, spaces, then ':' or '='
while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_' || b[j] == b'-') {
j += 1;
}
while j < b.len() && (b[j] == b'"' || b[j] == b'\'' || b[j] == b' ') {
j += 1;
}
if j >= b.len() || (b[j] != b':' && b[j] != b'=') {
continue;
}
j += 1;
while j < b.len() && (b[j] == b' ' || b[j] == b'"' || b[j] == b'\'') {
j += 1;
}
if lower[j..].starts_with("bearer ") || lower[j..].starts_with("basic ") {
j += lower[j..].find(' ').unwrap_or(0) + 1;
}
let start = j;
while j < b.len() && !matches!(b[j], b'"' | b'\'' | b',' | b'}' | b' ' | b'\n' | b'&' | b';') {
j += 1;
}
if j > start {
cut.push((start, j));
}
}
}
cut.sort();
let mut out = String::new();
let mut at = 0;
for (a, z) in cut {
if a < at {
continue;
}
out.push_str(&s[at..a]);
out.push_str("[redacted]");
at = z;
}
out.push_str(&s[at..]);
out
}
/// Long random-looking words (keys, JWTs, hashes of 32+ characters with letters and digits) and e-mail addresses.
fn redact_words(s: &str) -> String {
let is_word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '/' | '=' | '.' | '@');
let mut out = String::with_capacity(s.len());
let mut word = String::new();
let flush = |w: &mut String, out: &mut String| {
if !w.is_empty() {
out.push_str(&judge(w));
w.clear();
}
};
for c in s.chars() {
if is_word(c) {
word.push(c);
} else {
flush(&mut word, &mut out);
out.push(c);
}
}
flush(&mut word, &mut out);
out
}
fn judge(w: &str) -> String {
if let Some((user, domain)) = w.split_once('@') {
if !user.is_empty() && domain.contains('.') && !domain.starts_with('.') {
return "[email]".into();
}
}
let mixed = |p: &str| p.chars().any(|c| c.is_ascii_alphabetic()) && p.chars().any(|c| c.is_ascii_digit());
// a long base64 / hex run (may hold '/' or '+'): not a path, not a dotted name
if w.len() >= 40 && !w.starts_with(['/', '~']) && !w.contains('.') && mixed(w) {
return "[redacted]".into();
}
// paths and dotted names stay (file names in stacks); one long run of key-like characters inside them goes
for part in w.split(['/', '.']) {
if part.len() >= 32 && mixed(part) {
return w.replace(part, "[redacted]");
}
}
w.to_string()
}
/// Records panics in host.log (release builds abort right after).
pub fn panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
host("error", &format!("panic: {info}"));
prev(info);
}));
}

View file

@ -2,6 +2,11 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
// `storebound desktop` reads the installed version this way (Windows, Linux; macOS reads Info.plist)
if std::env::args().nth(1).as_deref() == Some("--version") {
println!("storebound-desktop {}", env!("CARGO_PKG_VERSION"));
return;
}
// Release smoke test of the exact artifact (tools/desktop-release.ts, the Forgejo workflow): sidecar handshake, // Release smoke test of the exact artifact (tools/desktop-release.ts, the Forgejo workflow): sidecar handshake,
// one JSON line, exit. Runs before Tauri starts, so no window opens. // one JSON line, exit. Runs before Tauri starts, so no window opens.
if std::env::args().skip(1).any(|a| a == storebound_desktop::selftest::FLAG) { if std::env::args().skip(1).any(|a| a == storebound_desktop::selftest::FLAG) {

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 {
@ -53,7 +77,8 @@ pub enum Kind {
Session, Session,
/// Account-scoped store reads (apps.list, account.check): the host runs them with one account's key. /// Account-scoped store reads (apps.list, account.check): the host runs them with one account's key.
Account, Account,
/// Owner actions (rollout share, phased release, sending one approved reply): like writes, a dry run from the /// Owner actions (rollout share, phased release, sending one approved reply, submit for review, the Play production
/// release, price changes): like writes, a dry run from the
/// window; the real run only through `write_with_confirm`, whose dialog says that users see it at once. /// window; the real run only through `write_with_confirm`, whose dialog says that users see it at once.
Owner, Owner,
} }
@ -106,12 +131,32 @@ pub fn sanitize_js_call(method: &str, kind: Option<Kind>, params: &Value) -> Res
Ok(Value::Object(out)) Ok(Value::Object(out))
} }
/// Owner actions that send the app to review or to production (cli/serve.ts OWNER_SUBMIT): App Store submit, resubmit
/// after a rejection, in-app purchases to App Review, the Play production release. Like every owner action they run
/// for real only through `write_with_confirm`; their dialog says that the app goes to review and may go live by itself.
pub const SUBMIT_METHODS: &[&str] = &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"];
/// Write methods whose project is a new folder without a config (the host picks it in a native dialog and runs them /// Write methods whose project is a new folder without a config (the host picks it in a native dialog and runs them
/// in its own commands: `project_init`, `project_import_fastlane`); `write_with_confirm` never runs them. /// in its own commands: `project_init`, `project_import_fastlane`); `write_with_confirm` never runs them.
pub const NEW_FOLDER_METHODS: &[&str] = &["project.init", "import.fastlane"]; pub const NEW_FOLDER_METHODS: &[&str] = &["project.init", "import.fastlane"];
/// Reads that are never kept in the offline cache (big: one rendered image as a data: URL). /// `console.fill`: a write whose real run opens a visible browser on a store console form and lasts until the owner
pub const NO_CACHE_METHODS: &[&str] = &["project.render"]; /// closes it. `write_with_confirm` shows its answers and this note instead of a dry-run log; a cancel (`job_cancel`)
/// stops the command, which closes the browser.
pub const CONSOLE_FILL: &str = "console.fill";
pub const CONSOLE_FILL_NOTE: &str = "Console pages change. This is AI-assisted: review every field in the browser and press the final button (Publish, Submit, Save or Create) yourself. storebound moves through the pages for you and never submits or publishes; App Privacy answers stay a draft until you press Publish.";
/// Writes a store-only app (no folder) may run: they send only what the call carries (one language's texts), never
/// the sidecar's generated config. Dry run from the window like every write; the real run only through
/// `write_with_confirm`, its token bound to the store ids (`virtual`) instead of a folder.
pub const STORE_ONLY_WRITES: &[&str] = &["listing.set"];
pub fn store_only_write(method: &str, kind: Option<Kind>) -> bool {
kind == Some(Kind::Write) && STORE_ONLY_WRITES.contains(&method)
}
/// Reads that are never kept in the offline cache (big: one rendered image or one live store thumbnail as a data: URL).
pub const NO_CACHE_METHODS: &[&str] = &["project.render", "art.thumb"];
/// File kinds the window may export through `export_save`, with their extension. /// File kinds the window may export through `export_save`, with their extension.
pub const EXPORT_KINDS: &[&str] = &["csv", "md", "json", "txt"]; pub const EXPORT_KINDS: &[&str] = &["csv", "md", "json", "txt"];
@ -153,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.
@ -164,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

@ -0,0 +1,398 @@
//! The store accounts of this machine, shared with the storebound CLI: credential profiles in
//! `~/.storebound/credentials` (STOREBOUND_CREDENTIALS_DIR), the same files `storebound accounts` lists and every
//! terminal command reads (cli/lib/credentials.ts). A profile `<name>.json` holds one App Store Connect team key and / or
//! one Play service account; its key files live next to it in `<name>/` (mode 0600, folders 0700) and the profile names
//! them by relative path:
//! { "label": "Acme", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "acme/app-store-connect.p8",
//! "PLAY_SERVICE_ACCOUNT": "acme/play-service-account.json" }
//! One desktop account = one store of one profile (`Account::profile`). Accounts the CLI added show up in the app
//! (`sync`), and keys the app adds are there for the CLI. Desktop accounts of earlier versions kept their keys in the
//! OS keychain; `sync` moves each one into a profile once (the same key as an existing profile = that profile) and
//! then deletes the keychain entry. Secrets still never reach the webview: they are read here right before a sidecar
//! call. A removed account's key files are moved to `.removed/` (never deleted outright).
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::accounts::{Account, AccountKind, Secret, SecretStore};
pub const P8_FILE: &str = "app-store-connect.p8";
pub const PLAY_FILE: &str = "play-service-account.json";
/// The folder: STOREBOUND_CREDENTIALS_DIR, else ~/.storebound/credentials (the CLI's profileDir()).
pub fn default_dir(home: Option<&Path>) -> PathBuf {
if let Some(d) = std::env::var_os("STOREBOUND_CREDENTIALS_DIR").filter(|d| !d.is_empty()) {
return PathBuf::from(d);
}
home.map(Path::to_path_buf).unwrap_or_default().join(".storebound").join("credentials")
}
/// A profile name: the CLI's PROFILE_RE (letters, digits, . _ -, starting with a letter or digit, max 64).
pub fn valid_name(n: &str) -> bool {
let mut c = n.chars();
matches!(c.next(), Some(f) if f.is_ascii_alphanumeric()) && n.len() <= 64 && n.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
/// A profile name from a label: lower case, other characters as `-` (the CLI's slug).
pub fn slug(label: &str) -> String {
let mut out = String::new();
for c in label.to_lowercase().chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let s: String = out.trim_matches(|c| c == '-' || c == '.').chars().take(56).collect();
if s.is_empty() || !valid_name(&s) {
"account".into()
} else {
s
}
}
/// 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.
/// 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 {
let store = match kind {
AccountKind::Apple => "apple",
AccountKind::Play => "play",
};
let d = Sha256::digest(format!("{store}:{profile}").as_bytes());
d.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
fn str_of<'a>(m: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
m.get(k).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
}
pub struct ProfileStore {
pub dir: PathBuf,
home: Option<PathBuf>,
}
/// What a profile reaches, for matching (never shown).
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Identity {
pub apple_key_id: Option<String>,
pub play_email: Option<String>,
}
impl ProfileStore {
pub fn new(dir: PathBuf, home: Option<PathBuf>) -> ProfileStore {
ProfileStore { dir, home }
}
/// The profiles (`*.json` with a valid name), sorted.
pub fn names(&self) -> Vec<String> {
let mut out: Vec<String> = fs::read_dir(&self.dir)
.map(|rd| rd.filter_map(Result::ok).filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(str::to_string)).filter(|n| valid_name(n)).collect())
.unwrap_or_default();
out.sort();
out
}
pub fn read(&self, name: &str) -> Result<Map<String, Value>, String> {
if !valid_name(name) {
return Err(format!("profile {name}: not a profile name"));
}
let text = fs::read_to_string(self.dir.join(format!("{name}.json"))).map_err(|e| format!("profile {name}: {e}"))?;
match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(m)) => Ok(m),
_ => Err(format!("profile {name}: not a JSON object")),
}
}
/// A value that is a key file path: relative to the credentials folder, `~/` from home, or absolute.
fn path_of(&self, v: &str) -> PathBuf {
if let (Some(rest), Some(h)) = (v.strip_prefix("~/"), self.home.as_ref()) {
return h.join(rest);
}
let p = Path::new(v);
if p.is_absolute() {
p.to_path_buf()
} else {
self.dir.join(p)
}
}
/// The content of a value that is inline (PEM / JSON) or a file.
fn content(&self, v: &str) -> Result<String, String> {
if v.contains("BEGIN PRIVATE KEY") || v.trim_start().starts_with('{') {
return Ok(v.to_string());
}
fs::read_to_string(self.path_of(v)).map_err(|e| format!("key file: {e}"))
}
pub fn kinds(&self, name: &str) -> Vec<AccountKind> {
let Ok(m) = self.read(name) else { return vec![] };
let mut out = vec![];
if str_of(&m, "ASC_KEY_ID").is_some() && str_of(&m, "ASC_ISSUER_ID").is_some() {
out.push(AccountKind::Apple);
}
if str_of(&m, "PLAY_SERVICE_ACCOUNT").is_some() {
out.push(AccountKind::Play);
}
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).
pub fn label(&self, name: &str) -> Option<String> {
self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string))
}
/// One store's keys of a profile, read from its files right now.
pub fn secret(&self, name: &str, kind: AccountKind) -> Result<Secret, String> {
let m = self.read(name)?;
match kind {
AccountKind::Apple => {
let key_id = str_of(&m, "ASC_KEY_ID").ok_or_else(|| format!("profile {name}: no ASC_KEY_ID"))?.to_string();
let issuer_id = str_of(&m, "ASC_ISSUER_ID").ok_or_else(|| format!("profile {name}: no ASC_ISSUER_ID"))?.to_string();
// no ASC_P8: the CLI looks in ~/.appstoreconnect/private_keys itself
let p8 = match str_of(&m, "ASC_P8") {
Some(v) => self.content(v)?,
None => String::new(),
};
Ok(Secret::Apple { key_id, issuer_id, p8 })
}
AccountKind::Play => {
let v = str_of(&m, "PLAY_SERVICE_ACCOUNT").ok_or_else(|| format!("profile {name}: no PLAY_SERVICE_ACCOUNT"))?;
Ok(Secret::Play { service_account: self.content(v)? })
}
}
}
pub fn identity(&self, name: &str) -> Identity {
let apple_key_id = self.read(name).ok().and_then(|m| str_of(&m, "ASC_KEY_ID").map(str::to_string));
let play_email = match self.secret(name, AccountKind::Play) {
Ok(Secret::Play { service_account }) => email_of(&service_account),
_ => None,
};
Identity { apple_key_id, play_email }
}
/// Where a new key goes: the profile that already has this key (same key id / service account e-mail), else the
/// profile named after the label when it has no key of this store yet, else a free name.
pub fn place(&self, label: &str, secret: &Secret) -> String {
let names = self.names();
for n in &names {
let id = self.identity(n);
let same = match secret {
Secret::Apple { key_id, .. } => id.apple_key_id.as_deref() == Some(key_id.as_str()),
Secret::Play { service_account } => id.play_email.is_some() && id.play_email == email_of(service_account),
};
if same {
return n.clone();
}
}
let base = slug(label);
if !names.contains(&base) || !self.kinds(&base).contains(&secret.kind()) {
return base;
}
let kind = match secret.kind() {
AccountKind::Apple => "apple",
AccountKind::Play => "play",
};
let mut i = 1;
loop {
let n = if i == 1 { format!("{base}-{kind}") } else { format!("{base}-{kind}-{i}") };
if !names.contains(&n) || !self.kinds(&n).contains(&secret.kind()) {
return n;
}
i += 1;
}
}
fn ensure_dir(&self, d: &Path) -> Result<(), String> {
fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?;
private_dir(d);
Ok(())
}
/// Writes one store's keys into profile `name` (created when missing): the key file in `<name>/` (0600), then the
/// profile JSON (0600, atomic), keeping the profile's other store and fields. The written profile is read back.
pub fn save(&self, name: &str, label: &str, secret: &Secret) -> Result<(), String> {
if !valid_name(name) {
return Err(format!("profile {name}: not a profile name"));
}
self.ensure_dir(&self.dir)?;
let mut m = if self.dir.join(format!("{name}.json")).exists() { self.read(name)? } else { Map::new() };
let keys = self.dir.join(name);
self.ensure_dir(&keys)?;
match secret {
Secret::Apple { key_id, issuer_id, p8 } => {
if !p8.is_empty() {
write_private(&keys.join(P8_FILE), p8.as_bytes())?;
m.insert("ASC_P8".into(), Value::String(format!("{name}/{P8_FILE}")));
}
m.insert("ASC_KEY_ID".into(), Value::String(key_id.clone()));
m.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone()));
}
Secret::Play { service_account } => {
write_private(&keys.join(PLAY_FILE), service_account.as_bytes())?;
m.insert("PLAY_SERVICE_ACCOUNT".into(), Value::String(format!("{name}/{PLAY_FILE}")));
}
}
if !m.contains_key("label") && !label.trim().is_empty() {
m.insert("label".into(), Value::String(label.trim().to_string()));
}
let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "\n";
write_private(&self.dir.join(format!("{name}.json")), text.as_bytes())?;
match self.secret(name, secret.kind()) {
Ok(back) if back == *secret || matches!((&back, secret), (Secret::Apple { key_id: a, .. }, Secret::Apple { key_id: b, p8, .. }) if a == b && p8.is_empty()) => Ok(()),
Ok(_) => Err(format!("profile {name}: the key did not read back the same")),
Err(e) => Err(e),
}
}
/// Removes one store from a profile: its key file goes to `.removed/<name>-<time>/`, its fields leave the profile,
/// and a profile with no store left moves there too. Nothing is deleted outright.
pub fn remove(&self, name: &str, kind: AccountKind, stamp: &str) -> Result<(), String> {
let mut m = self.read(name)?;
let archive = self.dir.join(".removed").join(format!("{name}-{stamp}"));
self.ensure_dir(&archive)?;
let (fields, file): (&[&str], &str) = match kind {
AccountKind::Apple => (&["ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "ASC_VENDOR_NUMBER"], "ASC_P8"),
AccountKind::Play => (&["PLAY_SERVICE_ACCOUNT"], "PLAY_SERVICE_ACCOUNT"),
};
// only key files inside the credentials folder are moved; a file the owner keeps elsewhere stays where it is
if let Some(v) = str_of(&m, file) {
let p = self.path_of(v);
if p.starts_with(&self.dir) && p.is_file() {
let to = archive.join(p.file_name().unwrap_or_default());
fs::rename(&p, &to).map_err(|e| format!("{}: {e}", p.display()))?;
}
}
for f in fields {
m.remove(*f);
}
let left = ["ASC_KEY_ID", "PLAY_SERVICE_ACCOUNT"].iter().any(|k| m.contains_key(*k));
let json = self.dir.join(format!("{name}.json"));
if left {
let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "\n";
write_private(&json, text.as_bytes())
} else {
fs::rename(&json, archive.join(format!("{name}.json"))).map_err(|e| e.to_string())?;
let _ = fs::remove_dir(self.dir.join(name));
Ok(())
}
}
}
/// The client_email of a service account JSON.
pub fn email_of(sa: &str) -> Option<String> {
serde_json::from_str::<Value>(sa).ok()?.get("client_email")?.as_str().map(str::to_string)
}
fn private_dir(d: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(d, fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
let _ = d;
}
/// Writes a file only its owner can read (0600 on unix), atomically (temp file, then rename).
pub fn write_private(path: &Path, data: &[u8]) -> Result<(), String> {
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
{
let mut o = fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
let mut f = o.open(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
f.write_all(data).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
}
fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
}
/// What `sync` did, for host.log (no values).
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SyncReport {
pub migrated: Vec<String>,
pub added: Vec<String>,
pub notes: Vec<String>,
}
impl SyncReport {
pub fn changed(&self) -> bool {
!self.migrated.is_empty() || !self.added.is_empty()
}
}
/// Brings the app's account list in line with the shared profiles: (1) an account of an earlier version still in the
/// keychain moves into a profile (its id, label, studio and app mappings stay) and its keychain entry is deleted once
/// the profile reads back; (2) every profile store without an account gets one (id: `account_id`, label: the
/// profile's label or name). Accounts whose profile disappeared stay (the app says the key is missing).
pub fn sync(accounts: &mut Vec<Account>, store: &ProfileStore, legacy: &dyn SecretStore, now: &str) -> SyncReport {
let mut r = SyncReport::default();
for a in accounts.iter_mut().filter(|a| a.profile.is_none()) {
match legacy.get(&a.id) {
Ok(secret) => {
let name = store.place(&a.label, &secret);
match store.save(&name, &a.label, &secret) {
Ok(()) => {
a.profile = Some(name.clone());
if let Err(e) = legacy.delete(&a.id) {
r.notes.push(format!("account {}: moved to profile {name}, the keychain entry stays ({e})", a.id));
}
r.migrated.push(format!("{} -> {name}", a.id));
}
Err(e) => r.notes.push(format!("account {}: not moved to a profile yet ({e}); the keychain keeps it", a.id)),
}
}
Err(e) => r.notes.push(format!("account {}: no key in the keychain ({e})", a.id)),
}
}
for name in store.names() {
for kind in store.kinds(&name) {
if accounts.iter().any(|a| a.kind == kind && a.profile.as_deref() == Some(name.as_str())) {
continue;
}
let id = account_id(kind, &name);
if accounts.iter().any(|a| a.id == id) {
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()), developer_id: None });
r.added.push(format!("{id} ({name})"));
}
}
r
}

View file

@ -0,0 +1,365 @@
//! The app folders of this machine, shared with the storebound CLI: `~/.storebound/projects.json`
//! (STOREBOUND_REGISTRY), the same file as `storebound apps folders` / `apps add` / `storebound desktop` / `ws`
//! (cli/lib/registry.ts). The list of folders is the registry's; the app keeps its per-folder extras (mapped accounts)
//! in state.json under the same id. `sync` runs at start, when the list is shown and before a deep link is handled;
//! adding, removing and locating a folder in the app write the registry too. The first start with a registry missing
//! writes the app's own list into it.
//! { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }],
//! "storeApps": [{ "id": "<16 hex>", "store": "apple", "appId": "6400000000", "account": "<profile>", "name": "…" }] }
//! `storeApps` (written by `storebound apps add --store … --app-id …`) are apps live on a store with no project on this
//! machine: they join the app list of the account with that credential profile (`store_apps_cached`), so the
//! portfolio shows them read-only ("store only"). Writing the folder list keeps them.
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::store::{Project, State};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entry {
pub id: String,
pub path: String,
pub name: String,
#[serde(default)]
pub added: String,
}
pub fn default_file(home: Option<&Path>) -> PathBuf {
if let Some(f) = std::env::var_os("STOREBOUND_REGISTRY").filter(|f| !f.is_empty()) {
return PathBuf::from(f);
}
home.map(Path::to_path_buf).unwrap_or_default().join(".storebound").join("projects.json")
}
/// The CLI's id for a folder it adds: sha256("project:<path>"), 16 hex (cli/lib/registry.ts folderId).
pub fn folder_id(path: &str) -> String {
Sha256::digest(format!("project:{path}").as_bytes()).iter().take(8).map(|b| format!("{b:02x}")).collect()
}
fn hex16(s: &str) -> bool {
s.len() == 16 && s.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
/// Parses the file (pure): entries without an absolute path or with a repeated path are dropped.
pub fn parse(text: &str) -> Vec<Entry> {
let Ok(v) = serde_json::from_str::<Value>(text) else { return vec![] };
let mut out: Vec<Entry> = vec![];
for p in v.get("projects").and_then(Value::as_array).into_iter().flatten() {
let Some(path) = p.get("path").and_then(Value::as_str) else { continue };
if !Path::new(path).is_absolute() || out.iter().any(|e| e.path == path) {
continue;
}
let id = p.get("id").and_then(Value::as_str).filter(|i| hex16(i) && !out.iter().any(|e| e.id == *i)).map(str::to_string).unwrap_or_else(|| folder_id(path));
let name = p.get("name").and_then(Value::as_str).filter(|n| !n.trim().is_empty()).map(str::to_string)
.unwrap_or_else(|| Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "App".into()));
out.push(Entry { id, path: path.to_string(), name, added: p.get("added").and_then(Value::as_str).unwrap_or_default().to_string() });
}
out
}
/// None = the file does not exist (first start); Some(list) otherwise (an unreadable file reads as empty).
pub fn read(file: &Path) -> Option<Vec<Entry>> {
match fs::read_to_string(file) {
Ok(t) => Some(parse(&t)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => Some(vec![]),
}
}
pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> {
if let Some(d) = file.parent() {
fs::create_dir_all(d).map_err(|e| e.to_string())?;
}
// 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 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 {
doc["storeApps"] = s;
}
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())
}
/// An app that is live on a store with no project on this machine (cli/lib/registry.ts StoreOnlyApp).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoreOnly {
pub id: String,
/// "apple" | "play"
pub store: String,
/// Apple: the numeric app id; Play: the package name.
pub app_id: String,
/// The credential profile that reads it; None = the account of that store when there is only one.
pub account: Option<String>,
pub name: String,
pub bundle_id: Option<String>,
}
/// The store-only entries of the file's text (pure): malformed ones are dropped.
pub fn parse_store_apps(text: &str) -> Vec<StoreOnly> {
let Ok(v) = serde_json::from_str::<Value>(text) else { return vec![] };
let mut out: Vec<StoreOnly> = vec![];
for x in v.get("storeApps").and_then(Value::as_array).into_iter().flatten() {
let s = |k: &str| x.get(k).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
let (Some(store), Some(app_id)) = (s("store"), s("appId")) else { continue };
let ok = match store.as_str() {
"apple" => app_id.len() >= 6 && app_id.len() <= 12 && app_id.chars().all(|c| c.is_ascii_digit()),
"play" => app_id.contains('.') && app_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_'),
_ => false,
};
if !ok || out.iter().any(|o| o.store == store && o.app_id == app_id) {
continue;
}
let id: String = Sha256::digest(format!("store:{store}:{app_id}").as_bytes()).iter().take(8).map(|b| format!("{b:02x}")).collect();
let bundle_id = s("bundleId").or_else(|| (store == "play").then(|| app_id.clone()));
out.push(StoreOnly { id, name: s("name").unwrap_or_else(|| app_id.clone()), account: s("account"), bundle_id, store, app_id });
}
out
}
pub fn read_store_apps(file: &Path) -> Vec<StoreOnly> {
fs::read_to_string(file).map(|t| parse_store_apps(&t)).unwrap_or_default()
}
/// One account's app list (`apps.list` JSON, or None when never read) with the store-only entries of its profile
/// added (pure). `store` is "apple" | "play"; `only` = this is the only account of that store (entries that name no
/// profile go to it). The entries already in the list (same id) are left as the store reported them.
pub fn with_store_only(list: Option<Value>, store: &str, profile: Option<&str>, only: bool, apps: &[StoreOnly], at: &str) -> Option<Value> {
let mine: Vec<&StoreOnly> = apps.iter().filter(|a| a.store == store && (a.account.as_deref().is_some_and(|p| Some(p) == profile) || (a.account.is_none() && only))).collect();
if mine.is_empty() {
return list;
}
let ios = store == "apple";
let mut v = list.unwrap_or_else(|| serde_json::json!({ "store": if ios { "ios" } else { "android" }, "apps": [], "warnings": [], "at": at }));
if !v.get("apps").is_some_and(Value::is_array) {
v["apps"] = serde_json::json!([]);
}
let have: Vec<String> = v["apps"].as_array().map(|a| a.iter().filter_map(|x| x.get("id").and_then(Value::as_str).map(str::to_string)).collect()).unwrap_or_default();
for a in mine {
if have.contains(&a.app_id) {
continue;
}
let app = serde_json::json!({
"store": if ios { "ios" } else { "android" }, "id": a.app_id, "bundleId": a.bundle_id.clone().unwrap_or_default(), "name": a.name,
"platforms": [if ios { "IOS" } else { "ANDROID" }], "sku": null, "primaryLocale": null, "latest": null, "icon": null, "registered": true,
});
if let Some(list) = v["apps"].as_array_mut() {
list.push(app);
}
}
Some(v)
}
fn entry_of(p: &Project) -> Entry {
Entry { id: p.id.clone(), path: p.path.clone(), name: p.name.clone(), added: p.added.clone() }
}
/// Makes the app's folder list the registry's (pure on the values). A registry folder the app has (by path) keeps the
/// app's id, name and accounts; a new one comes in with the registry's id; a folder that left the registry leaves the
/// app (with its links, ignore entries and studio moves). Returns (changed state, the registry to write back if it
/// needs the app's ids or names).
pub fn merge(state: &mut State, reg: Option<Vec<Entry>>) -> (bool, Option<Vec<Entry>>) {
let Some(reg) = reg else {
// first start with a shared list: the app's folders become it
return (false, Some(state.projects.iter().map(entry_of).collect()));
};
let mut projects = Vec::with_capacity(reg.len());
let mut write_back = false;
for e in &reg {
match state.projects.iter().find(|p| p.path == e.path) {
Some(p) => {
if p.id != e.id || p.name != e.name {
write_back = true;
}
projects.push(p.clone());
}
None => {
// an id the app already uses for another folder gets the CLI's id instead
let id = if state.projects.iter().any(|p| p.id == e.id) { folder_id(&e.path) } else { e.id.clone() };
write_back |= id != e.id;
projects.push(Project { id, path: e.path.clone(), name: e.name.clone(), play_account: None, apple_account: None, added: e.added.clone() });
}
}
}
let gone: Vec<String> = state.projects.iter().filter(|p| !projects.iter().any(|x| x.id == p.id)).map(|p| p.id.clone()).collect();
let changed = projects != state.projects;
for id in &gone {
let key = format!("project:{id}");
state.overrides.links.retain(|l| &l.project != id);
state.overrides.ignored.retain(|i| i != &key);
state.overrides.app_studio.retain(|a| a.key != key);
}
state.projects = projects;
(changed, if write_back { Some(state.projects.iter().map(entry_of).collect()) } else { None })
}
/// Reads the registry, merges, writes it back when needed. Returns whether the state changed.
pub fn sync(state: &mut State, file: &Path) -> Result<bool, String> {
let (changed, back) = merge(state, read(file));
if let Some(list) = back {
write(file, &list)?;
}
Ok(changed)
}
/// After the app changed its list (add, remove, locate): the registry gets the app's list, keeping folders another
/// tool added since the last sync.
pub fn publish(state: &State, file: &Path, removed: &[String]) -> Result<(), String> {
let mut list: Vec<Entry> = state.projects.iter().map(entry_of).collect();
for e in read(file).unwrap_or_default() {
if !list.iter().any(|x| x.path == e.path || x.id == e.id) && !removed.contains(&e.path) {
list.push(e);
}
}
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

@ -187,7 +187,14 @@ impl Sidecar {
return Ok(r.clone()); return Ok(r.clone());
} }
} }
let r = Arc::new(start(app).await?); let r = match start(app).await {
Ok(r) => Arc::new(r),
Err(e) => {
crate::logs::host("error", &format!("sidecar did not start: {}", e.message));
return Err(e);
}
};
crate::logs::host("info", &format!("sidecar started: storebound {} ({})", r.version, r.protocol));
*cur = Some(r.clone()); *cur = Some(r.clone());
Ok(r) Ok(r)
} }
@ -249,6 +256,9 @@ async fn start<R: Runtime>(app: &AppHandle<R>) -> Result<Running, Fail> {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut out: Vec<u8> = Vec::new(); let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new(); let mut err: Vec<u8> = Vec::new();
// the last stderr lines, written to host.log when the sidecar stops
let mut tail: std::collections::VecDeque<String> = std::collections::VecDeque::new();
let mut end = String::from("closed");
while let Some(ev) = rx.recv().await { while let Some(ev) = rx.recv().await {
match ev { match ev {
CommandEvent::Stdout(bytes) => { CommandEvent::Stdout(bytes) => {
@ -262,16 +272,30 @@ async fn start<R: Runtime>(app: &AppHandle<R>) -> Result<Running, Fail> {
err.extend_from_slice(&bytes); err.extend_from_slice(&bytes);
while let Some(nl) = err.iter().position(|b| *b == b'\n') { while let Some(nl) = err.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = err.drain(..=nl).collect(); let line: Vec<u8> = err.drain(..=nl).collect();
let text = String::from_utf8_lossy(&line).to_string();
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
eprint!("[sidecar] {}", String::from_utf8_lossy(&line)); eprint!("[sidecar] {text}");
}
if tail.len() == 20 {
tail.pop_front();
}
tail.push_back(text.trim_end().to_string());
} }
} }
CommandEvent::Terminated(t) => {
end = format!("exit code {:?}, signal {:?}", t.code, t.signal);
break;
}
CommandEvent::Error(e) => {
end = format!("error: {e}");
break;
} }
CommandEvent::Terminated(_) | CommandEvent::Error(_) => break,
_ => {} _ => {}
} }
} }
a.store(false, Ordering::SeqCst); let expected = !a.swap(false, Ordering::SeqCst);
let tail: Vec<String> = tail.into_iter().collect();
crate::logs::host(if expected { "info" } else { "warn" }, &format!("sidecar stopped ({end}){}", if tail.is_empty() { String::new() } else { format!("\n{}", tail.join("\n")) }));
if let Ok(mut map) = p.lock() { if let Ok(mut map) = p.lock() {
for (_, tx) in map.drain() { for (_, tx) in map.drain() {
let _ = tx.send(Err(Fail::host("the storebound sidecar stopped"))); let _ = tx.send(Err(Fail::host("the storebound sidecar stopped")));

View file

@ -3,7 +3,9 @@
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::accounts::Account; use crate::accounts::Account;
use crate::workspace::{Overrides, Settings, Studio}; use crate::workspace::{Overrides, Settings, Studio};
@ -14,16 +16,25 @@ pub struct Project {
pub id: String, pub id: String,
/// Absolute folder that holds storebound.config.ts. /// Absolute folder that holds storebound.config.ts.
pub path: String, pub path: String,
/// Filled from the folder name when an old or hand-edited file has none.
#[serde(default)]
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub play_account: Option<String>, pub play_account: Option<String>,
#[serde(default)] #[serde(default)]
pub apple_account: Option<String>, pub apple_account: Option<String>,
#[serde(default)]
pub added: String, pub added: String,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] /// The state file format. Files without `version` are format 1 (desktop D1 to D3: projects and accounts, later
/// studios, overrides and settings, all optional); `migrate` lifts them to this one.
pub const VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct State { pub struct State {
#[serde(default)]
pub version: u32,
#[serde(default)] #[serde(default)]
pub projects: Vec<Project>, pub projects: Vec<Project>,
#[serde(default)] #[serde(default)]
@ -34,18 +45,178 @@ 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 {
fn default() -> State {
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] }
}
} }
pub const FILE: &str = "state.json"; pub const FILE: &str = "state.json";
/// What `load_report` found besides the state: the format it migrated from, entries it had to drop, and where the
/// original file was kept. Logged in host.log; nothing here is fatal.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct LoadReport {
pub migrated_from: Option<u32>,
pub notes: Vec<String>,
pub backup: Option<PathBuf>,
}
pub fn load(dir: &Path) -> State { pub fn load(dir: &Path) -> State {
fs::read_to_string(dir.join(FILE)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() load_report(dir).0
}
/// Reads state.json and migrates it to VERSION. Never fails: an unreadable file is kept as a backup and the app starts
/// empty; entries that do not parse are dropped one by one (and named in the report) instead of losing the whole file.
/// A migrated file is written back in the new format after the original is copied to `state.json.v<old>.bak`.
pub fn load_report(dir: &Path) -> (State, LoadReport) {
let mut report = LoadReport::default();
let path = dir.join(FILE);
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (State::default(), report),
Err(e) => {
report.notes.push(format!("state.json unreadable ({e})"));
report.backup = keep_copy(dir, &format!("{FILE}.unreadable-{}", stamp()));
return (State::default(), report);
}
};
let value: Value = match serde_json::from_str(&text) {
Ok(v @ Value::Object(_)) => v,
Ok(_) | Err(_) => {
report.notes.push("state.json is not a JSON object: started empty".into());
report.backup = keep_copy(dir, &format!("{FILE}.unreadable-{}", stamp()));
return (State::default(), report);
}
};
let (state, from, notes) = migrate(value);
report.notes = notes;
if from < VERSION {
report.migrated_from = Some(from);
report.backup = keep_copy(dir, &format!("{FILE}.v{from}.bak"));
if let Err(e) = save(dir, &state) {
report.notes.push(format!("migrated state not written: {e}"));
}
} else if from > VERSION {
report.notes.push(format!("state.json is format {from}, newer than this app ({VERSION}): read what it understands"));
}
(state, report)
}
/// Lifts a state file of any known format to VERSION. Returns the state, the format it had, and notes on dropped
/// entries. Pure: tests call it on the old formats directly.
pub fn migrate(mut v: Value) -> (State, u32, Vec<String>) {
let from = v.get("version").and_then(Value::as_u64).map(|n| n.min(u32::MAX as u64) as u32).unwrap_or(1);
let mut notes = Vec::new();
if from < 2 {
migrate_1_to_2(&mut v);
}
let o = v.as_object().cloned().unwrap_or_default();
let mut state = State {
version: VERSION.max(from),
projects: list(&o, "projects", &mut notes),
accounts: list(&o, "accounts", &mut notes),
studios: list(&o, "studios", &mut notes),
overrides: one(&o, "overrides", &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
let mut seen = std::collections::HashSet::new();
state.projects.retain(|p| seen.insert(p.id.clone()));
for p in &mut state.projects {
if p.name.trim().is_empty() {
p.name = Path::new(&p.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "App".into());
}
}
(state, from, notes)
}
/// Format 1 -> 2: `version` added; studios, overrides and settings (added in D3) written out with their defaults, and
/// each account gets an explicit `studio` (null). Fields this app does not know are left alone.
fn migrate_1_to_2(v: &mut Value) {
let Some(o) = v.as_object_mut() else { return };
for k in ["projects", "accounts", "studios"] {
if !o.get(k).is_some_and(Value::is_array) {
o.insert(k.into(), Value::Array(vec![]));
}
}
if !o.get("overrides").is_some_and(Value::is_object) {
o.insert("overrides".into(), serde_json::to_value(Overrides::default()).unwrap_or(Value::Null));
}
if !o.get("settings").is_some_and(Value::is_object) {
o.insert("settings".into(), serde_json::to_value(Settings::default()).unwrap_or(Value::Null));
}
if let Some(Value::Array(accs)) = o.get_mut("accounts") {
for a in accs.iter_mut().filter_map(Value::as_object_mut) {
a.entry("studio").or_insert(Value::Null);
}
}
o.insert("version".into(), Value::from(2));
}
fn list<T: DeserializeOwned>(o: &Map<String, Value>, key: &str, notes: &mut Vec<String>) -> Vec<T> {
let Some(items) = o.get(key).and_then(Value::as_array) else { return vec![] };
items
.iter()
.enumerate()
.filter_map(|(i, x)| match serde_json::from_value::<T>(x.clone()) {
Ok(t) => Some(t),
Err(e) => {
notes.push(format!("{key}[{i}] dropped: {e}"));
None
}
})
.collect()
}
fn one<T: DeserializeOwned + Default>(o: &Map<String, Value>, key: &str, notes: &mut Vec<String>) -> T {
match o.get(key) {
None | Some(Value::Null) => T::default(),
Some(x) => serde_json::from_value(x.clone()).unwrap_or_else(|e| {
notes.push(format!("{key} reset to defaults: {e}"));
T::default()
}),
}
}
/// Copies state.json to `name` in the same folder (an existing copy is kept). The copy's path, or None.
fn keep_copy(dir: &Path, name: &str) -> Option<PathBuf> {
let to = dir.join(name);
if to.exists() {
return Some(to);
}
fs::copy(dir.join(FILE), &to).ok().map(|_| to)
}
/// Moves state.json aside as `state.json.reset-<time>.bak` (Reset saved state). The backup's path; None when there was
/// no file.
pub fn reset(dir: &Path) -> Result<Option<PathBuf>, String> {
let from = dir.join(FILE);
if !from.exists() {
return Ok(None);
}
let to = dir.join(format!("{FILE}.reset-{}.bak", stamp()));
fs::rename(&from, &to).map_err(|e| e.to_string())?;
Ok(Some(to))
}
/// File-name-safe UTC time: 20261011T101500Z.
fn stamp() -> String {
now_iso().replace(['-', ':'], "")
} }
pub fn save(dir: &Path, state: &State) -> Result<(), String> { pub fn save(dir: &Path, state: &State) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| e.to_string())?; fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let tmp: PathBuf = dir.join(format!("{FILE}.tmp")); let tmp: PathBuf = dir.join(format!("{FILE}.tmp"));
let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; let mut state = state.clone();
state.version = state.version.max(VERSION);
let json = serde_json::to_string_pretty(&state).map_err(|e| e.to_string())?;
fs::write(&tmp, json + "\n").map_err(|e| e.to_string())?; fs::write(&tmp, json + "\n").map_err(|e| e.to_string())?;
fs::rename(&tmp, dir.join(FILE)).map_err(|e| e.to_string()) fs::rename(&tmp, dir.join(FILE)).map_err(|e| e.to_string())
} }

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

@ -242,7 +242,7 @@ pub fn check_overrides(o: &Overrides) -> Result<(), String> {
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", default)]
pub struct RefreshSettings { pub struct RefreshSettings {
/// Minutes between automatic store reads; 0 = off. /// Minutes between automatic store reads; 0 = off.
pub auto_minutes: u32, pub auto_minutes: u32,
@ -251,59 +251,132 @@ pub struct RefreshSettings {
pub parallel: u8, pub parallel: u8,
/// Read app icons from the stores when importing (an extra request per app). /// Read app icons from the stores when importing (an extra request per app).
pub icons: bool, pub icons: bool,
/// Background reading of every app (status, releases, catalog, prices, listings, TestFlight, health, reviews,
/// analytics, checks): on start, when the window shows again and every `every_minutes` while it is open.
pub auto_read: bool,
/// Minutes between background passes while the window is visible (only reads past their TTL run); 0 = only on
/// start and when the window shows again.
pub every_minutes: u32,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", default)]
pub struct CacheSettings { pub struct CacheSettings {
/// Saved reads older than this many days are dropped; 0 = keep. /// Saved reads older than this many days are dropped; 0 = keep.
pub keep_days: u32, pub keep_days: u32,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[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)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", default)]
pub struct Settings { 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,
} }
// Every settings struct fills a missing field with its default (`#[serde(default)]`), so a file written by an older
// build that lacks a newer setting keeps the rest.
impl Default for RefreshSettings {
fn default() -> RefreshSettings {
RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true, auto_read: true, every_minutes: 15 }
}
}
impl Default for CacheSettings {
fn default() -> CacheSettings {
CacheSettings { keep_days: 30 }
}
}
impl Default for NotifySettings {
fn default() -> NotifySettings {
NotifySettings { review_state: true, rejected: true, approved: true, live: true, low_reviews: true, crash: true, quiet_hours: QuietHours::default() }
}
}
impl Default for Settings { impl Default for Settings {
fn default() -> Settings { fn default() -> Settings {
Settings { Settings { refresh: RefreshSettings::default(), cache: CacheSettings::default(), notify: NotifySettings::default(), channel: "stable".into() }
refresh: RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true },
cache: CacheSettings { keep_days: 30 },
notify: NotifySettings { review_state: true, low_reviews: true, crash: true },
channel: "stable".into(),
}
} }
} }
pub const AUTO_MINUTES: &[u32] = &[0, 15, 30, 60, 180, 720]; pub const AUTO_MINUTES: &[u32] = &[0, 15, 30, 60, 180, 720];
pub const KEEP_DAYS: &[u32] = &[0, 7, 30, 90, 365]; pub const KEEP_DAYS: &[u32] = &[0, 7, 30, 90, 365];
pub const EVERY_MINUTES: &[u32] = &[0, 5, 15, 30, 60];
pub fn check_settings(s: &Settings) -> Result<(), String> { pub fn check_settings(s: &Settings) -> Result<(), String> {
if !AUTO_MINUTES.contains(&s.refresh.auto_minutes) { if !AUTO_MINUTES.contains(&s.refresh.auto_minutes) {
return Err("refresh: 0 (off), 15, 30, 60, 180 or 720 minutes".into()); return Err("refresh: 0 (off), 15, 30, 60, 180 or 720 minutes".into());
} }
if !EVERY_MINUTES.contains(&s.refresh.every_minutes) {
return Err("background reading: 0 (only on start), 5, 15, 30 or 60 minutes".into());
}
if !(1..=8).contains(&s.refresh.parallel) { if !(1..=8).contains(&s.refresh.parallel) {
return Err("parallel reads: 1 to 8".into()); return Err("parallel reads: 1 to 8".into());
} }
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(())
} }
/// The storebound version an app folder pins: `storebound` in its package.json devDependencies or dependencies.
pub fn pinned_storebound(dir: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(dir.join("package.json")).ok()?;
let v: Value = serde_json::from_str(&text).ok()?;
["devDependencies", "dependencies"].iter().find_map(|k| v.get(*k)?.get("storebound")?.as_str().map(str::to_string))
}
/// Whether a pinned spec names another version than the app's (its bundled CLI). `^0.8.0`, `~0.8.0`, `=0.8.0` and
/// `0.8.0` name 0.8.0; tags, ranges like `*`, `latest`, `file:` / `link:` / `workspace:` specs name none (no notice).
pub fn pin_differs(spec: &str, app: &str) -> bool {
let s = spec.trim().trim_start_matches(['^', '~', '=', 'v']);
let looks = s.split('.').count() == 3 && s.split(['.', '-']).take(3).all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
looks && s != app
}

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.1.0", "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": {
@ -57,6 +58,7 @@
"targets": "all", "targets": "all",
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Every game's releases on both stores in one window", "shortDescription": "Every game's releases on both stores in one window",
"longDescription": "storebound desktop: store listings, builds, in-app purchases, reviews and releases of mobile apps and games on Google Play and the App Store, in one window. Every write shows a dry run first, and public steps wait for your confirmation.",
"copyright": "MIT license, storebound contributors", "copyright": "MIT license, storebound contributors",
"externalBin": ["binaries/storebound"], "externalBin": ["binaries/storebound"],
"createUpdaterArtifacts": false, "createUpdaterArtifacts": false,
@ -86,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,9 +9,9 @@ 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()) }; 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", "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,14 +32,18 @@ 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));
} }
#[test] #[test]
fn new_folder_methods_and_big_reads() { fn new_folder_methods_and_big_reads() {
assert_eq!(NEW_FOLDER_METHODS, &["project.init", "import.fastlane"]); assert_eq!(NEW_FOLDER_METHODS, &["project.init", "import.fastlane"]);
assert_eq!(NO_CACHE_METHODS, &["project.render"]); assert_eq!(NO_CACHE_METHODS, &["project.render", "art.thumb"]);
for c in ["project_import_fastlane", "export_save", "price_sheet_pick", "project_reveal"] { for c in ["project_import_fastlane", "export_save", "price_sheet_pick", "image_pick", "project_reveal"] {
assert!(COMMANDS.contains(&c), "{c}"); assert!(COMMANDS.contains(&c), "{c}");
} }
} }

View file

@ -2,9 +2,9 @@
//! after a dialog that names the action on its button and says users see it at once. Tokens are bound to exactly the //! after a dialog that names the action on its button and says users see it at once. Tokens are bound to exactly the
//! method and the { project, args } sent (the same vector as the TypeScript side checks). //! method and the { project, args } sent (the same vector as the TypeScript side checks).
use serde_json::json; use serde_json::json;
use storebound_desktop::commands::{dialog_labels, dialog_summary, owner_summary}; use storebound_desktop::commands::{console_fill_dialog, dialog_labels, dialog_summary, owner_summary, summary_for};
use storebound_desktop::confirm::{canonical, mac, mint}; use storebound_desktop::confirm::{canonical, mac, mint};
use storebound_desktop::policy::{sanitize_js_call, Kind}; use storebound_desktop::policy::{sanitize_js_call, Kind, CONSOLE_FILL, CONSOLE_FILL_NOTE, SUBMIT_METHODS};
#[test] #[test]
fn owner_actions_are_dry_runs_from_the_window() { fn owner_actions_are_dry_runs_from_the_window() {
@ -18,6 +18,27 @@ fn owner_actions_are_dry_runs_from_the_window() {
assert_eq!(w["dryRun"], json!(true)); assert_eq!(w["dryRun"], json!(true));
} }
#[test]
fn store_only_apps_take_only_the_listing_text_write() {
use storebound_desktop::policy::{store_only_write, STORE_ONLY_WRITES};
assert_eq!(STORE_ONLY_WRITES, &["listing.set"]);
assert!(store_only_write("listing.set", Some(Kind::Write)));
// the folder writes, owner actions and reads are not store-only writes
for m in ["play.listing", "apple.listing", "project.edit", "play.images"] {
assert!(!store_only_write(m, Some(Kind::Write)), "{m}");
}
assert!(!store_only_write("listing.set", Some(Kind::Owner)));
assert!(!store_only_write("listing.set", Some(Kind::Read)));
// a dry run from the window, like every write
let p = sanitize_js_call("listing.set", Some(Kind::Write), &json!({ "args": { "store": "play", "lang": "en-US", "fields": "{\"short\":\"x\"}" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
// the token binds the store ids instead of a folder: another app's ids give another token
let secret = [9u8; 32];
let a = json!({ "virtual": { "name": "com.example.a", "play": { "packageName": "com.example.a" } }, "args": { "lang": "en-US" } });
let b = json!({ "virtual": { "name": "com.example.b", "play": { "packageName": "com.example.b" } }, "args": { "lang": "en-US" } });
assert_ne!(mint(&secret, "listing.set", &a, 1_000, "n1").unwrap(), mint(&secret, "listing.set", &b, 1_000, "n1").unwrap());
}
#[test] #[test]
fn owner_dialogs_name_the_action() { fn owner_dialogs_name_the_action() {
let (title, ok) = dialog_labels(Kind::Owner, "Change the Play staged rollout"); let (title, ok) = dialog_labels(Kind::Owner, "Change the Play staged rollout");
@ -29,6 +50,25 @@ fn owner_dialogs_name_the_action() {
assert!(dialog_summary("Push", "d", "A", true, false).contains("not public until you submit")); assert!(dialog_summary("Push", "d", "A", true, false).contains("not public until you submit"));
} }
#[test]
fn submit_for_review_is_an_owner_action_with_its_own_dialog() {
assert_eq!(SUBMIT_METHODS, &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"]);
for m in SUBMIT_METHODS {
// a dry run from the window, whatever it sends; the real run only through write_with_confirm
let p = sanitize_js_call(m, Some(Kind::Owner), &json!({ "args": { "build": "52" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
for bad in [json!({ "dryRun": false }), json!({ "confirmToken": "sbt1.x" }), json!({ "args": {}, "project": "/x" })] {
assert!(sanitize_js_call(m, Some(Kind::Owner), &bad).is_err(), "{m} {bad}");
}
let s = summary_for(m, Kind::Owner, "Submit for App Review", "Sends the version.", "Prism Puddle", true, false);
assert!(s.contains("Prism Puddle") && s.contains("store's review") && s.contains("go live without another click") && s.contains("press the button"), "{s}");
}
// other owner actions keep their summary, writes theirs
assert!(summary_for("owner.play_rollout", Kind::Owner, "T", "d", "A", true, false).contains("right away"));
assert!(summary_for("play.listing", Kind::Write, "T", "d", "A", true, false).contains("not public until you submit"));
assert_eq!(dialog_labels(Kind::Owner, "Submit for App Review").1, "Yes: Submit for App Review");
}
#[test] #[test]
fn tokens_bind_method_project_and_args_of_each_write() { fn tokens_bind_method_project_and_args_of_each_write() {
let secret = [9u8; 32]; let secret = [9u8; 32];
@ -41,6 +81,9 @@ fn tokens_bind_method_project_and_args_of_each_write() {
("owner.play_rollout", json!({ "action": "percent", "percent": 50 })), ("owner.play_rollout", json!({ "action": "percent", "percent": 50 })),
("owner.ios_phased", json!({ "action": "resume", "version": "2.3.0" })), ("owner.ios_phased", json!({ "action": "resume", "version": "2.3.0" })),
("owner.reply_send", json!({ "file": "play-r-p1.md" })), ("owner.reply_send", json!({ "file": "play-r-p1.md" })),
("owner.apple_submit", json!({ "build": "52", "version": "2.4.0" })),
("owner.apple_resubmit", json!({ "build": "53", "resolve_iaps": true })),
("owner.play_production", json!({ "version_code": "24", "status": "inProgress", "percent": 10 })),
]; ];
for (method, args) in writes { for (method, args) in writes {
let bound = json!({ "project": "/apps/prism", "args": args }); let bound = json!({ "project": "/apps/prism", "args": args });
@ -58,3 +101,25 @@ fn tokens_bind_method_project_and_args_of_each_write() {
assert!(canonical(&json!({ "percent": 50 })).is_ok()); assert!(canonical(&json!({ "percent": 50 })).is_ok());
assert!(canonical(&json!({ "fraction": 0.5 })).is_err()); assert!(canonical(&json!({ "fraction": 0.5 })).is_err());
} }
#[test]
fn console_fill_is_a_dry_run_from_the_window_and_its_dialog_lists_the_answers() {
let p = sanitize_js_call(CONSOLE_FILL, Some(Kind::Write), &json!({ "args": { "form": "apple-age-rating" } })).unwrap();
assert_eq!(p["dryRun"], json!(true));
assert!(sanitize_js_call(CONSOLE_FILL, Some(Kind::Write), &json!({ "dryRun": false })).is_err());
let data = json!({ "title": "Age rating", "uncertain": 1, "answers": [
{ "id": "violence", "label": "Cartoon violence", "value": "None", "certain": true, "source": "apple.ageRating" },
{ "id": "gambling", "label": "Simulated gambling", "value": "", "certain": false, "source": "" },
] });
let (title, text, ok) = console_fill_dialog("Prism Puddle", &data);
assert_eq!(title, "Fill in console: Age rating");
assert_eq!(ok, "Open the browser");
assert!(text.contains("Prism Puddle") && text.contains(CONSOLE_FILL_NOTE));
assert!(text.contains("uses storebound's own browser profile") && text.contains("cookies are never copied"));
assert!(text.contains("- Cartoon violence: None") && text.contains("- Simulated gambling: you answer this"));
let (_, empty, _) = console_fill_dialog("A", &json!(null));
assert!(empty.contains("every field is yours"));
let many: Vec<_> = (0..400).map(|i| json!({ "label": format!("Question {i}"), "value": "Yes", "certain": true })).collect();
let (_, long, _) = console_fill_dialog("A", &json!({ "title": "T", "answers": many }));
assert!(long.contains("more in the app") && long.chars().count() < 3_200);
}

View file

@ -18,9 +18,10 @@ fn conf() -> Value {
} }
#[test] #[test]
fn one_version_from_the_root_package_json() { fn one_version_with_the_cli() {
let pkg: Value = serde_json::from_str(&fs::read_to_string(root().join("../../../package.json")).unwrap()).unwrap(); let pkg: Value = serde_json::from_str(&fs::read_to_string(root().join("../../../package.json")).unwrap()).unwrap();
let want = pkg["desktopVersion"].as_str().expect("package.json desktopVersion"); // one product, one number: the desktop app has the npm CLI's version (its bundled sidecar is that CLI)
let want = pkg["version"].as_str().expect("package.json version");
assert_eq!(env!("CARGO_PKG_VERSION"), want, "Cargo.toml (bun tools/desktop-version.ts --set {want})"); assert_eq!(env!("CARGO_PKG_VERSION"), want, "Cargo.toml (bun tools/desktop-version.ts --set {want})");
assert_eq!(conf()["version"], want, "tauri.conf.json"); assert_eq!(conf()["version"], want, "tauri.conf.json");
let app: Value = serde_json::from_str(&fs::read_to_string(root().join("../package.json")).unwrap()).unwrap(); let app: Value = serde_json::from_str(&fs::read_to_string(root().join("../package.json")).unwrap()).unwrap();
@ -40,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}");
@ -54,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}"),
@ -102,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

@ -0,0 +1,262 @@
//! One product per machine: the app's accounts are the CLI's credential profiles (~/.storebound/credentials), keychain
//! accounts of earlier versions move there once, the app folder list is the CLI's (~/.storebound/projects.json), and
//! a folder that pins another storebound version says so. No real keys anywhere.
use std::collections::HashMap;
use std::sync::Mutex;
use serde_json::json;
use storebound_desktop::accounts::{Account, AccountKind, Secret, SecretStore};
use storebound_desktop::profiles::{self, ProfileStore};
use storebound_desktop::registry::{self, Entry};
use storebound_desktop::store::{Project, State};
use storebound_desktop::workspace::{pin_differs, pinned_storebound};
const PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n";
fn sa(email: &str) -> String {
json!({ "type": "service_account", "client_email": email, "private_key": PEM }).to_string()
}
fn apple(key: &str) -> Secret {
Secret::Apple { key_id: key.into(), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: PEM.into() }
}
#[derive(Default)]
struct MapStore(Mutex<HashMap<String, Secret>>);
impl SecretStore for MapStore {
fn set(&self, id: &str, s: &Secret) -> Result<(), String> {
self.0.lock().unwrap().insert(id.into(), s.clone());
Ok(())
}
fn get(&self, id: &str) -> Result<Secret, String> {
self.0.lock().unwrap().get(id).cloned().ok_or_else(|| "no entry".into())
}
fn delete(&self, id: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(id);
Ok(())
}
}
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), developer_id: None }
}
#[test]
fn ids_match_the_cli() {
// 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::Play, "default"), "82a8c2561d950999");
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_eq!(profiles::slug("Acme Games: Play!"), "acme-games-play");
assert_eq!(profiles::slug("???"), "account");
}
#[test]
fn a_profile_round_trips_in_the_cli_format_with_private_files() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
store.save("acme", "Acme", &apple("ABCDE12345")).unwrap();
store.save("acme", "Acme", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
let m = store.read("acme").unwrap();
assert_eq!(m["ASC_P8"], "acme/app-store-connect.p8");
assert_eq!(m["PLAY_SERVICE_ACCOUNT"], "acme/play-service-account.json");
assert_eq!(m["label"], "Acme");
assert!(!serde_json::to_string(&m).unwrap().contains("not-a-real-key"), "keys stay in their own files");
assert_eq!(store.secret("acme", AccountKind::Apple).unwrap(), apple("ABCDE12345"));
assert_eq!(store.kinds("acme"), vec![AccountKind::Apple, AccountKind::Play]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&store.dir.join("acme.json")), 0o600);
assert_eq!(mode(&store.dir.join("acme").join(profiles::P8_FILE)), 0o600);
assert_eq!(mode(&store.dir.join("acme")), 0o700);
assert_eq!(mode(&store.dir), 0o700);
}
// the same key again lands in the same profile; another key in a profile of its own
assert_eq!(store.place("Anything", &apple("ABCDE12345")), "acme");
assert_eq!(store.place("Acme", &apple("ZZZZZ99999")), "acme-apple");
assert_eq!(store.place("Acme", &Secret::Play { service_account: sa("bot@example.com") }), "acme");
// a profile the CLI wrote by hand, with an absolute key path and no ASC_P8
std::fs::write(store.dir.join("cli.json"), json!({ "ASC_KEY_ID": "CLI0000000", "ASC_ISSUER_ID": "i" }).to_string()).unwrap();
let mut creds = serde_json::Map::new();
store.secret("cli", AccountKind::Apple).unwrap().to_credentials(&mut creds);
assert_eq!(creds.keys().cloned().collect::<Vec<_>>(), vec!["ASC_ISSUER_ID", "ASC_KEY_ID"], "no ASC_P8: the CLI looks in ~/.appstoreconnect itself");
// removing one store archives its key file; the last store archives the profile
store.remove("acme", AccountKind::Play, "20261013T000000Z").unwrap();
assert_eq!(store.kinds("acme"), vec![AccountKind::Apple]);
assert!(store.dir.join(".removed/acme-20261013T000000Z").join(profiles::PLAY_FILE).is_file());
store.remove("acme", AccountKind::Apple, "20261013T000001Z").unwrap();
assert!(!store.names().contains(&"acme".to_string()));
assert!(store.dir.join(".removed/acme-20261013T000001Z/acme.json").is_file());
}
#[test]
fn keychain_accounts_move_into_profiles_once_and_cli_profiles_show_up() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
// the CLI already imported the shared account ("accounts import" -> profile default)
store.save("default", "", &apple("ABCDE12345")).unwrap();
store.save("default", "", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
let legacy = MapStore::default();
legacy.set("0123456789abcdef", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
legacy.set("fedcba9876543210", &apple("SIDE000000")).unwrap();
let mut accs = vec![
account("0123456789abcdef", "Example Games Play", AccountKind::Play, None),
account("fedcba9876543210", "Side project", AccountKind::Apple, None),
account("00112233445566ff", "Lost key", AccountKind::Apple, None),
];
let r = profiles::sync(&mut accs, &store, &legacy, "2026-10-13T00:00:00Z");
// the same service account as the CLI's profile: that profile; the other key: a profile named after its label
assert_eq!(accs[0].profile.as_deref(), Some("default"));
assert_eq!(accs[1].profile.as_deref(), Some("side-project"));
assert_eq!(accs[2].profile, None, "no keychain entry: stays, said in the log");
assert_eq!(accs[0].studio.as_deref(), Some("s1"), "id, label, studio stay");
assert!(legacy.get("0123456789abcdef").is_err() && legacy.get("fedcba9876543210").is_err(), "moved entries leave the keychain");
assert_eq!(r.migrated.len(), 2);
assert_eq!(r.notes.len(), 1);
// the CLI profile's Apple key had no app account yet: it comes in with the CLI workspace's id
let added: Vec<_> = accs.iter().skip(3).map(|a| (a.id.clone(), a.kind, a.profile.clone())).collect();
assert_eq!(added, vec![(profiles::account_id(AccountKind::Apple, "default"), AccountKind::Apple, Some("default".into()))]);
// a second start changes nothing
let again = profiles::sync(&mut accs, &store, &legacy, "2026-10-14T00:00:00Z");
assert!(!again.changed());
assert_eq!(accs.len(), 4);
}
fn project(id: &str, path: &str, play: Option<&str>) -> Project {
Project { id: id.into(), path: path.into(), name: id.into(), play_account: play.map(str::to_string), apple_account: None, added: String::new() }
}
#[test]
fn the_folder_list_is_the_machines() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("projects.json");
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
assert!(!registry::sync(&mut st, &file).unwrap());
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
let mut list = registry::read(&file).unwrap();
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();
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/demo-game"]);
assert_eq!(st.projects[0].play_account.as_deref(), Some("acc"));
// the CLI removes one (apps remove): it leaves the app with its overrides
st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/demo-game")));
registry::write(&file, &list[..1]).unwrap();
assert!(registry::sync(&mut st, &file).unwrap());
assert_eq!(st.projects.len(), 1);
assert!(st.overrides.ignored.is_empty());
// the app adds one; a folder another tool added meanwhile is kept
st.projects.push(project("bbbbbbbbbbbbbbbb", "/w/third", None));
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();
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"]);
// 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")]);
}
#[test]
fn store_only_apps_join_their_profiles_account_and_survive_folder_writes() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("projects.json");
// as `storebound apps add --store apple --app-id 6400000999 --account teamA` and a Play one without a profile wrote it
std::fs::write(&file, r#"{"version":1,"projects":[],"storeApps":[
{"id":"x","store":"apple","appId":"6400000999","account":"teamA","name":"Example Aquarium","bundleId":"com.example.aquarium","added":"t"},
{"store":"play","appId":"com.example.legacy","name":"Legacy"},
{"store":"apple","appId":"not-a-number"}]}"#).unwrap();
let apps = registry::read_store_apps(&file);
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"));
// 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() };
registry::publish(&st, &file, &[]).unwrap();
assert_eq!(registry::read_store_apps(&file).len(), 2);
assert!(registry::sync(&mut st, &file).is_ok());
assert_eq!(registry::read_store_apps(&file).len(), 2);
// an account of profile teamA that never listed its apps: the entry shows as its app, read-only, no folder
let v = registry::with_store_only(None, "apple", Some("teamA"), false, &apps, "t").unwrap();
assert_eq!(v["store"], "ios");
assert_eq!(v["apps"][0]["id"], "6400000999");
assert_eq!(v["apps"][0]["bundleId"], "com.example.aquarium");
assert_eq!(v["apps"][0]["registered"], true);
// an account of another profile gets nothing; the list the store returned is kept, the entry not doubled
assert_eq!(registry::with_store_only(None, "apple", Some("teamB"), false, &apps, "t"), None);
let listed = serde_json::json!({ "store": "ios", "apps": [{ "id": "6400000999", "name": "From the store" }], "warnings": [], "at": "t" });
assert_eq!(registry::with_store_only(Some(listed.clone()), "apple", Some("teamA"), false, &apps, "t"), Some(listed));
// no profile named: the only Play account takes it
let p = registry::with_store_only(None, "play", Some("any"), true, &apps, "t").unwrap();
assert_eq!(p["apps"][0]["name"], "Legacy");
assert_eq!(registry::with_store_only(None, "play", Some("any"), false, &apps, "t"), None);
}
#[test]
fn a_folder_that_pins_another_storebound_says_so() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(pinned_storebound(dir.path()), None);
std::fs::write(dir.path().join("package.json"), r#"{"devDependencies":{"storebound":"^0.7.1"}}"#).unwrap();
assert_eq!(pinned_storebound(dir.path()).as_deref(), Some("^0.7.1"));
assert!(pin_differs("^0.7.1", "0.8.0"));
assert!(!pin_differs("0.8.0", "0.8.0"));
assert!(!pin_differs("~0.8.0", "0.8.0"));
for spec in ["latest", "*", "file:../storebound", "workspace:*", ">=0.7"] {
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,271 @@
//! Saved state: old formats migrate to the current one (with a backup of the original), damaged files and entries
//! never take the rest down, missing folders are reported as such, Reset keeps a backup, and the logs stay clean.
use serde_json::{json, Value};
use storebound_desktop::commands::project_view;
use storebound_desktop::logs;
use storebound_desktop::store::{self, State, VERSION};
use storebound_desktop::accounts::{AccountKind, Secret, SecretStore};
use storebound_desktop::profiles::{self, ProfileStore};
use storebound_desktop::registry;
use storebound_desktop::workspace::{Overrides, Settings};
const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json");
const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json");
const EMPTY: &str = include_str!("../../tests/fixtures/state/empty.json");
const SHARED: &str = include_str!("../../tests/fixtures/state/shared-profiles.json");
/// Keychain entries of versions before 0.8, for the migration into credential profiles.
#[derive(Default)]
struct Keychain(std::sync::Mutex<std::collections::HashMap<String, Secret>>);
impl SecretStore for Keychain {
fn set(&self, id: &str, s: &Secret) -> Result<(), String> {
self.0.lock().unwrap().insert(id.into(), s.clone());
Ok(())
}
fn get(&self, id: &str) -> Result<Secret, String> {
self.0.lock().unwrap().get(id).cloned().ok_or_else(|| "no entry".into())
}
fn delete(&self, id: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(id);
Ok(())
}
}
const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n";
fn fake_secret(kind: AccountKind, n: usize) -> Secret {
match kind {
AccountKind::Apple => Secret::Apple { key_id: format!("FAKEKEY{n:03}"), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: FAKE_PEM.into() },
AccountKind::Play => Secret::Play { service_account: json!({ "client_email": format!("bot{n}@example.iam.gserviceaccount.com"), "private_key": FAKE_PEM }).to_string() },
}
}
fn write(dir: &std::path::Path, text: &str) {
std::fs::write(dir.join(store::FILE), text).unwrap();
}
#[test]
fn d1_state_with_missing_folders_migrates_to_the_current_format() {
let (st, from, notes) = store::migrate(serde_json::from_str(D1).unwrap());
assert_eq!((from, st.version), (1, VERSION));
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(st.projects.len(), 2);
assert_eq!(st.projects[0].name, "Prism Puddle");
assert!(st.accounts.is_empty() && st.studios.is_empty());
assert_eq!((st.overrides.clone(), st.settings.clone()), (Overrides::default(), Settings::default()));
// the folders are gone: the window gets `missing: true` for each, nothing fails
for p in &st.projects {
let v = serde_json::to_value(project_view(p)).unwrap();
assert_eq!(v["missing"], true);
assert_eq!(v["path"], p.path.as_str());
assert!(v.get("playAccount").is_some(), "camelCase, flattened");
}
// on disk: migrated once, the original kept as state.json.v1.bak, the new file carries the version
let dir = tempfile::tempdir().unwrap();
write(dir.path(), D1);
let (loaded, report) = store::load_report(dir.path());
assert_eq!(loaded, st);
assert_eq!(report.migrated_from, Some(1));
let bak = dir.path().join("state.json.v1.bak");
assert_eq!(report.backup.as_deref(), Some(bak.as_path()));
assert_eq!(std::fs::read_to_string(&bak).unwrap(), D1);
let saved: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap();
assert_eq!(saved["version"], VERSION);
assert_eq!(saved["settings"]["cache"]["keepDays"], 30);
let (again, report) = store::load_report(dir.path());
assert_eq!(again, st);
assert_eq!(report.migrated_from, None, "migrates only once");
}
#[test]
fn d3_state_without_version_keeps_everything() {
let (st, from, notes) = store::migrate(serde_json::from_str(D3).unwrap());
assert_eq!(from, 1);
assert!(notes.is_empty(), "{notes:?}");
assert_eq!((st.projects.len(), st.accounts.len(), st.studios.len()), (2, 3, 2));
assert_eq!(st.accounts[0].studio.as_deref(), Some("s1"));
assert_eq!(st.accounts[2].studio, None);
assert_eq!(st.studios[1].logo, None);
assert_eq!(st.overrides.ignored, vec!["account:00112233445566ff".to_string()]);
assert_eq!(st.overrides.app_studio[0].studio, "s2");
}
#[test]
fn empty_and_missing_files_start_empty() {
let (st, from, _) = store::migrate(serde_json::from_str(EMPTY).unwrap());
assert_eq!(from, 1);
assert_eq!(st, State::default());
let dir = tempfile::tempdir().unwrap();
assert_eq!(store::load_report(dir.path()), (State::default(), store::LoadReport::default()));
assert!(!dir.path().join(store::FILE).exists(), "nothing written for a fresh install");
}
#[test]
fn partial_settings_keep_what_is_there() {
let (st, from, notes) = store::migrate(json!({ "version": 2, "settings": { "refresh": { "autoMinutes": 15 }, "channel": "beta" } }));
assert_eq!(from, 2);
assert!(notes.is_empty(), "{notes:?}");
assert_eq!((st.settings.refresh.auto_minutes, st.settings.refresh.parallel, st.settings.cache.keep_days, st.settings.channel.as_str()), (15, 4, 30, "beta"));
assert!(st.settings.notify.crash);
}
#[test]
fn bad_entries_are_dropped_one_by_one() {
let (st, _, notes) = store::migrate(json!({
"projects": [
{ "id": "a", "path": "/x/a", "name": "A", "added": "2026-10-01T00:00:00Z" },
{ "id": 7 },
{ "id": "b", "path": "/x/Beta" },
{ "id": "a", "path": "/x/dup", "name": "Dup" }
],
"accounts": [{ "id": "0123456789abcdef", "label": "P", "kind": "play", "created": "2026-10-01T00:00:00Z" }, { "kind": "windows-phone" }],
"studios": "not a list",
"overrides": { "ignored": "nope" },
"settings": null
}));
assert_eq!(st.projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["A", "Beta"], "no name: the folder name; duplicate id: first wins");
assert_eq!(st.accounts.len(), 1);
assert!(st.studios.is_empty());
assert_eq!(st.overrides, Overrides::default());
assert_eq!(st.settings, Settings::default());
assert_eq!(notes.len(), 3, "{notes:?}");
assert!(notes[0].starts_with("projects[1] dropped"));
}
#[test]
fn unreadable_files_are_kept_aside_and_newer_formats_read() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "{broken");
let (st, report) = store::load_report(dir.path());
assert_eq!(st, State::default());
let bak = report.backup.expect("a copy of the unreadable file");
assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.unreadable-"));
assert_eq!(std::fs::read_to_string(bak).unwrap(), "{broken");
write(dir.path(), "[1,2]");
assert_eq!(store::load(dir.path()), State::default());
let (st, from, _) = store::migrate(json!({ "version": 99, "projects": [{ "id": "a", "path": "/x", "name": "A", "added": "" }], "future": true }));
assert_eq!((from, st.version, st.projects.len()), (99, 99, 1));
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &json!({ "version": 99 }).to_string());
let (_, report) = store::load_report(dir.path());
assert!(report.notes[0].contains("newer"), "{:?}", report.notes);
assert_eq!(report.migrated_from, None);
}
#[test]
fn reset_moves_the_file_aside() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(store::reset(dir.path()).unwrap(), None);
write(dir.path(), D1);
let bak = store::reset(dir.path()).unwrap().unwrap();
assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.reset-"));
assert_eq!(std::fs::read_to_string(bak).unwrap(), D1);
assert!(!dir.path().join(store::FILE).exists());
assert_eq!(store::load(dir.path()), State::default());
}
#[test]
fn saved_files_always_carry_the_version() {
let dir = tempfile::tempdir().unwrap();
store::save(dir.path(), &State { version: 0, ..State::default() }).unwrap();
let v: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap();
assert_eq!(v["version"], VERSION);
}
#[test]
fn logs_hide_home_keys_tokens_and_emails() {
let home = Some("/Users/example");
let s = logs::sanitize("TypeError at /Users/example/Library/x.js:1\n\tat f", home);
assert_eq!(s, "TypeError at ~/Library/x.js:1\n\tat f");
let pem = "key: -----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49\n-----END PRIVATE KEY----- done";
assert_eq!(logs::sanitize(pem, None), "key: [redacted key] done");
let s = logs::sanitize(r#"{"password": "hunter22", "token":"abc.def", "name":"ok"} Authorization: Bearer xyz123 url?api_key=K1&x=1"#, None);
assert!(!s.contains("hunter22") && !s.contains("abc.def") && !s.contains("xyz123") && !s.contains("K1&"), "{s}");
assert!(s.contains(r#""name":"ok""#), "{s}");
let s = logs::sanitize("tester person@example.com saw eyJhbGciOiJFUzI1NiJ9abcdef1234567890ABCDEF and 0123456789abcdef0123456789abcdef01", None);
assert!(!s.contains("person@example.com") && !s.contains("eyJhbGci") && !s.contains("0123456789abcdef0123"), "{s}");
assert!(s.contains("[email]"));
// ordinary paths, file names and error text stay readable
let ok = "at qg (tauri://localhost/assets/index-DXUigr_F.js:49:175893) Cannot read properties of undefined (reading 'map')";
assert_eq!(logs::sanitize(ok, home), ok);
assert_eq!(logs::sanitize("a\u{7}b\u{1b}[31m", None), "ab[31m");
}
#[test]
fn log_entries_are_one_block_and_rotate() {
let e = logs::entry("2026-10-11T00:00:00Z", "webview", "error", "boom\nat f\n");
assert_eq!(e, "2026-10-11T00:00:00Z error [webview] boom\n at f\n");
let long = "x".repeat(logs::MAX_ENTRY + 10);
assert!(logs::entry("t", "host", "info", &long).ends_with(" [cut]\n"));
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("host.log"), vec![b'a'; logs::MAX_BYTES as usize]).unwrap();
logs::append(dir.path(), "host.log", "new\n").unwrap();
assert_eq!(std::fs::read_to_string(dir.path().join("host.log")).unwrap(), "new\n");
assert_eq!(std::fs::metadata(dir.path().join("host.1.log")).unwrap().len(), logs::MAX_BYTES);
}
/// What the host answers for each fixture (projects_list without `missing`, accounts_list, workspace_get), written
/// next to it as `<name>.host.json`, after the start-up sync with the machine's shared stores: D1 keeps its accounts in a
/// keychain that has no entries (the keys were never added: the accounts stay without a profile), D3's keychain has
/// them (they move into credential profiles), and shared-profiles is the 0.8 format whose apple account comes from a
/// profile the CLI wrote, with a folder that pins storebound 0.6.0 and a store-only app of that profile (`storebound
/// apps add --store apple --app-id … --account default`: store_apps_cached lists it under the apple account).
/// tools/desktop-boot-check.ts boots the built window on exactly these answers.
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
#[test]
fn host_answers_for_the_fixtures_are_current() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/state");
for (name, text, keychain) in [("empty", EMPTY, false), ("d1-missing-folders", D1, false), ("d3-full", D3, true), ("shared-profiles", SHARED, false)] {
let (mut st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
assert!(notes.is_empty(), "{name}: {notes:?}");
let machine = tempfile::tempdir().unwrap();
let profiles = ProfileStore::new(machine.path().join("credentials"), None);
let legacy = Keychain::default();
if keychain {
for (i, a) in st.accounts.iter().enumerate() {
legacy.set(&a.id, &fake_secret(a.kind, i)).unwrap();
}
}
if name == "shared-profiles" {
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Play, 0)).unwrap();
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Apple, 0)).unwrap();
}
profiles::sync(&mut st.accounts, &profiles, &legacy, "2026-10-13T00:00:00Z");
registry::sync(&mut st, &machine.path().join("projects.json")).unwrap();
let mut projects = serde_json::to_value(&st.projects).unwrap();
if name == "shared-profiles" {
// what project_view says for a folder whose package.json pins storebound 0.6.0
let pinned = tempfile::tempdir().unwrap();
std::fs::write(pinned.path().join("package.json"), r#"{"devDependencies":{"storebound":"0.6.0"}}"#).unwrap();
let mut p = st.projects[0].clone();
p.path = pinned.path().to_string_lossy().to_string();
let pin = project_view(&p).storebound_pin.expect("0.6.0 is not this app");
projects[0]["storeboundPin"] = json!(pin);
}
// store_apps_cached before any store read: only the machine's store-only entries (registry.rs)
let store_only = if name == "shared-profiles" {
registry::parse_store_apps(r#"{"storeApps":[{"store":"apple","appId":"6400000999","account":"default","name":"Example Aquarium","bundleId":"com.example.aquarium"}]}"#)
} else {
vec![]
};
let cached: Vec<serde_json::Value> = st.accounts.iter().map(|a| {
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
let only = st.accounts.iter().filter(|x| x.kind == a.kind).count() == 1;
let entry = registry::with_store_only(None, store, a.profile.as_deref(), only, &store_only, "2026-10-13T00:00:00Z").map(|value| json!({ "value": value, "at": "2026-10-13T00:00:00Z" }));
json!({ "account": a.id, "entry": entry })
}).collect();
let host = json!({
"projects_list": projects,
"accounts_list": st.accounts,
"store_apps_cached": cached,
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
});
let want = format!("{}\n", serde_json::to_string_pretty(&host).unwrap());
let file = dir.join(format!("{name}.host.json"));
if std::env::var_os("STOREBOUND_UPDATE_FIXTURES").is_some() {
std::fs::write(&file, &want).unwrap();
}
let have = std::fs::read_to_string(&file).unwrap_or_default();
assert!(have == want, "{} is stale: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state", file.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

@ -106,6 +106,15 @@ fn settings_have_safe_defaults_and_bounds() {
s = d.clone(); s = d.clone();
s.refresh.auto_minutes = 1; s.refresh.auto_minutes = 1;
assert!(check_settings(&s).is_err(), "no tight polling of the store APIs"); assert!(check_settings(&s).is_err(), "no tight polling of the store APIs");
assert_eq!((d.refresh.auto_read, d.refresh.every_minutes), (true, 15), "background reading is on by default, every 15 minutes");
s = d.clone();
s.refresh.every_minutes = 1;
assert!(check_settings(&s).is_err(), "no background pass more often than every 5 minutes");
s = d.clone();
s.refresh.every_minutes = 0;
assert!(check_settings(&s).is_ok());
let old: Settings = serde_json::from_value(serde_json::json!({ "refresh": { "autoMinutes": 15 } })).unwrap();
assert_eq!((old.refresh.auto_read, old.refresh.every_minutes, old.refresh.auto_minutes), (true, 15, 15), "an older settings file gets the new defaults");
s = d.clone(); s = d.clone();
s.cache.keep_days = 3; s.cache.keep_days = 3;
assert!(check_settings(&s).is_err()); assert!(check_settings(&s).is_err());

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