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:
parent
bba46b5c7f
commit
d2ce185441
60 changed files with 2443 additions and 246 deletions
375
apps/desktop/src-tauri/src/profiles.rs
Normal file
375
apps/desktop/src-tauri/src/profiles.rs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue