382 lines
15 KiB
Rust
382 lines
15 KiB
Rust
//! 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:<account id>:<App Store Connect app id>" or "android:<account id>:<package name>"
|
|
//! Store target id: "store:ios:<account>:<app id>:<bundle id>;android:<account>:<package>" (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-<name>` 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<String>,
|
|
pub created: String,
|
|
}
|
|
|
|
pub const LOGO_MAX: usize = 256 * 1024;
|
|
|
|
pub fn check_studio_name(s: &str) -> Result<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<StoreAppKey, String> {
|
|
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:<account>:<app id> or android:<account>:<package>")),
|
|
}
|
|
}
|
|
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<StoreTarget, String> {
|
|
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:<id>".
|
|
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<Pair>,
|
|
#[serde(default)]
|
|
pub unpairs: Vec<Pair>,
|
|
#[serde(default)]
|
|
pub links: Vec<Link>,
|
|
#[serde(default)]
|
|
pub app_studio: Vec<AppStudio>,
|
|
/// "app:<store app key>", "project:<id>" or "account:<id>".
|
|
#[serde(default)]
|
|
pub ignored: Vec<String>,
|
|
}
|
|
|
|
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::<u8>().is_ok_and(|h| h < 24) && s[3..].parse::<u8>().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<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
|
|
}
|