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

This commit is contained in:
CRTkafa 2026-10-23 01:35:27 +08:00
parent 13b91dd423
commit 9610c9477e
14 changed files with 347 additions and 17 deletions

View file

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

View file

@ -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::<Value>(&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::<Value>(&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:<id>" (both) / "project:<id>:ios" / "project:<id>:android"
// storeApps: "ignored": ["apple"] <-> "app:ios:<account id>:<app id>" (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<String> {
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<String> {
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<String> {
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:<ios|android>:<account>:<app id>" 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<String> {
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<Value> {
serde_json::from_str::<Value>(&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<bool, String> {
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<String>| {
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)
}

View file

@ -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"]));
}