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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 | null>('account_add_apple', { label, keyId, issuerId, studio }),
|
||||
removeAccount: (id: string) => invoke<void>('account_remove', { id }),
|
||||
updateAccount: (id: string, label: string, studio: string | null) => invoke<Account>('account_update', { id, label, studio }),
|
||||
setPlayDeveloper: (id: string, developerId: string | null) => invoke<Account>('account_set_play_developer', { id, developerId }),
|
||||
/** Does the key work, can it see apps (live, not cached). */
|
||||
checkAccount: (id: string, job?: string) => invoke<AccountCheck>('account_check', { id, job: job ?? null }),
|
||||
/** Reads the apps an account can see from its store and keeps the list. */
|
||||
|
|
|
|||
|
|
@ -565,6 +565,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
}
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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/<id>. 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",
|
||||
|
|
|
|||
|
|
@ -2043,6 +2043,10 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'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/<id> 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",
|
||||
|
|
|
|||
|
|
@ -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 ? <span className="mono-id selectable" title="~/.storebound">{t('acct.profile', { name: a.profile })}</span> : 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 ? <> · <span className="mono-id selectable" title={t('acct.devIdHint')}>{t('acct.devId', { id: a.developerId })}</span></> : <> · <span title={t('acct.devIdHint')}>{t('acct.noDevId')}</span></>)}
|
||||
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -100,7 +103,10 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
|
|||
<option value="">{t('studio.none')}</option>
|
||||
{d.studios.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select></div>
|
||||
<div className="actions"><button className="btn btn-primary btn-sm" type="submit" disabled={!label.trim() || !!busy}><Icon name="check" />{t('common.save')}</button><button type="button" className="btn btn-sm btn-ghost" onClick={() => setEdit(false)}>{t('common.cancel')}</button></div>
|
||||
{a.kind === 'play' && a.profile && <div className="field"><label htmlFor={`ad-${a.id}`}>{t('acct.devIdLabel')}</label>
|
||||
<input id={`ad-${a.id}`} className="input mono" inputMode="numeric" value={dev} onChange={e => setDev(e.target.value)} placeholder="5550000000000000001" aria-invalid={!devOk} aria-describedby={`adh-${a.id}`} />
|
||||
<small id={`adh-${a.id}`} className="muted">{t('acct.devIdHint')}</small></div>}
|
||||
<div className="actions"><button className="btn btn-primary btn-sm" type="submit" disabled={!label.trim() || !devOk || !!busy}><Icon name="check" />{t('common.save')}</button><button type="button" className="btn btn-sm btn-ghost" onClick={() => setEdit(false)}>{t('common.cancel')}</button></div>
|
||||
</form>
|
||||
)}
|
||||
{check && check.status !== 'ok' && check.fix && (
|
||||
|
|
|
|||
|
|
@ -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<UnifiedApp['pairing'], Key> = { 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 && <button className="btn btn-sm btn-ghost" onClick={() => open(ascUrl(ios.ascAppId))} title={t('app.consoleHint')}><Icon name="external" />App Store Connect</button>}
|
||||
{android && <button className="btn btn-sm btn-ghost" onClick={() => open(PLAY_CONSOLE)} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
||||
{android && <button className="btn btn-sm btn-ghost" onClick={() => open(playUrlFor(d, app))} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
||||
</>}
|
||||
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} />
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
])}>
|
||||
<div className="cat-card-h">
|
||||
<span className="cat-ic" aria-hidden="true"><Icon name={r.kind === 'subscription' ? 'refresh' : 'tag'} /></span>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Section title={t('gr.playExp')} icon="flask" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="android" className="ic13" />{t('gr.playExpSub')}</span>}
|
||||
actions={<button className="btn btn-sm" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>}>
|
||||
actions={<button className="btn btn-sm" onClick={() => api.openExternal(playUrlFor(d, app)).catch(() => {})}><Icon name="external" />Play Console</button>}>
|
||||
<div className="gr-body">
|
||||
<p className="gr-text">{t('gr.playExpText')}</p>
|
||||
{app.project && <div><button className="btn btn-sm" onClick={() => nav.go({ screen: 'app', appId: app.key, tab: 'art' })}><Icon name="grid" />{t('gr.seeVariants')}</button></div>}
|
||||
|
|
|
|||
|
|
@ -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<SubmissionKind, ['ok' | 'warn' | 'bad' | 'info' | 'neutral', Key]> = {
|
||||
|
|
@ -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={<>
|
||||
<ReadControl r={r} label={t('sub.read')} />
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(PLAY_CONSOLE).catch(() => {})}><Icon name="external" />Play Console</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal(playUrlFor(d, app)).catch(() => {})}><Icon name="external" />Play Console</button>
|
||||
</>} />
|
||||
<div className="sub-body">
|
||||
{r.error && <ErrorNotice error={r.error} keep={!!r.data} />}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
];
|
||||
}
|
||||
|
|
|
|||
19
apps/desktop/tests/play-link.test.ts
Normal file
19
apps/desktop/tests/play-link.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue