//! Studios, app pairing overrides, the ignore list, app settings and store-only app targets. Pure data and checks, so //! tests/workspace.rs can pin every rule. Nothing here is a secret: studios are names and colours, overrides are store //! app keys (account id + store app id), settings are numbers and switches. //! //! Store app key: "ios::" or "android::" //! Store target id: "store:ios:::;android::" (either part optional, at least //! one): a store-only app (no app folder) the window reads through the sidecar's generated config. use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; /// Colours a studio can carry (CSS classes `studio-` in the window, contrast-checked there). pub const STUDIO_COLORS: &[&str] = &["indigo", "violet", "rose", "amber", "emerald", "teal", "sky", "slate"]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Studio { pub id: String, pub name: String, pub color: String, /// Small logo as a data: URL (PNG / JPEG / WebP, at most LOGO_MAX bytes before encoding), or none (monogram). #[serde(default)] pub logo: Option, pub created: String, } pub const LOGO_MAX: usize = 256 * 1024; pub fn check_studio_name(s: &str) -> Result { let s = s.trim(); if s.is_empty() || s.chars().count() > 48 || s.chars().any(char::is_control) { return Err("studio name: 1 to 48 characters".into()); } Ok(s.to_string()) } pub fn check_color(s: &str) -> Result { if STUDIO_COLORS.contains(&s) { Ok(s.to_string()) } else { Err(format!("colour: one of {}", STUDIO_COLORS.join(", "))) } } /// Logo bytes -> data URL; only small raster images whose bytes match the type. pub fn logo_data_url(bytes: &[u8]) -> Result { use base64::{engine::general_purpose::STANDARD, Engine}; if bytes.len() > LOGO_MAX { return Err("logo: at most 256 KB".into()); } let mime = if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png" } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { "image/jpeg" } else if bytes.len() > 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { "image/webp" } else { return Err("logo: a PNG, JPEG or WebP image".into()); }; Ok(format!("data:{mime};base64,{}", STANDARD.encode(bytes))) } /// Account ids are what store::new_id makes (16 hex). pub fn is_account_id(s: &str) -> bool { s.len() == 16 && s.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) } fn is_bundle(s: &str) -> bool { !s.is_empty() && s.len() <= 155 && s.contains('.') && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') && s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()) } fn is_package(s: &str) -> bool { let parts: Vec<&str> = s.split('.').collect(); s.len() <= 155 && parts.len() >= 2 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) && parts[0].chars().next().is_some_and(|c| c.is_ascii_alphabetic()) } fn is_asc_id(s: &str) -> bool { !s.is_empty() && s.len() <= 20 && s.chars().all(|c| c.is_ascii_digit()) } /// One app record on one store account. #[derive(Debug, Clone, PartialEq, Eq)] pub enum StoreAppKey { Ios { account: String, app_id: String }, Android { account: String, package: String }, } impl StoreAppKey { pub fn parse(s: &str) -> Result { let mut it = s.splitn(3, ':'); let (store, account, id) = (it.next().unwrap_or_default(), it.next().unwrap_or_default(), it.next().unwrap_or_default()); if !is_account_id(account) { return Err(format!("store app key {s}: bad account id")); } match store { "ios" if is_asc_id(id) => Ok(StoreAppKey::Ios { account: account.into(), app_id: id.into() }), "android" if is_package(id) => Ok(StoreAppKey::Android { account: account.into(), package: id.into() }), _ => Err(format!("store app key {s}: ios:: or android::")), } } pub fn account(&self) -> &str { match self { StoreAppKey::Ios { account, .. } | StoreAppKey::Android { account, .. } => account, } } } /// A store-only app the window reads: which accounts, which store ids. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct StoreTarget { /// (account, App Store Connect app id, bundle id) pub ios: Option<(String, String, String)>, /// (account, package name) pub android: Option<(String, String)>, } impl StoreTarget { pub fn parse(s: &str) -> Result { let rest = s.strip_prefix("store:").ok_or("store target: store:...")?; if rest.len() > 400 { return Err("store target: too long".into()); } let mut t = StoreTarget::default(); for part in rest.split(';') { let f: Vec<&str> = part.split(':').collect(); match f.as_slice() { ["ios", acc, id, bundle] if t.ios.is_none() && is_account_id(acc) && is_asc_id(id) && is_bundle(bundle) => { t.ios = Some((acc.to_string(), id.to_string(), bundle.to_string())); } ["android", acc, pkg] if t.android.is_none() && is_account_id(acc) && is_package(pkg) => { t.android = Some((acc.to_string(), pkg.to_string())); } _ => return Err(format!("store target: bad part '{part}'")), } } if t.ios.is_none() && t.android.is_none() { return Err("store target: ios and / or android".into()); } Ok(t) } /// The sidecar's `virtual` object: store ids only, named after them. pub fn virtual_project(&self) -> Value { let name = self.ios.as_ref().map(|i| i.2.clone()).or_else(|| self.android.as_ref().map(|a| a.1.clone())).unwrap_or_default(); let mut v = json!({ "name": name }); if let Some((_, id, bundle)) = &self.ios { v["apple"] = json!({ "bundleId": bundle, "appId": id }); } if let Some((_, pkg)) = &self.android { v["play"] = json!({ "packageName": pkg }); } v } pub fn accounts(&self) -> Vec<&str> { self.ios.iter().map(|i| i.0.as_str()).chain(self.android.iter().map(|a| a.0.as_str())).collect() } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Pair { pub ios: String, pub android: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Link { /// Store app key. pub key: String, /// Project id (app folder). pub project: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AppStudio { /// Store app key or "project:". pub key: String, pub studio: String, } /// The owner's corrections to automatic pairing, links of store apps to folders, studio moves and the ignore list. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Overrides { #[serde(default)] pub pairs: Vec, #[serde(default)] pub unpairs: Vec, #[serde(default)] pub links: Vec, #[serde(default)] pub app_studio: Vec, /// "app:", "project:" or "account:". #[serde(default)] pub ignored: Vec, } pub const MAX_OVERRIDES: usize = 2000; /// Checks every key; drops nothing silently (an error names the bad entry). pub fn check_overrides(o: &Overrides) -> Result<(), String> { if o.pairs.len() + o.unpairs.len() + o.links.len() + o.app_studio.len() + o.ignored.len() > MAX_OVERRIDES { return Err("too many overrides".into()); } for p in o.pairs.iter().chain(o.unpairs.iter()) { match (StoreAppKey::parse(&p.ios)?, StoreAppKey::parse(&p.android)?) { (StoreAppKey::Ios { .. }, StoreAppKey::Android { .. }) => {} _ => return Err("pair: an iOS key and an Android key".into()), } } for l in &o.links { StoreAppKey::parse(&l.key)?; if l.project.is_empty() || l.project.len() > 64 { return Err("link: project id".into()); } } for a in &o.app_studio { if let Some(p) = a.key.strip_prefix("project:") { if p.is_empty() || p.len() > 64 { return Err("app studio: project id".into()); } } else { StoreAppKey::parse(&a.key)?; } if a.studio.is_empty() || a.studio.len() > 64 { return Err("app studio: studio id".into()); } } for i in &o.ignored { let ok = match i.split_once(':') { Some(("app", k)) => StoreAppKey::parse(k).is_ok(), Some(("account", id)) => is_account_id(id), Some(("project", id)) => !id.is_empty() && id.len() <= 64, _ => false, }; if !ok { return Err(format!("ignored: bad entry '{i}'")); } } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct RefreshSettings { /// Minutes between automatic store reads; 0 = off. pub auto_minutes: u32, pub on_launch: bool, /// Apps read at the same time (1-8). pub parallel: u8, /// Read app icons from the stores when importing (an extra request per app). pub icons: bool, /// Background reading of every app (status, releases, catalog, prices, listings, TestFlight, health, reviews, /// analytics, checks): on start, when the window shows again and every `every_minutes` while it is open. pub auto_read: bool, /// Minutes between background passes while the window is visible (only reads past their TTL run); 0 = only on /// start and when the window shows again. pub every_minutes: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct CacheSettings { /// Saved reads older than this many days are dropped; 0 = keep. pub keep_days: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct NotifySettings { /// Other release state changes (submitted, in review). pub review_state: bool, /// A version the store rejected. pub rejected: bool, /// A version that passed review (ready for release). pub approved: bool, /// A version that went live or started rolling out. pub live: bool, pub low_reviews: bool, /// A Play crash / ANR rate over the threshold, or a halted rollout. pub crash: bool, pub quiet_hours: QuietHours, } /// No notification between `from` and `to` (HH:MM, local time; the window may cross midnight). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct QuietHours { pub enabled: bool, pub from: String, pub to: String, } impl Default for QuietHours { fn default() -> QuietHours { QuietHours { enabled: false, from: "23:00".into(), to: "08:00".into() } } } /// "HH:MM" with a valid hour and minute. pub fn valid_hhmm(s: &str) -> bool { let b = s.as_bytes(); b.len() == 5 && b[2] == b':' && b.iter().enumerate().all(|(i, c)| i == 2 || c.is_ascii_digit()) && s[..2].parse::().is_ok_and(|h| h < 24) && s[3..].parse::().is_ok_and(|m| m < 60) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Settings { pub refresh: RefreshSettings, pub cache: CacheSettings, pub notify: NotifySettings, /// Update channel (Settings > Updates): stable | dev (beta kept for saved states). crate::updates::endpoint. pub channel: String, } // Every settings struct fills a missing field with its default (`#[serde(default)]`), so a file written by an older // build that lacks a newer setting keeps the rest. impl Default for RefreshSettings { fn default() -> RefreshSettings { RefreshSettings { auto_minutes: 0, on_launch: false, parallel: 4, icons: true, auto_read: true, every_minutes: 15 } } } impl Default for CacheSettings { fn default() -> CacheSettings { CacheSettings { keep_days: 30 } } } impl Default for NotifySettings { fn default() -> NotifySettings { NotifySettings { review_state: true, rejected: true, approved: true, live: true, low_reviews: true, crash: true, quiet_hours: QuietHours::default() } } } impl Default for Settings { fn default() -> Settings { Settings { refresh: RefreshSettings::default(), cache: CacheSettings::default(), notify: NotifySettings::default(), channel: "stable".into() } } } pub const AUTO_MINUTES: &[u32] = &[0, 15, 30, 60, 180, 720]; pub const KEEP_DAYS: &[u32] = &[0, 7, 30, 90, 365]; pub const EVERY_MINUTES: &[u32] = &[0, 5, 15, 30, 60]; pub fn check_settings(s: &Settings) -> Result<(), String> { if !AUTO_MINUTES.contains(&s.refresh.auto_minutes) { return Err("refresh: 0 (off), 15, 30, 60, 180 or 720 minutes".into()); } if !EVERY_MINUTES.contains(&s.refresh.every_minutes) { return Err("background reading: 0 (only on start), 5, 15, 30 or 60 minutes".into()); } if !(1..=8).contains(&s.refresh.parallel) { return Err("parallel reads: 1 to 8".into()); } if !KEEP_DAYS.contains(&s.cache.keep_days) { return Err("keep saved reads: 0 (always), 7, 30, 90 or 365 days".into()); } if !valid_hhmm(&s.notify.quiet_hours.from) || !valid_hhmm(&s.notify.quiet_hours.to) { return Err("quiet hours: times as HH:MM (00:00 to 23:59)".into()); } if !crate::updates::CHANNELS.contains(&s.channel.as_str()) { return Err("channel: stable or dev".into()); } 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 { 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 }