Open in Google Play Console deep-links the app: Play developer id per credential profile (accounts play-developer, desktop Accounts), shared playAppUrl builder for desktop, CLI and watch; developer chooser instead of the console root

This commit is contained in:
CRTkafa 2026-10-23 09:20:35 +08:00
parent 7c769c18f4
commit 4a74989a95
33 changed files with 311 additions and 40 deletions

View file

@ -35,6 +35,7 @@
"allow-studio-remove",
"allow-studio-logo",
"allow-account-update",
"allow-account-set-play-developer",
"allow-account-check",
"allow-store-apps-refresh",
"allow-store-apps-cached",

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-account-set-play-developer"
description = "Enables the account_set_play_developer command without any pre-configured scope."
commands.allow = ["account_set_play_developer"]
[[permission]]
identifier = "deny-account-set-play-developer"
description = "Denies the account_set_play_developer command without any pre-configured scope."
commands.deny = ["account_set_play_developer"]

View file

@ -31,6 +31,10 @@ pub struct Account {
/// None only for an account of an earlier version whose keychain entry has not moved yet.
#[serde(default)]
pub profile: Option<String>,
/// Play only: the developer account id (PLAY_DEVELOPER_ID in its credential profile, shared with the CLI), for
/// Play Console deep links. Read from the profile on every list; never a secret.
#[serde(default, rename = "developerId", skip_serializing_if = "Option::is_none")]
pub developer_id: Option<String>,
}
/// The keychain entry of one account (JSON).

View file

@ -803,7 +803,11 @@ pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, i
pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> {
guard(&window)?;
sync_machine(&state)?;
Ok(saved(&state)?.accounts.clone())
let mut out = saved(&state)?.accounts.clone();
for a in out.iter_mut().filter(|a| a.kind == accounts::AccountKind::Play) {
a.developer_id = a.profile.as_deref().and_then(|p| state.profiles.play_developer_id(p));
}
Ok(out)
}
fn check_studio(state: &AppState, studio: &Option<String>) -> Res<()> {
@ -834,7 +838,7 @@ fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<Str
}
let id = profiles::account_id(secret.kind(), &profile);
let id = if st.accounts.iter().any(|a| a.id == id) { store::new_id()? } else { id };
let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile) };
let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile), developer_id: None };
st.accounts.push(acc.clone());
persist(state, &st)?;
Ok(acc)
@ -1135,6 +1139,23 @@ pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: Str
Ok(out)
}
/// Sets or clears the Play developer account id of a Play account (PLAY_DEVELOPER_ID in its credential profile, the
/// same field `storebound accounts play-developer` writes): Play Console links then open each app's own page.
#[tauri::command]
pub fn account_set_play_developer(window: WebviewWindow, state: State<'_, AppState>, id: String, developer_id: Option<String>) -> Res<Account> {
guard(&window)?;
let st = saved(&state)?;
let mut a = st.accounts.iter().find(|a| a.id == id).cloned().ok_or_else(|| Fail::host("unknown account"))?;
if a.kind != accounts::AccountKind::Play {
return Err(Fail::host("a Play account only"));
}
let profile = a.profile.clone().ok_or_else(|| Fail::host("this account has no credential profile yet"))?;
let v = developer_id.as_deref().map(str::trim).filter(|v| !v.is_empty());
state.profiles.set_play_developer_id(&profile, v).map_err(Fail::host)?;
a.developer_id = state.profiles.play_developer_id(&profile);
Ok(a)
}
/// Does the account's key work, and can it see apps (sidecar `account.check`). Not cached: it is a live answer.
#[tauri::command]
pub async fn account_check(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String, job: Option<String>) -> Res<Value> {

View file

@ -27,6 +27,7 @@ pub const COMMANDS: &[&str] = &[
"studio_remove",
"studio_logo",
"account_update",
"account_set_play_developer",
"account_check",
"store_apps_refresh",
"store_apps_cached",

View file

@ -344,6 +344,7 @@ pub fn run() {
commands::studio_remove,
commands::studio_logo,
commands::account_update,
commands::account_set_play_developer,
commands::account_check,
commands::store_apps_refresh,
commands::store_apps_cached,

View file

@ -56,6 +56,11 @@ pub fn slug(label: &str) -> String {
/// The CLI workspace's account id for a profile's store: sha256("<apple|play>:<profile>"), 16 hex (cli/lib/workspace.ts
/// accountId), so the same account has the same id in `storebound.workspace.json` and in the app.
/// A Play developer account id: 1-25 digits.
pub fn valid_developer_id(v: &str) -> bool {
!v.is_empty() && v.len() <= 25 && v.bytes().all(|b| b.is_ascii_digit())
}
pub fn account_id(kind: AccountKind, profile: &str) -> String {
let store = match kind {
AccountKind::Apple => "apple",
@ -139,6 +144,24 @@ impl ProfileStore {
out
}
/// The Play developer account id a profile keeps (PLAY_DEVELOPER_ID, digits), if any.
pub fn play_developer_id(&self, name: &str) -> Option<String> {
self.read(name).ok().and_then(|m| str_of(&m, "PLAY_DEVELOPER_ID").map(str::trim).filter(|v| valid_developer_id(v)).map(str::to_string))
}
/// Sets or clears (None) PLAY_DEVELOPER_ID in an existing profile; every other field stays.
pub fn set_play_developer_id(&self, name: &str, id: Option<&str>) -> Result<(), String> {
let mut m = self.read(name)?;
match id {
Some(v) if valid_developer_id(v) => { m.insert("PLAY_DEVELOPER_ID".into(), Value::String(v.to_string())); }
Some(_) => return Err("the Play developer id is the digits in play.google.com/console/u/0/developers/<id>".into()),
None => { m.remove("PLAY_DEVELOPER_ID"); }
}
let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "
";
write_private(&self.dir.join(format!("{name}.json")), text.as_bytes())
}
/// The label the profile carries, if any (written by the app; the CLI ignores it).
pub fn label(&self, name: &str) -> Option<String> {
self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string))
@ -367,7 +390,7 @@ pub fn sync(accounts: &mut Vec<Account>, store: &ProfileStore, legacy: &dyn Secr
if accounts.iter().any(|a| a.id == id) {
continue;
}
accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()) });
accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()), developer_id: None });
r.added.push(format!("{id} ({name})"));
}
}

View file

@ -9,7 +9,7 @@ const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PR
#[test]
fn shown_metadata_carries_no_secret() {
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()) };
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()), developer_id: None };
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "profile", "studio"]);
assert_eq!(v["kind"], "play");

View file

@ -38,7 +38,7 @@ impl SecretStore for MapStore {
}
fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account {
Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string) }
Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string), developer_id: None }
}
#[test]
@ -244,3 +244,19 @@ fn ignored_flags_travel_between_the_registry_and_the_apps_ignore_keys() {
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"]));
}
#[test]
fn a_play_developer_id_is_kept_in_the_profile_for_the_cli() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(dir.path().join("credentials"), None);
store.save("studio", "Studio", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
assert_eq!(store.play_developer_id("studio"), None);
store.set_play_developer_id("studio", Some("5550000000000000001")).unwrap();
assert_eq!(store.play_developer_id("studio").as_deref(), Some("5550000000000000001"));
let m = store.read("studio").unwrap();
assert_eq!(m.get("PLAY_DEVELOPER_ID").and_then(|v| v.as_str()), Some("5550000000000000001"));
assert!(m.contains_key("PLAY_SERVICE_ACCOUNT"));
assert!(store.set_play_developer_id("studio", Some("abc")).is_err());
store.set_play_developer_id("studio", None).unwrap();
assert_eq!(store.play_developer_id("studio"), None);
}