Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52)
This commit is contained in:
parent
d2ce185441
commit
e5f148e534
55 changed files with 1648 additions and 158 deletions
|
|
@ -1091,9 +1091,21 @@ pub async fn store_apps_refresh(window: WebviewWindow, app: AppHandle, state: St
|
|||
let value = r.call_job("apps.list", json!({ "credentials": creds, "args": { "icons": icons } }), job.as_deref()).await?;
|
||||
let at = store::now_iso();
|
||||
state.cache.put(&format!("acct:{id}"), "apps.list", &json!({}), &value, &at).map_err(Fail::host)?;
|
||||
let st = saved(&state)?;
|
||||
let value = match st.accounts.iter().find(|a| a.id == id) {
|
||||
Some(a) => store_only_into(&st.accounts, a, Some(value.clone()), ®istry::read_store_apps(&state.registry), &at).unwrap_or(value),
|
||||
None => value,
|
||||
};
|
||||
Ok(Entry { value, at })
|
||||
}
|
||||
|
||||
/// One account's app list with the machine's store-only apps of its credential profile added (registry.rs).
|
||||
fn store_only_into(all: &[Account], a: &Account, list: Option<Value>, apps: &[registry::StoreOnly], at: &str) -> Option<Value> {
|
||||
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
|
||||
let only = all.iter().filter(|x| x.kind == a.kind).count() == 1;
|
||||
registry::with_store_only(list, store, a.profile.as_deref(), only, apps, at)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountApps {
|
||||
account: String,
|
||||
|
|
@ -1104,11 +1116,19 @@ pub struct AccountApps {
|
|||
#[tauri::command]
|
||||
pub fn store_apps_cached(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<AccountApps>> {
|
||||
guard(&window)?;
|
||||
let ids: Vec<String> = saved(&state)?.accounts.iter().map(|a| a.id.clone()).collect();
|
||||
ids.into_iter()
|
||||
.map(|account| {
|
||||
let entry = state.cache.get(&format!("acct:{account}"), "apps.list", &json!({})).map_err(Fail::host)?;
|
||||
Ok(AccountApps { account, entry })
|
||||
let accounts = saved(&state)?.accounts.clone();
|
||||
let store_only = registry::read_store_apps(&state.registry);
|
||||
accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let entry = state.cache.get(&format!("acct:{}", a.id), "apps.list", &json!({})).map_err(Fail::host)?;
|
||||
// apps live on a store with no project on this machine (`storebound apps add --store … --app-id …`)
|
||||
let at = entry.as_ref().map(|e| e.at.clone()).unwrap_or_else(store::now_iso);
|
||||
let entry = match store_only_into(&accounts, a, entry.as_ref().map(|e| e.value.clone()), &store_only, &at) {
|
||||
Some(value) => Some(Entry { value, at }),
|
||||
None => entry,
|
||||
};
|
||||
Ok(AccountApps { account: a.id.clone(), entry })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
//! in state.json under the same id. `sync` runs at start, when the list is shown and before a deep link is handled;
|
||||
//! adding, removing and locating a folder in the app write the registry too. The first start with a registry missing
|
||||
//! writes the app's own list into it.
|
||||
//! { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }] }
|
||||
//! { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }],
|
||||
//! "storeApps": [{ "id": "<16 hex>", "store": "apple", "appId": "6400000000", "account": "<profile>", "name": "…" }] }
|
||||
//! `storeApps` (written by `storebound apps add --store … --app-id …`) are apps live on a store with no project on this
|
||||
//! machine: they join the app list of the account with that credential profile (`store_apps_cached`), so the
|
||||
//! portfolio shows them read-only ("store only"). Writing the folder list keeps them.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -69,12 +73,87 @@ pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> {
|
|||
if let Some(d) = file.parent() {
|
||||
fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&serde_json::json!({ "version": 1, "projects": entries })).map_err(|e| e.to_string())? + "\n";
|
||||
// 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 });
|
||||
if let Some(s) = keep {
|
||||
doc["storeApps"] = s;
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
/// An app that is live on a store with no project on this machine (cli/lib/registry.ts StoreOnlyApp).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StoreOnly {
|
||||
pub id: String,
|
||||
/// "apple" | "play"
|
||||
pub store: String,
|
||||
/// Apple: the numeric app id; Play: the package name.
|
||||
pub app_id: String,
|
||||
/// The credential profile that reads it; None = the account of that store when there is only one.
|
||||
pub account: Option<String>,
|
||||
pub name: String,
|
||||
pub bundle_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The store-only entries of the file's text (pure): malformed ones are dropped.
|
||||
pub fn parse_store_apps(text: &str) -> Vec<StoreOnly> {
|
||||
let Ok(v) = serde_json::from_str::<Value>(text) else { return vec![] };
|
||||
let mut out: Vec<StoreOnly> = vec![];
|
||||
for x in v.get("storeApps").and_then(Value::as_array).into_iter().flatten() {
|
||||
let s = |k: &str| x.get(k).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let (Some(store), Some(app_id)) = (s("store"), s("appId")) else { continue };
|
||||
let ok = match store.as_str() {
|
||||
"apple" => app_id.len() >= 6 && app_id.len() <= 12 && app_id.chars().all(|c| c.is_ascii_digit()),
|
||||
"play" => app_id.contains('.') && app_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_'),
|
||||
_ => false,
|
||||
};
|
||||
if !ok || out.iter().any(|o| o.store == store && o.app_id == app_id) {
|
||||
continue;
|
||||
}
|
||||
let id: String = Sha256::digest(format!("store:{store}:{app_id}").as_bytes()).iter().take(8).map(|b| format!("{b:02x}")).collect();
|
||||
let bundle_id = s("bundleId").or_else(|| (store == "play").then(|| app_id.clone()));
|
||||
out.push(StoreOnly { id, name: s("name").unwrap_or_else(|| app_id.clone()), account: s("account"), bundle_id, store, app_id });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn read_store_apps(file: &Path) -> Vec<StoreOnly> {
|
||||
fs::read_to_string(file).map(|t| parse_store_apps(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One account's app list (`apps.list` JSON, or None when never read) with the store-only entries of its profile
|
||||
/// added (pure). `store` is "apple" | "play"; `only` = this is the only account of that store (entries that name no
|
||||
/// profile go to it). The entries already in the list (same id) are left as the store reported them.
|
||||
pub fn with_store_only(list: Option<Value>, store: &str, profile: Option<&str>, only: bool, apps: &[StoreOnly], at: &str) -> Option<Value> {
|
||||
let mine: Vec<&StoreOnly> = apps.iter().filter(|a| a.store == store && (a.account.as_deref().is_some_and(|p| Some(p) == profile) || (a.account.is_none() && only))).collect();
|
||||
if mine.is_empty() {
|
||||
return list;
|
||||
}
|
||||
let ios = store == "apple";
|
||||
let mut v = list.unwrap_or_else(|| serde_json::json!({ "store": if ios { "ios" } else { "android" }, "apps": [], "warnings": [], "at": at }));
|
||||
if !v.get("apps").is_some_and(Value::is_array) {
|
||||
v["apps"] = serde_json::json!([]);
|
||||
}
|
||||
let have: Vec<String> = v["apps"].as_array().map(|a| a.iter().filter_map(|x| x.get("id").and_then(Value::as_str).map(str::to_string)).collect()).unwrap_or_default();
|
||||
for a in mine {
|
||||
if have.contains(&a.app_id) {
|
||||
continue;
|
||||
}
|
||||
let app = serde_json::json!({
|
||||
"store": if ios { "ios" } else { "android" }, "id": a.app_id, "bundleId": a.bundle_id.clone().unwrap_or_default(), "name": a.name,
|
||||
"platforms": [if ios { "IOS" } else { "ANDROID" }], "sku": null, "primaryLocale": null, "latest": null, "icon": null, "registered": true,
|
||||
});
|
||||
if let Some(list) = v["apps"].as_array_mut() {
|
||||
list.push(app);
|
||||
}
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn entry_of(p: &Project) -> Entry {
|
||||
Entry { id: p.id.clone(), path: p.path.clone(), name: p.name.clone(), added: p.added.clone() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,40 @@ fn the_folder_list_is_the_machines() {
|
|||
assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::<Vec<_>>(), vec![registry::folder_id("/a")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_only_apps_join_their_profiles_account_and_survive_folder_writes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("projects.json");
|
||||
// as `storebound apps add --store apple --app-id 6400000999 --account teamA` and a Play one without a profile wrote it
|
||||
std::fs::write(&file, r#"{"version":1,"projects":[],"storeApps":[
|
||||
{"id":"x","store":"apple","appId":"6400000999","account":"teamA","name":"Example Aquarium","bundleId":"com.example.aquarium","added":"t"},
|
||||
{"store":"play","appId":"com.example.legacy","name":"Legacy"},
|
||||
{"store":"apple","appId":"not-a-number"}]}"#).unwrap();
|
||||
let apps = registry::read_store_apps(&file);
|
||||
assert_eq!(apps.iter().map(|a| a.app_id.as_str()).collect::<Vec<_>>(), vec!["6400000999", "com.example.legacy"]);
|
||||
assert_eq!(apps[1].bundle_id.as_deref(), Some("com.example.legacy"));
|
||||
// the app writes its folder list: the store-only entries stay
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", None)], ..State::default() };
|
||||
registry::publish(&st, &file, &[]).unwrap();
|
||||
assert_eq!(registry::read_store_apps(&file).len(), 2);
|
||||
assert!(registry::sync(&mut st, &file).is_ok());
|
||||
assert_eq!(registry::read_store_apps(&file).len(), 2);
|
||||
// an account of profile teamA that never listed its apps: the entry shows as its app, read-only, no folder
|
||||
let v = registry::with_store_only(None, "apple", Some("teamA"), false, &apps, "t").unwrap();
|
||||
assert_eq!(v["store"], "ios");
|
||||
assert_eq!(v["apps"][0]["id"], "6400000999");
|
||||
assert_eq!(v["apps"][0]["bundleId"], "com.example.aquarium");
|
||||
assert_eq!(v["apps"][0]["registered"], true);
|
||||
// an account of another profile gets nothing; the list the store returned is kept, the entry not doubled
|
||||
assert_eq!(registry::with_store_only(None, "apple", Some("teamB"), false, &apps, "t"), None);
|
||||
let listed = serde_json::json!({ "store": "ios", "apps": [{ "id": "6400000999", "name": "From the store" }], "warnings": [], "at": "t" });
|
||||
assert_eq!(registry::with_store_only(Some(listed.clone()), "apple", Some("teamA"), false, &apps, "t"), Some(listed));
|
||||
// no profile named: the only Play account takes it
|
||||
let p = registry::with_store_only(None, "play", Some("any"), true, &apps, "t").unwrap();
|
||||
assert_eq!(p["apps"][0]["name"], "Legacy");
|
||||
assert_eq!(registry::with_store_only(None, "play", Some("any"), false, &apps, "t"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_pins_another_storebound_says_so() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -208,7 +208,9 @@ fn log_entries_are_one_block_and_rotate() {
|
|||
/// next to it as `<name>.host.json`, after the start-up sync with the machine's shared stores: D1 keeps its accounts in a
|
||||
/// keychain that has no entries (the keys were never added: the accounts stay without a profile), D3's keychain has
|
||||
/// them (they move into credential profiles), and shared-profiles is the 0.8 format whose apple account comes from a
|
||||
/// profile the CLI wrote, with a folder that pins storebound 0.6.0. tools/desktop-boot-check.ts boots the built window on exactly these answers.
|
||||
/// profile the CLI wrote, with a folder that pins storebound 0.6.0 and a store-only app of that profile (`storebound
|
||||
/// apps add --store apple --app-id … --account default`: store_apps_cached lists it under the apple account).
|
||||
/// tools/desktop-boot-check.ts boots the built window on exactly these answers.
|
||||
/// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state
|
||||
#[test]
|
||||
fn host_answers_for_the_fixtures_are_current() {
|
||||
|
|
@ -240,9 +242,22 @@ fn host_answers_for_the_fixtures_are_current() {
|
|||
let pin = project_view(&p).storebound_pin.expect("0.6.0 is not this app");
|
||||
projects[0]["storeboundPin"] = json!(pin);
|
||||
}
|
||||
// store_apps_cached before any store read: only the machine's store-only entries (registry.rs)
|
||||
let store_only = if name == "shared-profiles" {
|
||||
registry::parse_store_apps(r#"{"storeApps":[{"store":"apple","appId":"6400000999","account":"default","name":"Example Aquarium","bundleId":"com.example.aquarium"}]}"#)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let cached: Vec<serde_json::Value> = st.accounts.iter().map(|a| {
|
||||
let store = match a.kind { AccountKind::Apple => "apple", AccountKind::Play => "play" };
|
||||
let only = st.accounts.iter().filter(|x| x.kind == a.kind).count() == 1;
|
||||
let entry = registry::with_store_only(None, store, a.profile.as_deref(), only, &store_only, "2026-10-13T00:00:00Z").map(|value| json!({ "value": value, "at": "2026-10-13T00:00:00Z" }));
|
||||
json!({ "account": a.id, "entry": entry })
|
||||
}).collect();
|
||||
let host = json!({
|
||||
"projects_list": projects,
|
||||
"accounts_list": st.accounts,
|
||||
"store_apps_cached": cached,
|
||||
"workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings },
|
||||
});
|
||||
let want = format!("{}\n", serde_json::to_string_pretty(&host).unwrap());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue