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 @@
{
"name": "@storebound/desktop",
"version": "0.1.0",
"version": "0.7.1",
"private": true,
"license": "MIT",
"type": "module",

View file

@ -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",

View file

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

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
}

View file

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "storebound",
"mainBinaryName": "storebound-desktop",
"version": "0.1.0",
"version": "0.7.1",
"identifier": "dev.storebound.desktop",
"build": {
"frontendDist": "../dist",

View file

@ -9,9 +9,9 @@ const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PR
#[test]
fn shown_metadata_carries_no_secret() {
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()) };
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()) };
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "studio"]);
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "profile", "studio"]);
assert_eq!(v["kind"], "play");
}

View file

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

View file

@ -0,0 +1,175 @@
//! One product per machine: the app's accounts are the CLI's credential profiles (~/.storebound/credentials), keychain
//! accounts of earlier versions move there once, the app folder list is the CLI's (~/.storebound/projects.json), and
//! a folder that pins another storebound version says so. No real keys anywhere.
use std::collections::HashMap;
use std::sync::Mutex;
use serde_json::json;
use storebound_desktop::accounts::{Account, AccountKind, Secret, SecretStore};
use storebound_desktop::profiles::{self, ProfileStore};
use storebound_desktop::registry::{self, Entry};
use storebound_desktop::store::{Project, State};
use storebound_desktop::workspace::{pin_differs, pinned_storebound};
const PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n";
fn sa(email: &str) -> String {
json!({ "type": "service_account", "client_email": email, "private_key": PEM }).to_string()
}
fn apple(key: &str) -> Secret {
Secret::Apple { key_id: key.into(), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: PEM.into() }
}
#[derive(Default)]
struct MapStore(Mutex<HashMap<String, Secret>>);
impl SecretStore for MapStore {
fn set(&self, id: &str, s: &Secret) -> Result<(), String> {
self.0.lock().unwrap().insert(id.into(), s.clone());
Ok(())
}
fn get(&self, id: &str) -> Result<Secret, String> {
self.0.lock().unwrap().get(id).cloned().ok_or_else(|| "no entry".into())
}
fn delete(&self, id: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(id);
Ok(())
}
}
fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account {
Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string) }
}
#[test]
fn ids_match_the_cli() {
// cli/lib/workspace.ts accountId and cli/lib/registry.ts folderId give the same values
assert_eq!(profiles::account_id(AccountKind::Apple, "acme"), "747351dbf2569d17");
assert_eq!(profiles::account_id(AccountKind::Play, "default"), "82a8c2561d950999");
assert_eq!(registry::folder_id("/w/Star Dash"), "f4994b9746e7eb18");
assert!(profiles::valid_name("default") && profiles::valid_name("acme-games.2") && !profiles::valid_name("../x") && !profiles::valid_name("-a"));
assert_eq!(profiles::slug("Acme Games: Play!"), "acme-games-play");
assert_eq!(profiles::slug("???"), "account");
}
#[test]
fn a_profile_round_trips_in_the_cli_format_with_private_files() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
store.save("acme", "Acme", &apple("ABCDE12345")).unwrap();
store.save("acme", "Acme", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
let m = store.read("acme").unwrap();
assert_eq!(m["ASC_P8"], "acme/app-store-connect.p8");
assert_eq!(m["PLAY_SERVICE_ACCOUNT"], "acme/play-service-account.json");
assert_eq!(m["label"], "Acme");
assert!(!serde_json::to_string(&m).unwrap().contains("not-a-real-key"), "keys stay in their own files");
assert_eq!(store.secret("acme", AccountKind::Apple).unwrap(), apple("ABCDE12345"));
assert_eq!(store.kinds("acme"), vec![AccountKind::Apple, AccountKind::Play]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&store.dir.join("acme.json")), 0o600);
assert_eq!(mode(&store.dir.join("acme").join(profiles::P8_FILE)), 0o600);
assert_eq!(mode(&store.dir.join("acme")), 0o700);
assert_eq!(mode(&store.dir), 0o700);
}
// the same key again lands in the same profile; another key in a profile of its own
assert_eq!(store.place("Anything", &apple("ABCDE12345")), "acme");
assert_eq!(store.place("Acme", &apple("ZZZZZ99999")), "acme-apple");
assert_eq!(store.place("Acme", &Secret::Play { service_account: sa("bot@example.com") }), "acme");
// a profile the CLI wrote by hand, with an absolute key path and no ASC_P8
std::fs::write(store.dir.join("cli.json"), json!({ "ASC_KEY_ID": "CLI0000000", "ASC_ISSUER_ID": "i" }).to_string()).unwrap();
let mut creds = serde_json::Map::new();
store.secret("cli", AccountKind::Apple).unwrap().to_credentials(&mut creds);
assert_eq!(creds.keys().cloned().collect::<Vec<_>>(), vec!["ASC_ISSUER_ID", "ASC_KEY_ID"], "no ASC_P8: the CLI looks in ~/.appstoreconnect itself");
// removing one store archives its key file; the last store archives the profile
store.remove("acme", AccountKind::Play, "20261013T000000Z").unwrap();
assert_eq!(store.kinds("acme"), vec![AccountKind::Apple]);
assert!(store.dir.join(".removed/acme-20261013T000000Z").join(profiles::PLAY_FILE).is_file());
store.remove("acme", AccountKind::Apple, "20261013T000001Z").unwrap();
assert!(!store.names().contains(&"acme".to_string()));
assert!(store.dir.join(".removed/acme-20261013T000001Z/acme.json").is_file());
}
#[test]
fn keychain_accounts_move_into_profiles_once_and_cli_profiles_show_up() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
// the CLI already imported the shared account ("accounts import" -> profile default)
store.save("default", "", &apple("ABCDE12345")).unwrap();
store.save("default", "", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
let legacy = MapStore::default();
legacy.set("0123456789abcdef", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
legacy.set("fedcba9876543210", &apple("SIDE000000")).unwrap();
let mut accs = vec![
account("0123456789abcdef", "Example Games Play", AccountKind::Play, None),
account("fedcba9876543210", "Side project", AccountKind::Apple, None),
account("00112233445566ff", "Lost key", AccountKind::Apple, None),
];
let r = profiles::sync(&mut accs, &store, &legacy, "2026-10-13T00:00:00Z");
// the same service account as the CLI's profile: that profile; the other key: a profile named after its label
assert_eq!(accs[0].profile.as_deref(), Some("default"));
assert_eq!(accs[1].profile.as_deref(), Some("side-project"));
assert_eq!(accs[2].profile, None, "no keychain entry: stays, said in the log");
assert_eq!(accs[0].studio.as_deref(), Some("s1"), "id, label, studio stay");
assert!(legacy.get("0123456789abcdef").is_err() && legacy.get("fedcba9876543210").is_err(), "moved entries leave the keychain");
assert_eq!(r.migrated.len(), 2);
assert_eq!(r.notes.len(), 1);
// the CLI profile's Apple key had no app account yet: it comes in with the CLI workspace's id
let added: Vec<_> = accs.iter().skip(3).map(|a| (a.id.clone(), a.kind, a.profile.clone())).collect();
assert_eq!(added, vec![(profiles::account_id(AccountKind::Apple, "default"), AccountKind::Apple, Some("default".into()))]);
// a second start changes nothing
let again = profiles::sync(&mut accs, &store, &legacy, "2026-10-14T00:00:00Z");
assert!(!again.changed());
assert_eq!(accs.len(), 4);
}
fn project(id: &str, path: &str, play: Option<&str>) -> Project {
Project { id: id.into(), path: path.into(), name: id.into(), play_account: play.map(str::to_string), apple_account: None, added: String::new() }
}
#[test]
fn the_folder_list_is_the_machines() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("projects.json");
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", Some("acc"))], ..State::default() };
// first start: the app's list becomes the shared one
assert!(!registry::sync(&mut st, &file).unwrap());
assert_eq!(registry::read(&file).unwrap()[0].id, "aaaaaaaaaaaaaaaa");
// the CLI adds a folder (storebound desktop / apps add): the app shows it, the first keeps its accounts
let mut list = registry::read(&file).unwrap();
list.push(Entry { id: registry::folder_id("/w/a game"), path: "/w/a game".into(), name: "a game".into(), added: "t".into() });
registry::write(&file, &list).unwrap();
assert!(registry::sync(&mut st, &file).unwrap());
assert_eq!(st.projects.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["/w/Star Dash", "/w/a game"]);
assert_eq!(st.projects[0].play_account.as_deref(), Some("acc"));
// the CLI removes one (apps remove): it leaves the app with its overrides
st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/a game")));
registry::write(&file, &list[..1]).unwrap();
assert!(registry::sync(&mut st, &file).unwrap());
assert_eq!(st.projects.len(), 1);
assert!(st.overrides.ignored.is_empty());
// the app adds one; a folder another tool added meanwhile is kept
st.projects.push(project("bbbbbbbbbbbbbbbb", "/w/third", None));
registry::write(&file, &[list[0].clone(), Entry { id: registry::folder_id("/w/cli"), path: "/w/cli".into(), name: "cli".into(), added: String::new() }]).unwrap();
registry::publish(&st, &file, &[]).unwrap();
let paths: Vec<String> = registry::read(&file).unwrap().into_iter().map(|e| e.path).collect();
assert_eq!(paths, vec!["/w/Star Dash", "/w/third", "/w/cli"]);
// bad entries are dropped, not the whole file
assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::<Vec<_>>(), vec![registry::folder_id("/a")]);
}
#[test]
fn a_folder_that_pins_another_storebound_says_so() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(pinned_storebound(dir.path()), None);
std::fs::write(dir.path().join("package.json"), r#"{"devDependencies":{"storebound":"^0.7.1"}}"#).unwrap();
assert_eq!(pinned_storebound(dir.path()).as_deref(), Some("^0.7.1"));
assert!(pin_differs("^0.7.1", "0.8.0"));
assert!(!pin_differs("0.8.0", "0.8.0"));
assert!(!pin_differs("~0.8.0", "0.8.0"));
for spec in ["latest", "*", "file:../storebound", "workspace:*", ">=0.7"] {
assert!(!pin_differs(spec, "0.8.0"), "{spec}");
}
}

View file

@ -4,11 +4,39 @@ use serde_json::{json, Value};
use storebound_desktop::commands::project_view;
use storebound_desktop::logs;
use storebound_desktop::store::{self, State, VERSION};
use storebound_desktop::accounts::{AccountKind, Secret, SecretStore};
use storebound_desktop::profiles::{self, ProfileStore};
use storebound_desktop::registry;
use storebound_desktop::workspace::{Overrides, Settings};
const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json");
const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json");
const EMPTY: &str = include_str!("../../tests/fixtures/state/empty.json");
const SHARED: &str = include_str!("../../tests/fixtures/state/shared-profiles.json");
/// Keychain entries of versions before 0.8, for the migration into credential profiles.
#[derive(Default)]
struct Keychain(std::sync::Mutex<std::collections::HashMap<String, Secret>>);
impl SecretStore for Keychain {
fn set(&self, id: &str, s: &Secret) -> Result<(), String> {
self.0.lock().unwrap().insert(id.into(), s.clone());
Ok(())
}
fn get(&self, id: &str) -> Result<Secret, String> {
self.0.lock().unwrap().get(id).cloned().ok_or_else(|| "no entry".into())
}
fn delete(&self, id: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(id);
Ok(())
}
}
const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n";
fn fake_secret(kind: AccountKind, n: usize) -> Secret {
match kind {
AccountKind::Apple => Secret::Apple { key_id: format!("FAKEKEY{n:03}"), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: FAKE_PEM.into() },
AccountKind::Play => Secret::Play { service_account: json!({ "client_email": format!("bot{n}@example.iam.gserviceaccount.com"), "private_key": FAKE_PEM }).to_string() },
}
}
fn write(dir: &std::path::Path, text: &str) {
std::fs::write(dir.join(store::FILE), text).unwrap();
@ -177,16 +205,43 @@ fn log_entries_are_one_block_and_rotate() {
}
/// What the host answers for each fixture (projects_list without `missing`, accounts_list, workspace_get), written
/// next to it as `<name>.host.json`. tools/desktop-boot-check.ts boots the built window on exactly these answers.
/// next to it as `<name>.host.json`, after the start-up sync with the machine's shared stores: D1 keeps its accounts in a
/// keychain that has no entries (the keys were never added: the accounts stay without a profile), D3's keychain has
/// them (they move into credential profiles), and shared-profiles is the 0.8 format whose apple account comes from a
/// profile the CLI wrote, with a folder that pins storebound 0.6.0. tools/desktop-boot-check.ts boots the built window on exactly these answers.
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
#[test]
fn host_answers_for_the_fixtures_are_current() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/state");
for (name, text) in [("empty", EMPTY), ("d1-missing-folders", D1), ("d3-full", D3)] {
let (st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
for (name, text, keychain) in [("empty", EMPTY, false), ("d1-missing-folders", D1, false), ("d3-full", D3, true), ("shared-profiles", SHARED, false)] {
let (mut st, _, notes) = store::migrate(serde_json::from_str(text).unwrap());
assert!(notes.is_empty(), "{name}: {notes:?}");
let machine = tempfile::tempdir().unwrap();
let profiles = ProfileStore::new(machine.path().join("credentials"), None);
let legacy = Keychain::default();
if keychain {
for (i, a) in st.accounts.iter().enumerate() {
legacy.set(&a.id, &fake_secret(a.kind, i)).unwrap();
}
}
if name == "shared-profiles" {
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Play, 0)).unwrap();
profiles.save("default", "Acme Games", &fake_secret(AccountKind::Apple, 0)).unwrap();
}
profiles::sync(&mut st.accounts, &profiles, &legacy, "2026-10-13T00:00:00Z");
registry::sync(&mut st, &machine.path().join("projects.json")).unwrap();
let mut projects = serde_json::to_value(&st.projects).unwrap();
if name == "shared-profiles" {
// what project_view says for a folder whose package.json pins storebound 0.6.0
let pinned = tempfile::tempdir().unwrap();
std::fs::write(pinned.path().join("package.json"), r#"{"devDependencies":{"storebound":"0.6.0"}}"#).unwrap();
let mut p = st.projects[0].clone();
p.path = pinned.path().to_string_lossy().to_string();
let pin = project_view(&p).storebound_pin.expect("0.6.0 is not this app");
projects[0]["storeboundPin"] = json!(pin);
}
let host = json!({
"projects_list": st.projects,
"projects_list": projects,
"accounts_list": st.accounts,
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
});

View file

@ -20,9 +20,9 @@ export type { AndroidSdkLookup } from '../../../cli/lib/tools';
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[]; androidSdk?: import('../../../cli/lib/tools').AndroidSdkLookup }
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean }
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null }
export type AccountKind = 'play' | 'apple';
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null }
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null }
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
export type StudioColor = (typeof STUDIO_COLORS)[number];
/** A user-named group of store accounts (and so of apps). */

View file

@ -47,11 +47,11 @@ let projects: Project[] = [
{ id: 'p-tidy', path: `${HOME}/apps/tidy-lists`, name: 'Tidy Lists', playAccount: P1, appleAccount: A2, added: iso(60 * 24 * 5) },
];
let accounts: Account[] = [
{ id: P1, label: 'Example Games · Play', kind: 'play', created: iso(60 * 24 * 30), studio: S1 },
{ id: A1, label: 'Example Games · App Store (company)', kind: 'apple', created: iso(60 * 24 * 30), studio: S1 },
{ id: P2, label: 'Sample Labs · Play', kind: 'play', created: iso(60 * 24 * 20), studio: S2 },
{ id: A2, label: 'Sample Labs · App Store', kind: 'apple', created: iso(60 * 24 * 20), studio: S2 },
{ id: A3, label: 'Demo Indie · App Store (individual)', kind: 'apple', created: iso(60 * 24 * 9), studio: S3 },
{ id: P1, label: 'Example Games · Play', kind: 'play', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
{ id: A1, label: 'Example Games · App Store (company)', kind: 'apple', created: iso(60 * 24 * 30), studio: S1, profile: 'example-games' },
{ id: P2, label: 'Sample Labs · Play', kind: 'play', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
{ id: A2, label: 'Sample Labs · App Store', kind: 'apple', created: iso(60 * 24 * 20), studio: S2, profile: 'sample-labs' },
{ id: A3, label: 'Demo Indie · App Store (individual)', kind: 'apple', created: iso(60 * 24 * 9), studio: S3, profile: 'demo-indie' },
];
if (import.meta.env.VITE_FIXTURES === 'empty') { projects = []; accounts = []; studios = []; }

View file

@ -411,7 +411,7 @@ export const en = {
'checks.sev.accepted': 'Accepted',
'checks.sev.low': 'Low',
'folders.lead': 'One folder per app with its storebound.config.ts (iOS and Android in one config). Pick the store accounts each app uses; the keys stay in the keychain.',
'folders.lead': 'One folder per app with its storebound.config.ts (iOS and Android in one config). This list is the machine\'s (~/.storebound/projects.json): `storebound desktop` or `storebound apps add` in a terminal adds to it too. Pick the store accounts each app uses.',
'folders.list': 'App folders',
'folders.listSub': '{count} folders',
'folders.listSub_one': '1 folder',
@ -445,16 +445,19 @@ export const en = {
'run.dryFailed': 'The dry run failed: nothing was written',
'run.done': 'Done',
'accounts.lead': 'Store API keys live in the system keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). They are never shown again, never written to your app folders, and reach storebound in memory for one call.',
'accounts.lead': 'Store accounts live once per machine in ~/.storebound, shared with the storebound CLI (`storebound accounts`): one credential profile per account, key files readable only by you. They are never shown again, never written to your app folders, and reach storebound in memory for one call.',
'accounts.list': 'Store accounts',
'accounts.listSub': 'Labels only: the keys are in the keychain',
'accounts.listSub': 'Labels and profile names only: the keys are in ~/.storebound',
'accounts.emptyTitle': 'No store accounts yet',
'accounts.emptyText': 'Add a Google Play service account or an App Store Connect API key below. Store reads need them; app folders work without.',
'accounts.unused': 'Not used',
'accounts.remove': 'Remove',
'accounts.removeAria': 'Remove {label} and delete its key from the keychain',
'accounts.removeAria': 'Remove {label}; its key file moves to a .removed folder in ~/.storebound',
'accounts.saved': '"{label}" saved',
'accounts.savedDetail': 'The key is in the system keychain.',
'accounts.savedDetail': 'The key is in a credential profile in ~/.storebound (the CLI uses it too).',
'acct.profile': 'profile {name}',
'acct.noProfile': 'key not moved from the keychain yet',
'app.pinNotice': 'This project uses storebound {pin} (package.json), the app runs {app}. The app works with its own copy; update the project with `bun add -d storebound@{app}` (or npm i -D) to keep both the same.',
'accounts.playSub': 'Service account JSON key',
'accounts.appleSub': 'Team API key (.p8) from Users and Access, Integrations',
'accounts.label': 'Name',
@ -465,7 +468,7 @@ export const en = {
'accounts.issuerId': 'Issuer ID',
'accounts.choosePlay': 'Choose the JSON key',
'accounts.chooseApple': 'Choose the .p8 key',
'accounts.safety': 'Keys are picked in the system file dialog and go straight to the keychain. The window never sees them.',
'accounts.safety': 'Keys are picked in the system file dialog and go straight into ~/.storebound (readable only by you). The window never sees them.',
'deps.lead': 'Local tools for screenshots, app previews, icons and simulator or emulator captures. Store work (listings, products, releases, reviews) needs none of them.',
'deps.checkAgain': 'Check again',
@ -547,7 +550,7 @@ export const en = {
'wz.p2t': 'Safe by design',
'wz.p2s': 'Reads are read-only. Every change shows a dry run and a native confirmation first.',
'wz.p3t': 'Your keys stay yours',
'wz.p3s': 'Store keys live in the system keychain. No account, no telemetry.',
'wz.p3s': 'Store keys live in ~/.storebound, shared with the storebound CLI. No account, no telemetry.',
'wz.appsTitle': 'Add your apps',
'wz.appsLead': 'Each app is a folder with a storebound.config.ts that describes it on both stores.',
'wz.addTitle': 'Add an existing folder',
@ -607,11 +610,11 @@ export const en = {
'color.teal': 'Teal',
'color.sky': 'Sky',
'color.slate': 'Slate',
'accounts.leadStudios': 'Studios group your store accounts: Google Play service accounts and App Store Connect team keys. Keys stay in the system keychain; apps come in with Import apps.',
'accounts.leadStudios': 'Studios group your store accounts: Google Play service accounts and App Store Connect team keys. Keys live in ~/.storebound, shared with the CLI; apps come in with Import apps.',
'accounts.emptyTextStudios': 'Add a studio, then its Google Play service account and App Store Connect keys. Import apps then lists every app those keys can see.',
'accounts.playSubMany': 'Service account JSON key. One service account can be invited to several Play developer accounts: it sees all their apps.',
'accounts.appleSubMany': 'One API key per team (company or individual): key id, issuer id and the .p8 file.',
'accounts.safetyStudios': 'Keys are read once from the file you pick, stored in the system keychain and never shown again. storebound gets one account\'s key in memory for one call. Studio names, labels and app lists stay on this computer.',
'accounts.safetyStudios': 'Keys are read once from the file you pick, stored in ~/.storebound (readable only by you, shared with the storebound CLI) and never shown again. storebound gets one account\'s key in memory for one call. Studio names, labels and app lists stay on this computer.',
'acct.ok': 'Works',
'acct.noApps': 'No apps',
'acct.permission': 'Permission missing',
@ -768,7 +771,7 @@ export const en = {
'set.writesText': 'Dry run first, then your Apply in a system dialog. Submit, release and publish stay in the consoles.',
'set.privacy': 'Privacy',
'set.p1': 'No telemetry, no analytics, no crash reports: the app talks only to the stores, with your keys, when you ask.',
'set.p2': 'Store keys live in the system keychain; the app data folder holds names, settings and saved reads, never keys.',
'set.p2': 'Store keys live in ~/.storebound and the app folder list in ~/.storebound/projects.json, shared with the CLI; the app data folder holds labels, settings and saved reads, never keys.',
'set.p3': 'Nothing is written into your app folders unless you confirm it in a system dialog.',
'set.p4': 'Tester emails stay masked; review drafts stay in each app folder\'s .storebound/ (gitignored).',
'set.reveal': 'Show the data folder',
@ -785,7 +788,7 @@ export const en = {
'set.updateInstall': 'Install {version}…',
'set.updateInstalling': 'Downloading the update… {done}',
'set.reset': 'Reset',
'set.resetText': 'Forgets {accounts} accounts (their keys are removed from the keychain), {studios} studios, {folders} app folders (the folders stay on disk), pairing, the ignore list, settings and saved reads. A system dialog asks first.',
'set.resetText': 'Forgets {studios} studios, the labels of {accounts} accounts, pairing, the ignore list, settings and saved reads. The accounts in ~/.storebound and the {folders} app folders stay: the CLI uses them too. A system dialog asks first.',
'set.resetButton': 'Reset the app',
'set.resetDone': 'storebound desktop was reset',
'set.footer': '{n} apps known to this computer',
@ -1275,7 +1278,7 @@ export const en = {
'crash.retry': 'Try again',
'crash.reload': 'Reload',
'crash.reset': 'Reset saved state (keeps a backup)',
'crash.resetHint': 'Starts with an empty list. The saved state is kept as a backup file, store keys stay in the keychain, app folders are not touched.',
'crash.resetHint': 'Starts again from the machine\'s accounts and app folders. The saved state is kept as a backup file, store keys stay in ~/.storebound, app folders are not touched.',
'crash.resetDone': 'Saved state reset. Backup: {path}. Reloading…',
'crash.logNote': 'Also written to logs/webview.log in the app data folder (home folder shown as ~, no keys).',
'folder.missing': 'Folder not found.',

View file

@ -413,7 +413,7 @@ export const tr: Record<keyof typeof en, string> = {
'checks.sev.accepted': 'Kabul',
'checks.sev.low': 'Düşük',
'folders.lead': 'Her uygulama için storebound.config.ts dosyası olan bir klasör (iOS ve Android tek ayarda). Her uygulamanın kullandığı mağaza hesaplarını seçin; anahtarlar anahtar zincirinde kalır.',
'folders.lead': 'Her uygulama için storebound.config.ts dosyası olan bir klasör (iOS ve Android tek ayarda). Bu liste bilgisayarın listesidir (~/.storebound/projects.json): terminalde `storebound desktop` ya da `storebound apps add` da buraya ekler. Her uygulamanın kullandığı mağaza hesaplarını seçin.',
'folders.list': 'Uygulama klasörleri',
'folders.listSub': '{count} klasör',
'folders.listSub_one': '1 klasör',
@ -447,16 +447,19 @@ export const tr: Record<keyof typeof en, string> = {
'run.dryFailed': 'Deneme çalıştırması başarısız: hiçbir şey yazılmadı',
'run.done': 'Bitti',
'accounts.lead': 'Mağaza API anahtarları sistemin anahtar zincirinde durur (macOS\'ta Anahtar Zinciri, Windows\'ta Kimlik Bilgisi Yöneticisi, Linux\'ta Secret Service). Bir daha gösterilmez, uygulama klasörlerinize yazılmaz ve storebound\'a yalnızca bir çağrı için bellekte verilir.',
'accounts.lead': 'Mağaza hesapları bilgisayarda bir kez, ~/.storebound içinde durur ve storebound CLI ile paylaşılır (`storebound accounts`): her hesap için bir kimlik profili, anahtar dosyalarını yalnızca siz okuyabilirsiniz. Bir daha gösterilmez, uygulama klasörlerinize yazılmaz, storebound\'a bir çağrı için bellekte ulaşır.',
'accounts.list': 'Mağaza hesapları',
'accounts.listSub': 'Yalnızca etiketler: anahtarlar anahtar zincirinde',
'accounts.listSub': 'Yalnızca etiketler ve profil adları: anahtarlar ~/.storebound içinde',
'accounts.emptyTitle': 'Henüz mağaza hesabı yok',
'accounts.emptyText': 'Aşağıdan bir Google Play hizmet hesabı veya App Store Connect API anahtarı ekleyin. Mağaza okumaları bunlara ihtiyaç duyar; uygulama klasörleri onlarsız da çalışır.',
'accounts.unused': 'Kullanılmıyor',
'accounts.remove': 'Kaldır',
'accounts.removeAria': '{label} hesabını kaldır ve anahtarını anahtar zincirinden sil',
'accounts.removeAria': '{label} hesabını kaldır; anahtar dosyası ~/.storebound içindeki .removed klasörüne taşınır',
'accounts.saved': '"{label}" kaydedildi',
'accounts.savedDetail': 'Anahtar sistemin anahtar zincirinde.',
'accounts.savedDetail': 'Anahtar ~/.storebound içindeki bir kimlik profilinde (CLI da kullanır).',
'acct.profile': 'profil {name}',
'acct.noProfile': 'anahtar henüz anahtar zincirinden taşınmadı',
'app.pinNotice': 'Bu proje storebound {pin} kullanıyor (package.json), uygulama {app} çalıştırıyor. Uygulama kendi kopyasıyla çalışır; ikisini aynı tutmak için projeyi `bun add -d storebound@{app}` (ya da npm i -D) ile güncelleyin.',
'accounts.playSub': 'Hizmet hesabı JSON anahtarı',
'accounts.appleSub': 'Kullanıcılar ve Erişim, Entegrasyonlar altındaki ekip API anahtarı (.p8)',
'accounts.label': 'Ad',
@ -467,7 +470,7 @@ export const tr: Record<keyof typeof en, string> = {
'accounts.issuerId': 'Veren kimliği',
'accounts.choosePlay': 'JSON anahtarını seç',
'accounts.chooseApple': '.p8 anahtarını seç',
'accounts.safety': 'Anahtarlar sistemin dosya penceresinde seçilir ve doğrudan anahtar zincirine gider. Uygulama penceresi onları hiç görmez.',
'accounts.safety': 'Anahtarlar sistemin dosya penceresinde seçilir ve doğrudan ~/.storebound klasörüne gider (yalnızca siz okuyabilirsiniz). Pencere onları hiç görmez.',
'deps.lead': 'Ekran görüntüleri, uygulama önizlemeleri, simgeler ve simülatör ya da emülatör kayıtları için yerel araçlar. Mağaza işleri (metinler, ürünler, sürümler, yorumlar) hiçbirine ihtiyaç duymaz.',
'deps.checkAgain': 'Yeniden kontrol et',
@ -549,7 +552,7 @@ export const tr: Record<keyof typeof en, string> = {
'wz.p2t': 'Tasarımı gereği güvenli',
'wz.p2s': 'Okumalar salt okunurdur. Her değişiklik önce bir deneme çalıştırması ve sistem onayı gösterir.',
'wz.p3t': 'Anahtarlarınız sizde kalır',
'wz.p3s': 'Mağaza anahtarları sistemin anahtar zincirinde durur. Hesap yok, telemetri yok.',
'wz.p3s': 'Mağaza anahtarları ~/.storebound içinde, storebound CLI ile paylaşılır. Hesap yok, telemetri yok.',
'wz.appsTitle': 'Uygulamalarınızı ekleyin',
'wz.appsLead': 'Her uygulama, onu iki mağazada anlatan bir storebound.config.ts dosyası olan bir klasördür.',
'wz.addTitle': 'Var olan klasörü ekle',
@ -609,11 +612,11 @@ export const tr: Record<keyof typeof en, string> = {
'color.teal': 'Deniz mavisi',
'color.sky': 'Gök mavisi',
'color.slate': 'Arduvaz',
'accounts.leadStudios': 'Stüdyolar mağaza hesaplarınızı gruplar: Google Play hizmet hesapları ve App Store Connect ekip anahtarları. Anahtarlar sistem anahtar zincirinde kalır; uygulamalar "Uygulamaları içe aktar" ile gelir.',
'accounts.leadStudios': 'Stüdyolar mağaza hesaplarınızı gruplar: Google Play hizmet hesapları ve App Store Connect ekip anahtarları. Anahtarlar ~/.storebound içinde, CLI ile paylaşılır; uygulamalar Uygulamaları içe aktar ile gelir.',
'accounts.emptyTextStudios': 'Bir stüdyo, sonra onun Google Play hizmet hesabını ve App Store Connect anahtarlarını ekleyin. "Uygulamaları içe aktar" bu anahtarların gördüğü tüm uygulamaları listeler.',
'accounts.playSubMany': 'Hizmet hesabı JSON anahtarı. Bir hizmet hesabı birden çok Play geliştirici hesabına davet edilebilir: hepsinin uygulamalarını görür.',
'accounts.appleSubMany': 'Ekip başına bir API anahtarı (şirket ya da bireysel): anahtar kimliği, issuer kimliği ve .p8 dosyası.',
'accounts.safetyStudios': 'Anahtarlar seçtiğiniz dosyadan bir kez okunur, sistem anahtar zincirine kaydedilir ve bir daha gösterilmez. storebound bir hesabın anahtarını tek bir çağrı için bellekte alır. Stüdyo adları, etiketler ve uygulama listeleri bu bilgisayarda kalır.',
'accounts.safetyStudios': 'Anahtarlar seçtiğiniz dosyadan bir kez okunur, ~/.storebound içinde saklanır (yalnızca siz okuyabilirsiniz, storebound CLI ile paylaşılır) ve bir daha gösterilmez. storebound bir hesabın anahtarını bir çağrı için bellekte alır. Stüdyo adları, etiketler ve uygulama listeleri bu bilgisayarda kalır.',
'acct.ok': 'Çalışıyor',
'acct.noApps': 'Uygulama yok',
'acct.permission': 'İzin eksik',
@ -770,7 +773,7 @@ export const tr: Record<keyof typeof en, string> = {
'set.writesText': 'Önce deneme, sonra sistem penceresinde sizin Uygula onayınız. Gönderme, yayınlama ve yayımlama konsollarda kalır.',
'set.privacy': 'Gizlilik',
'set.p1': 'Telemetri, analiz ya da çökme raporu yok: uygulama yalnızca siz istediğinizde, anahtarlarınızla mağazalarla konuşur.',
'set.p2': 'Mağaza anahtarları sistem anahtar zincirinde durur; uygulama veri klasöründe adlar, ayarlar ve kayıtlı okumalar vardır, anahtar asla.',
'set.p2': 'Mağaza anahtarları ~/.storebound, uygulama klasörü listesi ~/.storebound/projects.json içinde, CLI ile paylaşılır; uygulama veri klasöründe etiketler, ayarlar ve kayıtlı okumalar durur, anahtar asla.',
'set.p3': 'Siz bir sistem penceresinde onaylamadıkça uygulama klasörlerinize hiçbir şey yazılmaz.',
'set.p4': 'Test kullanıcısı e-postaları maskeli kalır; yorum taslakları her uygulama klasörünün .storebound/ klasöründe (gitignore) kalır.',
'set.reveal': 'Veri klasörünü göster',
@ -787,7 +790,7 @@ export const tr: Record<keyof typeof en, string> = {
'set.updateInstall': '{version} sürümünü yükle…',
'set.updateInstalling': 'Güncelleme indiriliyor… {done}',
'set.reset': 'Sıfırla',
'set.resetText': '{accounts} hesabı (anahtarları anahtar zincirinden silinir), {studios} stüdyoyu, {folders} uygulama klasörünü (klasörler diskte kalır), eşleştirmeyi, yok sayılanları, ayarları ve kayıtlı okumaları unutur. Önce bir sistem penceresi sorar.',
'set.resetText': '{studios} stüdyoyu, {accounts} hesabın etiketlerini, eşleştirmeyi, yok sayılanları, ayarları ve kayıtlı okumaları unutur. ~/.storebound içindeki hesaplar ve {folders} uygulama klasörü kalır: CLI da kullanır. Önce bir sistem penceresi sorar.',
'set.resetButton': 'Uygulamayı sıfırla',
'set.resetDone': 'storebound masaüstü sıfırlandı',
'set.footer': 'Bu bilgisayarın bildiği {n} uygulama',
@ -1277,7 +1280,7 @@ export const tr: Record<keyof typeof en, string> = {
'crash.retry': 'Yeniden dene',
'crash.reload': 'Yeniden yükle',
'crash.reset': 'Kayıtlı durumu sıfırla (yedek tutulur)',
'crash.resetHint': 'Boş bir listeyle başlar. Kayıtlı durum bir yedek dosyada saklanır, mağaza anahtarları anahtar zincirinde kalır, uygulama klasörlerine dokunulmaz.',
'crash.resetHint': 'Bilgisayarın hesapları ve uygulama klasörleriyle yeniden başlar. Kayıtlı durum yedek dosya olarak saklanır, mağaza anahtarları ~/.storebound içinde kalır, uygulama klasörlerine dokunulmaz.',
'crash.resetDone': 'Kayıtlı durum sıfırlandı. Yedek: {path}. Yeniden yükleniyor…',
'crash.logNote': 'Uygulama veri klasöründeki logs/webview.log dosyasına da yazıldı (ana klasör ~ olarak, anahtarlar olmadan).',
'folder.missing': 'Klasör bulunamadı.',

View file

@ -15,13 +15,13 @@ export function normalizeProjects(v: unknown): Project[] {
const seen = new Set<string>();
return arr(v).filter(isRec).filter(p => typeof p.id === 'string' && typeof p.path === 'string' && !seen.has(p.id) && !!seen.add(p.id)).map(p => {
const path = p.path as string;
return { id: p.id as string, path, name: str(p.name).trim() || folderName(path), playAccount: strOrNull(p.playAccount), appleAccount: strOrNull(p.appleAccount), added: str(p.added), missing: p.missing === true };
return { id: p.id as string, path, name: str(p.name).trim() || folderName(path), playAccount: strOrNull(p.playAccount), appleAccount: strOrNull(p.appleAccount), added: str(p.added), missing: p.missing === true, storeboundPin: strOrNull(p.storeboundPin) };
});
}
export function normalizeAccounts(v: unknown): Account[] {
return arr(v).filter(isRec).filter(a => typeof a.id === 'string' && (a.kind === 'play' || a.kind === 'apple'))
.map(a => ({ id: a.id as string, label: str(a.label) || (a.id as string), kind: a.kind as Account['kind'], created: str(a.created), studio: strOrNull(a.studio) }));
.map(a => ({ id: a.id as string, label: str(a.label) || (a.id as string), kind: a.kind as Account['kind'], created: str(a.created), studio: strOrNull(a.studio), profile: strOrNull(a.profile) }));
}
function studios(v: unknown): Studio[] {

View file

@ -1,6 +1,7 @@
// Studios & accounts: studios (name, colour, logo) group store accounts; each account is a Google Play service account
// (one JSON key; it can be invited to several Play developer accounts) or an App Store Connect team key (key id,
// issuer id, .p8). Keys live in the system keychain; the app keeps a label and the studio only. Per account: a health
// issuer id, .p8). Keys live in the machine's credential profiles (~/.storebound, shared with the CLI);
// the app keeps a label, the studio and the profile name only. Per account: a health
// check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see).
import { type FormEvent, useEffect, useState } from 'react';
import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api';
@ -59,6 +60,7 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
<div className="row" style={{ gap: 8 }}><span className="strong">{a.label}</span><HealthChip h={h} />{ignored && <Chip tone="neutral" icon="eyeOff">{t('acct.ignored')}</Chip>}</div>
<div className="small muted">
{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')}
{' · '}{a.profile ? <span className="mono-id selectable" title="~/.storebound">{t('acct.profile', { name: a.profile })}</span> : t('acct.noProfile')}
{' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')}
{used ? ` · ${t('acct.folders', { count: used })}` : ''}
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}

View file

@ -140,6 +140,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
</div>
<AppControls app={app} />
{!app.project && <StoreOnlyBanner app={app} />}
{app.project?.storeboundPin && <Notice tone="info" icon="info">{t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}</Notice>}
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
{tabs.map((x, i) => (

View file

@ -6,3 +6,7 @@ empty.json no file content yet (fresh install)
d1-missing-folders.json desktop D1 format (projects + accounts only, no version) with two folders that are gone
d3-full.json D3 format without version: studios, accounts in studios, overrides, settings, one folder
that exists and one that is gone
shared-profiles.json 0.8 format: accounts name their credential profile (~/.storebound/credentials, shared with
the CLI); the apple account comes from a profile the CLI wrote; the folder pins storebound 0.6.0
The .host.json answers come after the start-up sync: D1's accounts have no keychain entry (they stay without a
profile), D3's keychain entries move into credential profiles.

View file

@ -5,6 +5,7 @@
"id": "0123456789abcdef",
"kind": "play",
"label": "Example Games Play",
"profile": "example-games-play",
"studio": "s1"
},
{
@ -12,6 +13,7 @@
"id": "fedcba9876543210",
"kind": "apple",
"label": "Example Games App Store",
"profile": "example-games-app-store",
"studio": "s1"
},
{
@ -19,6 +21,7 @@
"id": "00112233445566ff",
"kind": "apple",
"label": "Side project",
"profile": "side-project",
"studio": null
}
],

View file

@ -0,0 +1,58 @@
{
"accounts_list": [
{
"created": "2026-10-13T00:00:00Z",
"id": "82a8c2561d950999",
"kind": "play",
"label": "Acme Games",
"profile": "default",
"studio": null
},
{
"created": "2026-10-13T00:00:00Z",
"id": "8e4050ce9325b4da",
"kind": "apple",
"label": "Acme Games",
"profile": "default",
"studio": null
}
],
"projects_list": [
{
"added": "2026-10-13T00:00:00Z",
"appleAccount": "8e4050ce9325b4da",
"id": "p0game0000000001",
"name": "demo-game",
"path": "@EXAMPLES@/demo-game",
"playAccount": "82a8c2561d950999",
"storeboundPin": "0.6.0"
}
],
"workspace_get": {
"overrides": {
"appStudio": [],
"ignored": [],
"links": [],
"pairs": [],
"unpairs": []
},
"settings": {
"cache": {
"keepDays": 30
},
"channel": "stable",
"notify": {
"crash": true,
"lowReviews": true,
"reviewState": true
},
"refresh": {
"autoMinutes": 0,
"icons": true,
"onLaunch": false,
"parallel": 4
}
},
"studios": []
}
}

View file

@ -0,0 +1,17 @@
{
"version": 2,
"projects": [
{ "id": "p0game0000000001", "path": "@EXAMPLES@/demo-game", "name": "demo-game", "playAccount": "82a8c2561d950999", "appleAccount": "8e4050ce9325b4da", "added": "2026-10-13T00:00:00Z" }
],
"accounts": [
{ "id": "82a8c2561d950999", "label": "Acme Games", "kind": "play", "created": "2026-10-13T00:00:00Z", "studio": null, "profile": "default" }
],
"studios": [],
"overrides": { "pairs": [], "unpairs": [], "links": [], "appStudio": [], "ignored": [] },
"settings": {
"refresh": { "autoMinutes": 0, "onLaunch": false, "parallel": 4, "icons": true },
"cache": { "keepDays": 30 },
"notify": { "reviewState": true, "lowReviews": true, "crash": true },
"channel": "stable"
}
}

View file

@ -72,7 +72,10 @@ describe('host data of any age', () => {
]);
expect(p.map(x => [x.id, x.name, x.missing])).toEqual([['a', 'Prism Puddle', true], ['b', 'Tidy', false]]);
expect(normalizeProjects(undefined)).toEqual([]);
expect(normalizeAccounts([{ id: 'x', kind: 'play' }, { id: 'y', kind: 'fax' }])).toEqual([{ id: 'x', label: 'x', kind: 'play', created: '', studio: null }]);
expect(normalizeAccounts([{ id: 'x', kind: 'play' }, { id: 'y', kind: 'fax' }])).toEqual([{ id: 'x', label: 'x', kind: 'play', created: '', studio: null, profile: null }]);
// 0.8: the credential profile shared with the CLI; a folder pinning another storebound version
expect(normalizeAccounts([{ id: 'z', kind: 'apple', profile: 'default' }])[0]!.profile).toBe('default');
expect(normalizeProjects([{ id: 'p', path: '/w/a', storeboundPin: '0.6.0' }])[0]!.storeboundPin).toBe('0.6.0');
});
});