From 9610c9477e169df19bf707452a969bc84f5f46a5 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 01:35:27 +0800 Subject: [PATCH] Desktop: per-store ignore (hide only the App Store or Google Play side), N ignored show toggle in sidebar and portfolio, Unignore in the app controls; ignore flags sync with the shared app list --- apps/desktop/src-tauri/src/commands.rs | 7 +- apps/desktop/src-tauri/src/registry.rs | 146 ++++++++++++++++++++++++ apps/desktop/src-tauri/tests/machine.rs | 37 ++++++ apps/desktop/src/App.tsx | 13 ++- apps/desktop/src/i18n/en.ts | 10 ++ apps/desktop/src/i18n/tr.ts | 10 ++ apps/desktop/src/lib/identity.ts | 2 +- apps/desktop/src/lib/show-ignored.ts | 25 ++++ apps/desktop/src/screens/AppView.tsx | 13 ++- apps/desktop/src/screens/Ignored.tsx | 7 +- apps/desktop/src/screens/Portfolio.tsx | 17 ++- apps/desktop/src/store.tsx | 3 +- apps/desktop/tests/identity.test.ts | 33 +++++- cli/lib/identity.ts | 41 ++++++- 14 files changed, 347 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/lib/show-ignored.ts diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 445cb59..b295112 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -73,7 +73,8 @@ pub fn sync_machine(s: &AppState) -> Res<()> { false } }; - if report.changed() || folders { + let ignored = registry::pull_ignored(&mut st, &s.registry); + if report.changed() || folders || ignored { persist(s, &st)?; } Ok(()) @@ -1206,6 +1207,10 @@ pub fn overrides_set(window: WebviewWindow, state: State<'_, AppState>, override let mut st = saved(&state)?; st.overrides = overrides.clone(); persist(&state, &st)?; + // apps the shared list knows carry their ignore flags there (the CLI's watch and portfolio reads honour them) + if let Err(e) = registry::push_ignored(&st, &state.registry) { + crate::logs::host("warn", &format!("ignored apps not written to the app list: {e}")); + } Ok(overrides) } diff --git a/apps/desktop/src-tauri/src/registry.rs b/apps/desktop/src-tauri/src/registry.rs index 2ae732b..a67d261 100644 --- a/apps/desktop/src-tauri/src/registry.rs +++ b/apps/desktop/src-tauri/src/registry.rs @@ -76,6 +76,16 @@ pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> { // the store-only entries the CLI wrote stay as they are let keep = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::(&t).ok()).and_then(|v| v.get("storeApps").cloned()).filter(|s| s.as_array().is_some_and(|a| !a.is_empty())); let mut doc = serde_json::json!({ "version": 1, "projects": entries }); + // the folders' `ignored` flags (written by the CLI or by push_ignored) stay with their folder + let old = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::(&t).ok()); + if let (Some(old), Some(list)) = (old.as_ref().and_then(|o| o.get("projects")).and_then(Value::as_array), doc.get_mut("projects").and_then(Value::as_array_mut)) { + for e in list { + let ig = e.get("path").and_then(Value::as_str).and_then(|path| old.iter().find(|o| o.get("path").and_then(Value::as_str) == Some(path))).and_then(|o| o.get("ignored")).cloned(); + if let (Some(ig), Some(o)) = (ig, e.as_object_mut()) { + o.insert("ignored".into(), ig); + } + } + } if let Some(s) = keep { doc["storeApps"] = s; } @@ -217,3 +227,139 @@ pub fn publish(state: &State, file: &Path, removed: &[String]) -> Result<(), Str } write(file, &list) } + +// ---- ignored apps: the registry's `ignored` flags (cli/lib/registry.ts) <-> the app's ignore keys ---- +// projects: "ignored": ["apple", "play"] (both = the whole app) <-> "project:" (both) / "project::ios" / "project::android" +// storeApps: "ignored": ["apple"] <-> "app:ios::" (android for play) +// The registry is the truth for the apps it lists: `pull_ignored` (start, list shown) replaces the app's keys for those +// apps with the registry's flags, `push_ignored` (the owner ignores or restores in the app) writes them back. Apps +// that are not in the registry (listed by a store account only) keep their keys in the app's state alone. +const SIDES: [&str; 2] = ["apple", "play"]; + +fn word_of(side: &str) -> &'static str { + if side == "apple" { "ios" } else { "android" } +} + +fn sides_in(v: &Value) -> Vec { + let have: Vec<&str> = v.get("ignored").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + SIDES.iter().filter(|s| have.contains(s)).map(|s| s.to_string()).collect() +} + +fn is_project_key_of(k: &str, id: &str) -> bool { + k == format!("project:{id}") || k.starts_with(&format!("project:{id}:")) +} + +/// The ignore keys of one folder for its ignored store sides (pure). +pub fn project_keys(id: &str, sides: &[String]) -> Vec { + if SIDES.iter().all(|s| sides.iter().any(|x| x == s)) { + return vec![format!("project:{id}")]; + } + sides.iter().filter(|s| SIDES.contains(&s.as_str())).map(|s| format!("project:{id}:{}", word_of(s))).collect() +} + +/// The ignored store sides of a folder, from the app's ignore keys (pure). +pub fn project_sides(keys: &[String], id: &str) -> Vec { + let mut on = [false, false]; + for k in keys { + if *k == format!("project:{id}") { + on = [true, true]; + } else if *k == format!("project:{id}:ios") { + on[0] = true; + } else if *k == format!("project:{id}:android") { + on[1] = true; + } + } + SIDES.iter().enumerate().filter(|(i, _)| on[*i]).map(|(_, s)| s.to_string()).collect() +} + +/// Whether an ignore key "app:::" is this store app (pure). +pub fn app_key_matches(k: &str, store: &str, app_id: &str) -> bool { + let Some(rest) = k.strip_prefix("app:") else { return false }; + rest.split(':').next() == Some(word_of(store)) && rest.rsplit(':').next() == Some(app_id) +} + +/// The account (id) that reads a store-only entry: the one with its credential profile, else the only account of that store. +pub fn account_for(accounts: &[crate::accounts::Account], store: &str, profile: Option<&str>) -> Option { + use crate::accounts::AccountKind; + let kind = if store == "apple" { AccountKind::Apple } else { AccountKind::Play }; + let of_kind: Vec<_> = accounts.iter().filter(|a| a.kind == kind).collect(); + match profile { + Some(p) => of_kind.iter().find(|a| a.profile.as_deref() == Some(p)).map(|a| a.id.clone()), + None => (of_kind.len() == 1).then(|| of_kind[0].id.clone()), + } +} + +fn parse_doc(file: &Path) -> Option { + serde_json::from_str::(&fs::read_to_string(file).ok()?).ok() +} + +/// Registry flags -> the app's ignore keys. True when the keys changed. +pub fn pull_ignored(state: &mut State, file: &Path) -> bool { + let Some(doc) = parse_doc(file) else { return false }; + let before = state.overrides.ignored.clone(); + let mut keys = before.clone(); + for p in doc.get("projects").and_then(Value::as_array).into_iter().flatten() { + let Some(path) = p.get("path").and_then(Value::as_str) else { continue }; + let Some(id) = state.projects.iter().find(|x| x.path == path).map(|x| x.id.clone()) else { continue }; + keys.retain(|k| !is_project_key_of(k, &id)); + keys.extend(project_keys(&id, &sides_in(p))); + } + for a in doc.get("storeApps").and_then(Value::as_array).into_iter().flatten() { + let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str), a.get("appId").and_then(Value::as_str)) else { continue }; + let Some(acct) = account_for(&state.accounts, store, a.get("account").and_then(Value::as_str)) else { continue }; + keys.retain(|k| !app_key_matches(k, store, app_id)); + if sides_in(a).iter().any(|s| s == store) { + keys.push(format!("app:{}:{acct}:{app_id}", word_of(store))); + } + } + let mut seen = std::collections::HashSet::new(); + keys.retain(|k| seen.insert(k.clone())); + if keys == before { + return false; + } + state.overrides.ignored = keys; + true +} + +/// The app's ignore keys -> registry flags (only the apps the registry lists). Writes when something changed. +pub fn push_ignored(state: &State, file: &Path) -> Result { + let Some(mut doc) = parse_doc(file) else { return Ok(false) }; + let keys = &state.overrides.ignored; + let mut changed = false; + let mut set = |entry: &mut Value, sides: Vec| { + if sides_in(entry) == sides { + return; + } + changed = true; + if let Some(o) = entry.as_object_mut() { + if sides.is_empty() { + o.remove("ignored"); + } else { + o.insert("ignored".into(), serde_json::json!(sides)); + } + } + }; + if let Some(list) = doc.get_mut("projects").and_then(Value::as_array_mut) { + for p in list { + let Some(id) = p.get("path").and_then(Value::as_str).and_then(|path| state.projects.iter().find(|x| x.path == path)).map(|x| x.id.clone()) else { continue }; + set(p, project_sides(keys, &id)); + } + } + if let Some(list) = doc.get_mut("storeApps").and_then(Value::as_array_mut) { + for a in list { + let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str).map(str::to_string), a.get("appId").and_then(Value::as_str).map(str::to_string)) else { continue }; + if account_for(&state.accounts, &store, a.get("account").and_then(Value::as_str)).is_none() { + continue; + } + let on = keys.iter().any(|k| app_key_matches(k, &store, &app_id)); + set(a, if on { vec![store] } else { vec![] }); + } + } + if changed { + let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n"; + let tmp = file.with_extension(format!("tmp-{}", std::process::id())); + fs::write(&tmp, text).map_err(|e| e.to_string())?; + fs::rename(&tmp, file).map_err(|e| e.to_string())?; + } + Ok(changed) +} diff --git a/apps/desktop/src-tauri/tests/machine.rs b/apps/desktop/src-tauri/tests/machine.rs index 3dde508..5e58f27 100644 --- a/apps/desktop/src-tauri/tests/machine.rs +++ b/apps/desktop/src-tauri/tests/machine.rs @@ -207,3 +207,40 @@ fn a_folder_that_pins_another_storebound_says_so() { assert!(!pin_differs(spec, "0.8.0"), "{spec}"); } } + +#[test] +fn ignored_flags_travel_between_the_registry_and_the_apps_ignore_keys() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("projects.json"); + // as `storebound apps ignore` wrote it: one folder fully, one store side of another, one store-only app + let (one, two) = (dir.path().join("one").to_string_lossy().to_string(), dir.path().join("two").to_string_lossy().to_string()); + let doc = json!({ "version": 1, "projects": [ + { "id": "aaaaaaaaaaaaaaaa", "path": one, "name": "One", "added": "t", "ignored": ["apple", "play"] }, + { "id": "bbbbbbbbbbbbbbbb", "path": two, "name": "Two", "added": "t", "ignored": ["play"] }], + "storeApps": [{ "store": "apple", "appId": "6400000999", "account": "teamA", "name": "Demo", "ignored": ["apple"] }] }); + std::fs::write(&file, doc.to_string()).unwrap(); + let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", &one, None), project("bbbbbbbbbbbbbbbb", &two, None)], ..State::default() }; + st.accounts = vec![account("acct1", "Team A", AccountKind::Apple, Some("teamA"))]; + st.overrides.ignored = vec!["account:zzz".into(), "app:ios:other:6400000999".into()]; + assert!(registry::pull_ignored(&mut st, &file)); + assert!(st.overrides.ignored.contains(&"account:zzz".to_string())); + assert!(st.overrides.ignored.contains(&"project:aaaaaaaaaaaaaaaa".to_string())); + assert!(st.overrides.ignored.contains(&"project:bbbbbbbbbbbbbbbb:android".to_string())); + assert!(st.overrides.ignored.contains(&"app:ios:acct1:6400000999".to_string())); + assert!(!st.overrides.ignored.contains(&"app:ios:other:6400000999".to_string())); + assert!(!registry::pull_ignored(&mut st, &file), "second pull changes nothing"); + // the owner restores one folder's play side and ignores the other's apple side in the app + st.overrides.ignored.retain(|k| k != "project:bbbbbbbbbbbbbbbb:android" && k != "app:ios:acct1:6400000999"); + st.overrides.ignored.push("project:bbbbbbbbbbbbbbbb:ios".into()); + assert!(registry::push_ignored(&st, &file).unwrap()); + let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(doc["projects"][0]["ignored"], json!(["apple", "play"])); + assert_eq!(doc["projects"][1]["ignored"], json!(["apple"])); + assert!(doc["storeApps"][0].get("ignored").is_none()); + assert!(!registry::push_ignored(&st, &file).unwrap()); + // writing the folder list keeps the flags of the folders that stay + let list = registry::read(&file).unwrap(); + registry::write(&file, &list).unwrap(); + let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(doc["projects"][1]["ignored"], json!(["apple"])); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6538f00..3c53ec8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -13,6 +13,7 @@ import { AppFolders } from './screens/AppFolders'; import { AppView } from './screens/AppView'; import { Dependencies } from './screens/Dependencies'; import { Ignored } from './screens/Ignored'; +import { useShowIgnored } from './lib/show-ignored'; import { Onboarding } from './screens/Onboarding'; import { Portfolio } from './screens/Portfolio'; import { Settings } from './screens/Settings'; @@ -47,6 +48,9 @@ export function App({ onTheme }: { onTheme: () => void }) { const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []); const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); + const [showIgnored, setShowIgnored] = useShowIgnored(); + const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); + const listed = showIgnored ? [...visible, ...ignoredApps] : visible; const toggleGroup = (id: string) => setCollapsed(c => { const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id]; try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ } @@ -142,7 +146,7 @@ export function App({ onTheme }: { onTheme: () => void }) { useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]); const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]); const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined); - const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }]; + const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }]; const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length; const appItem = (a: UnifiedApp) => { @@ -152,10 +156,12 @@ export function App({ onTheme }: { onTheme: () => void }) { const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); return (
-
); })} - {!visible.length &&

{t('nav.noApps')}

} + {!visible.length && !showIgnored &&

{t('nav.noApps')}

} + {ignoredApps.length > 0 && }
{t('nav.workspace')}
{WORKSPACE.map((s, i) => ( } - + {sides.length === 2 && sides.map(k => { + const off = app.ignored || app.ignoredStores.includes(k); + const store = k === 'ios' ? 'App Store' : 'Google Play'; + return ; + })} + {app.ignored + ? + : } ); } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 57c9a49..3a3ac39 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -1,7 +1,7 @@ // Ignored: apps and whole store accounts left out of the portfolio, the sidebar and store reads. Restore brings them // back (nothing on the stores changes either way). import { t } from '../i18n'; -import { withIgnored } from '../lib/identity'; +import { withIgnored, withSideIgnored } from '../lib/identity'; import { useNav } from '../nav'; import { useData } from '../store'; import { Icon, StoreGlyph } from '../ui/icons'; @@ -12,7 +12,7 @@ import '../styles/folders.css'; export function Ignored() { const d = useData(); const nav = useNav(); - const apps = d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)); + const apps = d.apps.filter(a => a.ignored || a.ignoredStores.length > 0).sort((a, b) => a.name.localeCompare(b.name)); const accounts = d.accounts.filter(a => d.overrides.ignored.includes(`account:${a.id}`)); const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] })); const empty = !apps.length && !accounts.length; @@ -37,7 +37,8 @@ export function Ignored() { {a.android && {a.android.app.id}} - + {a.ignoredStores.map(k => )} + {a.ignored && } ); })} diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 41cc6f3..14ff770 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -8,7 +8,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; -import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity'; +import { useShowIgnored } from '../lib/show-ignored'; +import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp, withIgnored } from '../lib/identity'; import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; import { type AppTab, useNav } from '../nav'; import type { IconName } from '../ui/icons'; @@ -282,6 +283,8 @@ export function Portfolio() { const [query, setQuery] = useState(''); const setView = (v: Partial) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; }); const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]); + const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored), [d.apps]); + const [showIgnored, setShowIgnored] = useShowIgnored(); const [round, setRound] = useState(0); const [rechecking, setRechecking] = useState(false); const health = useCachedHealth(visible.map(a => a.target), round); @@ -427,6 +430,18 @@ export function Portfolio() { setView({ layout })} label={t('pf.layout')} options={[['cards', t('pf.cards'), 'grid'], ['table', t('pf.table'), 'layers']]} /> + {ignoredApps.length > 0 && ( +
+ + {showIgnored && ignoredApps.map(a => ( + + + + + ))} +
+ )} + {view.layout === 'cards' && (rows.length ? grouped.map(g => { const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null; return ( diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index cca9d6d..845e577 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -59,7 +59,8 @@ export function classifyRead(result: unknown, error: unknown, task: ReadTask { expect([linked.ios?.app.id, linked.android?.app.id, linked.pairing]).toEqual(['102', 'com.example.orbit', 'folder']); expect(apps.some(a => a.name === 'Orbit Hop' && !a.project)).toBe(false); }); + test('ignore one store side: a folder hides only that store, both sides is the whole app, restore splits it', () => { + const base = byName(build(), 'Prism Puddle'); + expect([base.ios?.app.id, base.android?.app.id, base.ignored, base.ignoredStores]).toEqual(['101', 'com.example.prism', false, []]); + const noIos = withSideIgnored(NO_OVERRIDES, base, 'ios', true); + expect(noIos.ignored).toEqual(['project:p1:ios']); + const half = byName(build(noIos), 'Prism Puddle'); + expect([half.ios, half.android?.app.id, half.ignored, half.ignoredStores]).toEqual([null, 'com.example.prism', false, ['ios']]); + expect(half.sideKeys.ios).toBe('project:p1:ios'); + // the hidden iOS app does not come back as a store-only app of its own + expect(build(noIos).filter(a => a.ios?.app.id === '101')).toHaveLength(0); + const both = withSideIgnored(noIos, half, 'android', true); + expect(both.ignored).toEqual(['project:p1']); + const whole = byName(build(both), 'Prism Puddle'); + expect([whole.ignored, whole.ignoredStores]).toEqual([true, []]); + const back = withSideIgnored(both, whole, 'ios', false); + expect(back.ignored.sort()).toEqual(['project:p1:android']); + expect(byName(build(back), 'Prism Puddle').ignoredStores).toEqual(['android']); + // restoring the whole app clears every key of it + expect(withIgnored(both, whole.ignoreKeys, false).ignored).toEqual([]); + }); + test('ignore one side of a paired store-only app; ignoring both sides ignores the app', () => { + const orbit = byName(build(), 'Orbit Hop'); + expect(orbit.ios && orbit.android).toBeTruthy(); + const o = withSideIgnored(NO_OVERRIDES, orbit, 'android', true); + const half = byName(build(o), 'Orbit Hop'); + expect([half.ios?.app.id, half.android, half.ignored, half.ignoredStores]).toEqual(['102', null, false, ['android']]); + const all = withSideIgnored(o, half, 'ios', true); + const full = byName(build(all), 'Orbit Hop'); + expect(full.ignored).toBe(true); + expect(full.ios && full.android).toBeTruthy(); + }); test('ignore: an app (all its store keys) or a whole account; studio moves', () => { const tycoon = byName(build(), 'Pocket Tycoon'); const o = withIgnored(NO_OVERRIDES, tycoon.ignoreKeys, true); diff --git a/cli/lib/identity.ts b/cli/lib/identity.ts index 7a9763b..192e718 100644 --- a/cli/lib/identity.ts +++ b/cli/lib/identity.ts @@ -46,7 +46,12 @@ export interface UnifiedApp

{ pairing: Pairing; /** The keys an "ignore" puts on the list ("project:" or "app:"). */ ignoreKeys: string[]; + /** Ignored entirely (every store side it has). */ ignored: boolean; + /** Store sides left out while the other side stays (a half-ignored app): their ref is dropped from ios / android. */ + ignoredStores: ('ios' | 'android')[]; + /** The ignore key of each side (null: no such side), for the per-store toggles. */ + sideKeys: { ios: string | null; android: string | null }; /** Store icon (data URL) when the folder has none. */ storeIcon: string | null; } @@ -115,9 +120,17 @@ export function buildApps

(i: BuildInput

): UnifiedApp

[ const studio = studioOverride.get(`project:${p.id}`) ?? (p.appleAccount && accountStudio.get(p.appleAccount)) ?? (p.playAccount && accountStudio.get(p.playAccount)) ?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null; + // ignored: the whole folder, or every store side it has; one side alone hides just that store (project::ios | :android) + const whole = ignored.has(`project:${p.id}`); + const sideOff = { ios: whole || ignored.has(`project:${p.id}:ios`), android: whole || ignored.has(`project:${p.id}:android`) }; + const has = { ios: !!ios || !!s?.apple || !!p.appleAccount, android: !!android || !!s?.play || !!p.playAccount }; + const present = (['ios', 'android'] as const).filter(k => has[k]); + const allOff = whole || (present.length > 0 && present.every(k => sideOff[k])); + const hidden = allOff ? [] : present.filter(k => sideOff[k]); out.push({ - key: p.id, target: p.id, name: p.name, project: p, ios, android, studio, pairing: 'folder', - ignoreKeys: [`project:${p.id}`], ignored: ignored.has(`project:${p.id}`), storeIcon: ios?.app.icon ?? android?.app.icon ?? null, + key: p.id, target: p.id, name: p.name, project: p, ios: hidden.includes('ios') ? null : ios, android: hidden.includes('android') ? null : android, studio, pairing: 'folder', + ignoreKeys: [`project:${p.id}`, `project:${p.id}:ios`, `project:${p.id}:android`], ignored: allOff, ignoredStores: hidden, sideKeys: { ios: `project:${p.id}:ios`, android: `project:${p.id}:android` }, + storeIcon: ios?.app.icon ?? android?.app.icon ?? null, }); } @@ -147,9 +160,14 @@ export function buildApps

(i: BuildInput

): UnifiedApp

[ const keys = [ios, android].filter((r): r is StoreRef => !!r).map(r => r.key); const studio = keys.map(k => studioOverride.get(k)).find(Boolean) ?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null; + // a paired app is ignored when every side is; one ignored side alone hides just that store + const off = (r: StoreRef | null) => !!r && ignored.has(`app:${r.key}`); + const allOff = [ios, android].filter(Boolean).length > 0 && [ios, android].every(r => !r || off(r)); + const hidden = allOff ? [] : (['ios', 'android'] as const).filter(k => off(k === 'ios' ? ios : android)); out.push({ - key: target, target, name: ios?.app.name ?? android?.app.name ?? '?', project: null, ios, android, studio, pairing, - ignoreKeys: keys.map(k => `app:${k}`), ignored: keys.some(k => ignored.has(`app:${k}`)), storeIcon: ios?.app.icon ?? android?.app.icon ?? null, + key: target, target, name: ios?.app.name ?? android?.app.name ?? '?', project: null, ios: hidden.includes('ios') ? null : ios, android: hidden.includes('android') ? null : android, studio, pairing, + ignoreKeys: keys.map(k => `app:${k}`), ignored: allOff, ignoredStores: hidden, sideKeys: { ios: ios ? `app:${ios.key}` : null, android: android ? `app:${android.key}` : null }, + storeIcon: ios?.app.icon ?? android?.app.icon ?? null, }); }; for (const [a, b, how] of pairs) storeOnly(a, b, how); @@ -180,6 +198,21 @@ export function withUnpair(o: Overrides, ios: string, android: string): Override return { ...o, pairs: o.pairs.filter(p => !samePair(p, ios, android)), unpairs: [...o.unpairs.filter(p => !samePair(p, ios, android)), { ios, android }] }; } +/** Overrides after ignoring (or restoring) ONE store side of an app. A whole-folder key is split into its sides first. */ +export function withSideIgnored(o: Overrides, app: UnifiedApp, side: 'ios' | 'android', on: boolean): Overrides { + const key = app.sideKeys[side]; + if (!key) return o; + const set = new Set(o.ignored); + if (app.project) { + const whole = `project:${app.project.id}`; + if (set.delete(whole)) { set.add(app.sideKeys.ios!); set.add(app.sideKeys.android!); } + // both sides ignored is the same as the whole folder + if (on) set.add(key); else set.delete(key); + if (set.has(app.sideKeys.ios!) && set.has(app.sideKeys.android!)) { set.delete(app.sideKeys.ios!); set.delete(app.sideKeys.android!); set.add(whole); } + } else if (on) set.add(key); else set.delete(key); + return { ...o, ignored: [...set] }; +} + export function withIgnored(o: Overrides, keys: string[], on: boolean): Overrides { const set = new Set(o.ignored); for (const k of keys) { if (on) set.add(k); else set.delete(k); }