One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list

- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
This commit is contained in:
CRTkafa 2026-10-13 06:17:31 +08:00
parent bba46b5c7f
commit d2ce185441
60 changed files with 2443 additions and 246 deletions

View file

@ -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<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 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<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>> {
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<String, V
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();
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.
if let Ok(secret) = s.secrets.get(id) {
// A missing key is not fatal: the command says which credential it lacks.
if let Ok(secret) = secret_of(s, id) {
secret.to_credentials(&mut creds);
}
}
@ -81,7 +132,7 @@ fn target_params(s: &AppState, t: &StoreTarget) -> Res<Map<String, Value>> {
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<Map<String, Value>> {
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<String>,
}
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<Vec<ProjectView>> {
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<Projec
let mut st = saved(state)?;
st.projects.push(project.clone());
persist(state, &st)?;
publish_folders(state, &st, &[]);
Ok(project)
}
@ -639,13 +698,16 @@ pub fn project_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, Ap
pub fn project_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
guard(&window)?;
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.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<Vec<Account>> {
guard(&window)?;
sync_machine(&state)?;
Ok(saved(&state)?.accounts.clone())
}
@ -686,17 +749,28 @@ fn check_studio(state: &AppState, studio: &Option<String>) -> 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<String>) -> Res<Account> {
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<String>) -> Res<Option<Account>> {
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<String>) -> Res<Option<Account>> {
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<String>) -> Res<Account> {
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,