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,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<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>,
}
/// 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_<id>.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<String, 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 {
fn set(&self, id: &str, secret: &Secret) -> Result<(), String>;
fn get(&self, id: &str) -> Result<Secret, String>;

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,

View file

@ -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())?)?;

View file

@ -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) {

View file

@ -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 `<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.
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 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()) });
r.added.push(format!("{id} ({name})"));
}
}
r
}

View file

@ -0,0 +1,140 @@
//! 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>" }] }
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())?;
}
let text = serde_json::to_string_pretty(&serde_json::json!({ "version": 1, "projects": entries })).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())
}
fn entry_of(p: &Project) -> Entry {
Entry { id: p.id.clone(), path: p.path.clone(), name: p.name.clone(), added: p.added.clone() }
}
/// Makes the app's folder list the registry's (pure on the values). A registry folder the app has (by path) keeps the
/// app's id, name and accounts; a new one comes in with the registry's id; a folder that left the registry leaves the
/// app (with its links, ignore entries and studio moves). Returns (changed state, the registry to write back if it
/// needs the app's ids or names).
pub fn merge(state: &mut State, reg: Option<Vec<Entry>>) -> (bool, Option<Vec<Entry>>) {
let Some(reg) = reg else {
// first start with a shared list: the app's folders become it
return (false, Some(state.projects.iter().map(entry_of).collect()));
};
let mut projects = Vec::with_capacity(reg.len());
let mut write_back = false;
for e in &reg {
match state.projects.iter().find(|p| p.path == e.path) {
Some(p) => {
if p.id != e.id || p.name != e.name {
write_back = true;
}
projects.push(p.clone());
}
None => {
// an id the app already uses for another folder gets the CLI's id instead
let id = if state.projects.iter().any(|p| p.id == e.id) { folder_id(&e.path) } else { e.id.clone() };
write_back |= id != e.id;
projects.push(Project { id, path: e.path.clone(), name: e.name.clone(), play_account: None, apple_account: None, added: e.added.clone() });
}
}
}
let gone: Vec<String> = state.projects.iter().filter(|p| !projects.iter().any(|x| x.id == p.id)).map(|p| p.id.clone()).collect();
let changed = projects != state.projects;
for id in &gone {
let key = format!("project:{id}");
state.overrides.links.retain(|l| &l.project != id);
state.overrides.ignored.retain(|i| i != &key);
state.overrides.app_studio.retain(|a| a.key != key);
}
state.projects = projects;
(changed, if write_back { Some(state.projects.iter().map(entry_of).collect()) } else { None })
}
/// Reads the registry, merges, writes it back when needed. Returns whether the state changed.
pub fn sync(state: &mut State, file: &Path) -> Result<bool, String> {
let (changed, back) = merge(state, read(file));
if let Some(list) = back {
write(file, &list)?;
}
Ok(changed)
}
/// After the app changed its list (add, remove, locate): the registry gets the app's list, keeping folders another
/// tool added since the last sync.
pub fn publish(state: &State, file: &Path, removed: &[String]) -> Result<(), String> {
let mut list: Vec<Entry> = state.projects.iter().map(entry_of).collect();
for e in read(file).unwrap_or_default() {
if !list.iter().any(|x| x.path == e.path || x.id == e.id) && !removed.contains(&e.path) {
list.push(e);
}
}
write(file, &list)
}

View file

@ -322,3 +322,18 @@ pub fn check_settings(s: &Settings) -> Result<(), String> {
}
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
}