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:
parent
7c769c18f4
commit
4a74989a95
33 changed files with 311 additions and 40 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue