Compare commits
618 commits
no-handmad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a5acf20730 | |||
| 1a66c50d56 | |||
| 4b6c0e4030 | |||
| 0995e8093c | |||
| 7c8b6d2fb9 | |||
| 9dfdb7d741 | |||
| b702f7574f | |||
| 79101e0bf6 | |||
| 4a94892db0 | |||
| e9adc079b7 | |||
| c20ed04799 | |||
| 9ac3f9bb30 | |||
| e3065963ff | |||
| e814ddb9d7 | |||
| a0af8cddd9 | |||
| 77ccba0bbd | |||
| a25718d6d8 | |||
| e11fd43fa5 | |||
| 63369353af | |||
| dfc6362335 | |||
| 84f1b7127a | |||
| a21db7b460 | |||
| e74fa1c0aa | |||
| 98e123f403 | |||
| 425a99b801 | |||
| f3edfccbcf | |||
| e786f43a15 | |||
| bbf29ab569 | |||
| 537809865d | |||
| 19942368a0 | |||
| 46f37dec22 | |||
| 3e91e2464d | |||
| a2bf1c78c1 | |||
| 891ff8f074 | |||
| f56cf58559 | |||
| c2afa256c8 | |||
| 37b9d03f56 | |||
| a1249b445d | |||
| 83f1d3f826 | |||
| 638bae6645 | |||
| 81cfee1e40 | |||
| 38c72e4afa | |||
| b966a93812 | |||
| 4580c6d9d3 | |||
| 20a870c80b | |||
| fb41775f4f | |||
| 6bf3f42e3e | |||
| 1f620f248c | |||
| a655eff0c9 | |||
| b6169d7b29 | |||
| ece1ab9f6a | |||
| 82d6ec00e3 | |||
| f5f67a5074 | |||
| b0a5d0de4f | |||
| 6318f4fe78 | |||
| e668f9b82c | |||
| 15a76cbb20 | |||
| fed3d50657 | |||
| 78bc066d59 | |||
| 54ae1f4960 | |||
| 3d25c34127 | |||
| 24d551ba13 | |||
| 7f8be3f4c7 | |||
| 6506fbe46b | |||
| 376f3560f8 | |||
| 82710194f2 | |||
| 7413df037b | |||
| a09bbaf005 | |||
| bdcc79173f | |||
| 87fbce4487 | |||
| a417dde2d0 | |||
| 777e513061 | |||
| 6d79a22697 | |||
| 9159dfeedf | |||
| 34d053d19b | |||
| 9ef107d1ca | |||
| ca8dad1663 | |||
| fd5028a042 | |||
| 03fbbd5f99 | |||
| 3916575bdc | |||
| 65f975ecff | |||
| 8154d64f31 | |||
| 569b419fb5 | |||
| 0e39750b8f | |||
| 97d1eca0a7 | |||
| 517582af78 | |||
| 8a96f38784 | |||
| 0c1157688e | |||
| e52edba4bc | |||
| a4f43187ad | |||
| 564e885517 | |||
| 6521abae59 | |||
| 343e69dcbc | |||
| 7d41a3a3eb | |||
| 7922651fb4 | |||
| 2c23254a9a | |||
| 1de68f0616 | |||
| 9791f066e4 | |||
| 7940289272 | |||
| b13d7d36f6 | |||
| 3b08002f78 | |||
| b944bbd835 | |||
| e569d79a63 | |||
| 4811ad5cf2 | |||
| 84b15dc6fd | |||
| e100001017 | |||
| eac6388705 | |||
| f9577f4a8f | |||
| 1e40e65580 | |||
| 4fea3b63a9 | |||
| ee711c32ca | |||
| 35f094a758 | |||
| 889d350486 | |||
| 2788654965 | |||
| fc90a4fa5d | |||
| c9428647bf | |||
| 86b64ecdf2 | |||
| efbd37e406 | |||
| 3642ec9a2b | |||
| 7bef01aa54 | |||
| d07bef5cfb | |||
| 72696950d4 | |||
| 76ccca43d7 | |||
| 8bcf407527 | |||
| e94f9bb9fb | |||
| 9e166bf9a3 | |||
| 6738ec3149 | |||
| a25b8bdb4d | |||
| 9dccf357a9 | |||
| 8bdb801937 | |||
| 78a7598221 | |||
| 6d0238d9de | |||
| 67534f8df2 | |||
| 4004050245 | |||
| 9c3f1756fa | |||
| 0ddb871b26 | |||
| fe46ae6cd1 | |||
| 222befc097 | |||
| e1fc29139c | |||
| c319ed8313 | |||
| eb2e8cbd58 | |||
| eb0c71970e | |||
| 3f823eb2ef | |||
| 17a42fc2c9 | |||
| 776eb9ac4f | |||
| be5a2d454a | |||
| bfe44f2c15 | |||
| 1e374a56b1 | |||
| 86d8d8a147 | |||
| f53deca39e | |||
| 8fa9153f8f | |||
| 9e3a66420b | |||
| c70221acf7 | |||
| 7f63b8b6a6 | |||
| d8afe954f7 | |||
| d12348c33f | |||
| d70c4d39c2 | |||
| 1960649a26 | |||
| f648f6b269 | |||
| ed9c4e7183 | |||
| b08f28888a | |||
| 0c34a823af | |||
| 8a2f4832b4 | |||
| ecd00ef369 | |||
| f72d8ef00b | |||
| 0161b6daf5 | |||
| 9a01763e73 | |||
| d2202b930b | |||
| f8c76c607e | |||
| 170dc84cf1 | |||
| 8f3912d13e | |||
| addb4e8ccc | |||
| 409adb932e | |||
| 8385db2dad | |||
| 367ce3fbd7 | |||
| 731347e636 | |||
| 8a6ad61996 | |||
| 5db5a127e8 | |||
| c8e5968237 | |||
| a836b87a32 | |||
| dfddc453fe | |||
| 301df0ac67 | |||
| 5be831e04a | |||
| b3e15d5f1f | |||
| fa297983cb | |||
| aac04fb39a | |||
| 2e4e242251 | |||
| 5dfb89443d | |||
| 866089c8c4 | |||
| 721ac626ae | |||
| 3f8456b9cb | |||
| 03c3e207a9 | |||
| 20278c4bac | |||
| 0f09ff823d | |||
| 2b4de14e59 | |||
| 9741a77e66 | |||
| c7c97b34c1 | |||
| 8481199f67 | |||
| 8d7c4e5c91 | |||
| 1bef60590a | |||
| c3c43c7ab4 | |||
| 87a0f98ae3 | |||
| ebe7b8faac | |||
| ccbffbd176 | |||
| 101a5b2da8 | |||
| b9e821ef00 | |||
| 3112df31d4 | |||
| c6156584ca | |||
| bb377802f0 | |||
| c37dfadfcc | |||
| ea5192c8fa | |||
| b54b780d64 | |||
| 32e5034612 | |||
| f58add10f1 | |||
| e2ef09a848 | |||
| 1729cb6cc8 | |||
| ba036a0e83 | |||
| b4084d0b16 | |||
| cefa3580a0 | |||
| 9b68cc57a1 | |||
| 68ac313114 | |||
| 0c749bf2d5 | |||
| 33126d15a8 | |||
| e3de4a1b9d | |||
| dd8090d41b | |||
| c6c8fd9b21 | |||
| 0a8f2b7155 | |||
| b36619bc24 | |||
| 8adb84164a | |||
| 89853583cb | |||
| 1dc643aab7 | |||
| 2accb961f6 | |||
| 9499a9211f | |||
| bf60001e4b | |||
| 2f5f962611 | |||
| 1ebafbd91a | |||
| ed82b5f2f9 | |||
| a13114ffdd | |||
| a566cd3a92 | |||
| d6775e0a1a | |||
| 87fe790385 | |||
| df4381c6f5 | |||
| 3d45cabb12 | |||
| 4b37aeedc8 | |||
| ffe3b7d06a | |||
| 2cc92bf335 | |||
| fff087e923 | |||
| 044e8bb9b2 | |||
| 26d7b3ff37 | |||
| 8e5dccbc43 | |||
| d8cda61a33 | |||
| 3deaf353be | |||
| 9b2a6fc9ff | |||
| 55862cca50 | |||
| 7660bd9dc7 | |||
| 38aee7dafb | |||
| 4bddbe07dd | |||
| 4db6cb34f7 | |||
| 92922a333b | |||
| d5877504ee | |||
| ec1ecd567b | |||
| ba5f5334e6 | |||
| 3783d85a6d | |||
| f30f6f5456 | |||
| b2ebebc6ff | |||
| e02772881a | |||
| 7e5bb5ed53 | |||
| d8a293706b | |||
| ebc82f3615 | |||
| 44c746244b | |||
| dd6aa1e704 | |||
| a434ddbf2e | |||
| 6abd49e8fa | |||
| 190553ea31 | |||
| ee290c993d | |||
| bdd007b0bf | |||
| b98eff3c5f | |||
| 9241c8ada0 | |||
| f1b8bcecdd | |||
| 32a0045ef7 | |||
| 9e1ce42fe5 | |||
| 55f8b7ca16 | |||
| c711782c81 | |||
| 4fecf2e0b1 | |||
| 06433f3d3c | |||
| 2c3b7e25a8 | |||
| 4e1076f411 | |||
| afb1393a18 | |||
| 63b1f2c585 | |||
| 7b8ceb81e5 | |||
| 6eff4b3ea3 | |||
| 2f157e1323 | |||
| b627a18990 | |||
| 803d48dab8 | |||
| b3a22259cf | |||
| 9bf96c77a0 | |||
| 1494a7157f | |||
| ea52614b5e | |||
| d05e64612b | |||
| bc0e4e549d | |||
| d4c40e3fb7 | |||
| c4f2875b3a | |||
| 73d3d2b30d | |||
| c56c45d7d4 | |||
| ba62ecd0c5 | |||
| 8a32050821 | |||
| 5212c10766 | |||
| 3419cdd93c | |||
| 186dacd3a5 | |||
| 32ad9147e0 | |||
| d2be1b7eaf | |||
| 2c6bbb6453 | |||
| 77d144d393 | |||
| c77854f258 | |||
| 3fbe545e5f | |||
| d0af818154 | |||
| 6cfabbcb9e | |||
| 086b49aa7a | |||
| af51acc195 | |||
| d82be973f4 | |||
| 3184f5077e | |||
| 9c60336304 | |||
| e6983cf41d | |||
| d6b97a362b | |||
| bfe7c4b417 | |||
| 08ffd99518 | |||
| c0ab9f371c | |||
| e4297e5731 | |||
| 787704a653 | |||
| 872585c658 | |||
| 254e9c1d3f | |||
| 83f870f9a1 | |||
| b33743ed96 | |||
| 699f61e4b7 | |||
| 2fc126fa4a | |||
| a599e5cb90 | |||
| 10768bc078 | |||
| 338def4fe3 | |||
| 664c694a09 | |||
| 2d67c9b1a7 | |||
| 066b099e96 | |||
| 043fba6c0a | |||
| f9e7af11b4 | |||
| 0bc308d53f | |||
| 27eaf35779 | |||
| abb4a183f0 | |||
| f30547f0af | |||
| b8516553e2 | |||
| ac0707888f | |||
| c6836ef5fe | |||
| 615122d22c | |||
| a10f6f70ca | |||
| 8fa3da9998 | |||
| 4032657695 | |||
| 83639ce41e | |||
| c047ef7a6f | |||
| 241bf6a735 | |||
| 1b4ccb6527 | |||
| 2b5244a3ff | |||
| 7d4a2e4986 | |||
| 6ed8f3d544 | |||
| cfd957711d | |||
| 42835c94f1 | |||
| 65cb5de5eb | |||
| 748da4fb49 | |||
| 6ed862ac3c | |||
| 0649abe5c4 | |||
| 5971075f59 | |||
| 69c0dd7e43 | |||
| 61c0c0e04a | |||
| 7e2206e48f | |||
| 5aae98365d | |||
| 4f675b0612 | |||
| 145901ffb3 | |||
| d029dadda1 | |||
| 992e09bb8d | |||
| 8d3eced2a5 | |||
| f6aac93762 | |||
| 66283b9e8b | |||
| 34e37dc4e0 | |||
| e3b7decf7a | |||
| 0c3575043c | |||
| df8793b02c | |||
| 3e4208e656 | |||
| 325554f813 | |||
| f917f7e40d | |||
| 8ee2819a7f | |||
| 1cde3e6663 | |||
| 63d901fa02 | |||
| 621ac3ed73 | |||
| d95ee4d988 | |||
| b74160a73d | |||
| 68f0ec1a9b | |||
| f6d995d3b3 | |||
| 683996e1e7 | |||
| fa0fda46f9 | |||
| 38a621970f | |||
| 023aaddc05 | |||
| 39e1d879ec | |||
| 457ac9175f | |||
| bf131834bc | |||
| 93cad15023 | |||
| 5a521737ca | |||
| 9ffb60fe8b | |||
| e0fb59324b | |||
| b6c9523037 | |||
| 608f9cea2f | |||
| 1cceb0fc38 | |||
| d8c3cc88c2 | |||
| 94f61e471c | |||
| 1b160a9bef | |||
| 5f08e3ad7c | |||
| 3aeba5aa33 | |||
| 42b68a5bf1 | |||
| ccc2cb1f70 | |||
| 91aec822ba | |||
| 1a25555552 | |||
| 7730385b0e | |||
| ca9d933257 | |||
| 94174870fa | |||
| f0cd8dc670 | |||
| f1eab75348 | |||
| a52225d871 | |||
| b593215b1d | |||
| 16eeab0e74 | |||
| 381ce04afe | |||
| a9fa7786e6 | |||
| 627d523a2f | |||
| 80b57b9795 | |||
| 388849116d | |||
| 866c8c67a2 | |||
| b5758b3ebb | |||
| cb5b02c21a | |||
| 0882522d15 | |||
| 675aee58fe | |||
| 9836c10d15 | |||
| 7f922168ab | |||
| feea9166f4 | |||
| 40f899cb16 | |||
| 186affd84f | |||
| b051313495 | |||
| ad408a305e | |||
| 961eeb5c17 | |||
| 4877d950fb | |||
| 7e609eb5b6 | |||
| f309a5e57b | |||
| 5df8f1807a | |||
| 086e1c7935 | |||
| 32f93fa5a0 | |||
| 428c0268af | |||
| 2204bc735a | |||
| ad9474150d | |||
| e5d0f46a0c | |||
| 337c32fd73 | |||
| 94745c9f39 | |||
| 3c9517f7fd | |||
| 05144a460a | |||
| dc2289fab4 | |||
| 53d27ac999 | |||
| 3e51b50d7a | |||
| a101c01cb8 | |||
| 02ed1247ca | |||
| 9f04ee0f00 | |||
| 03865d24f3 | |||
| 7a2a11454a | |||
| 8765f6e79f | |||
| a642104ca8 | |||
| 6d7ebed604 | |||
| 308f2ea990 | |||
| cb4d82b037 | |||
| 7f92fe874a | |||
| a9b54d7d9d | |||
| 3b1852bb5b | |||
| 8e76c3403c | |||
| c616b70c73 | |||
| 3275a3cba1 | |||
| 6dbedb6d6e | |||
| ac44ca7d34 | |||
| dc288b86eb | |||
| 7ed16d619f | |||
| f60919b632 | |||
| 13295d10d8 | |||
| 6a9a1df322 | |||
| 9e922f5dd5 | |||
| 19f22f6335 | |||
| e060a1fcfc | |||
| 99f70dab20 | |||
| 0f534749bf | |||
| e52f839880 | |||
| e24287e390 | |||
| 16e30d6b06 | |||
| e5abf3db18 | |||
| 8597f94a77 | |||
| 12eed58f41 | |||
| 3de295c294 | |||
| af827be884 | |||
| de511500d9 | |||
| 4c85495501 | |||
| 476cce7ad3 | |||
| 540869d4f4 | |||
| cdd8502385 | |||
| 0c9b37039c | |||
| 4d8a64cbed | |||
| 4a436077fa | |||
| 8cda5e28a1 | |||
| dc8bfa8fb4 | |||
| 376c4c8c54 | |||
| eb2cba74d4 | |||
| e22c2101aa | |||
| f714731c43 | |||
| 81313b6fa4 | |||
| ece6644522 | |||
| 8ffc83bb39 | |||
| b39e44e6ab | |||
| de243cdbd9 | |||
| e21e274c2a | |||
| db363be37b | |||
| d531626836 | |||
| 04fb0089b5 | |||
| 7746a984ec | |||
| af08d7113a | |||
| c51b689070 | |||
| 29e1ec799e | |||
| d9515fa8f0 | |||
| c00bb0be61 | |||
| 44db357b73 | |||
| 2508d9031d | |||
| 66bf1f82eb | |||
| 5e1357116d | |||
| fd82db0d75 | |||
| a7569deb26 | |||
| a5295db539 | |||
| bd15f790cc | |||
| 3fb3f2108a | |||
| 96d1e6b62d | |||
| a2009fa7ac | |||
| e38f68fa33 | |||
| 354daba672 | |||
| 17f4413afa | |||
| d68028caba | |||
| 124e3097ec | |||
| b65843828b | |||
| 7e26941ffc | |||
| 4533e3e1c4 | |||
| d5bc7b8889 | |||
| 8f41ed4c02 | |||
| 00cf2ed341 | |||
| 704231457f | |||
| 1150d4e4e6 | |||
| 5cb5dc2c32 | |||
| 75d6f4cb71 | |||
| 143325c033 | |||
| d466d4a3c2 | |||
| 578d3a5669 | |||
| e1e0b689d2 | |||
| 51e30a42b5 | |||
| 866aa4a041 | |||
| dc3ff364a4 | |||
| 6f561393f4 | |||
| 59e60960b0 | |||
| 6c4f8a4551 | |||
| b88648499e | |||
| 221f08aaf6 | |||
| 5afe4613c4 | |||
| 296aa6a6ae | |||
| 8bfa49807d | |||
| d942275920 | |||
| 8186b25575 | |||
| 9e15cf43e3 | |||
| 21c0c2ed60 | |||
| 930f960318 | |||
| e5f610a033 | |||
| ce69fbd600 | |||
| bee5627ec0 | |||
| e92fda612c | |||
| 3dba3682b3 | |||
| 582ea909ca | |||
| eecdcc4f83 | |||
| f07f3acbd8 | |||
| 179e4d3815 | |||
| 0547433c2f | |||
| 20f8d6a311 | |||
| 8a23bf7eec | |||
| 549dfeefc8 | |||
| d88635db67 | |||
| 236977940e | |||
| d4140437f1 | |||
| 8fcc78bb52 | |||
| 657bfdc35e | |||
| 4a894a1937 | |||
| f2d58d5b15 | |||
| bd4579ebbf | |||
| 2d2fd9040d | |||
| cea2dffdc3 | |||
| 97ab678691 | |||
| d972c36fa8 | |||
| 84ef94253c | |||
| b63f2fdfbc | |||
| 0092a67fc7 | |||
| 2a91a791be | |||
| 217d42cc9a | |||
| 41319562d8 | |||
| f7fde413e0 | |||
| 77aa09235e | |||
| 54dd012cfa | |||
| 2165322c02 | |||
| ae58948abd | |||
| 346d80e07e | |||
| cec0ac2ffe | |||
| 451ffd601c | |||
| de555b7524 | |||
| f9a53f508e | |||
| bd3ab20e1d | |||
| ae2ffd8dbf | |||
| b76ccb20b8 | |||
| 3af1b3e5f4 | |||
| 52d1e379e3 | |||
| b4d924b132 |
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
31
.forgejo/actions/ci-run/action.yml
Normal 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
|
||||||
43
.forgejo/actions/linux-setup/action.yml
Normal 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
|
||||||
110
.forgejo/workflows/acceptance.yml
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
78
.forgejo/workflows/release.yml
Normal 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
|
|
@ -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
|
||||||
94
.github/copilot-instructions.md
vendored
|
|
@ -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
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
AGENTS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
1755
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
94
GEMINI.md
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/storebound)
|
[](https://www.npmjs.com/package/storebound)
|
||||||
[](LICENSE)
|
[](https://git.crt.fyi/CRT/storebound/src/branch/main/LICENSE)
|
||||||
[](CHANGELOG.md)
|
[](#install-and-start)
|
||||||
[](https://bun.sh)
|
[](https://git.crt.fyi/CRT/storebound/src/branch/main/package.json)
|
||||||
[](package.json)
|
[](https://git.crt.fyi/CRT/storebound/src/branch/main/skills)
|
||||||
[](skills)
|
[](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/mcp.md)
|
||||||
[](#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
|
[](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
|
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 |
|

|
||||||
|---|---|---|---|---|
|
|
||||||
| 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
|

|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</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/`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|

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

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
| Play feature graphic | App preview (App Store, 886 x 1920) |
|
|
||||||
|---|---|
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Contact sheet of one language (every size, packed into a grid)</summary>
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
</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.
|
|
||||||
|
|
|
||||||
68
apps/desktop/build/freeze-safe.ts
Normal 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
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
119
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
apps/desktop/src-tauri/capabilities/card.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
apps/desktop/src-tauri/capabilities/menu.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 69 KiB |
BIN
apps/desktop/src-tauri/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.4 KiB |
BIN
apps/desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
apps/desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
apps/desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
apps/desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
apps/desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 228 KiB |
|
|
@ -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 |
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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,7 +62,10 @@ 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()));
|
||||||
into.insert("ASC_P8".into(), Value::String(p8.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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
471
apps/desktop/src-tauri/src/ai_fix.rs
Normal 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()))
|
||||||
|
}
|
||||||
188
apps/desktop/src-tauri/src/card.rs
Normal 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};
|
||||||
|
|
@ -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 {
|
||||||
let text = format!("{summary}\n\nDry run ({}):\n{}", dry["command"].as_str().unwrap_or_default(), dialog_excerpt(dry["output"].as_str().unwrap_or_default()));
|
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 (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());
|
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
||||||
let (title, ok) = dialog_labels(kind, &d.title);
|
|
||||||
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()), ®istry::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(¤t.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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
|
|
||||||
|
|
@ -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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
213
apps/desktop/src-tauri/src/logs.rs
Normal 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);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
749
apps/desktop/src-tauri/src/menu_panel.rs
Normal 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::*;
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
398
apps/desktop/src-tauri/src/profiles.rs
Normal 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
|
||||||
|
}
|
||||||
365
apps/desktop/src-tauri/src/registry.rs
Normal 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 ® {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -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(_) | CommandEvent::Error(_) => break,
|
CommandEvent::Terminated(t) => {
|
||||||
|
end = format!("exit code {:?}, signal {:?}", t.code, t.signal);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
CommandEvent::Error(e) => {
|
||||||
|
end = format!("error: {e}");
|
||||||
|
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")));
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
286
apps/desktop/src-tauri/src/tray.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -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/")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
178
apps/desktop/src-tauri/tests/ai_fix.rs
Normal 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"));
|
||||||
|
}
|
||||||
77
apps/desktop/src-tauri/tests/card.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(&[]));
|
||||||
|
}
|
||||||
|
|
|
||||||
262
apps/desktop/src-tauri/tests/machine.rs
Normal 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);
|
||||||
|
}
|
||||||
214
apps/desktop/src-tauri/tests/menu_panel.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
271
apps/desktop/src-tauri/tests/state.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
122
apps/desktop/src-tauri/tests/tray.rs
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||