The signed release showed only its background colour and never started the sidecar. Tauri's freezePrototype freezes Object.prototype before the bundle runs; d3-color and d3-format (under the visx charts) assign `constructor` / `toString` on plain objects while loading, which throws on a frozen prototype, so the bundle stopped before React drew anything. The saved state was not the cause. - build/freeze-safe.ts: Vite plugin that rewrites those assignments in dependencies into a helper that defines the property when the frozen prototype would refuse it - error boundaries for the window, each screen and each app tab (Copy details, Reload, Reset saved state with a backup); public/boot.js shows the same panel when the bundle fails or the window stays empty - logs/host.log (start, saved state, sidecar start/stop, panics) and logs/webview.log (log_webview), rotated, home as ~, keys, tokens and e-mail addresses replaced - state.json `version` 2 with an explicit 1 -> 2 migration (original kept as state.json.v1.bak), per-entry tolerant parsing, partial settings keep their fields, unreadable files kept aside - folders that are gone: "Folder not found" with Locate... (project_locate) / Remove; portfolio reads skip them - tools/desktop-boot-check.ts: the built window with the release CSP and the frozen prototype, a mocked host from the state fixtures (empty, D1 with folders gone, D3) and the real sidecar, through every screen; in bun run ci, the Forgejo CI workflow and the macOS release
324 lines
12 KiB
Rust
324 lines
12 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,
|
|
}
|
|
|
|
#[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 {
|
|
pub review_state: bool,
|
|
pub low_reviews: bool,
|
|
pub crash: bool,
|
|
}
|
|
|
|
#[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 (placeholder until the updater ships): stable | beta.
|
|
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 }
|
|
}
|
|
}
|
|
|
|
impl Default for CacheSettings {
|
|
fn default() -> CacheSettings {
|
|
CacheSettings { keep_days: 30 }
|
|
}
|
|
}
|
|
|
|
impl Default for NotifySettings {
|
|
fn default() -> NotifySettings {
|
|
NotifySettings { review_state: true, low_reviews: true, crash: true }
|
|
}
|
|
}
|
|
|
|
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 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 !(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 !["stable", "beta"].contains(&s.channel.as_str()) {
|
|
return Err("channel: stable or beta".into());
|
|
}
|
|
Ok(())
|
|
}
|