storebound/apps/desktop/src-tauri/src/workspace.rs
CRT bd3ab20e1d Desktop: never a blank window (frozen-prototype fix, error screens, logs, state migration, boot check)
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
2026-10-11 09:36:34 +08:00

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(())
}