From d2ce18544188d4922e07ebd379237cdf299fb4fb Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 13 Oct 2026 06:17:31 +0800 Subject: [PATCH] One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list - one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG - storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S, Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download; the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl//latest.json - credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI made; precedence env > named profile > project file > default profile; config `account`, "account" in the project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use - app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop, apps folders | add | remove, ws init and ws without a workspace file - desktop: notice when a folder pins another storebound version; storebound-desktop --version - tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust), keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state --- CHANGELOG.md | 38 ++ CONTRIBUTING.md | 4 + README.md | 21 +- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/accounts.rs | 21 +- apps/desktop/src-tauri/src/commands.rs | 135 +++++-- apps/desktop/src-tauri/src/lib.rs | 11 +- apps/desktop/src-tauri/src/main.rs | 5 + apps/desktop/src-tauri/src/profiles.rs | 375 ++++++++++++++++++ apps/desktop/src-tauri/src/registry.rs | 140 +++++++ apps/desktop/src-tauri/src/workspace.rs | 15 + apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src-tauri/tests/accounts.rs | 4 +- apps/desktop/src-tauri/tests/d5.rs | 5 +- apps/desktop/src-tauri/tests/machine.rs | 175 ++++++++ apps/desktop/src-tauri/tests/state.rs | 63 ++- apps/desktop/src/api.ts | 4 +- apps/desktop/src/dev/fixtures.ts | 10 +- apps/desktop/src/i18n/en.ts | 27 +- apps/desktop/src/i18n/tr.ts | 27 +- apps/desktop/src/lib/normalize.ts | 4 +- apps/desktop/src/screens/Accounts.tsx | 4 +- apps/desktop/src/screens/AppView.tsx | 1 + apps/desktop/tests/fixtures/state/README.txt | 4 + .../tests/fixtures/state/d3-full.host.json | 3 + .../fixtures/state/shared-profiles.host.json | 58 +++ .../tests/fixtures/state/shared-profiles.json | 17 + apps/desktop/tests/resilience.test.ts | 5 +- cli/accounts.ts | 65 +++ cli/apps.ts | 35 +- cli/commands.ts | 5 +- cli/data/flags.json | 26 +- cli/desktop.ts | 250 +++++++++--- cli/lib/accounts.ts | 207 ++++++++++ cli/lib/config.ts | 7 + cli/lib/credentials.ts | 80 +++- cli/lib/desktop-dist.ts | 10 +- cli/lib/desktop-install.ts | 186 +++++++++ cli/lib/registry.ts | 82 ++++ cli/lib/workspace.ts | 15 +- cli/serve.ts | 3 +- cli/workspace.ts | 24 +- docs/commands.md | 4 +- docs/desktop-release.md | 30 +- docs/desktop.md | 68 +++- docs/getting-started.md | 28 +- docs/studios.md | 9 +- package.json | 1 - site/index.html | 19 +- tests/desktop-release.test.ts | 16 +- tests/helpers/preload.ts | 9 + tests/machine.test.ts | 231 +++++++++++ tests/store-apps.test.ts | 3 +- tests/workspace.test.ts | 7 +- tools/desktop-boot-check.ts | 26 ++ tools/desktop-release.ts | 14 +- tools/desktop-version.ts | 38 +- tools/vds/storebound-desktop-publish | 7 +- 60 files changed, 2443 insertions(+), 246 deletions(-) create mode 100644 apps/desktop/src-tauri/src/profiles.rs create mode 100644 apps/desktop/src-tauri/src/registry.rs create mode 100644 apps/desktop/src-tauri/tests/machine.rs create mode 100644 apps/desktop/tests/fixtures/state/shared-profiles.host.json create mode 100644 apps/desktop/tests/fixtures/state/shared-profiles.json create mode 100644 cli/accounts.ts create mode 100644 cli/lib/accounts.ts create mode 100644 cli/lib/desktop-install.ts create mode 100644 cli/lib/registry.ts create mode 100644 tests/machine.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a428e57..f157978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ ## Unreleased +One product, one mental model: storebound is installed once per machine (`npx storebound` and the desktop app), +each project has its `storebound.config.ts` and `.storebound/`, and store accounts and app folders live once per +machine in `~/.storebound`, shared by the CLI and the app. + +- **One version.** The desktop app has the CLI's version from now on; the next release is 0.8.0 for both. + `package.json`'s `version` is the authority (`desktopVersion` is gone); `tools/desktop-version.ts` keeps the plugin + manifests and the app's copies equal (CI, and `tests/desktop-release.test.ts` / `src-tauri/tests/d5.rs` as the + drift tests), `desktop-release.ts publish` refuses a folder of another version, and the release order is the app + first, then `npm publish`. The desktop 0.1.0 release candidate was never published, so nothing public changes. +- **`npx storebound desktop` just works.** The app has this CLI's version: it opens on the current folder. Not + installed: it downloads the app for this system (the CLI's own version, else the newest published), checks size, + SHA-256 and the minisign signature, installs it for this user without admin rights (macOS: from the read-only DMG + into /Applications or ~/Applications, no xattr changes; Windows: the per-user NSIS installer, silent; Linux: the + AppImage in ~/.local/bin with a `.desktop` entry and the `storebound://` handler) and opens it. An older app is + updated the same way (never while it runs), a newer one is opened with a note (downgrade only with `--force`). It + asks once (`[Y/n]`); `--yes` skips the question; without a terminal and `--yes` it prints the command and exits 3. + `--dry` / `--json` change nothing, `--download` only saves the checked installer. The publish script also writes + `desktop/dl//latest.json` (redeploy `tools/vds/storebound-desktop-publish` on the server). +- **Store accounts, one store.** Credential profiles in `~/.storebound/credentials` are shared by the CLI and the + desktop app: the app writes its keys there (files 0600, folders 0700) and shows the profiles the CLI made; accounts + of earlier desktop builds move from the OS keychain into profiles on first start (ids, studios and app mappings + kept; the keychain entry is deleted after the profile reads back). A removed account's key file goes to + `credentials/.removed/`. Precedence: env, then a profile the project names, then the project's + `.storebound/credentials.json`, then the default profile (the only one, or `default`). A project names its profile + with `account:` in the config, `"account"` in its `.storebound/credentials.json`, or `--account`. +- **`storebound accounts`** lists the profiles (masked) and which one this folder uses; `accounts import` moves the + store keys of a project's `.storebound/credentials.json` into a profile (key files copied to + `~/.storebound/credentials//`, the same Apple key id or service account e-mail = the same profile, the + keystore and review contact stay in the project, a backup of the project file is kept, safe to run again; `--dry` + first, never prints values); `accounts use ` points a project at one. +- **App folders, one list.** `~/.storebound/projects.json` is the desktop's App folders and the CLI's + `storebound apps folders | add | remove`; `storebound desktop` in an app folder and `ws init` add to it, and `ws` + without a workspace file works over it. The app syncs it at start, when the list is shown and before a + `storebound://open` link, so a folder added in the terminal is selected without a question. +- **Desktop:** a folder whose `package.json` pins another storebound version shows "This project uses storebound X, + the app runs Y". `storebound-desktop --version`. `ws desktop import / export` carry the accounts' profile names. +- `ws` children get the app's own account choice when the workspace names no account (before: no profile at all). + Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons 35 to 37): - Fix (`scan`, review-prompt 3.2.2(x)): a bare "rate" next to "bonus" (`const rate = F.genRate(lv, bonus.gen(r))`) is diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b40a41b..14431c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,10 @@ gitignored) with `bun tools/build-node.ts`. Before publishing: `bun run ci` gree 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-.tgz --version`, then `init`). +The npm package and the desktop app are one release with one version (`bun tools/desktop-version.ts --set ` +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 One topic per PR, `bun run ci` green, a line in `CHANGELOG.md` under an "Unreleased" heading, and for anything diff --git a/README.md b/README.md index 1bf7789..3ff3849 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ Every rule, what it proves and what only a reviewer can see: [docs/rejections.md Every app and game you ship on Google Play and the App Store side by side: releases, charts, catalog and prices, crash health, reviews, TestFlight and the rejection checks. Send for review from there too: a dry run first, then a -confirm, then your click. Same storebound underneath; store keys stay in the system keychain. +confirm, then your click. Same storebound underneath, same version number as the CLI, and the same store accounts +and app folders as your terminal (`~/.storebound`). ![storebound desktop, Releases › Send for review: the dry run of the submission with the build picked by number and the confirm step](https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/desktop/send-for-review-dark.webp) @@ -64,8 +65,8 @@ confirm, then your click. Same storebound underneath; store keys stay in the sys -Download for macOS, Windows and Linux: [storebound.dev/#download](https://storebound.dev/#download), or -`storebound desktop --download` (checks the signature, never installs). Internals: [docs/desktop.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop.md). +`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 @@ -84,16 +85,18 @@ You need [Node](https://nodejs.org) 20+ or [Bun](https://bun.sh) 1.1+ (and `ffmp ```sh cd my-app -npm i -D storebound playwright # or: bun add -d storebound playwright -npx storebound init # --profile app (default) | subscription-app | game (Bun: bunx storebound init) -npx storebound install --agent claude # or codex, cursor, gemini, copilot, windsurf, opencode -npx storebound check # offline: config, limits, Data safety, products, files +npx storebound init # storebound.config.ts + .storebound/ in this project (--profile app | subscription-app | game) +npx storebound desktop # installs the desktop app once (asks first) and opens it on this folder ``` -Every `npx storebound ...` below works as `bunx storebound ...` too. +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. Then ask your agent: "take my app to Google Play and the App Store". Claude Code plugin, credentials, coming from -fastlane: [docs/getting-started.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/getting-started.md). Desktop app: [storebound.dev/#download](https://storebound.dev/#download). +fastlane: [docs/getting-started.md](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/getting-started.md). ## What it covers diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e20323c..111ec05 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.1.0", + "version": "0.7.1", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d262f3f..c011025 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4010,7 +4010,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.1.0" +version = "0.7.1" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 301fe2e..fc71d87 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.1.0" +version = "0.7.1" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/src/accounts.rs b/apps/desktop/src-tauri/src/accounts.rs index 7d1f12d..f842af7 100644 --- a/apps/desktop/src-tauri/src/accounts.rs +++ b/apps/desktop/src-tauri/src/accounts.rs @@ -1,8 +1,10 @@ //! 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 -//! creation time are kept in the app data folder. Secrets are never sent to the webview: they are read from the -//! keychain right before a sidecar call and handed to it in that call's params (memory only), which passes them to -//! the storebound command as environment variables. +//! lives in the machine's credential profiles, shared with the CLI (profiles.rs: ~/.storebound/credentials, files +//! 0600); the app data folder keeps the label, the kind, the studio and the profile name. Versions before 0.8 kept +//! the secret in the OS keychain (service `dev.storebound.desktop`): `Keychain` is still here to move those entries +//! 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_json::{Map, Value}; @@ -25,6 +27,10 @@ pub struct Account { /// The studio this account belongs to (workspace::Studio id), if any. #[serde(default)] pub studio: Option, + /// The credential profile that holds the key (~/.storebound/credentials/.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, } /// The keychain entry of one account (JSON). @@ -52,7 +58,10 @@ impl Secret { Secret::Apple { key_id, issuer_id, p8 } => { 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_P8".into(), Value::String(p8.clone())); + // a profile without ASC_P8: the CLI finds ~/.appstoreconnect/private_keys/AuthKey_.p8 itself + if !p8.is_empty() { + into.insert("ASC_P8".into(), Value::String(p8.clone())); + } } } } @@ -110,7 +119,7 @@ pub fn check_label(s: &str) -> Result { 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 { fn set(&self, id: &str, secret: &Secret) -> Result<(), String>; fn get(&self, id: &str) -> Result; diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 7626260..5224cd9 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1,6 +1,6 @@ //! 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 -//! straight into the sidecar call. +//! the webview: account files are picked in native dialogs and read here, and credentials go from the machine's +//! credential profiles (profiles.rs, shared with the CLI) straight into the sidecar call. use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; @@ -12,6 +12,8 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_opener::OpenerExt; use crate::accounts::{self, Account, AccountKind, Secret, SecretStore}; +use crate::profiles::{self, ProfileStore}; +use crate::registry; use crate::cache::{Cache, Entry}; use crate::downloads; use crate::policy::{self, Kind}; @@ -24,7 +26,12 @@ pub struct AppState { pub sidecar: Sidecar, pub data_dir: PathBuf, pub saved: Mutex, - pub secrets: Box, + /// 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, + /// The machine's app folder list (~/.storebound/projects.json, shared with the CLI). + pub registry: PathBuf, pub cache: Cache, pub downloads: downloads::Jobs, } @@ -44,6 +51,50 @@ fn persist(s: &AppState, st: &Saved) -> Res<()> { 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 + } + }; + if report.changed() || folders { + 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 { + 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) -> Res> { match job { Some(j) if !valid_job(&j) => Err(Fail::host("bad job id")), @@ -56,8 +107,8 @@ fn project_params(s: &AppState, project_id: &str) -> Res<(Project, Map Res> { drop(st); let mut creds = Map::new(); 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); } } @@ -94,7 +145,7 @@ fn account_creds(s: &AppState, id: &str) -> Res> { return Err(Fail::host("unknown account")); } 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) } @@ -382,15 +433,21 @@ 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, } pub fn project_view(p: &Project) -> ProjectView { - ProjectView { project: p.clone(), missing: !Path::new(&p.path).is_dir() } + 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] pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res> { guard(&window)?; + sync_machine(&state)?; Ok(saved(&state)?.projects.iter().map(project_view).collect()) } @@ -423,6 +480,7 @@ pub async fn project_locate(window: WebviewWindow, app: AppHandle, state: State< } 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)) } @@ -462,6 +520,7 @@ async fn add_folder(app: &AppHandle, state: &AppState, dir: &Path) -> Res, id: String) -> Res<()> { guard(&window)?; let mut st = saved(&state)?; + let removed: Vec = st.projects.iter().filter(|p| p.id == id).map(|p| p.path.clone()).collect(); st.projects.retain(|p| p.id != id); st.overrides.links.retain(|l| l.project != id); let (ig, key) = (format!("project:{id}"), format!("project:{id}")); st.overrides.ignored.retain(|i| i != &ig); st.overrides.app_studio.retain(|a| a.key != key); let _ = state.cache.forget_project(&id); - persist(&state, &st) + persist(&state, &st)?; + publish_folders(&state, &st, &removed); + Ok(()) } #[tauri::command] @@ -674,6 +736,7 @@ pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, i #[tauri::command] pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res> { guard(&window)?; + sync_machine(&state)?; Ok(saved(&state)?.accounts.clone()) } @@ -686,17 +749,28 @@ fn check_studio(state: &AppState, studio: &Option) -> Res<()> { 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) -> Res { let label = accounts::check_label(label).map_err(Fail::host)?; check_studio(state, &studio)?; - let acc = Account { id: store::new_id()?, label, kind: secret.kind(), created: store::now_iso(), studio }; - state.secrets.set(&acc.id, &secret).map_err(Fail::host)?; + let profile = state.profiles.place(&label, &secret); + state.profiles.save(&profile, &label, &secret).map_err(Fail::host)?; let mut st = saved(state)?; - st.accounts.push(acc.clone()); - if let Err(e) = persist(state, &st) { - let _ = state.secrets.delete(&acc.id); - return Err(e); + if let Some(a) = st.accounts.iter_mut().find(|a| a.kind == secret.kind() && a.profile.as_deref() == Some(profile.as_str())) { + a.label = label; + if studio.is_some() { + 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) }; + st.accounts.push(acc.clone()); + persist(state, &st)?; Ok(acc) } @@ -714,7 +788,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())) } -/// 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] pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, label: String, studio: Option) -> Res> { guard(&window)?; @@ -725,7 +799,8 @@ pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, 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] pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>, label: String, key_id: String, issuer_id: String, studio: Option) -> Res> { guard(&window)?; @@ -741,8 +816,18 @@ pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState> #[tauri::command] pub fn account_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> { guard(&window)?; - state.secrets.delete(&id).map_err(Fail::host)?; 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); let ignored = format!("account:{id}"); st.overrides.ignored.retain(|i| i != &ignored); @@ -969,7 +1054,7 @@ pub async fn studio_logo(window: WebviewWindow, state: State<'_, AppState>, id: 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] pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: String, label: String, studio: Option) -> Res { guard(&window)?; @@ -1105,7 +1190,7 @@ pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res let yes = tauri::async_runtime::spawn_blocking(move || { w.app_handle() .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?") .kind(MessageDialogKind::Warning) .buttons(MessageDialogButtons::OkCancelCustom("Reset".into(), "Cancel".into())) @@ -1118,13 +1203,15 @@ pub async fn app_reset(window: WebviewWindow, state: State<'_, AppState>) -> Res return Ok(false); } let mut st = saved(&state)?; - for a in &st.accounts { - state.secrets.delete(&a.id).map_err(Fail::host)?; + // keys of earlier versions still in the keychain go; the shared credential profiles stay (the CLI uses them) + for a in st.accounts.iter().filter(|a| a.profile.is_none()) { + state.legacy.delete(&a.id).map_err(Fail::host)?; } *st = Saved::default(); persist(&state, &st)?; drop(st); state.cache.clear().map_err(Fail::host)?; + sync_machine(&state)?; Ok(true) } @@ -1138,7 +1225,7 @@ pub async fn state_reset(window: WebviewWindow, app: AppHandle, state: State<'_, let yes = tauri::async_runtime::spawn_blocking(move || { w.app_handle() .dialog() - .message("storebound forgets its list of app folders, accounts, studios, pairing and settings, and starts empty.\n\nNothing is deleted: the saved state is kept as a backup file in the app data folder, store keys stay in the system keychain, and your app folders are not touched.") + .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())) @@ -1302,6 +1389,8 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> { ))); } 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 id = match known { Some(id) => id, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3e2a9b8..75ca92b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -7,6 +7,8 @@ pub mod confirm; pub mod downloads; pub mod logs; pub mod policy; +pub mod profiles; +pub mod registry; pub mod selftest; pub mod updates; pub mod sidecar; @@ -147,6 +149,7 @@ pub fn run() { dev_seed(&data_dir); let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?; 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)); @@ -162,12 +165,18 @@ pub fn run() { sidecar: sidecar::Sidecar::default(), saved: Mutex::new(saved), 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, downloads: downloads::Jobs::default(), }; // Settings > Offline cache: drop saved reads older than the chosen number of 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)); + } app.manage(state); #[cfg(target_os = "macos")] app.set_menu(menu(app.handle())?)?; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 4ff8920..e70052d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -2,6 +2,11 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 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, // 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) { diff --git a/apps/desktop/src-tauri/src/profiles.rs b/apps/desktop/src-tauri/src/profiles.rs new file mode 100644 index 0000000..350b66f --- /dev/null +++ b/apps/desktop/src-tauri/src/profiles.rs @@ -0,0 +1,375 @@ +//! 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 `.json` holds one App Store Connect team key and / or +//! one Play service account; its key files live next to it in `/` (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(":"), 16 hex (cli/lib/workspace.ts +/// accountId), so the same account has the same id in `storebound.workspace.json` and in the app. +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, 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, +} + +/// What a profile reaches, for matching (never shown). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Identity { + pub apple_key_id: Option, + pub play_email: Option, +} + +impl ProfileStore { + pub fn new(dir: PathBuf, home: Option) -> ProfileStore { + ProfileStore { dir, home } + } + + /// The profiles (`*.json` with a valid name), sorted. + pub fn names(&self) -> Vec { + let mut out: Vec = 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, 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::(&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 { + 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 { + 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 label the profile carries, if any (written by the app; the CLI ignores it). + pub fn label(&self, name: &str) -> Option { + 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 { + 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 `/` (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/-