diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index 3d1bd60..cd3e8f6 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -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", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/account_set_play_developer.toml b/apps/desktop/src-tauri/permissions/autogenerated/account_set_play_developer.toml new file mode 100644 index 0000000..b80223a --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/account_set_play_developer.toml @@ -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"] diff --git a/apps/desktop/src-tauri/src/accounts.rs b/apps/desktop/src-tauri/src/accounts.rs index f842af7..faf7b9c 100644 --- a/apps/desktop/src-tauri/src/accounts.rs +++ b/apps/desktop/src-tauri/src/accounts.rs @@ -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, + /// 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, } /// The keychain entry of one account (JSON). diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index b295112..aabda11 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -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> { 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) -> Res<()> { @@ -834,7 +838,7 @@ fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option, 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) -> Res { + 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) -> Res { diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index abb8da4..4c2b4cf 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -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", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index f6581b7..4b405a1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/apps/desktop/src-tauri/src/profiles.rs b/apps/desktop/src-tauri/src/profiles.rs index 350b66f..072484f 100644 --- a/apps/desktop/src-tauri/src/profiles.rs +++ b/apps/desktop/src-tauri/src/profiles.rs @@ -56,6 +56,11 @@ pub fn slug(label: &str) -> String { /// The CLI workspace's account id for a profile's store: sha256(":"), 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 { + 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/".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 { 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, 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})")); } } diff --git a/apps/desktop/src-tauri/tests/accounts.rs b/apps/desktop/src-tauri/tests/accounts.rs index de742b5..7bcdca6 100644 --- a/apps/desktop/src-tauri/tests/accounts.rs +++ b/apps/desktop/src-tauri/tests/accounts.rs @@ -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!["created", "id", "kind", "label", "profile", "studio"]); assert_eq!(v["kind"], "play"); diff --git a/apps/desktop/src-tauri/tests/machine.rs b/apps/desktop/src-tauri/tests/machine.rs index 5e58f27..8fc1076 100644 --- a/apps/desktop/src-tauri/tests/machine.rs +++ b/apps/desktop/src-tauri/tests/machine.rs @@ -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); +} diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index a431266..343a570 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -25,7 +25,7 @@ export interface SidecarStatus { running: boolean; version: string; protocol: st /** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */ export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null } export type AccountKind = 'play' | 'apple'; -export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null } +export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null; /** Play: the developer account id (PLAY_DEVELOPER_ID in the profile) for Play Console deep links. */ developerId?: string | null } export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const; export type StudioColor = (typeof STUDIO_COLORS)[number]; /** A user-named group of store accounts (and so of apps). */ @@ -110,6 +110,7 @@ export const api = { addAppleAccount: (label: string, keyId: string, issuerId: string, studio: string | null = null) => invoke('account_add_apple', { label, keyId, issuerId, studio }), removeAccount: (id: string) => invoke('account_remove', { id }), updateAccount: (id: string, label: string, studio: string | null) => invoke('account_update', { id, label, studio }), + setPlayDeveloper: (id: string, developerId: string | null) => invoke('account_set_play_developer', { id, developerId }), /** Does the key work, can it see apps (live, not cached). */ checkAccount: (id: string, job?: string) => invoke('account_check', { id, job: job ?? null }), /** Reads the apps an account can see from its store and keeps the list. */ diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 0b60aa7..003694b 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -565,6 +565,7 @@ async function answer(cmd: string, a: Record): Promise } case 'studio_remove': studios = studios.filter(s => s.id !== a.id); accounts = accounts.map(x => (x.studio === a.id ? { ...x, studio: null } : x)); return null; case 'studio_logo': return null; + case 'account_set_play_developer': { const x = accounts.find(y => y.id === a.id)!; const v = (a.developerId as string | null) ?? null; if (v && !/^\d{1,25}$/.test(v)) throw new Error('the Play developer id is digits'); (x as { developerId?: string | null }).developerId = v; return x; } case 'account_update': { const x = accounts.find(y => y.id === a.id)!; x.label = a.label as string; x.studio = (a.studio as string | null) ?? null; return x; } case 'overrides_set': overrides = a.overrides as Overrides; return overrides; case 'settings_set': settings = a.settings as Settings; return settings; diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index ba5b634..6beb640 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2041,6 +2041,10 @@ export const en = { 'cm.refreshing': "Reading {name} again", 'cm.ignoredNoReads': "Ignored apps are not read", 'cm.ignore': "Ignore", + 'acct.devId': 'Play developer id {id}', + 'acct.noDevId': 'no Play developer id', + 'acct.devIdLabel': 'Play developer account id', + 'acct.devIdHint': "The digits in play.google.com/console/u/0/developers/. With it, Play Console links open each app's own page; the CLI uses it too.", 'cm.more': "More actions", 'cm.showIgnored': "Show ignored apps", 'cm.copyVersion': "Copy version", diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index fd90625..587c795 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2043,6 +2043,10 @@ export const tr: Record = { 'cm.refreshing': "{name} yeniden okunuyor", 'cm.ignoredNoReads': "Yok sayılan uygulamalar okunmaz", 'cm.ignore': "Yok say", + 'acct.devId': 'Play geliştirici kimliği {id}', + 'acct.noDevId': 'Play geliştirici kimliği yok', + 'acct.devIdLabel': 'Play geliştirici hesabı kimliği', + 'acct.devIdHint': 'play.google.com/console/u/0/developers/ içindeki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.', 'cm.more': "Diğer işlemler", 'cm.showIgnored': "Yok sayılanları göster", 'cm.copyVersion': "Sürümü kopyala", diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx index 6d4a244..c0af2d0 100644 --- a/apps/desktop/src/screens/Accounts.tsx +++ b/apps/desktop/src/screens/Accounts.tsx @@ -46,6 +46,8 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | const [edit, setEdit] = useState(false); const [label, setLabel] = useState(a.label); const [studio, setStudio] = useState(a.studio ?? ''); + const [dev, setDev] = useState(a.developerId ?? ''); + const devOk = !dev.trim() || /^\d{1,25}$/.test(dev.trim()); const h = d.accountHealth[a.id]; const list = d.storeApps[a.id]; const ignored = d.overrides.ignored.includes(`account:${a.id}`); @@ -54,7 +56,7 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | setBusy(key); onError(null); try { await fn(); } catch (e) { onError(errorText(e)); } finally { setBusy(null); } }; - const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); await d.reload(); setEdit(false); }); }; + const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); if (a.kind === 'play' && a.profile && dev.trim() !== (a.developerId ?? '')) await api.setPlayDeveloper(a.id, dev.trim() || null); await d.reload(); setEdit(false); }); }; const check = h?.check; const cm = useContextMenu(); return ( @@ -81,6 +83,7 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | {' · '}{a.profile ? {t('acct.profile', { name: a.profile })} : t('acct.noProfile')} {' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')} {used ? ` · ${t('acct.folders', { count: used })}` : ''} + {a.kind === 'play' && (a.developerId ? <> · {t('acct.devId', { id: a.developerId })} : <> · {t('acct.noDevId')})} {check?.principal ? <> · {check.principal} : null} @@ -100,7 +103,10 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | {d.studios.map(s => )} -
+ {a.kind === 'play' && a.profile &&
+ setDev(e.target.value)} placeholder="5550000000000000001" aria-invalid={!devOk} aria-describedby={`adh-${a.id}`} /> + {t('acct.devIdHint')}
} +
)} {check && check.status !== 'ok' && check.fix && ( diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 50e4449..19a047c 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -2,7 +2,7 @@ // app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and // "Start an app folder here" to make it a full app. import { useContextMenu, useMenuButton } from '../ui/ContextMenu'; -import { appMenu } from '../ui/app-menu'; +import { appMenu, playUrlFor } from '../ui/app-menu'; import { useShowIgnored } from '../lib/show-ignored'; import { useState } from 'react'; import { api, errorText } from '../api'; @@ -34,7 +34,6 @@ import { StoreListingEditor } from './editors/StoreListingEditor'; import { useEdits } from '../ui/Editor'; export const ascUrl = (appId: string | null | undefined): string => (appId ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(appId)}/distribution` : 'https://appstoreconnect.apple.com/apps'); -export const PLAY_CONSOLE = 'https://play.google.com/console/'; const PAIRING: Record = { folder: 'pair.folder', manual: 'pair.manual', id: 'pair.id', close: 'pair.close', name: 'pair.name', single: 'pair.single' }; @@ -135,7 +134,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { } links={(ios || android) && <> {ios && } - {android && } + {android && } } all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> diff --git a/apps/desktop/src/screens/tabs/Catalog.tsx b/apps/desktop/src/screens/tabs/Catalog.tsx index 27b38cf..c028a26 100644 --- a/apps/desktop/src/screens/tabs/Catalog.tsx +++ b/apps/desktop/src/screens/tabs/Catalog.tsx @@ -1,7 +1,7 @@ // Catalog: products and subscriptions defined once in the config against both stores (catalog diff): a parity // matrix (present, mismatched, missing, not sold there, not read), and price gaps between the stores per region. import { useContextMenu } from '../../ui/ContextMenu'; -import { copyToast, projectConsoles } from '../../ui/app-menu'; +import { copyToast, playUrlFor, projectConsoles } from '../../ui/app-menu'; import '../../styles/catalog.css'; import type { Project } from '../../api'; import { type Key, t } from '../../i18n'; @@ -110,7 +110,7 @@ export function CatalogTab({ project }: { project: Project }) { { label: t('cm.copyProductId'), icon: 'copy', shortcut: r.id, onSelect: () => copyToast(d, r.id) }, { label: t('cm.showPrices'), icon: 'coins', onSelect: () => nav.go({ screen: 'app', appId: project.id, tab: 'prices' }) }, 'sep', - ...projectConsoles(d, plat?.ios?.ascAppId, !!plat?.ios, !!plat?.android), + ...projectConsoles(d, plat?.ios?.ascAppId, !!plat?.ios, !!plat?.android, playUrlFor(d, d.appByKey(project.id))), ])}>
diff --git a/apps/desktop/src/screens/tabs/Growth.tsx b/apps/desktop/src/screens/tabs/Growth.tsx index e4bbcc9..ed91df8 100644 --- a/apps/desktop/src/screens/tabs/Growth.tsx +++ b/apps/desktop/src/screens/tabs/Growth.tsx @@ -20,7 +20,8 @@ import '../../styles/growth.css'; import { LineChart } from '../../ui/LineChart'; import { RankChart } from '../../ui/RankChart'; import { WriteButton } from '../../ui/WriteButton'; -import { ascUrl, PLAY_CONSOLE } from '../AppView'; +import { ascUrl } from '../AppView'; +import { playUrlFor } from '../../ui/app-menu'; const DONE = ['COMPLETED', 'STOPPED']; const started = (x: PpoTest) => !!x.startDate && !x.endDate && !DONE.includes(x.state); @@ -243,10 +244,11 @@ function Experiments({ app }: { app: UnifiedApp }) { } function PlayExperiments({ app }: { app: UnifiedApp }) { + const d = useData(); const nav = useNav(); return (
{t('gr.playExpSub')}} - actions={}> + actions={}>

{t('gr.playExpText')}

{app.project &&
} diff --git a/apps/desktop/src/screens/tabs/Submit.tsx b/apps/desktop/src/screens/tabs/Submit.tsx index 42adafb..af7b8de 100644 --- a/apps/desktop/src/screens/tabs/Submit.tsx +++ b/apps/desktop/src/screens/tabs/Submit.tsx @@ -19,7 +19,8 @@ import { Icon, StoreGlyph } from '../../ui/icons'; import { Chip, ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit'; import { EmptyState, StatusPill } from '../../ui/ds'; import { OwnerAction } from '../../ui/OwnerAction'; -import { ascUrl, PLAY_CONSOLE } from '../AppView'; +import { ascUrl } from '../AppView'; +import { playUrlFor } from '../../ui/app-menu'; import '../../styles/submit.css'; const SUB_KIND: Record = { @@ -219,7 +220,7 @@ export function PlayProductionCard({ app, project }: { app: UnifiedApp; project: sub={r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.playSub')} actions={<> - + } />
{r.error && } diff --git a/apps/desktop/src/ui/app-menu.ts b/apps/desktop/src/ui/app-menu.ts index 7c76c80..d82ba47 100644 --- a/apps/desktop/src/ui/app-menu.ts +++ b/apps/desktop/src/ui/app-menu.ts @@ -8,9 +8,18 @@ import { setShowIgnored } from '../lib/show-ignored'; import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav'; import type { Data } from '../store'; import type { MenuInput, MenuItem } from './menu-logic'; +import { pickConsoleId, playAppUrl } from '../../../../cli/lib/console-links'; export const ASC_APP = (id: string | null | undefined): string => (id ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(id)}/distribution` : 'https://appstoreconnect.apple.com/apps'); -export const PLAY_HOME = 'https://play.google.com/console/'; +/** The Play Console page of one app (console-links playAppUrl, shared with the CLI and watch): the app's dashboard when + * the developer id (status from the config / profile, else the app's Play account) and the console app id are known, + * else the developer's app list, else the developer chooser. Never the bare console root. */ +export function playUrlFor(d: Data, app: UnifiedApp | null | undefined): string { + const st = app ? d.statusFor(app)?.app?.platforms.android : null; + const accId = app?.android?.account ?? app?.project?.playAccount ?? null; + const acc = accId ? d.accounts.find(a => a.id === accId) : undefined; + return playAppUrl({ developerId: pickConsoleId(st?.developerId, acc?.developerId), appId: st?.appId ?? null }); +} export function openUrl(d: Data, url: string): void { api.openExternal(url).catch(e => d.toast({ kind: 'bad', title: t('link.openFailed'), detail: errorText(e) })); @@ -31,7 +40,7 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: 'sep', (ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [ ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) }, - android && { label: 'Google Play Console', icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) }, + android && { label: 'Google Play Console', icon: 'external', onSelect: () => openUrl(d, playUrlFor(d, app)) }, ] }, p && !p.missing && { label: t('cm.openFolder'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(e => d.toast({ kind: 'bad', title: t('cm.folderFailed'), detail: errorText(e) })); } }, { label: t('cm.copy'), icon: 'copy', submenu: [ @@ -68,7 +77,7 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: export function consoleItem(d: Data, app: UnifiedApp, store: 'ios' | 'android'): MenuItem { return store === 'ios' ? { label: t('cm.openIn', { place: 'App Store Connect' }), icon: 'external', onSelect: () => openUrl(d, ASC_APP(app.ios?.app.id)) } - : { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) }; + : { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, playUrlFor(d, app)) }; } /** Menu of one store release: copy version / build, open the console. */ @@ -82,9 +91,9 @@ export function releaseMenu(d: Data, app: UnifiedApp, store: 'ios' | 'android', } /** Both consoles of an app folder (App Store Connect with the app's id when the status knows it). */ -export function projectConsoles(d: Data, ascAppId: string | null | undefined, ios: boolean, android: boolean): MenuInput { +export function projectConsoles(d: Data, ascAppId: string | null | undefined, ios: boolean, android: boolean, playUrl: string): MenuInput { return [ ios && { label: t('cm.openIn', { place: 'App Store Connect' }), icon: 'external', onSelect: () => openUrl(d, ASC_APP(ascAppId)) }, - android && { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) }, + android && { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, playUrl) }, ]; } diff --git a/apps/desktop/tests/play-link.test.ts b/apps/desktop/tests/play-link.test.ts new file mode 100644 index 0000000..c7e47cf --- /dev/null +++ b/apps/desktop/tests/play-link.test.ts @@ -0,0 +1,19 @@ +// "Open in Google Play Console" from the desktop: the app's developer id comes from its status (config / profile) or +// its Play account; the link is the shared console-links builder. +import { expect, test } from 'bun:test'; +import { playUrlFor } from '../src/ui/app-menu'; + +const DEV = '5550000000000000001', APP = '4970000000000000002'; +const app = { key: 'p1', android: { account: 'play-acme', key: 'k', store: 'android', app: { id: 'com.example.app' } }, project: null } as never; +const data = (status: unknown, devOnAccount: string | null) => ({ statusFor: () => status, accounts: [{ id: 'play-acme', kind: 'play', developerId: devOnAccount }] }) as never; + +test('status ids: the app dashboard', () => { + expect(playUrlFor(data({ app: { platforms: { android: { packageName: 'com.example.app', developerId: DEV, appId: APP } } } }, null), app)) + .toBe(`https://play.google.com/console/u/0/developers/${DEV}/app/${APP}/app-dashboard`); +}); +test('the Play account\'s developer id: its app list', () => { + expect(playUrlFor(data(null, DEV), app)).toBe(`https://play.google.com/console/u/0/developers/${DEV}/app-list`); +}); +test('nothing known: the developer chooser', () => { + expect(playUrlFor(data(null, null), app)).toBe('https://play.google.com/console/u/0/developers'); +}); diff --git a/cli/accounts.ts b/cli/accounts.ts index e08a8bd..e95199a 100644 --- a/cli/accounts.ts +++ b/cli/accounts.ts @@ -10,13 +10,17 @@ // contact and demo account stay in the project // storebound accounts use [--dir ] this project uses that profile ("account" in its // .storebound/credentials.json) +// storebound accounts play-developer | --clear +// the profile's Play developer account id (the digits in +// play.google.com/console/u/0/developers/): Play +// Console links open the app's own page // storebound accounts default | --clear the machine's default profile (a pointer file in the // credentials folder). Order: env > a profile the project // names > the project's own keys > this default > the only // profile (or the one named "default") import { resolve } from 'node:path'; import type { Args } from './lib/args'; -import { applyImport, describeProfiles, planImport } from './lib/accounts'; +import { applyImport, describeProfiles, planImport, setPlayDeveloperId } from './lib/accounts'; import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials'; import { confirm, type Ctx } from './lib/ctx'; import { textTable } from './lib/table'; @@ -38,7 +42,7 @@ export async function run(args: Args): Promise { console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`); return; } - console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : '']))); + console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : '']))); if (pointer && pointer !== def) console.log(`note: the default pointer names "${pointer}", which is not a profile here (storebound accounts default )`); console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`); console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and no default: storebound accounts default , or accounts use in the project)'); @@ -69,6 +73,15 @@ export async function run(args: Args): Promise { console.log(`${tilde(f)}: "account": "${name}"`); return; } + if (sub === 'play-developer') { + const name = args._[1]; + if (!name) throw new Error('storebound accounts play-developer | --clear'); + const id = args.has('clear') ? null : args._[2] ?? null; + if (!args.has('clear') && !id) throw new Error('storebound accounts play-developer : the digits in play.google.com/console/u/0/developers/'); + const f = setPlayDeveloperId(name, id); + console.log(id ? `${tilde(f)}: PLAY_DEVELOPER_ID ${id} (Play Console links of this account's apps open the app's page)` : `${tilde(f)}: Play developer id removed`); + return; + } if (sub === 'default') { if (args.has('clear')) { setDefaultPointer(null); console.log('default profile pointer removed (default: the only profile, or the one named "default")'); return; } const name = args._[1]; @@ -77,5 +90,5 @@ export async function run(args: Args): Promise { console.log(`${tilde(f)}: default profile "${name}" (used by every folder that names no profile and has no keys of its own)`); return; } - throw new Error('usage: storebound accounts [list | import [--dir ] [--name ] [--dry] [--yes] | use | default | default --clear]'); + throw new Error('usage: storebound accounts [list | import [--dir ] [--name ] [--dry] [--yes] | use | play-developer | default | default --clear]'); } diff --git a/cli/commands.ts b/cli/commands.ts index 5ff03ee..fb036bb 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -16,7 +16,7 @@ export const PLAIN: Record Promise, string]> = { 'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'], 'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [] | remove : the app folders of this machine, the same list as the desktop app | ignore|unignore [--store apple|play]: leave a dormant app (or one store side) out of watch, portfolio reads and the desktop lists; storebound only, the store is untouched'], 'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'], - 'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use | default (the machine default; --clear)'], + 'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use | play-developer (Play Console deep links) | default (the machine default; --clear)'], 'settings': [() => import('./settings'), 'per-machine settings: bypass on | off | status (owner opt-in: the agent also presses the owner-only buttons; default off)'], 'aab': [() => import('./aab'), 'offline: an AAB file: packageName, versionCode, versionName, min / target SDK, sha256 (no config, no upload); --json'], 'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install --yes; --json'], diff --git a/cli/inspect.ts b/cli/inspect.ts index ec1386f..4c2e7d5 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -14,6 +14,7 @@ import { maskUser, reviewContact, reviewDemoAccount } from './lib/credentials'; import { DATA_SAFETY_UPLOADED, declarations } from './lib/declarations'; import type { ProjectSummary } from './lib/desktop-protocol'; import { log, printJson } from './lib/log'; +import { playIdsOf } from './lib/play-ids'; import { findIcon, iconDataUrl, listingCompleteness, listingText, renderImage } from './lib/project-files'; export function summarize(ctx: Ctx): ProjectSummary { @@ -24,7 +25,7 @@ export function summarize(ctx: Ctx): ProjectSummary { config: path, name: config.app.name, languages: config.languages.map(l => l.id), - play: config.play ? { packageName: config.play.packageName } : null, + play: config.play ? { packageName: config.play.packageName, ...playIdsOf(config, root) } : null, apple: config.apple ? { bundleId: config.apple.bundleId, appId: config.apple.appId ?? null } : null, platforms: platformsOf(config), products: config.products?.length ?? 0, diff --git a/cli/lib/accounts.ts b/cli/lib/accounts.ts index 3d2163c..940e340 100644 --- a/cli/lib/accounts.ts +++ b/cli/lib/accounts.ts @@ -14,7 +14,7 @@ export type Kind = 'apple' | 'play'; /** The store keys that move to a profile (one developer account's), never the app's own ones. */ export const STORE_KEYS = PROFILE_KEYS; -export interface Identity { appleKeyId: string | null; playEmail: string | null; vendor: boolean } +export interface Identity { appleKeyId: string | null; playEmail: string | null; vendor: boolean; playDeveloperId: string | null } const PEM = /BEGIN PRIVATE KEY/; const isJsonText = (v: string) => /^\s*\{/.test(v); @@ -32,15 +32,15 @@ export function serviceAccountEmail(v: string, base: string): string | null { /** What a profile can reach: the Apple key id and the Play service account e-mail (for matching, never printed). */ export function profileIdentity(name: string): Identity { const env = profileEnv(name); - return { appleKeyId: env.ASC_KEY_ID ?? null, playEmail: env.PLAY_SERVICE_ACCOUNT ? serviceAccountEmail(env.PLAY_SERVICE_ACCOUNT, profileDir()) : null, vendor: !!env.ASC_VENDOR_NUMBER }; + return { appleKeyId: env.ASC_KEY_ID ?? null, playEmail: env.PLAY_SERVICE_ACCOUNT ? serviceAccountEmail(env.PLAY_SERVICE_ACCOUNT, profileDir()) : null, vendor: !!env.ASC_VENDOR_NUMBER, playDeveloperId: env.PLAY_DEVELOPER_ID ?? null }; } /** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */ -export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; error?: string }[] { +export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null; error?: string }[] { return profileNames().map(profile => { try { const id = profileIdentity(profile); - return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor }; + return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId }; } catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; } }); } @@ -205,3 +205,18 @@ export function useProfile(project: string, name: string): string { atomic(file, JSON.stringify({ ...cur, account: name }, null, 2) + '\n', 0o600); return file; } + +/** Sets (or clears with null) the Play developer account id of a profile: an account id, not a secret, used for Play + * Console deep links (play-ids.ts). The same field the desktop's Accounts screen writes. Returns the profile file. */ +export function setPlayDeveloperId(name: string, id: string | null): string { + if (!PROFILE_RE.test(name)) throw new Error(`${name}: letters, digits, . _ - (max 64)`); + if (!profileNames().includes(name)) throw new Error(`no profile "${name}" in ${profileDir()}: \`storebound accounts\` lists them`); + const v = id?.trim() ?? null; + if (v !== null && !/^\d{1,25}$/.test(v)) throw new Error('the Play developer id is the digits in play.google.com/console/u/0/developers//...'); + const file = join(profileDir(), `${name}.json`); + const cur = readJson(file); + const next: Json = { ...cur }; + if (v === null) delete next.PLAY_DEVELOPER_ID; else next.PLAY_DEVELOPER_ID = v; + atomic(file, JSON.stringify(next, null, 2) + '\n', 0o600); + return file; +} diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 0a02d45..9abb510 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -6,6 +6,7 @@ import { existsSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import * as rt from './rt'; import { PROFILE_RE, setConfigAccount } from './credentials'; +import { playIdsOf } from './play-ids'; import { validateCapture, validateRecord } from '../shots/devices'; import { featureBackgroundIssues, paletteIssues } from '../shots/palette'; import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy'; @@ -1286,7 +1287,8 @@ export async function loadConfig(explicit?: string, start = process.cwd()): Prom if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n ')); const root = dirname(path); setConfigAccount(root, config.account); - setConsoleIds(consoleIdsFromConfig(config)); + const play = playIdsOf(config, root); + setConsoleIds({ ...consoleIdsFromConfig(config), playDeveloperId: play.developerId, playAppId: play.appId }); return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) }; } diff --git a/cli/lib/console-links.ts b/cli/lib/console-links.ts index b5bb7ae..3a2958a 100644 --- a/cli/lib/console-links.ts +++ b/cli/lib/console-links.ts @@ -9,6 +9,8 @@ export const ASC = 'https://appstoreconnect.apple.com'; export const PLAY_CONSOLE = 'https://play.google.com/console'; +/** The developer account chooser: the fallback when no developer id is known (never the bare console root). */ +export const PLAY_DEVELOPERS = `${PLAY_CONSOLE}/u/0/developers`; export interface ConsoleIds { appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null } @@ -94,7 +96,7 @@ export function consoleUrl(page: ConsolePage, ids: ConsoleIds = current): string return app ? `${ASC}/apps/${app}${p.path}` : `${ASC}/apps`; } const dev = id(ids.playDeveloperId ?? current.playDeveloperId), app = id(ids.playAppId ?? current.playAppId); - if (!dev) return PLAY_CONSOLE; + if (!dev) return PLAY_DEVELOPERS; const base = `${PLAY_CONSOLE}/u/0/developers/${dev}`; if (p.scope === 'developer') return `${base}/${p.path}`; return app ? `${base}/app/${app}/${p.path}` : `${base}/app-list`; @@ -103,7 +105,22 @@ export function consoleUrl(page: ConsolePage, ids: ConsoleIds = current): string /** True when the URL is the page itself (ids known), false when it is a fallback (app list, chooser). */ export function consoleUrlExact(page: ConsolePage, ids: ConsoleIds = current): boolean { const u = consoleUrl(page, ids); - return u !== PLAY_CONSOLE && !(CONSOLE_PAGES[page].scope === 'app' && (u === `${ASC}/apps` || u.endsWith('/app-list'))); + return u !== PLAY_DEVELOPERS && !(CONSOLE_PAGES[page].scope === 'app' && (u === `${ASC}/apps` || u.endsWith('/app-list'))); +} + +/** + * "Open in Google Play Console" for one app: its dashboard when the developer id and the console's app id are known, + * else that developer's app list (searchable, the package name finds the row), else the developer chooser. Every + * desktop / CLI / watch link to an app's Play page goes through here. + */ +export function playAppUrl(ids: { developerId?: string | null; appId?: string | null }): string { + return consoleUrl('play.dashboard', { playDeveloperId: ids.developerId ?? null, playAppId: ids.appId ?? null, appleAppId: null }); +} + +/** The first candidate that is a console id (digits); the order of the sources is in play-ids.ts. */ +export function pickConsoleId(...candidates: (string | null | undefined)[]): string | null { + for (const c of candidates) if (c && /^\d{1,25}$/.test(c.trim())) return c.trim(); + return null; } /** "Readable > path: https://..." (the path suffix, e.g. "> Copy Cloud Storage URI", is appended to the label). */ diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index f027d57..709be38 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -44,7 +44,7 @@ function fileStore(root: string): Store { export function credentialValue(root: string, name: string): string | undefined { return value(root, name); } /** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number * for reports too; an app's Android upload keystore stays in the project. */ -export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER'] as const; +export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID'] as const; export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials'); diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index b1ad271..58a2582 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -122,7 +122,8 @@ export interface ProjectSummary { config: string; name: string; languages: string[]; - play: { packageName: string } | null; + /** developerId / appId: the Play Console ids for deep links (play-ids.ts), null when unknown. */ + play: { packageName: string; developerId?: string | null; appId?: string | null } | null; apple: { bundleId: string; appId: string | null } | null; /** app.platforms (default: from the apple / play sections). */ platforms: ('ios' | 'android')[]; @@ -155,7 +156,7 @@ export interface ProjectSummary { export interface AppIdentity { id: string; name: string; - platforms: { ios: { bundleId: string; ascAppId: string | null } | null; android: { packageName: string } | null }; + platforms: { ios: { bundleId: string; ascAppId: string | null } | null; android: { packageName: string; developerId?: string | null; appId?: string | null } | null }; } /** Where a version is on one store. */ diff --git a/cli/lib/play-ids.ts b/cli/lib/play-ids.ts new file mode 100644 index 0000000..81e2a69 --- /dev/null +++ b/cli/lib/play-ids.ts @@ -0,0 +1,43 @@ +// The Play Console ids an app's links use, from every place this machine keeps them (first one wins): +// developer id: play.developerId (config) > STOREBOUND_PLAY_DEVELOPER_ID (a workspace account, `ws`) > +// PLAY_DEVELOPER_ID of the credential profile (`storebound accounts play-developer`, the desktop's +// Accounts screen) > the id `console fill` remembered for the package (~/.storebound/console-accounts.json) +// app id: play.appId (config) > the remembered app id for the package +// Never a guess: an id that is not digits is skipped. The links themselves: console-links.ts (playAppUrl). +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { pickConsoleId } from './console-links'; +import { credentialValue } from './credentials'; + +const consoleAccountsFile = (): string => process.env.STOREBOUND_CONSOLE_ACCOUNTS || join(homedir(), '.storebound', 'console-accounts.json'); + +/** What `console fill` remembered for a package ({ developerId, appId? }); {} when nothing or unreadable. */ +export function rememberedPlay(pkg: string | null | undefined, file = consoleAccountsFile()): { developerId?: string; appId?: string } { + if (!pkg || !existsSync(file)) return {}; + try { + const e = (JSON.parse(readFileSync(file, 'utf8')) as { play?: Record }).play?.[pkg]; + return { developerId: typeof e?.developerId === 'string' ? e.developerId : undefined, appId: typeof e?.appId === 'string' ? e.appId : undefined }; + } catch { return {}; } +} + +export interface PlayIdSources { + config?: { developerId?: string | null; appId?: string | null; packageName?: string | null } | null; + workspace?: string | null; + profile?: string | null; + remembered?: { developerId?: string | null; appId?: string | null }; +} + +export function resolvePlayIds(s: PlayIdSources): { developerId: string | null; appId: string | null } { + return { + developerId: pickConsoleId(s.config?.developerId, s.workspace, s.profile, s.remembered?.developerId), + appId: pickConsoleId(s.config?.appId, s.remembered?.appId), + }; +} + +/** The ids for a loaded config (every source above). */ +export function playIdsOf(config: { play?: { developerId?: string; appId?: string; packageName?: string } }, root: string): { developerId: string | null; appId: string | null } { + let profile: string | null = null; + try { profile = credentialValue(root, 'PLAY_DEVELOPER_ID') ?? null; } catch { /* unreadable profile: no id from it */ } + return resolvePlayIds({ config: config.play ?? null, workspace: process.env.STOREBOUND_PLAY_DEVELOPER_ID, profile, remembered: rememberedPlay(config.play?.packageName) }); +} diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts index efa0693..ae906e6 100644 --- a/cli/lib/portfolio.ts +++ b/cli/lib/portfolio.ts @@ -11,7 +11,7 @@ export function appIdentity(s: ProjectSummary): AppIdentity { name: s.name, platforms: { ios: s.apple && s.platforms.includes('ios') ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null, - android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName } : null, + android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName, developerId: s.play.developerId ?? null, appId: s.play.appId ?? null } : null, }, }; } diff --git a/cli/watch.ts b/cli/watch.ts index 42528eb..50b8f5a 100644 --- a/cli/watch.ts +++ b/cli/watch.ts @@ -100,7 +100,7 @@ async function readApp(t: Target, prev: AppSnap | undefined, now: number): Promi const useApple = !!s.apple && !t.skip.includes('apple'), usePlay = !!s.play && !t.skip.includes('play'); const snap: AppSnap = { name: s.name || t.name, ids: { appleAppId: s.apple?.appId ?? null } }; if (usePlay && !t.virtual && !prev?.ids.playDeveloperId) { - try { const { loadConfig } = await import('./lib/config'); Object.assign(snap.ids, consoleIdsFromConfig((await loadConfig(undefined, t.dir)).config)); } catch { /* links fall back to the console home */ } + try { const { loadConfig } = await import('./lib/config'); const { playIdsOf } = await import('./lib/play-ids'); const l = await loadConfig(undefined, t.dir); const play = playIdsOf(l.config, l.root); Object.assign(snap.ids, consoleIdsFromConfig(l.config), { playDeveloperId: play.developerId, playAppId: play.appId }); } catch { /* links fall back to the app list / developer chooser */ } } const all = e.release.all ?? []; snap.releases = {}; diff --git a/tests/console-links.test.ts b/tests/console-links.test.ts index 2182ce7..bd4b2c4 100644 --- a/tests/console-links.test.ts +++ b/tests/console-links.test.ts @@ -33,7 +33,7 @@ describe('console-links', () => { test('fallbacks: app list / developer chooser; reused verified shapes', () => { expect(consoleUrl('asc.age-rating', {})).toBe('https://appstoreconnect.apple.com/apps'); - expect(consoleUrl('play.data-safety', {})).toBe('https://play.google.com/console'); + expect(consoleUrl('play.data-safety', {})).toBe('https://play.google.com/console/u/0/developers'); expect(consoleUrl('play.data-safety', { playDeveloperId: IDS.playDeveloperId })).toBe(`https://play.google.com/console/u/0/developers/${IDS.playDeveloperId}/app-list`); expect(consoleUrl('play.dashboard', IDS)).toBe(`https://play.google.com/console/u/0/developers/${IDS.playDeveloperId}/app/${IDS.playAppId}/app-dashboard`); expect(consoleUrl('play.content-rating', IDS)).toEndWith('/app-content/content-rating-overview'); diff --git a/tests/play-ids.test.ts b/tests/play-ids.test.ts new file mode 100644 index 0000000..0bbb02f --- /dev/null +++ b/tests/play-ids.test.ts @@ -0,0 +1,55 @@ +// Play Console deep links of one app: the link builder (playAppUrl) and where the ids come from (play-ids.ts). +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PLAY_DEVELOPERS, pickConsoleId, playAppUrl } from '../cli/lib/console-links'; +import { rememberedPlay, resolvePlayIds } from '../cli/lib/play-ids'; + +const DEV = '5550000000000000001', APP = '4970000000000000002'; + +describe('playAppUrl', () => { + test('both ids: the app dashboard', () => { + expect(playAppUrl({ developerId: DEV, appId: APP })).toBe(`https://play.google.com/console/u/0/developers/${DEV}/app/${APP}/app-dashboard`); + }); + test('developer id only: that developer\'s app list', () => { + expect(playAppUrl({ developerId: DEV })).toBe(`https://play.google.com/console/u/0/developers/${DEV}/app-list`); + }); + test('nothing known: the developer chooser, never the console root', () => { + expect(playAppUrl({})).toBe(PLAY_DEVELOPERS); + expect(playAppUrl({ appId: APP })).toBe(PLAY_DEVELOPERS); + expect(playAppUrl({ developerId: 'abc' })).toBe(PLAY_DEVELOPERS); + expect(playAppUrl({})).not.toBe('https://play.google.com/console'); + }); +}); + +describe('resolvePlayIds', () => { + test('order: config > workspace > profile > remembered', () => { + expect(resolvePlayIds({ config: { developerId: '1' }, workspace: '2', profile: '3', remembered: { developerId: '4' } }).developerId).toBe('1'); + expect(resolvePlayIds({ workspace: '2', profile: '3', remembered: { developerId: '4' } }).developerId).toBe('2'); + expect(resolvePlayIds({ profile: '3', remembered: { developerId: '4' } }).developerId).toBe('3'); + expect(resolvePlayIds({ remembered: { developerId: '4' } }).developerId).toBe('4'); + expect(resolvePlayIds({})).toEqual({ developerId: null, appId: null }); + }); + test('non-digit values are skipped, never guessed', () => { + expect(resolvePlayIds({ config: { developerId: 'x1' }, profile: ` ${DEV} ` }).developerId).toBe(DEV); + expect(pickConsoleId('', null, undefined, '12a')).toBeNull(); + }); + test('app id: config, then the remembered one', () => { + expect(resolvePlayIds({ config: { appId: APP }, remembered: { appId: '9' } }).appId).toBe(APP); + expect(resolvePlayIds({ remembered: { appId: '9' } }).appId).toBe('9'); + }); +}); + +describe('rememberedPlay', () => { + test('reads console-accounts.json per package; missing / broken file = {}', () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-playids-')); + const f = join(dir, 'console-accounts.json'); + expect(rememberedPlay('com.example.app', f)).toEqual({}); + writeFileSync(f, JSON.stringify({ version: 1, play: { 'com.example.app': { developerId: DEV, account: 'Example', at: '2026-10-01' } } })); + expect(rememberedPlay('com.example.app', f)).toEqual({ developerId: DEV, appId: undefined }); + expect(rememberedPlay('com.example.other', f)).toEqual({}); + writeFileSync(f, '{nope'); + expect(rememberedPlay('com.example.app', f)).toEqual({}); + }); +});