Compare commits
No commits in common. "0ce0c51ff6ebea9f973b63ad70bec42e327b2e50" and "f5bca70c6088a3bd0959a32b08488c196e03bc20" have entirely different histories.
0ce0c51ff6
...
f5bca70c60
141 changed files with 70 additions and 17707 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -7,11 +7,3 @@ node_modules/
|
|||
*.p12
|
||||
.DS_Store
|
||||
media/teaser/out/
|
||||
|
||||
# desktop app (apps/desktop): build output, compiled sidecars, Tauri generated schemas
|
||||
apps/desktop/dist/
|
||||
apps/desktop/src-tauri/target/
|
||||
apps/desktop/src-tauri/binaries/
|
||||
apps/desktop/src-tauri/gen/
|
||||
apps/desktop/.screens/
|
||||
.scratch/
|
||||
|
|
|
|||
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -1,40 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Desktop machine interface** (`serve --stdio`): JSON-RPC session for the coming desktop app, built on the MCP core.
|
||||
Typed methods with argument schemas, structured results, progress notifications, cancellation, `project.inspect`
|
||||
and `portfolio.status`: apps across iOS and Android with identity, config check, release trains (current and next
|
||||
version on both stores) and catalog parity (`live` reads App Store and Play status). `system.tools` reports ffmpeg,
|
||||
Chrome, Xcode tools, simctl, adb and git with versions; `STOREBOUND_TOOL_PATH` / `STOREBOUND_CHROME` point the CLI
|
||||
at app-managed copies. Writes are dry runs unless the host
|
||||
sends a single-use confirm token bound to the method, arguments and a short expiry (HMAC with a per-session secret
|
||||
handed over on stdin). No submit / release / publish method exists. Under `serve`, terminal confirmations always
|
||||
answer no.
|
||||
- `--json` on `check` (and `check --aso`), `apple testflight status`, `apple ppo status`, `apple next-build`,
|
||||
`play next-version-code`, `analytics compare` and `prices import` (read-only diff); new `inspect` command.
|
||||
- **Desktop app shell** (`apps/desktop`, not released yet): Tauri 2 + React 19, app-centric (Portfolio, App with
|
||||
Releases / Catalog / Listings / Health / Reviews / TestFlight, App folders, Accounts, Dependencies, Settings), store
|
||||
keys in the OS keychain, native confirm dialogs with the dry run before any write. See `docs/desktop.md`.
|
||||
- `--json` on `catalog diff` goes through the same machine-readable result line.
|
||||
- **`storebound doctor`** (`--json`): ffmpeg, ffprobe, Chrome, playwright, Xcode tools, simctl, adb and git with
|
||||
versions (the same detection as the desktop Dependencies screen) and the install commands for this OS. It runs none.
|
||||
- `playwright` is an optional peer dependency (it was only documented before). The compiled desktop sidecar bundles
|
||||
`playwright-core` and renders with the app's Chrome for Testing (`STOREBOUND_CHROME`), so desktop users need neither.
|
||||
- `inspect --icon` / `inspect --listings` (listing completeness per language on both stores, rendered screenshots),
|
||||
`reviews drafts [--json]` (local reply drafts with the send checks), `reviews approve <draft> [--undo] [--dry]`,
|
||||
`init --dry`; templates are embedded so `init` works from a single-file binary.
|
||||
- `apple status --json` includes the phased release of the newest live versions; release trains now count a staged
|
||||
rollout or iOS phased release (also halted / paused) as the current train.
|
||||
- **Desktop D2** (`apps/desktop`): redesigned shell (command palette, toasts with progress and cancel, skeletons,
|
||||
illustrated empty states, first-run guide), release timeline per app, catalog parity matrix, listing completeness,
|
||||
health sparklines against the Play thresholds, reviews inbox with star distribution and draft approval, TestFlight
|
||||
groups and builds, store readiness with fix hints; offline cache of the last reads (SQLite, "last read" stamps);
|
||||
one-click ffmpeg (pinned SHA-256) and Chrome for Testing (Google's code signature) downloads into app data;
|
||||
English and Turkish with an i18n check, 7:1 text contrast checked in both themes.
|
||||
- `tools/build-sidecars.ts`: single-file executables for macOS (arm64, x64), Linux (x64, arm64) and Windows (x64),
|
||||
re-signed ad hoc on macOS and checked for build-machine paths. The MCP server now also runs from such a binary.
|
||||
|
||||
## 0.5.0 · 2026-10-11
|
||||
|
||||
Apps and games: storebound is for every mobile app, not only games, and the in-app catalog is complete on both stores.
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -175,15 +175,12 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
|||
| `init [--profile app\|game\|subscription-app]` | local | copy the templates of a profile (default app), gitignore `.storebound/` |
|
||||
| `install --agent <tool>` | local | skills + MCP entry for an AI tool |
|
||||
| `mcp` | local | stdio MCP server wrapping the commands |
|
||||
| `serve --stdio` | local | JSON-RPC session for the storebound desktop app (see [Desktop machine interface](#desktop-machine-interface)) |
|
||||
| `inspect [--json]` | local | offline summary of the config: name, store ids, languages, products |
|
||||
| `check` | local | config, copy limits, data safety, products, subscriptions, profile (delete-account URL, demo account, terms URL), files; warns on untranslated release notes |
|
||||
| `catalog diff [--store] [--threshold 10]` | API, read-only | one catalog vs both stores: missing / extra / mismatched products and subscriptions, US and override prices, locales, offers, per-region price differences between the stores; exit 1 on mismatches |
|
||||
| `check --aso` | local | keyword audit: App Store keyword field rules, Play stuffing / claims |
|
||||
| `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left |
|
||||
| `whats-new [--since <tag>]` | local | draft player-facing release notes from git log into both listing files |
|
||||
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
||||
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
|
||||
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
||||
|
||||
</details>
|
||||
|
|
@ -380,19 +377,6 @@ per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same proc
|
|||
Manual config for a tool that `install` does not know: command `bunx` with args `["storebound", "mcp"]` (or the
|
||||
absolute path of `bin/storebound` plus `mcp`).
|
||||
|
||||
### Desktop machine interface
|
||||
|
||||
`storebound serve --stdio` is what the desktop app (`apps/desktop`, in progress) runs as its sidecar: newline-delimited
|
||||
JSON-RPC on the same core as the MCP server (tool list, validation, forbidden flags, child-process runner), with typed
|
||||
methods (`check`, `validate`, `apple.status`, `play.listing`, ... plus `project.inspect` and `portfolio.status` over
|
||||
many project folders), structured results (the commands' `--json` output), progress notifications and cancellation.
|
||||
The first request is `session.hello` with a per-session secret only the host knows. Write methods are dry runs unless
|
||||
the call carries a single-use confirm token (HMAC over method, arguments and expiry) that the host mints after the
|
||||
owner confirmed a native dialog showing that dry run. There is no method that submits, releases or publishes; Play
|
||||
testing releases and phased release changes stay out of the desktop profile too. Types: `cli/lib/desktop-protocol.ts`.
|
||||
`bun tools/build-sidecars.ts` compiles the CLI for macOS, Linux and Windows (`bun build --compile`). Design, safety
|
||||
model and the Dependencies screen (tool sources and licenses): [docs/desktop.md](docs/desktop.md).
|
||||
|
||||
## Safety model
|
||||
|
||||
- **Credentials** live in env vars or the gitignored `.storebound/credentials.json`, never in the config or the repo.
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>storebound</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "@storebound/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"description": "storebound desktop: every game's releases on both stores in one window (Tauri shell around the storebound CLI sidecar).",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 1420 --strictPort",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test tests",
|
||||
"sidecar": "bun ../../tools/build-sidecars.ts --host",
|
||||
"tauri": "tauri",
|
||||
"app:dev": "bun run sidecar && tauri dev",
|
||||
"app:build:debug": "bun run sidecar && tauri build --debug --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.12.1",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
// Screenshots of every desktop screen in light and dark, from the Vite dev server in fixture mode (no Tauri, no store
|
||||
// accounts), plus a few interaction checks (command palette, toast progress, keyboard focus). Dev tool only.
|
||||
// cd apps/desktop && VITE_FIXTURES=1 bunx vite --port 1431 &
|
||||
// bun scripts/screens.ts --base http://127.0.0.1:1431 --out <dir> [--only portfolio,app:health] [--themes light,dark]
|
||||
// Uses playwright (root devDependency) with the installed Google Chrome (channel "chrome").
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { chromium, type Page } from 'playwright';
|
||||
|
||||
const arg = (k: string, d: string) => { const i = process.argv.indexOf(`--${k}`); return i >= 0 ? process.argv[i + 1] ?? d : d; };
|
||||
const base = arg('base', 'http://127.0.0.1:1431');
|
||||
const out = arg('out', join(import.meta.dir, '..', '.screens'));
|
||||
const only = arg('only', '').split(',').filter(Boolean);
|
||||
const themes = arg('themes', 'light,dark').split(',');
|
||||
const lang = arg('lang', 'en');
|
||||
const suffix = arg('suffix', '');
|
||||
|
||||
export const SCREENS: [string, string][] = [
|
||||
['portfolio', 'portfolio'], ['releases', 'app:releases'], ['releases-2', 'app:releases:2'], ['catalog', 'app:catalog'], ['listings', 'app:listings'],
|
||||
['health', 'app:health'], ['reviews', 'app:reviews'], ['testflight', 'app:testflight'], ['checks', 'app:checks'],
|
||||
['apps', 'apps'], ['accounts', 'accounts'], ['dependencies', 'dependencies'], ['settings', 'settings'],
|
||||
['palette', 'palette'], ['welcome-1', 'welcome:1'], ['welcome-2', 'welcome:2'], ['welcome-3', 'welcome:3'], ['welcome-4', 'welcome:4'], ['welcome-5', 'welcome:5'],
|
||||
];
|
||||
|
||||
async function settle(page: Page) {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
|
||||
mkdirSync(out, { recursive: true });
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const files: string[] = [];
|
||||
for (const theme of themes) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, colorScheme: theme === 'dark' ? 'dark' : 'light', reducedMotion: 'reduce' });
|
||||
const page = await ctx.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', e => errors.push(String(e)));
|
||||
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||
for (const [name, start] of SCREENS) {
|
||||
if (only.length && !only.includes(name)) continue;
|
||||
await page.goto(`${base}/?start=${encodeURIComponent(start)}&theme=${theme}&lang=${lang}`);
|
||||
await settle(page);
|
||||
const f = join(out, `d2-${name}${suffix}-${theme}${lang === 'en' ? '' : `-${lang}`}.png`);
|
||||
await page.screenshot({ path: f });
|
||||
files.push(f);
|
||||
}
|
||||
if (!only.length || only.includes('toast')) {
|
||||
// a background job: Read the stores, caught mid-way with its progress toast
|
||||
await page.goto(`${base}/?start=portfolio&theme=${theme}&lang=${lang}`);
|
||||
await settle(page);
|
||||
await page.getByRole('button', { name: /Read the stores|Mağazaları oku/ }).first().click();
|
||||
await page.waitForTimeout(900);
|
||||
const f = join(out, `d2-toast${suffix}-${theme}${lang === 'en' ? '' : `-${lang}`}.png`);
|
||||
await page.screenshot({ path: f });
|
||||
files.push(f);
|
||||
}
|
||||
if (errors.length) console.error(`${theme}: page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
console.log(files.join('\n'));
|
||||
6262
apps/desktop/src-tauri/Cargo.lock
generated
6262
apps/desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,59 +0,0 @@
|
|||
[package]
|
||||
name = "storebound-desktop"
|
||||
version = "0.1.0"
|
||||
description = "storebound desktop: every game's releases on both stores in one window"
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "storebound_desktop"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "storebound-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.12", features = [] }
|
||||
# Spawns the storebound sidecar from Rust only; no capability grants the plugin's JS API.
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
# Opener, dialog, notification, updater and process are driven from Rust only (no JS permissions).
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
# Store credentials: macOS Keychain, Windows Credential Manager, Secret Service on Linux.
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.3"
|
||||
url = "2"
|
||||
tokio = { version = "1", features = ["sync", "time"] }
|
||||
# Offline cache of the last store reads (app data cache.sqlite); SQLite compiled in, nothing system-wide.
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
# Dependencies screen downloads (ffmpeg, Chrome for Testing): the same HTTP stack the updater already brings.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "system-proxy"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// App command manifest: every #[tauri::command] gets an `allow-<name>` permission, and a command no capability lists
|
||||
// is denied. The list lives in src/commands_list.rs so tests/security.rs can compare it with the capability files.
|
||||
include!("src/commands_list.rs");
|
||||
|
||||
fn main() {
|
||||
tauri_build::try_build(
|
||||
tauri_build::Attributes::new()
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(COMMANDS)),
|
||||
)
|
||||
.expect("tauri build script failed");
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main-window",
|
||||
"description": "The main window with the app's own bundled pages only (no remote content). It may listen to app events (sidecar progress, tool downloads, deep links) and call the storebound app commands; every command re-checks the window label and page origin in Rust. No plugin API is granted to the webview: the sidecar is spawned from Rust with fixed arguments, dialogs, the opener, notifications, the updater and process control are driven from Rust only, and credentials never reach the page.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"local": true,
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"allow-app-info",
|
||||
"allow-sidecar-status",
|
||||
"allow-sidecar-call",
|
||||
"allow-cache-get",
|
||||
"allow-cache-clear",
|
||||
"allow-job-cancel",
|
||||
"allow-write-with-confirm",
|
||||
"allow-projects-list",
|
||||
"allow-project-add",
|
||||
"allow-project-init",
|
||||
"allow-project-remove",
|
||||
"allow-project-set-accounts",
|
||||
"allow-accounts-list",
|
||||
"allow-account-add-play",
|
||||
"allow-account-add-apple",
|
||||
"allow-account-remove",
|
||||
"allow-portfolio-status",
|
||||
"allow-open-external",
|
||||
"allow-tools-info",
|
||||
"allow-tools-install",
|
||||
"allow-tools-uninstall"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#4338ca"/></linearGradient></defs><rect x="100" y="100" width="824" height="824" rx="185" fill="url(#g)"/><path d="M512 288v384M336 464 512 288l176 176M256 640v176h512V640" fill="none" stroke="#fff" stroke-width="88" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
Before Width: | Height: | Size: 461 B |
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-account-add-apple"
|
||||
description = "Enables the account_add_apple command without any pre-configured scope."
|
||||
commands.allow = ["account_add_apple"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-account-add-apple"
|
||||
description = "Denies the account_add_apple command without any pre-configured scope."
|
||||
commands.deny = ["account_add_apple"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-account-add-play"
|
||||
description = "Enables the account_add_play command without any pre-configured scope."
|
||||
commands.allow = ["account_add_play"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-account-add-play"
|
||||
description = "Denies the account_add_play command without any pre-configured scope."
|
||||
commands.deny = ["account_add_play"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-account-remove"
|
||||
description = "Enables the account_remove command without any pre-configured scope."
|
||||
commands.allow = ["account_remove"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-account-remove"
|
||||
description = "Denies the account_remove command without any pre-configured scope."
|
||||
commands.deny = ["account_remove"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-accounts-list"
|
||||
description = "Enables the accounts_list command without any pre-configured scope."
|
||||
commands.allow = ["accounts_list"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-accounts-list"
|
||||
description = "Denies the accounts_list command without any pre-configured scope."
|
||||
commands.deny = ["accounts_list"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-app-info"
|
||||
description = "Enables the app_info command without any pre-configured scope."
|
||||
commands.allow = ["app_info"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-app-info"
|
||||
description = "Denies the app_info command without any pre-configured scope."
|
||||
commands.deny = ["app_info"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-cache-clear"
|
||||
description = "Enables the cache_clear command without any pre-configured scope."
|
||||
commands.allow = ["cache_clear"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-cache-clear"
|
||||
description = "Denies the cache_clear command without any pre-configured scope."
|
||||
commands.deny = ["cache_clear"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-cache-get"
|
||||
description = "Enables the cache_get command without any pre-configured scope."
|
||||
commands.allow = ["cache_get"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-cache-get"
|
||||
description = "Denies the cache_get command without any pre-configured scope."
|
||||
commands.deny = ["cache_get"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-job-cancel"
|
||||
description = "Enables the job_cancel command without any pre-configured scope."
|
||||
commands.allow = ["job_cancel"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-job-cancel"
|
||||
description = "Denies the job_cancel command without any pre-configured scope."
|
||||
commands.deny = ["job_cancel"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-external"
|
||||
description = "Enables the open_external command without any pre-configured scope."
|
||||
commands.allow = ["open_external"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-external"
|
||||
description = "Denies the open_external command without any pre-configured scope."
|
||||
commands.deny = ["open_external"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-portfolio-status"
|
||||
description = "Enables the portfolio_status command without any pre-configured scope."
|
||||
commands.allow = ["portfolio_status"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-portfolio-status"
|
||||
description = "Denies the portfolio_status command without any pre-configured scope."
|
||||
commands.deny = ["portfolio_status"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-project-add"
|
||||
description = "Enables the project_add command without any pre-configured scope."
|
||||
commands.allow = ["project_add"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-project-add"
|
||||
description = "Denies the project_add command without any pre-configured scope."
|
||||
commands.deny = ["project_add"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-project-init"
|
||||
description = "Enables the project_init command without any pre-configured scope."
|
||||
commands.allow = ["project_init"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-project-init"
|
||||
description = "Denies the project_init command without any pre-configured scope."
|
||||
commands.deny = ["project_init"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-project-remove"
|
||||
description = "Enables the project_remove command without any pre-configured scope."
|
||||
commands.allow = ["project_remove"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-project-remove"
|
||||
description = "Denies the project_remove command without any pre-configured scope."
|
||||
commands.deny = ["project_remove"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-project-set-accounts"
|
||||
description = "Enables the project_set_accounts command without any pre-configured scope."
|
||||
commands.allow = ["project_set_accounts"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-project-set-accounts"
|
||||
description = "Denies the project_set_accounts command without any pre-configured scope."
|
||||
commands.deny = ["project_set_accounts"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-projects-list"
|
||||
description = "Enables the projects_list command without any pre-configured scope."
|
||||
commands.allow = ["projects_list"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-projects-list"
|
||||
description = "Denies the projects_list command without any pre-configured scope."
|
||||
commands.deny = ["projects_list"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-sidecar-call"
|
||||
description = "Enables the sidecar_call command without any pre-configured scope."
|
||||
commands.allow = ["sidecar_call"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-sidecar-call"
|
||||
description = "Denies the sidecar_call command without any pre-configured scope."
|
||||
commands.deny = ["sidecar_call"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-sidecar-status"
|
||||
description = "Enables the sidecar_status command without any pre-configured scope."
|
||||
commands.allow = ["sidecar_status"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-sidecar-status"
|
||||
description = "Denies the sidecar_status command without any pre-configured scope."
|
||||
commands.deny = ["sidecar_status"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-tools-info"
|
||||
description = "Enables the tools_info command without any pre-configured scope."
|
||||
commands.allow = ["tools_info"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-tools-info"
|
||||
description = "Denies the tools_info command without any pre-configured scope."
|
||||
commands.deny = ["tools_info"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-tools-install"
|
||||
description = "Enables the tools_install command without any pre-configured scope."
|
||||
commands.allow = ["tools_install"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-tools-install"
|
||||
description = "Denies the tools_install command without any pre-configured scope."
|
||||
commands.deny = ["tools_install"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-tools-uninstall"
|
||||
description = "Enables the tools_uninstall command without any pre-configured scope."
|
||||
commands.allow = ["tools_uninstall"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-tools-uninstall"
|
||||
description = "Denies the tools_uninstall command without any pre-configured scope."
|
||||
commands.deny = ["tools_uninstall"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-write-with-confirm"
|
||||
description = "Enables the write_with_confirm command without any pre-configured scope."
|
||||
commands.allow = ["write_with_confirm"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-write-with-confirm"
|
||||
description = "Denies the write_with_confirm command without any pre-configured scope."
|
||||
commands.deny = ["write_with_confirm"]
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
//! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer)
|
||||
//! goes into the OS credential store under the service `dev.storebound.desktop`; only a label, the kind and the
|
||||
//! creation time are kept in the app data folder. Secrets are never sent to the webview: they are read from the
|
||||
//! keychain right before a sidecar call and handed to it in that call's params (memory only), which passes them to
|
||||
//! the storebound command as environment variables.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub const KEYRING_SERVICE: &str = "dev.storebound.desktop";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AccountKind {
|
||||
Play,
|
||||
Apple,
|
||||
}
|
||||
|
||||
/// What the app keeps on disk and shows: no secret, no key id, no email.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub kind: AccountKind,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
/// The keychain entry of one account (JSON).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum Secret {
|
||||
Play { service_account: String },
|
||||
Apple { key_id: String, issuer_id: String, p8: String },
|
||||
}
|
||||
|
||||
impl Secret {
|
||||
pub fn kind(&self) -> AccountKind {
|
||||
match self {
|
||||
Secret::Play { .. } => AccountKind::Play,
|
||||
Secret::Apple { .. } => AccountKind::Apple,
|
||||
}
|
||||
}
|
||||
|
||||
/// The env names storebound reads (cli/lib/credentials.ts), as a sidecar `credentials` object.
|
||||
pub fn to_credentials(&self, into: &mut Map<String, Value>) {
|
||||
match self {
|
||||
Secret::Play { service_account } => {
|
||||
into.insert("PLAY_SERVICE_ACCOUNT".into(), Value::String(service_account.clone()));
|
||||
}
|
||||
Secret::Apple { key_id, issuer_id, p8 } => {
|
||||
into.insert("ASC_KEY_ID".into(), Value::String(key_id.clone()));
|
||||
into.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone()));
|
||||
into.insert("ASC_P8".into(), Value::String(p8.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Google service account JSON key: client_email and a PEM private key.
|
||||
pub fn check_service_account(text: &str) -> Result<String, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|_| "not a JSON file".to_string())?;
|
||||
let email = v.get("client_email").and_then(Value::as_str).unwrap_or_default();
|
||||
let key = v.get("private_key").and_then(Value::as_str).unwrap_or_default();
|
||||
if email.is_empty() || !key.contains("PRIVATE KEY") {
|
||||
return Err("not a Google service account key (client_email / private_key missing)".into());
|
||||
}
|
||||
// Store the compact form: no file path, nothing else from the disk.
|
||||
serde_json::to_string(&v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// App Store Connect key id: 10 upper-case letters or digits.
|
||||
pub fn check_key_id(s: &str) -> Result<String, String> {
|
||||
let s = s.trim();
|
||||
if s.len() == 10 && s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
|
||||
Ok(s.to_string())
|
||||
} else {
|
||||
Err("key id: 10 letters or digits, as shown in App Store Connect > Users and Access > Integrations".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// App Store Connect issuer id: a UUID.
|
||||
pub fn check_issuer_id(s: &str) -> Result<String, String> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
let parts: Vec<&str> = s.split('-').collect();
|
||||
let lens = [8, 4, 4, 4, 12];
|
||||
let ok = parts.len() == 5
|
||||
&& parts.iter().zip(lens).all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
if ok {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err("issuer id: the UUID shown above the keys in App Store Connect".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_p8(text: &str) -> Result<String, String> {
|
||||
if text.contains("-----BEGIN PRIVATE KEY-----") && text.contains("-----END PRIVATE KEY-----") {
|
||||
Ok(text.trim().to_string() + "\n")
|
||||
} else {
|
||||
Err("not an App Store Connect .p8 key (BEGIN PRIVATE KEY missing)".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_label(s: &str) -> Result<String, String> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || s.chars().count() > 60 || s.chars().any(char::is_control) {
|
||||
return Err("name: 1 to 60 characters".into());
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
/// Where secrets live. The OS keychain in the app; a map in tests.
|
||||
pub trait SecretStore: Send + Sync {
|
||||
fn set(&self, id: &str, secret: &Secret) -> Result<(), String>;
|
||||
fn get(&self, id: &str) -> Result<Secret, String>;
|
||||
fn delete(&self, id: &str) -> Result<(), String>;
|
||||
}
|
||||
|
||||
pub struct Keychain;
|
||||
|
||||
impl SecretStore for Keychain {
|
||||
fn set(&self, id: &str, secret: &Secret) -> Result<(), String> {
|
||||
let json = serde_json::to_string(secret).map_err(|e| e.to_string())?;
|
||||
keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.set_password(&json)).map_err(|e| format!("keychain: {e}"))
|
||||
}
|
||||
fn get(&self, id: &str) -> Result<Secret, String> {
|
||||
let json = keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.get_password()).map_err(|e| format!("keychain: {e}"))?;
|
||||
serde_json::from_str(&json).map_err(|_| "keychain: unreadable entry".to_string())
|
||||
}
|
||||
fn delete(&self, id: &str) -> Result<(), String> {
|
||||
match keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.delete_credential()) {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("keychain: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
//! Offline cache of the last successful reads (app data `cache.sqlite`): store status, health, reviews, TestFlight,
|
||||
//! catalog parity, listings, icons. The window shows a cached value with its "last read" time while nothing newer is
|
||||
//! there, and says "unknown" when there is none; a cached value is never shown as fresh. Secrets are never cached
|
||||
//! (results only, never params), and a project's rows go when the project is removed.
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
pub const FILE: &str = "cache.sqlite";
|
||||
/// Results above this size are not kept (logs of a long render, say); the window reads them live.
|
||||
pub const MAX_VALUE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
pub struct Cache {
|
||||
db: Mutex<Connection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Entry {
|
||||
pub value: Value,
|
||||
/// When it was read (RFC 3339 UTC).
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
/// Cache key: project id, method and the canonical arguments (the same canonical JSON as confirm tokens).
|
||||
pub fn key(project: &str, method: &str, args: &Value) -> String {
|
||||
let a = crate::confirm::canonical(args).unwrap_or_else(|_| args.to_string());
|
||||
format!("{project}\u{1f}{method}\u{1f}{a}")
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn open(dir: &Path) -> Result<Cache, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
Cache::with(Connection::open(dir.join(FILE)).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Result<Cache, String> {
|
||||
Cache::with(Connection::open_in_memory().map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
fn with(db: Connection) -> Result<Cache, String> {
|
||||
db.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS reads (
|
||||
key TEXT PRIMARY KEY,
|
||||
project TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS reads_project ON reads(project);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Cache { db: Mutex::new(db) })
|
||||
}
|
||||
|
||||
pub fn put(&self, project: &str, method: &str, args: &Value, value: &Value, at: &str) -> Result<(), String> {
|
||||
let text = value.to_string();
|
||||
if text.len() > MAX_VALUE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let db = self.db.lock().map_err(|_| "cache poisoned")?;
|
||||
db.execute(
|
||||
"INSERT INTO reads(key, project, method, value, at) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, at = excluded.at",
|
||||
params![key(project, method, args), project, method, text, at],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, project: &str, method: &str, args: &Value) -> Result<Option<Entry>, String> {
|
||||
let db = self.db.lock().map_err(|_| "cache poisoned")?;
|
||||
let row: Option<(String, String)> = db
|
||||
.query_row("SELECT value, at FROM reads WHERE key = ?1", params![key(project, method, args)], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(row.and_then(|(v, at)| serde_json::from_str(&v).ok().map(|value| Entry { value, at })))
|
||||
}
|
||||
|
||||
/// Every cached method of a project with its time (for the "last read" stamps).
|
||||
pub fn stamps(&self, project: &str) -> Result<Vec<(String, String)>, String> {
|
||||
let db = self.db.lock().map_err(|_| "cache poisoned")?;
|
||||
let mut st = db.prepare("SELECT method, MAX(at) FROM reads WHERE project = ?1 GROUP BY method ORDER BY method").map_err(|e| e.to_string())?;
|
||||
let rows = st.query_map(params![project], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn forget_project(&self, project: &str) -> Result<(), String> {
|
||||
let db = self.db.lock().map_err(|_| "cache poisoned")?;
|
||||
db.execute("DELETE FROM reads WHERE project = ?1", params![project]).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<(), String> {
|
||||
let db = self.db.lock().map_err(|_| "cache poisoned")?;
|
||||
db.execute("DELETE FROM reads", []).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,543 +0,0 @@
|
|||
//! App commands. Each one first checks the calling window and page (policy::require_main). Secrets never cross into
|
||||
//! the webview: account files are picked in native dialogs and read here, and credentials go from the keychain
|
||||
//! straight into the sidecar call.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
|
||||
use crate::cache::{Cache, Entry};
|
||||
use crate::downloads;
|
||||
use crate::policy::{self, Kind};
|
||||
use crate::sidecar::{valid_job, Fail, Progress, Sidecar};
|
||||
use crate::store::{self, Project, State as Saved};
|
||||
|
||||
pub struct AppState {
|
||||
pub sidecar: Sidecar,
|
||||
pub data_dir: PathBuf,
|
||||
pub saved: Mutex<Saved>,
|
||||
pub secrets: Box<dyn SecretStore>,
|
||||
pub cache: Cache,
|
||||
pub downloads: downloads::Jobs,
|
||||
}
|
||||
|
||||
type Res<T> = Result<T, Fail>;
|
||||
|
||||
fn guard(w: &WebviewWindow) -> Res<()> {
|
||||
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
|
||||
policy::require_main(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
|
||||
}
|
||||
|
||||
fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> {
|
||||
s.saved.lock().map_err(|_| Fail::host("state poisoned"))
|
||||
}
|
||||
|
||||
fn persist(s: &AppState, st: &Saved) -> Res<()> {
|
||||
store::save(&s.data_dir, st).map_err(Fail::host)
|
||||
}
|
||||
|
||||
fn job_of(job: Option<String>) -> Res<Option<String>> {
|
||||
match job {
|
||||
Some(j) if !valid_job(&j) => Err(Fail::host("bad job id")),
|
||||
j => Ok(j),
|
||||
}
|
||||
}
|
||||
|
||||
/// Project folder + credentials of its mapped accounts, as sidecar params.
|
||||
fn project_params(s: &AppState, project_id: &str) -> Res<(Project, Map<String, Value>)> {
|
||||
let p = saved(s)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
|
||||
let mut creds = Map::new();
|
||||
for id in [&p.play_account, &p.apple_account].into_iter().flatten() {
|
||||
// A missing keychain entry is not fatal: the command says which credential it lacks.
|
||||
if let Ok(secret) = s.secrets.get(id) {
|
||||
secret.to_credentials(&mut creds);
|
||||
}
|
||||
}
|
||||
Ok((p, creds))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AppInfo {
|
||||
version: &'static str,
|
||||
identifier: String,
|
||||
platform: &'static str,
|
||||
debug: bool,
|
||||
/// Shown as "~" in paths, so screenshots do not carry the account name.
|
||||
home: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn app_info(window: WebviewWindow, app: AppHandle) -> Res<AppInfo> {
|
||||
guard(&window)?;
|
||||
Ok(AppInfo {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
identifier: app.config().identifier.clone(),
|
||||
platform: std::env::consts::OS,
|
||||
debug: cfg!(debug_assertions),
|
||||
home: app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sidecar_status(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
let r = state.sidecar.get(&app).await?;
|
||||
let tools = r.call("system.tools", json!({})).await.unwrap_or(Value::Null);
|
||||
Ok(json!({ "running": r.alive(), "version": r.version, "protocol": r.protocol, "methods": r.methods, "tools": tools }))
|
||||
}
|
||||
|
||||
/// A read (or dry-run write) through the sidecar. Writes from here are always dry runs (policy::sanitize_js_call).
|
||||
/// Successful reads of a project are kept in the offline cache (results only, never the credentials).
|
||||
#[tauri::command]
|
||||
pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, method: String, project_id: Option<String>, params: Option<Value>, job: Option<String>) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
let job = job_of(job)?;
|
||||
let r = state.sidecar.get(&app).await?;
|
||||
let kind = r.kind(&method);
|
||||
let mut p = policy::sanitize_js_call(&method, kind, ¶ms.unwrap_or(Value::Null)).map_err(Fail::host)?;
|
||||
let args = p.get("args").cloned().unwrap_or_else(|| json!({}));
|
||||
let mut cache_project = None;
|
||||
if matches!(kind, Some(Kind::Read | Kind::Write)) {
|
||||
let id = project_id.ok_or_else(|| Fail::host(format!("{method} needs a project")))?;
|
||||
let (proj, creds) = project_params(&state, &id)?;
|
||||
let o = p.as_object_mut().ok_or_else(|| Fail::host("params"))?;
|
||||
o.insert("project".into(), Value::String(proj.path));
|
||||
if !creds.is_empty() && method != "project.inspect" {
|
||||
o.insert("credentials".into(), Value::Object(creds));
|
||||
}
|
||||
if kind == Some(Kind::Read) {
|
||||
cache_project = Some(id);
|
||||
}
|
||||
}
|
||||
let res = r.call_job(&method, p, job.as_deref()).await?;
|
||||
if let Some(id) = cache_project {
|
||||
// only results that read something: a failed store read must not hide the last good one
|
||||
if res["ok"] == Value::Bool(true) && !res["data"].is_null() {
|
||||
let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso());
|
||||
}
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// The last cached result of a read (`project_id` "*" for the portfolio), with its time; null when never read.
|
||||
#[tauri::command]
|
||||
pub fn cache_get(window: WebviewWindow, state: State<'_, AppState>, method: String, project_id: String, args: Option<Value>) -> Res<Option<Entry>> {
|
||||
guard(&window)?;
|
||||
state.cache.get(&project_id, &method, &args.unwrap_or_else(|| json!({}))).map_err(Fail::host)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cache_clear(window: WebviewWindow, state: State<'_, AppState>) -> Res<()> {
|
||||
guard(&window)?;
|
||||
state.cache.clear().map_err(Fail::host)
|
||||
}
|
||||
|
||||
/// Stops a running job: its sidecar requests (`$/cancelRequest`) and a running download.
|
||||
#[tauri::command]
|
||||
pub async fn job_cancel(window: WebviewWindow, state: State<'_, AppState>, job: String) -> Res<bool> {
|
||||
guard(&window)?;
|
||||
if !valid_job(&job) {
|
||||
return Err(Fail::host("bad job id"));
|
||||
}
|
||||
let mut hit = state.downloads.cancel(&job);
|
||||
if let Some(r) = state.sidecar.current().await {
|
||||
hit |= r.cancel_job(&job) > 0;
|
||||
hit |= r.cancel_job(&format!("{job}:p")) > 0;
|
||||
}
|
||||
Ok(hit)
|
||||
}
|
||||
|
||||
const DIALOG_LINES: usize = 28;
|
||||
const DIALOG_CHARS: usize = 2_400;
|
||||
|
||||
/// Last lines of a dry run for the dialog (native alerts do not scroll well).
|
||||
pub fn dialog_excerpt(output: &str) -> String {
|
||||
let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let start = lines.len().saturating_sub(DIALOG_LINES);
|
||||
let mut s = lines[start..].join("\n");
|
||||
if s.chars().count() > DIALOG_CHARS {
|
||||
s = s.chars().rev().take(DIALOG_CHARS).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
s = format!("...{s}");
|
||||
}
|
||||
if start > 0 {
|
||||
s = format!("({start} earlier lines in the app log)\n{s}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The home folder as "~" in dialog text, so a screenshot or screen share does not carry the account name.
|
||||
pub fn tilde(text: &str, home: Option<&str>) -> String {
|
||||
match home.map(|h| h.trim_end_matches(['/', '\\'])) {
|
||||
Some(h) if h.len() > 1 => text.replace(h, "~"),
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human summary shown above the dry run in the confirm dialog.
|
||||
pub fn dialog_summary(title: &str, description: &str, project: &str, network: bool, destructive: bool) -> String {
|
||||
let mut s = format!("{title} · {project}\n\n{description}\n\n");
|
||||
s.push_str(if network { "This changes your store records (not public until you submit or release in the console)." } else { "This changes local files only." });
|
||||
if destructive {
|
||||
s.push_str("\nIt replaces what is there now.");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Native Apply / Cancel dialog; true on Apply.
|
||||
async fn confirm_dialog(window: &WebviewWindow, title: String, text: String) -> Res<bool> {
|
||||
let w = window.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
w.app_handle()
|
||||
.dialog()
|
||||
.message(text)
|
||||
.title(title)
|
||||
.kind(MessageDialogKind::Warning)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("Apply".into(), "Cancel".into()))
|
||||
.parent(&w)
|
||||
.blocking_show()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Fail::host(e.to_string()))
|
||||
}
|
||||
|
||||
/// Dry run, then a native dialog with the summary and the dry run, then (only on Apply) the real run with a confirm
|
||||
/// token minted here for exactly this method, project and arguments.
|
||||
#[tauri::command]
|
||||
pub async fn write_with_confirm(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, method: String, project_id: String, args: Option<Value>, job: Option<String>) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
let job = job_of(job)?;
|
||||
let r = state.sidecar.get(&app).await?;
|
||||
if r.kind(&method) != Some(Kind::Write) || method == "project.init" {
|
||||
return Err(Fail::host(format!("{method} is not a write method of an app folder")));
|
||||
}
|
||||
let d = r.descriptor(&method).cloned().ok_or_else(|| Fail::host("unknown method"))?;
|
||||
let args = match args.unwrap_or(json!({})) {
|
||||
Value::Object(o) => Value::Object(o),
|
||||
_ => return Err(Fail::host("args must be an object")),
|
||||
};
|
||||
let (proj, creds) = project_params(&state, &project_id)?;
|
||||
let mut dry_params = json!({ "project": proj.path, "args": args, "dryRun": true });
|
||||
if !creds.is_empty() {
|
||||
dry_params["credentials"] = Value::Object(creds.clone());
|
||||
}
|
||||
let dry = r.call_job(&method, dry_params, job.as_deref()).await?;
|
||||
if dry["ok"] != Value::Bool(true) {
|
||||
return Ok(json!({ "confirmed": false, "stage": "dry-run", "dryRun": dry }));
|
||||
}
|
||||
let text = format!(
|
||||
"{}\n\nDry run ({}):\n{}",
|
||||
dialog_summary(&d.title, &d.description, &proj.name, d.network, d.destructive),
|
||||
dry["command"].as_str().unwrap_or_default(),
|
||||
dialog_excerpt(dry["output"].as_str().unwrap_or_default())
|
||||
);
|
||||
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
||||
if !confirm_dialog(&window, format!("Apply: {}", d.title), tilde(&text, home.as_deref())).await? {
|
||||
return Ok(json!({ "confirmed": false, "stage": "dialog", "dryRun": dry }));
|
||||
}
|
||||
let bound = json!({ "project": proj.path, "args": args });
|
||||
let token = r.mint(&method, &bound).map_err(Fail::host)?;
|
||||
let mut real = json!({ "project": proj.path, "args": args, "dryRun": false, "confirmToken": token });
|
||||
if !creds.is_empty() {
|
||||
real["credentials"] = Value::Object(creds);
|
||||
}
|
||||
let result = r.call_job(&method, real, job.as_deref()).await?;
|
||||
Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Project>> {
|
||||
guard(&window)?;
|
||||
Ok(saved(&state)?.projects.clone())
|
||||
}
|
||||
|
||||
const CONFIG_NAMES: [&str; 3] = ["storebound.config.ts", "storebound.config.js", "storebound.config.json"];
|
||||
|
||||
pub fn has_config(dir: &Path) -> bool {
|
||||
CONFIG_NAMES.iter().any(|n| dir.join(n).is_file())
|
||||
}
|
||||
|
||||
async fn pick_folder(window: &WebviewWindow, title: &'static str) -> Res<Option<PathBuf>> {
|
||||
let w = window.clone();
|
||||
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title(title).set_parent(&w).blocking_pick_folder())
|
||||
.await
|
||||
.map_err(|e| Fail::host(e.to_string()))?;
|
||||
let Some(picked) = picked else { return Ok(None) };
|
||||
let dir = picked.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
||||
Ok(Some(std::fs::canonicalize(&dir).unwrap_or(dir)))
|
||||
}
|
||||
|
||||
/// Adds a folder that has a config (inspected by the sidecar) to the list.
|
||||
async fn add_folder(app: &AppHandle, state: &AppState, dir: &Path) -> Res<Project> {
|
||||
let path = dir.to_string_lossy().to_string();
|
||||
if saved(state)?.projects.iter().any(|p| p.path == path) {
|
||||
return Err(Fail::host("this folder is already in the list"));
|
||||
}
|
||||
let r = state.sidecar.get(app).await?;
|
||||
let summary = r.call("project.inspect", json!({ "project": path })).await?;
|
||||
let project = Project {
|
||||
id: store::new_id()?,
|
||||
path,
|
||||
name: summary["name"].as_str().unwrap_or("?").to_string(),
|
||||
play_account: None,
|
||||
apple_account: None,
|
||||
added: store::now_iso(),
|
||||
};
|
||||
let mut st = saved(state)?;
|
||||
st.projects.push(project.clone());
|
||||
persist(state, &st)?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Native folder picker; the folder must contain storebound.config.ts and load (project.inspect). None = cancelled.
|
||||
#[tauri::command]
|
||||
pub async fn project_add(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>) -> Res<Option<Project>> {
|
||||
guard(&window)?;
|
||||
let Some(dir) = pick_folder(&window, "Add an app folder (with storebound.config.ts)").await? else { return Ok(None) };
|
||||
if !has_config(&dir) {
|
||||
return Err(Fail::host(format!("no storebound.config.ts in {}", dir.display())));
|
||||
}
|
||||
add_folder(&app, &state, &dir).await.map(Some)
|
||||
}
|
||||
|
||||
pub const PROFILES: [&str; 3] = ["app", "game", "subscription-app"];
|
||||
|
||||
/// `storebound init` for a new app folder: native folder picker, the dry run in a native dialog, then the templates
|
||||
/// for `profile` are written (existing files kept) and the folder is added. None = cancelled at any step.
|
||||
#[tauri::command]
|
||||
pub async fn project_init(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, profile: String) -> Res<Option<Project>> {
|
||||
guard(&window)?;
|
||||
if !PROFILES.contains(&profile.as_str()) {
|
||||
return Err(Fail::host("profile: app, game or subscription-app"));
|
||||
}
|
||||
let Some(dir) = pick_folder(&window, "Choose the app's folder (storebound files go into it)").await? else { return Ok(None) };
|
||||
if has_config(&dir) {
|
||||
return Err(Fail::host("this folder already has a storebound config: add it instead"));
|
||||
}
|
||||
let path = dir.to_string_lossy().to_string();
|
||||
let args = json!({ "profile": profile });
|
||||
let r = state.sidecar.get(&app).await?;
|
||||
let dry = r.call("project.init", json!({ "project": path, "args": args, "dryRun": true })).await?;
|
||||
if dry["ok"] != Value::Bool(true) {
|
||||
return Err(Fail::host(format!("init dry run failed: {}", dialog_excerpt(dry["output"].as_str().unwrap_or_default()))));
|
||||
}
|
||||
let home = app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string());
|
||||
let text = format!(
|
||||
"Start an app folder · {profile} profile\n\n{path}\n\nCopies the storebound templates into this folder; existing files are kept. Every answer in them is a starting point to edit.\n\n{}",
|
||||
dialog_excerpt(dry["output"].as_str().unwrap_or_default())
|
||||
);
|
||||
if !confirm_dialog(&window, "Apply: Start an app folder".into(), tilde(&text, home.as_deref())).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let token = r.mint("project.init", &json!({ "project": path, "args": args })).map_err(Fail::host)?;
|
||||
let res = r.call("project.init", json!({ "project": path, "args": args, "dryRun": false, "confirmToken": token })).await?;
|
||||
if res["ok"] != Value::Bool(true) {
|
||||
return Err(Fail::host(format!("init failed: {}", dialog_excerpt(res["output"].as_str().unwrap_or_default()))));
|
||||
}
|
||||
add_folder(&app, &state, &dir).await.map(Some)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn project_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
||||
guard(&window)?;
|
||||
let mut st = saved(&state)?;
|
||||
st.projects.retain(|p| p.id != id);
|
||||
let _ = state.cache.forget_project(&id);
|
||||
persist(&state, &st)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, id: String, play: Option<String>, apple: Option<String>) -> Res<Project> {
|
||||
guard(&window)?;
|
||||
let mut st = saved(&state)?;
|
||||
let kind_of = |acc: &Option<String>| acc.as_ref().map(|a| st.accounts.iter().find(|x| &x.id == a).map(|x| x.kind));
|
||||
if let Some(k) = kind_of(&play) {
|
||||
if k != Some(AccountKind::Play) {
|
||||
return Err(Fail::host("not a Google Play account"));
|
||||
}
|
||||
}
|
||||
if let Some(k) = kind_of(&apple) {
|
||||
if k != Some(AccountKind::Apple) {
|
||||
return Err(Fail::host("not an App Store Connect account"));
|
||||
}
|
||||
}
|
||||
let p = st.projects.iter_mut().find(|p| p.id == id).ok_or_else(|| Fail::host("unknown project"))?;
|
||||
p.play_account = play;
|
||||
p.apple_account = apple;
|
||||
let out = p.clone();
|
||||
persist(&state, &st)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> {
|
||||
guard(&window)?;
|
||||
Ok(saved(&state)?.accounts.clone())
|
||||
}
|
||||
|
||||
fn add_account(state: &AppState, label: &str, secret: Secret) -> Res<Account> {
|
||||
let label = accounts::check_label(label).map_err(Fail::host)?;
|
||||
let acc = Account { id: store::new_id()?, label, kind: secret.kind(), created: store::now_iso() };
|
||||
state.secrets.set(&acc.id, &secret).map_err(Fail::host)?;
|
||||
let mut st = saved(state)?;
|
||||
st.accounts.push(acc.clone());
|
||||
if let Err(e) = persist(state, &st) {
|
||||
let _ = state.secrets.delete(&acc.id);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(acc)
|
||||
}
|
||||
|
||||
async fn pick_file(window: &WebviewWindow, title: &'static str, filter: (&'static str, &'static [&'static str])) -> Res<Option<String>> {
|
||||
let w = window.clone();
|
||||
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title(title).add_filter(filter.0, filter.1).set_parent(&w).blocking_pick_file())
|
||||
.await
|
||||
.map_err(|e| Fail::host(e.to_string()))?;
|
||||
let Some(p) = picked else { return Ok(None) };
|
||||
let path = p.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
||||
let meta = std::fs::metadata(&path).map_err(|e| Fail::host(e.to_string()))?;
|
||||
if meta.len() > 64 * 1024 {
|
||||
return Err(Fail::host("file too large for a key"));
|
||||
}
|
||||
std::fs::read_to_string(&path).map(Some).map_err(|e| Fail::host(e.to_string()))
|
||||
}
|
||||
|
||||
/// Google Play: pick the service account JSON key in a native dialog; it goes to the keychain only.
|
||||
#[tauri::command]
|
||||
pub async fn account_add_play(window: WebviewWindow, state: State<'_, AppState>, label: String) -> Res<Option<Account>> {
|
||||
guard(&window)?;
|
||||
accounts::check_label(&label).map_err(Fail::host)?;
|
||||
let Some(text) = pick_file(&window, "Google Play service account key (JSON)", ("JSON key", &["json"])).await? else { return Ok(None) };
|
||||
let sa = accounts::check_service_account(&text).map_err(Fail::host)?;
|
||||
add_account(&state, &label, Secret::Play { service_account: sa }).map(Some)
|
||||
}
|
||||
|
||||
/// App Store Connect: key id + issuer id typed in the app, the .p8 picked in a native dialog; all to the keychain.
|
||||
#[tauri::command]
|
||||
pub async fn account_add_apple(window: WebviewWindow, state: State<'_, AppState>, label: String, key_id: String, issuer_id: String) -> Res<Option<Account>> {
|
||||
guard(&window)?;
|
||||
accounts::check_label(&label).map_err(Fail::host)?;
|
||||
let key_id = accounts::check_key_id(&key_id).map_err(Fail::host)?;
|
||||
let issuer_id = accounts::check_issuer_id(&issuer_id).map_err(Fail::host)?;
|
||||
let Some(text) = pick_file(&window, "App Store Connect API key (.p8)", ("API key", &["p8"])).await? else { return Ok(None) };
|
||||
let p8 = accounts::check_p8(&text).map_err(Fail::host)?;
|
||||
add_account(&state, &label, Secret::Apple { key_id, issuer_id, p8 }).map(Some)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn account_remove(window: WebviewWindow, state: State<'_, AppState>, id: String) -> Res<()> {
|
||||
guard(&window)?;
|
||||
state.secrets.delete(&id).map_err(Fail::host)?;
|
||||
let mut st = saved(&state)?;
|
||||
st.accounts.retain(|a| a.id != id);
|
||||
for p in st.projects.iter_mut() {
|
||||
if p.play_account.as_deref() == Some(id.as_str()) {
|
||||
p.play_account = None;
|
||||
}
|
||||
if p.apple_account.as_deref() == Some(id.as_str()) {
|
||||
p.apple_account = None;
|
||||
}
|
||||
}
|
||||
persist(&state, &st)
|
||||
}
|
||||
|
||||
/// Store reads run at most this many apps at once (each is two status reads).
|
||||
const LIVE_PARALLEL: usize = 4;
|
||||
|
||||
/// `portfolio.status` over every project. Offline unless `live`; live reads every app with its own accounts, several
|
||||
/// at once, and sends one progress line per finished app (job progress n / total). Each live app result is cached;
|
||||
/// the whole live result too (project "*"), so the portfolio shows the last read when offline.
|
||||
#[tauri::command]
|
||||
pub async fn portfolio_status(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, live: Option<bool>, job: Option<String>) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
let job = job_of(job)?;
|
||||
let projects = saved(&state)?.projects.clone();
|
||||
if projects.is_empty() {
|
||||
return Ok(json!({ "apps": [], "at": store::now_iso() }));
|
||||
}
|
||||
let r = state.sidecar.get(&app).await?;
|
||||
if live != Some(true) {
|
||||
let paths: Vec<&str> = projects.iter().map(|p| p.path.as_str()).collect();
|
||||
return r.call_job("portfolio.status", json!({ "projects": paths }), job.as_deref()).await;
|
||||
}
|
||||
let total = projects.len();
|
||||
let sem = Arc::new(tokio::sync::Semaphore::new(LIVE_PARALLEL));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut handles = Vec::new();
|
||||
for p in &projects {
|
||||
let (_, creds) = project_params(&state, &p.id)?;
|
||||
let (r, sem, done, app, job, path, id) = (r.clone(), sem.clone(), done.clone(), app.clone(), job.clone(), p.path.clone(), p.id.clone());
|
||||
handles.push(tauri::async_runtime::spawn(async move {
|
||||
let _permit = sem.acquire_owned().await.ok();
|
||||
let sub = job.as_ref().map(|j| format!("{j}:p"));
|
||||
let res = r.call_job("portfolio.status", json!({ "projects": [path], "live": true, "credentials": creds }), sub.as_deref()).await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
let _ = app.emit_to(policy::MAIN, "sidecar-progress", Progress { params: json!({ "requestId": 0, "n": n, "total": total, "project": path }), job: job.clone() });
|
||||
(id, res)
|
||||
}));
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
let at = store::now_iso();
|
||||
for h in handles {
|
||||
let (id, res) = h.await.map_err(|e| Fail::host(e.to_string()))?;
|
||||
let res = res?;
|
||||
for a in res["apps"].as_array().cloned().unwrap_or_default() {
|
||||
let _ = state.cache.put(&id, "portfolio.app", &json!({}), &a, &at);
|
||||
all.push(a);
|
||||
}
|
||||
}
|
||||
let out = json!({ "apps": all, "at": at });
|
||||
let _ = state.cache.put("*", "portfolio.status", &json!({ "live": true }), &out, &at);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_external(window: WebviewWindow, app: AppHandle, url: String) -> Res<()> {
|
||||
guard(&window)?;
|
||||
let u = url::Url::parse(&url).map_err(|_| Fail::host("not a URL"))?;
|
||||
if !policy::external_allowed(&u) {
|
||||
return Err(Fail::host("only the store consoles and storebound.dev open from here"));
|
||||
}
|
||||
app.opener().open_url(u.as_str(), None::<&str>).map_err(|e| Fail::host(e.to_string()))
|
||||
}
|
||||
|
||||
/// What the Dependencies screen can download on this platform, and what is installed.
|
||||
#[tauri::command]
|
||||
pub fn tools_info(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<downloads::DownloadInfo>> {
|
||||
guard(&window)?;
|
||||
downloads::info(&state.data_dir).map_err(Fail::host)
|
||||
}
|
||||
|
||||
/// Downloads, verifies and installs ffmpeg or Chrome for Testing into the app data folder (progress: `tool-download`
|
||||
/// events with the job id; job_cancel stops it). The sidecar restarts afterwards so it sees the new tool.
|
||||
#[tauri::command]
|
||||
pub async fn tools_install(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, tool: String, job: String) -> Res<String> {
|
||||
guard(&window)?;
|
||||
if !valid_job(&job) || !matches!(tool.as_str(), "ffmpeg" | "chrome") {
|
||||
return Err(Fail::host("bad tool or job"));
|
||||
}
|
||||
let flag = state.downloads.start(&job).map_err(Fail::host)?;
|
||||
let handle = app.clone();
|
||||
let res = downloads::install(&state.data_dir, &tool, &job, flag, move |e| {
|
||||
let _ = handle.emit_to(policy::MAIN, "tool-download", e);
|
||||
})
|
||||
.await;
|
||||
state.downloads.finish(&job);
|
||||
if res.is_ok() {
|
||||
state.sidecar.shutdown().await;
|
||||
}
|
||||
res.map_err(Fail::host)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn tools_uninstall(window: WebviewWindow, state: State<'_, AppState>, tool: String) -> Res<()> {
|
||||
guard(&window)?;
|
||||
downloads::uninstall(&state.data_dir, &tool).map_err(Fail::host)?;
|
||||
state.sidecar.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/// Every app command the webview can call. build.rs turns each into an `allow-<name>` permission (underscores become
|
||||
/// dashes); capabilities/main.json grants them to the `main` window only; tests/security.rs checks both lists match.
|
||||
pub const COMMANDS: &[&str] = &[
|
||||
"app_info",
|
||||
"sidecar_status",
|
||||
"sidecar_call",
|
||||
"cache_get",
|
||||
"cache_clear",
|
||||
"job_cancel",
|
||||
"write_with_confirm",
|
||||
"projects_list",
|
||||
"project_add",
|
||||
"project_init",
|
||||
"project_remove",
|
||||
"project_set_accounts",
|
||||
"accounts_list",
|
||||
"account_add_play",
|
||||
"account_add_apple",
|
||||
"account_remove",
|
||||
"portfolio_status",
|
||||
"open_external",
|
||||
"tools_info",
|
||||
"tools_install",
|
||||
"tools_uninstall",
|
||||
];
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
//! Confirm tokens, host side. The same scheme as cli/lib/confirm-token.ts (the sidecar verifies):
|
||||
//!
|
||||
//! token = "sbt1." + expiry_ms + "." + nonce + "." + base64url(HMAC-SHA256(secret, payload))
|
||||
//! payload = "storebound-confirm-v1\n" + method + "\n" + canonical(params) + "\n" + expiry_ms + "\n" + nonce
|
||||
//!
|
||||
//! The secret is 32 random bytes made per sidecar start and handed over in `session.hello` on the sidecar's stdin; it
|
||||
//! never leaves this process otherwise. Tokens are minted only by `commands::write_with_confirm`, right after the owner
|
||||
//! pressed the confirm button of the native dialog that showed the dry run.
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
|
||||
pub const PREFIX: &str = "sbt1";
|
||||
pub const DOMAIN: &str = "storebound-confirm-v1";
|
||||
/// Lifetime of a minted token: the real call follows the dialog right away.
|
||||
pub const TTL_MS: u64 = 60_000;
|
||||
const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
|
||||
|
||||
/// Canonical JSON, byte for byte what the TypeScript side builds: keys sorted by UTF-16 code units, no whitespace,
|
||||
/// JSON string escaping, numbers only as safe integers, null members kept (JSON has no undefined).
|
||||
pub fn canonical(v: &Value) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
write_canonical(v, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_canonical(v: &Value, out: &mut String) -> Result<(), String> {
|
||||
match v {
|
||||
Value::Null => out.push_str("null"),
|
||||
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
|
||||
Value::Number(n) => {
|
||||
let i = n
|
||||
.as_i64()
|
||||
.filter(|i| (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(i))
|
||||
.ok_or("canonical: only safe integers are allowed as numbers")?;
|
||||
out.push_str(&i.to_string());
|
||||
}
|
||||
Value::String(s) => out.push_str(&serde_json::to_string(s).map_err(|e| e.to_string())?),
|
||||
Value::Array(a) => {
|
||||
out.push('[');
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_canonical(x, out)?;
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut keys: Vec<&String> = o.keys().collect();
|
||||
keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
|
||||
out.push('{');
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&serde_json::to_string(k).map_err(|e| e.to_string())?);
|
||||
out.push(':');
|
||||
write_canonical(&o[k.as_str()], out)?;
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn payload(method: &str, params: &Value, expiry: u64, nonce: &str) -> Result<String, String> {
|
||||
Ok(format!("{DOMAIN}\n{method}\n{}\n{expiry}\n{nonce}", canonical(params)?))
|
||||
}
|
||||
|
||||
pub fn mac(secret: &[u8], method: &str, params: &Value, expiry: u64, nonce: &str) -> Result<String, String> {
|
||||
let mut m = Hmac::<Sha256>::new_from_slice(secret).map_err(|e| e.to_string())?;
|
||||
m.update(payload(method, params, expiry, nonce)?.as_bytes());
|
||||
Ok(URL_SAFE_NO_PAD.encode(m.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// A token for exactly (method, params), valid for TTL_MS from `now_ms`.
|
||||
pub fn mint(secret: &[u8], method: &str, params: &Value, now_ms: u64, nonce: &str) -> Result<String, String> {
|
||||
let expiry = now_ms + TTL_MS;
|
||||
Ok(format!("{PREFIX}.{expiry}.{nonce}.{}", mac(secret, method, params, expiry, nonce)?))
|
||||
}
|
||||
|
||||
/// 16 random bytes, base64url (22 characters).
|
||||
pub fn new_nonce() -> Result<String, String> {
|
||||
let mut b = [0u8; 16];
|
||||
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(b))
|
||||
}
|
||||
|
||||
/// The per-session secret.
|
||||
pub fn new_secret() -> Result<[u8; 32], String> {
|
||||
let mut b = [0u8; 32];
|
||||
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
pub fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
//! One-click downloads of the optional local tools (Dependencies screen) into the app data folder, never system-wide
|
||||
//! and never bundled with the installer (docs/desktop.md):
|
||||
//!
|
||||
//! - **ffmpeg + ffprobe**: the exact archives of `tools-manifest.json` (URL, SHA-256, size; written by
|
||||
//! tools/tools-manifest.ts per desktop release). The SHA-256 is checked while downloading; a mismatch deletes the file.
|
||||
//! - **Chrome for Testing**: Google's official JSON endpoint (Stable channel), archive from storage.googleapis.com. No
|
||||
//! hashes are published, so the unpacked browser must carry Google's code signature (macOS team EQHXZ8M8AV,
|
||||
//! Windows Authenticode by Google LLC); on Linux the version banner is checked.
|
||||
//!
|
||||
//! Everything happens in a staging folder; only a verified result replaces the installed copy. Progress goes to the
|
||||
//! window as `tool-download` events with the job id; cancel removes the staging folder. Redirects may only lead to
|
||||
//! the hosts in DOWNLOAD_HOSTS, over HTTPS.
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
pub const MANIFEST_JSON: &str = include_str!("../tools-manifest.json");
|
||||
|
||||
/// Hosts a download (or one of its redirects) may touch.
|
||||
pub const DOWNLOAD_HOSTS: &[&str] = &[
|
||||
"ffmpeg.martin-riedl.de",
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"release-assets.githubusercontent.com",
|
||||
"googlechromelabs.github.io",
|
||||
"storage.googleapis.com",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct FileMap {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Archive {
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub kind: String,
|
||||
pub files: Vec<FileMap>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Ffmpeg {
|
||||
pub version: String,
|
||||
pub license: String,
|
||||
pub license_url: String,
|
||||
pub sources: HashMap<String, String>,
|
||||
pub targets: HashMap<String, Vec<Archive>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Chrome {
|
||||
pub endpoint: String,
|
||||
pub channel: String,
|
||||
pub platforms: HashMap<String, String>,
|
||||
pub mac_team_id: String,
|
||||
pub windows_signer: String,
|
||||
pub license: String,
|
||||
pub license_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Manifest {
|
||||
pub generated: String,
|
||||
pub ffmpeg: Ffmpeg,
|
||||
pub chrome: Chrome,
|
||||
}
|
||||
|
||||
pub fn manifest() -> Result<Manifest, String> {
|
||||
serde_json::from_str(MANIFEST_JSON).map_err(|e| format!("tools manifest: {e}"))
|
||||
}
|
||||
|
||||
/// Rust target triple of this build (the manifest's keys).
|
||||
pub fn target_triple() -> &'static str {
|
||||
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
"aarch64-apple-darwin"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"x86_64-apple-darwin"
|
||||
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
||||
"aarch64-unknown-linux-gnu"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"x86_64-unknown-linux-gnu"
|
||||
} else {
|
||||
"x86_64-pc-windows-msvc"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn download_allowed(u: &Url) -> bool {
|
||||
u.scheme() == "https" && u.username().is_empty() && u.password().is_none() && u.port().is_none() && u.host_str().is_some_and(|h| DOWNLOAD_HOSTS.contains(&h))
|
||||
}
|
||||
|
||||
pub fn hex(b: &[u8]) -> String {
|
||||
crate::confirm::hex(b)
|
||||
}
|
||||
|
||||
/// What the Dependencies screen shows next to the Download buttons.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadInfo {
|
||||
pub tool: &'static str,
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub license: String,
|
||||
pub license_url: String,
|
||||
pub source: String,
|
||||
pub installed: bool,
|
||||
pub folder: String,
|
||||
}
|
||||
|
||||
pub fn info(data_dir: &Path) -> Result<Vec<DownloadInfo>, String> {
|
||||
let m = manifest()?;
|
||||
let t = target_triple();
|
||||
let ff = m.ffmpeg.targets.get(t);
|
||||
let os = if t.contains("apple") { "macos" } else if t.contains("windows") { "windows" } else { "linux" };
|
||||
let exe = if cfg!(windows) { ".exe" } else { "" };
|
||||
Ok(vec![
|
||||
DownloadInfo {
|
||||
tool: "ffmpeg",
|
||||
available: ff.is_some(),
|
||||
version: Some(m.ffmpeg.version.clone()),
|
||||
size: ff.map(|a| a.iter().map(|x| x.size).sum()),
|
||||
license: m.ffmpeg.license.clone(),
|
||||
license_url: m.ffmpeg.license_url.clone(),
|
||||
source: m.ffmpeg.sources.get(os).cloned().unwrap_or_default(),
|
||||
installed: data_dir.join("tools").join("bin").join(format!("ffmpeg{exe}")).is_file(),
|
||||
folder: "tools/bin".into(),
|
||||
},
|
||||
DownloadInfo {
|
||||
tool: "chrome",
|
||||
available: m.chrome.platforms.contains_key(t),
|
||||
version: None,
|
||||
size: None,
|
||||
license: m.chrome.license.clone(),
|
||||
license_url: m.chrome.license_url.clone(),
|
||||
source: "https://googlechromelabs.github.io/chrome-for-testing/".into(),
|
||||
installed: data_dir.join("tools").join("chrome").join(crate::policy::chrome_executable()).is_file(),
|
||||
folder: "tools/chrome".into(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/// Progress of a download job, sent to the window as `tool-download`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Event {
|
||||
pub job: String,
|
||||
pub tool: String,
|
||||
/// resolve, download, verify, unpack, install, done, error, cancelled
|
||||
pub stage: &'static str,
|
||||
pub received: u64,
|
||||
pub total: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Running download jobs and their cancel flags.
|
||||
#[derive(Default)]
|
||||
pub struct Jobs {
|
||||
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub fn start(&self, job: &str) -> Result<Arc<AtomicBool>, String> {
|
||||
let mut m = self.flags.lock().map_err(|_| "jobs poisoned")?;
|
||||
if m.contains_key(job) {
|
||||
return Err("this download is already running".into());
|
||||
}
|
||||
let f = Arc::new(AtomicBool::new(false));
|
||||
m.insert(job.to_string(), f.clone());
|
||||
Ok(f)
|
||||
}
|
||||
pub fn finish(&self, job: &str) {
|
||||
if let Ok(mut m) = self.flags.lock() {
|
||||
m.remove(job);
|
||||
}
|
||||
}
|
||||
pub fn cancel(&self, job: &str) -> bool {
|
||||
self.flags.lock().ok().and_then(|m| m.get(job).map(|f| f.store(true, Ordering::SeqCst))).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Chrome for Testing download of a platform from the endpoint JSON: (version, url).
|
||||
pub fn chrome_download(endpoint_json: &serde_json::Value, channel: &str, platform: &str) -> Result<(String, Url), String> {
|
||||
let ch = &endpoint_json["channels"][channel];
|
||||
let version = ch["version"].as_str().ok_or("Chrome for Testing: no version in the endpoint answer")?.to_string();
|
||||
let url = ch["downloads"]["chrome"]
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().find(|d| d["platform"].as_str() == Some(platform)))
|
||||
.and_then(|d| d["url"].as_str())
|
||||
.ok_or_else(|| format!("Chrome for Testing: no download for {platform}"))?;
|
||||
let u = Url::parse(url).map_err(|e| e.to_string())?;
|
||||
if !download_allowed(&u) {
|
||||
return Err(format!("Chrome for Testing: unexpected download host {}", u.host_str().unwrap_or("?")));
|
||||
}
|
||||
Ok((version, u))
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, String> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
reqwest::Client::builder()
|
||||
.user_agent(concat!("storebound-desktop/", env!("CARGO_PKG_VERSION")))
|
||||
.redirect(reqwest::redirect::Policy::custom(|a| {
|
||||
if a.previous().len() > 8 {
|
||||
a.error("too many redirects")
|
||||
} else if download_allowed(a.url()) {
|
||||
a.follow()
|
||||
} else {
|
||||
a.error("redirect to a host outside the download list")
|
||||
}
|
||||
}))
|
||||
.connect_timeout(std::time::Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Streams `url` into `dest`, returning its SHA-256 (hex). `progress(received_in_this_file)` after each chunk.
|
||||
async fn fetch(c: &reqwest::Client, url: &Url, dest: &Path, cancel: &AtomicBool, mut progress: impl FnMut(u64)) -> Result<String, String> {
|
||||
if !download_allowed(url) {
|
||||
return Err(format!("not an allowed download host: {}", url.host_str().unwrap_or("?")));
|
||||
}
|
||||
let mut resp = c.get(url.clone()).send().await.map_err(|e| format!("download: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("download: {} answered {}", url.host_str().unwrap_or("?"), resp.status()));
|
||||
}
|
||||
let mut f = fs::File::create(dest).map_err(|e| e.to_string())?;
|
||||
let mut h = Sha256::new();
|
||||
let mut got: u64 = 0;
|
||||
while let Some(chunk) = resp.chunk().await.map_err(|e| format!("download: {e}"))? {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
h.update(&chunk);
|
||||
f.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
got += chunk.len() as u64;
|
||||
progress(got);
|
||||
}
|
||||
f.flush().map_err(|e| e.to_string())?;
|
||||
Ok(hex(&h.finalize()))
|
||||
}
|
||||
|
||||
/// Unpacks an archive into `dir`. macOS: ditto (keeps the app bundles' symlinks and signatures intact); tar.xz: the
|
||||
/// system tar; other zips: the zip crate, which refuses entries outside `dir`.
|
||||
pub fn unpack(archive: &Path, kind: &str, dir: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let run = |cmd: &mut std::process::Command| -> Result<(), String> {
|
||||
let out = cmd.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().chars().take(300).collect())
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
"tar.xz" => run(std::process::Command::new("tar").arg("-xJf").arg(archive).arg("-C").arg(dir)),
|
||||
"zip" if cfg!(target_os = "macos") => run(std::process::Command::new("/usr/bin/ditto").arg("-x").arg("-k").arg(archive).arg(dir)),
|
||||
"zip" => {
|
||||
let f = fs::File::open(archive).map_err(|e| e.to_string())?;
|
||||
let mut z = zip::ZipArchive::new(f).map_err(|e| e.to_string())?;
|
||||
z.extract(dir).map_err(|e| e.to_string())
|
||||
}
|
||||
k => Err(format!("unknown archive kind {k}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `rel` inside `base`, refusing absolute paths and `..`.
|
||||
pub fn inside(base: &Path, rel: &str) -> Result<PathBuf, String> {
|
||||
let p = Path::new(rel);
|
||||
if p.is_absolute() || p.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
|
||||
return Err(format!("unsafe path in the manifest: {rel}"));
|
||||
}
|
||||
Ok(base.join(p))
|
||||
}
|
||||
|
||||
fn first_line(exe: &Path, arg: &str) -> Option<String> {
|
||||
let out = std::process::Command::new(exe).arg(arg).output().ok()?;
|
||||
let text = if out.stdout.is_empty() { out.stderr } else { out.stdout };
|
||||
String::from_utf8_lossy(&text).lines().next().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_executable(p: &Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(p, fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn make_executable(_: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Google's code signature on the unpacked Chrome for Testing.
|
||||
fn verify_chrome(root: &Path, exe: &Path, c: &Chrome) -> Result<(), String> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let app = root.join("Google Chrome for Testing.app");
|
||||
let v = std::process::Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(&app).output().map_err(|e| e.to_string())?;
|
||||
if !v.status.success() {
|
||||
return Err(format!("code signature invalid: {}", String::from_utf8_lossy(&v.stderr).trim()));
|
||||
}
|
||||
let d = std::process::Command::new("/usr/bin/codesign").args(["-dv", "--verbose=2"]).arg(&app).output().map_err(|e| e.to_string())?;
|
||||
let text = String::from_utf8_lossy(&d.stderr);
|
||||
if !text.lines().any(|l| l.trim() == format!("TeamIdentifier={}", c.mac_team_id)) {
|
||||
return Err("not signed by Google (team identifier differs)".into());
|
||||
}
|
||||
Ok(())
|
||||
} else if cfg!(windows) {
|
||||
let script = format!(
|
||||
"$s = Get-AuthenticodeSignature -LiteralPath '{}'; if ($s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '*O={}*') {{ exit 0 }} else {{ exit 1 }}",
|
||||
exe.display().to_string().replace('\'', "''"),
|
||||
c.windows_signer
|
||||
);
|
||||
let st = std::process::Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", &script]).status().map_err(|e| e.to_string())?;
|
||||
if st.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Authenticode signature by {} not found", c.windows_signer))
|
||||
}
|
||||
} else {
|
||||
match first_line(exe, "--version") {
|
||||
Some(l) if l.starts_with("Google Chrome for Testing") => Ok(()),
|
||||
other => Err(format!("unexpected version banner: {}", other.unwrap_or_default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs `tool` ("ffmpeg" or "chrome") into `<data_dir>/tools`. `emit` gets every progress event.
|
||||
pub async fn install(data_dir: &Path, tool: &str, job: &str, cancel: Arc<AtomicBool>, emit: impl Fn(Event) + Send + Sync) -> Result<String, String> {
|
||||
let m = manifest()?;
|
||||
let tools = data_dir.join("tools");
|
||||
let staging = tools.join(format!(".staging-{job}"));
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
fs::create_dir_all(&staging).map_err(|e| e.to_string())?;
|
||||
let ev = |stage: &'static str, received: u64, total: Option<u64>, message: Option<String>| emit(Event { job: job.into(), tool: tool.into(), stage, received, total, message });
|
||||
let res = async {
|
||||
let c = client()?;
|
||||
match tool {
|
||||
"ffmpeg" => {
|
||||
let archives = m.ffmpeg.targets.get(target_triple()).ok_or("no ffmpeg build for this platform in the manifest")?;
|
||||
let total: u64 = archives.iter().map(|a| a.size).sum();
|
||||
let mut done: u64 = 0;
|
||||
let bin = staging.join("bin");
|
||||
fs::create_dir_all(&bin).map_err(|e| e.to_string())?;
|
||||
for (i, a) in archives.iter().enumerate() {
|
||||
let url = Url::parse(&a.url).map_err(|e| e.to_string())?;
|
||||
let file = staging.join(format!("a{i}.{}", a.kind));
|
||||
let sum = fetch(&c, &url, &file, &cancel, |n| ev("download", done + n, Some(total), None)).await?;
|
||||
ev("verify", done + a.size, Some(total), None);
|
||||
if !sum.eq_ignore_ascii_case(&a.sha256) {
|
||||
let _ = fs::remove_file(&file);
|
||||
return Err(format!("checksum mismatch for {} (deleted)", url.path()));
|
||||
}
|
||||
done += a.size;
|
||||
ev("unpack", done, Some(total), None);
|
||||
let out = staging.join(format!("u{i}"));
|
||||
unpack(&file, &a.kind, &out)?;
|
||||
for f in &a.files {
|
||||
let src = inside(&out, &f.from)?;
|
||||
let dst = inside(&bin, &f.to)?;
|
||||
if !src.is_file() {
|
||||
return Err(format!("{} missing in the archive", f.from));
|
||||
}
|
||||
fs::rename(&src, &dst).or_else(|_| fs::copy(&src, &dst).map(|_| ())).map_err(|e| e.to_string())?;
|
||||
make_executable(&dst)?;
|
||||
}
|
||||
let _ = fs::remove_file(&file);
|
||||
}
|
||||
let exe = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
|
||||
let banner = first_line(&bin.join(exe), "-version").unwrap_or_default();
|
||||
if !banner.starts_with("ffmpeg version") {
|
||||
return Err(format!("the downloaded ffmpeg does not run: {banner}"));
|
||||
}
|
||||
ev("install", total, Some(total), None);
|
||||
let dest = tools.join("bin");
|
||||
fs::create_dir_all(&dest).map_err(|e| e.to_string())?;
|
||||
for e in fs::read_dir(&bin).map_err(|e| e.to_string())? {
|
||||
let e = e.map_err(|e| e.to_string())?;
|
||||
let target = dest.join(e.file_name());
|
||||
let _ = fs::remove_file(&target);
|
||||
fs::rename(e.path(), &target).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(banner)
|
||||
}
|
||||
"chrome" => {
|
||||
let platform = m.chrome.platforms.get(target_triple()).ok_or("Chrome for Testing has no build for this platform")?;
|
||||
ev("resolve", 0, None, None);
|
||||
let endpoint = Url::parse(&m.chrome.endpoint).map_err(|e| e.to_string())?;
|
||||
if !download_allowed(&endpoint) {
|
||||
return Err("endpoint host not allowed".into());
|
||||
}
|
||||
let json: serde_json::Value = c.get(endpoint).send().await.map_err(|e| e.to_string())?.json().await.map_err(|e| e.to_string())?;
|
||||
let (version, url) = chrome_download(&json, &m.chrome.channel, platform)?;
|
||||
let head = c.head(url.clone()).send().await.ok();
|
||||
let total = head.and_then(|h| h.content_length());
|
||||
let file = staging.join("chrome.zip");
|
||||
fetch(&c, &url, &file, &cancel, |n| ev("download", n, total, Some(version.clone()))).await?;
|
||||
ev("unpack", total.unwrap_or(0), total, None);
|
||||
let out = staging.join("u");
|
||||
unpack(&file, "zip", &out)?;
|
||||
let _ = fs::remove_file(&file);
|
||||
let root = out.join(format!("chrome-{platform}"));
|
||||
let exe = root.join(crate::policy::chrome_executable());
|
||||
if !exe.is_file() {
|
||||
return Err("Chrome for Testing archive has an unexpected layout".into());
|
||||
}
|
||||
ev("verify", total.unwrap_or(0), total, None);
|
||||
verify_chrome(&root, &exe, &m.chrome)?;
|
||||
ev("install", total.unwrap_or(0), total, None);
|
||||
let dest = tools.join("chrome");
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
fs::rename(&root, &dest).map_err(|e| e.to_string())?;
|
||||
Ok(format!("Chrome for Testing {version}"))
|
||||
}
|
||||
other => Err(format!("unknown tool {other}")),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
match &res {
|
||||
Ok(v) => ev("done", 0, None, Some(v.clone())),
|
||||
Err(e) if e == "cancelled" => ev("cancelled", 0, None, None),
|
||||
Err(e) => ev("error", 0, None, Some(e.clone())),
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Removes an app-managed tool.
|
||||
pub fn uninstall(data_dir: &Path, tool: &str) -> Result<(), String> {
|
||||
let tools = data_dir.join("tools");
|
||||
match tool {
|
||||
"ffmpeg" => {
|
||||
for n in ["ffmpeg", "ffprobe", "ffmpeg.exe", "ffprobe.exe"] {
|
||||
let _ = fs::remove_file(tools.join("bin").join(n));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"chrome" => {
|
||||
let p = tools.join("chrome");
|
||||
if p.exists() {
|
||||
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
other => Err(format!("unknown tool {other}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
//! storebound desktop: a Tauri shell around the storebound CLI, which runs as a sidecar (`storebound serve --stdio`).
|
||||
//! The GUI never talks to a store itself; every store call is a storebound command.
|
||||
pub mod accounts;
|
||||
pub mod cache;
|
||||
pub mod commands;
|
||||
pub mod confirm;
|
||||
pub mod downloads;
|
||||
pub mod policy;
|
||||
pub mod sidecar;
|
||||
pub mod store;
|
||||
|
||||
include!("commands_list.rs");
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID};
|
||||
use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
/// WebKitGTK's DMABUF renderer leaves the window blank on some Linux GPU / driver / VM combinations. Turn it off unless
|
||||
/// the user set the variable (WEBKIT_DISABLE_DMABUF_RENDERER=0 keeps DMABUF). Must run before any webview exists.
|
||||
pub fn linux_webkit_env() {
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
// SAFETY: called first thing in main, before any thread is started.
|
||||
unsafe { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1") };
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main<R: Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(w) = app.get_webview_window(policy::MAIN) {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS menu: app menu with Quit (Cmd+Q really quits), Edit (copy / paste in text fields), and a Window menu that
|
||||
/// keeps a permanent entry for the main window, since the automatic list drops a hidden window.
|
||||
fn menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
let name = "storebound";
|
||||
let app_menu = Submenu::with_items(
|
||||
app,
|
||||
name,
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(app, Some("About storebound"), None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::services(app, None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::hide(app, None)?,
|
||||
&PredefinedMenuItem::hide_others(app, None)?,
|
||||
&PredefinedMenuItem::show_all(app, None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::quit(app, Some("Quit storebound"))?,
|
||||
],
|
||||
)?;
|
||||
let edit = Submenu::with_items(
|
||||
app,
|
||||
"Edit",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::undo(app, None)?,
|
||||
&PredefinedMenuItem::redo(app, None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::cut(app, None)?,
|
||||
&PredefinedMenuItem::copy(app, None)?,
|
||||
&PredefinedMenuItem::paste(app, None)?,
|
||||
&PredefinedMenuItem::select_all(app, None)?,
|
||||
],
|
||||
)?;
|
||||
let window = Submenu::with_id_and_items(
|
||||
app,
|
||||
WINDOW_SUBMENU_ID,
|
||||
"Window",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::minimize(app, None)?,
|
||||
&PredefinedMenuItem::maximize(app, Some("Zoom"))?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&MenuItem::with_id(app, "show-main", "storebound", true, Some("CmdOrCtrl+0"))?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::close_window(app, None)?,
|
||||
],
|
||||
)?;
|
||||
Menu::with_items(app, &[&app_menu, &edit, &window])
|
||||
}
|
||||
|
||||
fn handle_deep_link<R: Runtime>(app: &AppHandle<R>, urls: &[url::Url]) {
|
||||
if let Some(screen) = urls.iter().find_map(policy::deep_link_screen) {
|
||||
show_main(app);
|
||||
let _ = app.emit_to(policy::MAIN, "deep-link", screen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug builds only: STOREBOUND_DEV_PROJECTS (folders separated by the OS path separator) seeds the app folder list
|
||||
/// once, for screenshots and manual checks with the demo apps. Release builds ignore it.
|
||||
fn dev_seed(data_dir: &std::path::Path) {
|
||||
if !cfg!(debug_assertions) {
|
||||
return;
|
||||
}
|
||||
let Some(v) = std::env::var_os("STOREBOUND_DEV_PROJECTS") else { return };
|
||||
let mut st = store::load(data_dir);
|
||||
let mut changed = false;
|
||||
for dir in std::env::split_paths(&v) {
|
||||
let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
|
||||
let path = dir.to_string_lossy().to_string();
|
||||
if !commands::has_config(&dir) || st.projects.iter().any(|p| p.path == path) {
|
||||
continue;
|
||||
}
|
||||
let name = dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if let Ok(id) = store::new_id() {
|
||||
st.projects.push(store::Project { id, path, name, play_account: None, apple_account: None, added: store::now_iso() });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let _ = store::save(data_dir, &st);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
linux_webkit_env();
|
||||
let app = tauri::Builder::default()
|
||||
// single-instance first: a second start (or a storebound:// link) focuses the running app instead
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app)))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::new().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.setup(|app| {
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
dev_seed(&data_dir);
|
||||
let cache = cache::Cache::open(&data_dir).or_else(|_| cache::Cache::in_memory()).map_err(std::io::Error::other)?;
|
||||
app.manage(commands::AppState {
|
||||
sidecar: sidecar::Sidecar::default(),
|
||||
saved: Mutex::new(store::load(&data_dir)),
|
||||
data_dir,
|
||||
secrets: Box::new(accounts::Keychain),
|
||||
cache,
|
||||
downloads: downloads::Jobs::default(),
|
||||
});
|
||||
#[cfg(target_os = "macos")]
|
||||
app.set_menu(menu(app.handle())?)?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = menu::<tauri::Wry>;
|
||||
app.on_menu_event(|app, e| {
|
||||
if e.id() == "show-main" {
|
||||
show_main(app);
|
||||
}
|
||||
});
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
app.deep_link().register_all()?;
|
||||
let handle = app.handle().clone();
|
||||
app.deep_link().on_open_url(move |e| handle_deep_link(&handle, &e.urls()));
|
||||
if let Ok(Some(urls)) = app.deep_link().get_current() {
|
||||
handle_deep_link(app.handle(), &urls);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|w, e| {
|
||||
// macOS: the close button hides the window (the app stays in the Dock and the Window menu);
|
||||
// Cmd+Q and the app menu's Quit still quit. Elsewhere closing the window quits.
|
||||
if cfg!(target_os = "macos") && w.label() == policy::MAIN {
|
||||
if let WindowEvent::CloseRequested { api, .. } = e {
|
||||
api.prevent_close();
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::app_info,
|
||||
commands::sidecar_status,
|
||||
commands::sidecar_call,
|
||||
commands::cache_get,
|
||||
commands::cache_clear,
|
||||
commands::job_cancel,
|
||||
commands::write_with_confirm,
|
||||
commands::projects_list,
|
||||
commands::project_add,
|
||||
commands::project_init,
|
||||
commands::project_remove,
|
||||
commands::project_set_accounts,
|
||||
commands::accounts_list,
|
||||
commands::account_add_play,
|
||||
commands::account_add_apple,
|
||||
commands::account_remove,
|
||||
commands::portfolio_status,
|
||||
commands::open_external,
|
||||
commands::tools_info,
|
||||
commands::tools_install,
|
||||
commands::tools_uninstall,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("storebound desktop failed to start");
|
||||
|
||||
app.run(|app, e| match e {
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen { .. } => show_main(app),
|
||||
RunEvent::Exit => {
|
||||
if let Some(s) = app.try_state::<commands::AppState>() {
|
||||
s.sidecar.shutdown_now();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// No console window on Windows release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
storebound_desktop::run();
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
//! Who may call what. Pure functions, so tests/security.rs can pin every rule.
|
||||
//!
|
||||
//! The capability file already limits the app commands to the `main` window with local content; every command checks
|
||||
//! again here (window label and the exact origin of the page that called), so a future second window, a stray
|
||||
//! navigation or a remote page can never reach them, whatever the capability files say.
|
||||
use serde_json::{Map, Value};
|
||||
use url::Url;
|
||||
|
||||
/// The one window that may call app commands.
|
||||
pub const MAIN: &str = "main";
|
||||
|
||||
/// The only arguments the sidecar is ever started with.
|
||||
pub const SIDECAR_ARGS: [&str; 2] = ["serve", "--stdio"];
|
||||
/// Name of the sidecar binary (`bundle.externalBin` = binaries/storebound).
|
||||
pub const SIDECAR: &str = "storebound";
|
||||
|
||||
/// Dev server origin (`build.devUrl`); accepted only in debug builds.
|
||||
pub const DEV_HOST: &str = "127.0.0.1";
|
||||
pub const DEV_PORT: u16 = 1420;
|
||||
|
||||
/// The app's own bundled pages: `tauri://localhost` (macOS, Linux) or `http(s)://tauri.localhost` (Windows).
|
||||
/// No port, no user info, nothing that only looks similar. In debug builds also the Vite dev server.
|
||||
pub fn is_local_origin(url: &Url, dev: bool) -> bool {
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return false;
|
||||
}
|
||||
let host = url.host_str().unwrap_or_default();
|
||||
match url.scheme() {
|
||||
"tauri" => host == "localhost" && url.port().is_none(),
|
||||
"http" | "https" if host == "tauri.localhost" => url.port().is_none(),
|
||||
"http" if dev => host == DEV_HOST && url.port() == Some(DEV_PORT),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_main(label: &str, url: &Url, dev: bool) -> Result<(), String> {
|
||||
if label != MAIN {
|
||||
return Err(format!("not allowed from window '{label}'"));
|
||||
}
|
||||
if !is_local_origin(url, dev) {
|
||||
return Err("not allowed from this page".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Method kinds the sidecar announces in its `session.hello` descriptors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Read,
|
||||
Write,
|
||||
System,
|
||||
Portfolio,
|
||||
Session,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn parse(s: &str) -> Option<Kind> {
|
||||
Some(match s {
|
||||
"read" => Kind::Read,
|
||||
"write" => Kind::Write,
|
||||
"system" => Kind::System,
|
||||
"portfolio" => Kind::Portfolio,
|
||||
"session" => Kind::Session,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Params keys the webview may send for a sidecar method. The project folder comes from the host's project list (the
|
||||
/// webview names a project id), and `confirmToken`, `credentials` and `dryRun` are the host's: tokens are minted only
|
||||
/// after the native dialog, credentials come from the keychain.
|
||||
const JS_PARAM_KEYS: &[&str] = &["args", "live"];
|
||||
|
||||
/// Checks a webview call to the sidecar and returns the params to send. Writes from the webview are always dry runs;
|
||||
/// the real run goes through `write_with_confirm` (native dialog, then a host-minted token).
|
||||
pub fn sanitize_js_call(method: &str, kind: Option<Kind>, params: &Value) -> Result<Value, String> {
|
||||
let kind = kind.ok_or_else(|| format!("unknown method: {method}"))?;
|
||||
match kind {
|
||||
Kind::Session => return Err(format!("{method} is the host's")),
|
||||
Kind::Portfolio => return Err(format!("{method}: use portfolio_status")),
|
||||
_ => {}
|
||||
}
|
||||
let empty = Map::new();
|
||||
let obj = match params {
|
||||
Value::Object(o) => o,
|
||||
Value::Null => &empty,
|
||||
_ => return Err("params must be an object".into()),
|
||||
};
|
||||
for k in obj.keys() {
|
||||
if !JS_PARAM_KEYS.contains(&k.as_str()) {
|
||||
return Err(format!("param '{k}' is not allowed from the window"));
|
||||
}
|
||||
}
|
||||
let mut out = obj.clone();
|
||||
if kind == Kind::Write {
|
||||
out.insert("dryRun".into(), Value::Bool(true));
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// Hosts `open_external` may open in the browser: the two store consoles and storebound's own site.
|
||||
pub const EXTERNAL_HOSTS: &[&str] = &[
|
||||
"play.google.com",
|
||||
"appstoreconnect.apple.com",
|
||||
"developer.apple.com",
|
||||
"storebound.dev",
|
||||
// Dependencies screen link-outs
|
||||
"developer.android.com",
|
||||
"ffmpeg.org",
|
||||
"googlechromelabs.github.io",
|
||||
"git-scm.com",
|
||||
];
|
||||
|
||||
/// Executable of a Chrome for Testing download, relative to its unpacked folder (app data `tools/chrome/`).
|
||||
pub fn chrome_executable() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
|
||||
} else if cfg!(windows) {
|
||||
"chrome.exe"
|
||||
} else {
|
||||
"chrome"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn external_allowed(url: &Url) -> bool {
|
||||
url.scheme() == "https"
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.port().is_none()
|
||||
&& url.host_str().is_some_and(|h| EXTERNAL_HOSTS.contains(&h))
|
||||
}
|
||||
|
||||
/// Deep links the app reacts to: `storebound://<screen>` for a known screen, nothing else.
|
||||
pub const DEEP_LINK_SCREENS: &[&str] = &["portfolio", "apps", "accounts", "dependencies", "settings"];
|
||||
|
||||
pub fn deep_link_screen(url: &Url) -> Option<&'static str> {
|
||||
if url.scheme() != "storebound" {
|
||||
return None;
|
||||
}
|
||||
let host = url.host_str().unwrap_or_default();
|
||||
DEEP_LINK_SCREENS.iter().copied().find(|s| *s == host)
|
||||
}
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
//! The storebound sidecar: `storebound serve --stdio`, started from Rust only (no capability grants the shell
|
||||
//! plugin to the webview), with the fixed arguments in policy::SIDECAR_ARGS. One process per app run, started on
|
||||
//! first use and again after it stops. Its first request is `session.hello` with a fresh 32-byte secret, so only this
|
||||
//! process can mint the confirm tokens the sidecar accepts for real writes.
|
||||
//!
|
||||
//! Jobs: the webview may tag a call with a short job id. Progress notifications of that request then carry the job id
|
||||
//! (so the right toast or screen shows them), and `cancel_job` sends `$/cancelRequest` for every request of the job.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::confirm;
|
||||
use crate::policy::{self, Kind};
|
||||
|
||||
/// A failed call, as the webview sees it.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Fail {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Value::is_null")]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl Fail {
|
||||
pub fn new(code: i64, message: impl Into<String>) -> Fail {
|
||||
Fail { code, message: message.into(), data: Value::Null }
|
||||
}
|
||||
/// The host refused (window, origin, params, state).
|
||||
pub fn host(message: impl Into<String>) -> Fail {
|
||||
Fail::new(-1, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Fail {
|
||||
fn from(s: String) -> Fail {
|
||||
Fail::host(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Descriptor {
|
||||
pub method: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub kind: String,
|
||||
pub network: bool,
|
||||
pub destructive: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, Fail>>>>>;
|
||||
/// Request id -> the webview's job id.
|
||||
type Jobs = Arc<Mutex<HashMap<u64, String>>>;
|
||||
|
||||
/// Job ids come from the webview and are echoed back in events: short and plain only.
|
||||
pub fn valid_job(job: &str) -> bool {
|
||||
!job.is_empty() && job.len() <= 64 && job.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.'))
|
||||
}
|
||||
|
||||
pub struct Running {
|
||||
child: Mutex<Option<CommandChild>>,
|
||||
secret: [u8; 32],
|
||||
pending: Pending,
|
||||
jobs: Jobs,
|
||||
next: AtomicU64,
|
||||
alive: Arc<AtomicBool>,
|
||||
pub version: String,
|
||||
pub protocol: String,
|
||||
pub methods: Vec<Descriptor>,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
pub fn kind(&self, method: &str) -> Option<Kind> {
|
||||
self.methods.iter().find(|d| d.method == method).and_then(|d| Kind::parse(&d.kind))
|
||||
}
|
||||
pub fn descriptor(&self, method: &str) -> Option<&Descriptor> {
|
||||
self.methods.iter().find(|d| d.method == method)
|
||||
}
|
||||
pub fn mint(&self, method: &str, params: &Value) -> Result<String, String> {
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_millis() as u64;
|
||||
confirm::mint(&self.secret, method, params, now, &confirm::new_nonce()?)
|
||||
}
|
||||
|
||||
fn send(&self, id: u64, method: &str, params: &Value) -> Result<oneshot::Receiver<Result<Value, Fail>>, Fail> {
|
||||
if !self.alive.load(Ordering::SeqCst) {
|
||||
return Err(Fail::host("the storebound sidecar stopped"));
|
||||
}
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().map_err(|_| Fail::host("sidecar state poisoned"))?.insert(id, tx);
|
||||
let mut line = serde_json::to_vec(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| Fail::host(e.to_string()))?;
|
||||
line.push(b'\n');
|
||||
let mut child = self.child.lock().map_err(|_| Fail::host("sidecar state poisoned"))?;
|
||||
match child.as_mut() {
|
||||
Some(c) => c.write(&line).map_err(|e| Fail::host(format!("sidecar write: {e}")))?,
|
||||
None => return Err(Fail::host("the storebound sidecar stopped")),
|
||||
}
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, Fail> {
|
||||
self.call_job(method, params, None).await
|
||||
}
|
||||
|
||||
/// A call whose progress events carry `job`, and which `cancel_job` can stop.
|
||||
pub async fn call_job(&self, method: &str, params: Value, job: Option<&str>) -> Result<Value, Fail> {
|
||||
let id = self.next.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(j) = job {
|
||||
if let Ok(mut m) = self.jobs.lock() {
|
||||
m.insert(id, j.to_string());
|
||||
}
|
||||
}
|
||||
let res = match self.send(id, method, ¶ms) {
|
||||
Ok(rx) => rx.await.map_err(|_| Fail::host("the storebound sidecar stopped")).and_then(|r| r),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if job.is_some() {
|
||||
if let Ok(mut m) = self.jobs.lock() {
|
||||
m.remove(&id);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Cancels every running request of a job (`$/cancelRequest`; they answer -32800). Returns how many.
|
||||
pub fn cancel_job(&self, job: &str) -> usize {
|
||||
let ids: Vec<u64> = self.jobs.lock().map(|m| m.iter().filter(|(_, j)| j.as_str() == job).map(|(id, _)| *id).collect()).unwrap_or_default();
|
||||
for id in &ids {
|
||||
self.notify("$/cancelRequest", json!({ "id": id }));
|
||||
}
|
||||
ids.len()
|
||||
}
|
||||
|
||||
pub fn notify(&self, method: &str, params: Value) {
|
||||
let mut line = match serde_json::to_vec(&json!({ "jsonrpc": "2.0", "method": method, "params": params })) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return,
|
||||
};
|
||||
line.push(b'\n');
|
||||
if let Ok(mut c) = self.child.lock() {
|
||||
if let Some(c) = c.as_mut() {
|
||||
let _ = c.write(&line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alive(&self) -> bool {
|
||||
self.alive.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn kill(&self) {
|
||||
self.alive.store(false, Ordering::SeqCst);
|
||||
if let Ok(mut c) = self.child.lock() {
|
||||
if let Some(c) = c.take() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Sidecar {
|
||||
current: tokio::sync::Mutex<Option<Arc<Running>>>,
|
||||
}
|
||||
|
||||
/// Progress of a running request, forwarded to the main window as `sidecar-progress`.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Progress {
|
||||
pub params: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job: Option<String>,
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
/// The running sidecar, started (and greeted) if needed.
|
||||
pub async fn get<R: Runtime>(&self, app: &AppHandle<R>) -> Result<Arc<Running>, Fail> {
|
||||
let mut cur = self.current.lock().await;
|
||||
if let Some(r) = cur.as_ref() {
|
||||
if r.alive() {
|
||||
return Ok(r.clone());
|
||||
}
|
||||
}
|
||||
let r = Arc::new(start(app).await?);
|
||||
*cur = Some(r.clone());
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The running sidecar, if there is one (never starts it).
|
||||
pub async fn current(&self) -> Option<Arc<Running>> {
|
||||
self.current.lock().await.as_ref().filter(|r| r.alive()).cloned()
|
||||
}
|
||||
|
||||
/// Stops the sidecar; the next call starts a fresh one (after a tool install, so it sees the new tool paths).
|
||||
pub async fn shutdown(&self) {
|
||||
if let Some(r) = self.current.lock().await.take() {
|
||||
r.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-async variant for the exit path (the event loop is ending).
|
||||
pub fn shutdown_now(&self) {
|
||||
if let Ok(mut cur) = self.current.try_lock() {
|
||||
if let Some(r) = cur.take() {
|
||||
r.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools the app manages in its data folder (Dependencies screen): `tools/bin` (ffmpeg, ffprobe) goes in front of
|
||||
/// PATH for every storebound command, `tools/chrome/<executable>` is the Chrome for Testing it renders with. Only
|
||||
/// paths that exist are passed.
|
||||
pub fn managed_tool_env(data_dir: &std::path::Path) -> Vec<(String, String)> {
|
||||
let mut env = Vec::new();
|
||||
let bin = data_dir.join("tools").join("bin");
|
||||
if bin.is_dir() {
|
||||
env.push(("STOREBOUND_TOOL_PATH".to_string(), bin.to_string_lossy().to_string()));
|
||||
}
|
||||
let chrome = data_dir.join("tools").join("chrome").join(policy::chrome_executable());
|
||||
if chrome.is_file() {
|
||||
env.push(("STOREBOUND_CHROME".to_string(), chrome.to_string_lossy().to_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
async fn start<R: Runtime>(app: &AppHandle<R>) -> Result<Running, Fail> {
|
||||
let tool_env = app.path().app_data_dir().map(|d| managed_tool_env(&d)).unwrap_or_default();
|
||||
let cmd = app
|
||||
.shell()
|
||||
.sidecar(policy::SIDECAR)
|
||||
.map_err(|e| Fail::host(format!("sidecar missing: {e} (bun tools/build-sidecars.ts --host)")))?
|
||||
.args(policy::SIDECAR_ARGS)
|
||||
.envs(tool_env)
|
||||
.set_raw_out(true)
|
||||
.set_process_group(true);
|
||||
let (mut rx, child) = cmd.spawn().map_err(|e| Fail::host(format!("sidecar did not start: {e}")))?;
|
||||
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
|
||||
let jobs: Jobs = Arc::new(Mutex::new(HashMap::new()));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let (p, a, handle, jb) = (pending.clone(), alive.clone(), app.clone(), jobs.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut err: Vec<u8> = Vec::new();
|
||||
while let Some(ev) = rx.recv().await {
|
||||
match ev {
|
||||
CommandEvent::Stdout(bytes) => {
|
||||
out.extend_from_slice(&bytes);
|
||||
while let Some(nl) = out.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = out.drain(..=nl).collect();
|
||||
route(&handle, &p, &jb, &line);
|
||||
}
|
||||
}
|
||||
CommandEvent::Stderr(bytes) => {
|
||||
err.extend_from_slice(&bytes);
|
||||
while let Some(nl) = err.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = err.drain(..=nl).collect();
|
||||
if cfg!(debug_assertions) {
|
||||
eprint!("[sidecar] {}", String::from_utf8_lossy(&line));
|
||||
}
|
||||
}
|
||||
}
|
||||
CommandEvent::Terminated(_) | CommandEvent::Error(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
a.store(false, Ordering::SeqCst);
|
||||
if let Ok(mut map) = p.lock() {
|
||||
for (_, tx) in map.drain() {
|
||||
let _ = tx.send(Err(Fail::host("the storebound sidecar stopped")));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let secret = confirm::new_secret()?;
|
||||
let mut running = Running {
|
||||
child: Mutex::new(Some(child)),
|
||||
secret,
|
||||
pending,
|
||||
jobs,
|
||||
next: AtomicU64::new(1),
|
||||
alive,
|
||||
version: String::new(),
|
||||
protocol: String::new(),
|
||||
methods: Vec::new(),
|
||||
};
|
||||
let hello = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
running.call("session.hello", json!({ "secret": confirm::hex(&secret), "client": { "name": "storebound-desktop", "version": env!("CARGO_PKG_VERSION") } })),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Fail::host("the storebound sidecar did not answer session.hello"))??;
|
||||
running.protocol = hello["protocol"].as_str().unwrap_or_default().to_string();
|
||||
running.version = hello["server"]["version"].as_str().unwrap_or_default().to_string();
|
||||
if running.protocol != "storebound-desktop/1" {
|
||||
running.kill();
|
||||
return Err(Fail::host(format!("unsupported sidecar protocol '{}'", running.protocol)));
|
||||
}
|
||||
running.methods = hello["methods"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|m| Descriptor {
|
||||
method: m["method"].as_str().unwrap_or_default().into(),
|
||||
title: m["title"].as_str().unwrap_or_default().into(),
|
||||
description: m["description"].as_str().unwrap_or_default().into(),
|
||||
kind: m["kind"].as_str().unwrap_or_default().into(),
|
||||
network: m["network"].as_bool().unwrap_or(true),
|
||||
destructive: m["destructive"].as_bool().unwrap_or(true),
|
||||
json: m["json"].as_bool().unwrap_or(false),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(running)
|
||||
}
|
||||
|
||||
fn route<R: Runtime>(app: &AppHandle<R>, pending: &Pending, jobs: &Jobs, line: &[u8]) {
|
||||
let Ok(msg) = serde_json::from_slice::<Value>(line) else { return };
|
||||
if let Some(id) = msg.get("id").and_then(Value::as_u64) {
|
||||
let tx = pending.lock().ok().and_then(|mut m| m.remove(&id));
|
||||
if let Some(tx) = tx {
|
||||
let res = match msg.get("error") {
|
||||
Some(e) => Err(Fail {
|
||||
code: e["code"].as_i64().unwrap_or(-32603),
|
||||
message: e["message"].as_str().unwrap_or("error").to_string(),
|
||||
data: e.get("data").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
None => Ok(msg.get("result").cloned().unwrap_or(Value::Null)),
|
||||
};
|
||||
let _ = tx.send(res);
|
||||
}
|
||||
} else if msg.get("method").and_then(Value::as_str) == Some("progress") {
|
||||
let job = msg["params"]["requestId"].as_u64().and_then(|id| jobs.lock().ok().and_then(|m| m.get(&id).cloned()));
|
||||
let _ = app.emit_to(policy::MAIN, "sidecar-progress", Progress { params: msg["params"].clone(), job });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
//! The app's own small state in its data folder: the project list and account labels (never secrets). Written
|
||||
//! atomically (temp file, then rename) so a crash never leaves half a file.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::accounts::Account;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
/// Absolute folder that holds storebound.config.ts.
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub play_account: Option<String>,
|
||||
#[serde(default)]
|
||||
pub apple_account: Option<String>,
|
||||
pub added: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
#[serde(default)]
|
||||
pub projects: Vec<Project>,
|
||||
#[serde(default)]
|
||||
pub accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
pub const FILE: &str = "state.json";
|
||||
|
||||
pub fn load(dir: &Path) -> State {
|
||||
fs::read_to_string(dir.join(FILE)).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(dir: &Path, state: &State) -> Result<(), String> {
|
||||
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let tmp: PathBuf = dir.join(format!("{FILE}.tmp"));
|
||||
let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
|
||||
fs::write(&tmp, json + "\n").map_err(|e| e.to_string())?;
|
||||
fs::rename(&tmp, dir.join(FILE)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Random id (hex) for projects and accounts.
|
||||
pub fn new_id() -> Result<String, String> {
|
||||
let mut b = [0u8; 8];
|
||||
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
|
||||
Ok(crate::confirm::hex(&b))
|
||||
}
|
||||
|
||||
/// Current time as an RFC 3339 UTC string, without a date crate.
|
||||
pub fn now_iso() -> String {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
iso_from_unix(secs)
|
||||
}
|
||||
|
||||
pub fn iso_from_unix(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let rem = secs % 86_400;
|
||||
// civil-from-days (Howard Hinnant)
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "storebound",
|
||||
"mainBinaryName": "storebound-desktop",
|
||||
"version": "../package.json",
|
||||
"identifier": "dev.storebound.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"beforeBuildCommand": "bun run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "storebound",
|
||||
"width": 1200,
|
||||
"height": 780,
|
||||
"minWidth": 880,
|
||||
"minHeight": 560,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self'",
|
||||
"script-src": "'self'",
|
||||
"style-src": "'self'",
|
||||
"img-src": "'self' data:",
|
||||
"font-src": "'self'",
|
||||
"connect-src": "ipc: http://ipc.localhost",
|
||||
"object-src": "'none'",
|
||||
"base-uri": "'none'",
|
||||
"frame-src": "'none'",
|
||||
"form-action": "'none'"
|
||||
},
|
||||
"devCsp": {
|
||||
"default-src": "'self'",
|
||||
"script-src": "'self'",
|
||||
"style-src": "'self' 'unsafe-inline'",
|
||||
"img-src": "'self' data:",
|
||||
"font-src": "'self'",
|
||||
"connect-src": "ipc: http://ipc.localhost ws://127.0.0.1:1420 http://127.0.0.1:1420",
|
||||
"object-src": "'none'",
|
||||
"base-uri": "'none'",
|
||||
"frame-src": "'none'",
|
||||
"form-action": "'none'"
|
||||
},
|
||||
"freezePrototype": true,
|
||||
"capabilities": ["main-window"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Every game's releases on both stores in one window",
|
||||
"copyright": "MIT license, storebound contributors",
|
||||
"externalBin": ["binaries/storebound"],
|
||||
"createUpdaterArtifacts": false,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["storebound"]
|
||||
}
|
||||
},
|
||||
"updater": {
|
||||
"endpoints": ["https://storebound.dev/desktop/latest.json"],
|
||||
"pubkey": "PLACEHOLDER-minisign-public-key-set-in-D5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
//! Account handling: what is stored where, what is shown, and the key checks. No real keys anywhere.
|
||||
use serde_json::{json, Map};
|
||||
use storebound_desktop::accounts::*;
|
||||
use storebound_desktop::commands::{dialog_excerpt, dialog_summary, has_config, tilde};
|
||||
use storebound_desktop::sidecar::managed_tool_env;
|
||||
use storebound_desktop::store::{self, Project, State};
|
||||
|
||||
const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----";
|
||||
|
||||
#[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() };
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label"]);
|
||||
assert_eq!(v["kind"], "play");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_map_to_the_cli_env_names() {
|
||||
let mut m = Map::new();
|
||||
Secret::Apple { key_id: "ABCDEFGH12".into(), issuer_id: "00000000-0000-0000-0000-000000000000".into(), p8: FAKE_PEM.into() }.to_credentials(&mut m);
|
||||
Secret::Play { service_account: "{}".into() }.to_credentials(&mut m);
|
||||
assert_eq!(m.keys().cloned().collect::<Vec<_>>(), vec!["ASC_ISSUER_ID", "ASC_KEY_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT"]);
|
||||
let s = Secret::Play { service_account: "{}".into() };
|
||||
let back: Secret = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_checks() {
|
||||
let sa = json!({ "type": "service_account", "client_email": "bot@example.com", "private_key": FAKE_PEM }).to_string();
|
||||
assert!(check_service_account(&sa).is_ok());
|
||||
assert!(check_service_account("{\"client_email\":\"x@example.com\"}").is_err());
|
||||
assert!(check_service_account("not json").is_err());
|
||||
assert_eq!(check_key_id(" ABCDEFGH12 ").unwrap(), "ABCDEFGH12");
|
||||
assert!(check_key_id("abc").is_err());
|
||||
assert!(check_key_id("abcdefgh12").is_err());
|
||||
let upper = "00000000-0000-0000-0000-00000000000A";
|
||||
assert_eq!(check_issuer_id(upper).unwrap(), upper.to_ascii_lowercase());
|
||||
assert!(check_issuer_id("0000-0000").is_err());
|
||||
assert!(check_p8(FAKE_PEM).is_ok());
|
||||
assert!(check_p8("hello").is_err());
|
||||
assert!(check_label("").is_err());
|
||||
assert!(check_label("a\u{7}").is_err());
|
||||
assert!(check_label(&"x".repeat(61)).is_err());
|
||||
assert_eq!(check_label(" Studio ").unwrap(), "Studio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_file_round_trip_is_atomic_and_tolerant() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(store::load(dir.path()), State::default());
|
||||
let st = State {
|
||||
projects: vec![Project { id: "1".into(), path: "/games/a".into(), name: "A".into(), play_account: Some("p".into()), apple_account: None, added: "2026-10-10T00:00:00Z".into() }],
|
||||
accounts: vec![],
|
||||
};
|
||||
store::save(dir.path(), &st).unwrap();
|
||||
assert_eq!(store::load(dir.path()), st);
|
||||
assert!(!dir.path().join("state.json.tmp").exists());
|
||||
let text = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
|
||||
assert!(text.contains("\"playAccount\": \"p\""));
|
||||
std::fs::write(dir.path().join("state.json"), "{broken").unwrap();
|
||||
assert_eq!(store::load(dir.path()), State::default());
|
||||
assert_eq!(store::iso_from_unix(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(store::iso_from_unix(1_700_000_000), "2023-11-14T22:13:20Z");
|
||||
assert_eq!(store::new_id().unwrap().len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_tools_are_passed_only_when_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(managed_tool_env(dir.path()).is_empty());
|
||||
std::fs::create_dir_all(dir.path().join("tools/bin")).unwrap();
|
||||
let env = managed_tool_env(dir.path());
|
||||
assert_eq!(env.len(), 1);
|
||||
assert_eq!(env[0].0, "STOREBOUND_TOOL_PATH");
|
||||
assert!(env[0].1.ends_with("bin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_dialog_text() {
|
||||
let s = dialog_summary("Push Play listing text", "Title and descriptions.", "Prism Puddle", true, true);
|
||||
assert!(s.starts_with("Push Play listing text · Prism Puddle"));
|
||||
assert!(s.contains("not public until you submit"));
|
||||
assert!(s.contains("replaces"));
|
||||
let out: String = (1..=40).map(|i| format!("line {i}\n")).collect();
|
||||
let ex = dialog_excerpt(&out);
|
||||
assert!(ex.starts_with("(12 earlier lines"));
|
||||
assert!(ex.ends_with("line 40"));
|
||||
assert!(dialog_excerpt(&"x".repeat(5000)).chars().count() < 2_500);
|
||||
assert_eq!(tilde("would write /home/you/a/index.html", Some("/home/you/")), "would write ~/a/index.html");
|
||||
assert_eq!(tilde("/x", None), "/x");
|
||||
assert_eq!(tilde("/x", Some("/")), "/x");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!has_config(dir.path()));
|
||||
std::fs::write(dir.path().join("storebound.config.ts"), "export default {}").unwrap();
|
||||
assert!(has_config(dir.path()));
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
//! The host mints exactly the tokens the sidecar verifies: same canonical JSON, same HMAC, same shape as
|
||||
//! cli/lib/confirm-token.ts. The vector is shared with tests/serve.test.ts in the repository root.
|
||||
use serde_json::json;
|
||||
use storebound_desktop::confirm::*;
|
||||
|
||||
fn secret() -> Vec<u8> {
|
||||
(0u8..32).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_vector() {
|
||||
let p = json!({ "project": "/tmp/game", "args": { "only": "en-US,tr", "b": [1, true, null, "ü\"\n"] } });
|
||||
assert_eq!(canonical(&p).unwrap(), "{\"args\":{\"b\":[1,true,null,\"ü\\\"\\n\"],\"only\":\"en-US,tr\"},\"project\":\"/tmp/game\"}");
|
||||
assert_eq!(mac(&secret(), "play.listing", &p, 1_700_000_060_000, "AAAAAAAAAAAAAAAAAAAAAA").unwrap(), "JhSxUL2ej4cm4yrmlcA23rTCPq3YMKJthbB7n6aixxw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_rules() {
|
||||
assert_eq!(canonical(&json!({ "b": 1, "a": [true, null, "x"], "c": { "z": "" } })).unwrap(), "{\"a\":[true,null,\"x\"],\"b\":1,\"c\":{\"z\":\"\"}}");
|
||||
assert!(canonical(&json!({ "f": 0.5 })).is_err());
|
||||
assert!(canonical(&json!({ "f": 9_007_199_254_740_992u64 })).is_err());
|
||||
assert_eq!(canonical(&json!(-9_007_199_254_740_991i64)).unwrap(), "-9007199254740991");
|
||||
// keys sort by UTF-16 code units like JavaScript: "\u{e9}" (U+00E9) after "z", astral after BMP private use
|
||||
assert_eq!(canonical(&json!({ "é": 1, "z": 2, "A": 3 })).unwrap(), "{\"A\":3,\"z\":2,\"é\":1}");
|
||||
assert_eq!(canonical(&json!("\u{1}\t</")).unwrap(), "\"\\u0001\\t</\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minted_tokens_have_the_sidecar_shape() {
|
||||
let p = json!({ "project": "/p", "args": {} });
|
||||
let t = mint(&secret(), "play.listing", &p, 1_000, "AAAAAAAAAAAAAAAAAAAAAA").unwrap();
|
||||
let parts: Vec<&str> = t.split('.').collect();
|
||||
assert_eq!(parts.len(), 4);
|
||||
assert_eq!(parts[0], "sbt1");
|
||||
assert_eq!(parts[1], (1_000 + TTL_MS).to_string());
|
||||
assert_eq!(parts[3].len(), 43);
|
||||
assert_eq!(parts[3], mac(&secret(), "play.listing", &p, 1_000 + TTL_MS, "AAAAAAAAAAAAAAAAAAAAAA").unwrap());
|
||||
let n = new_nonce().unwrap();
|
||||
assert_eq!(n.len(), 22);
|
||||
assert_ne!(n, new_nonce().unwrap());
|
||||
assert_ne!(new_secret().unwrap(), new_secret().unwrap());
|
||||
assert_eq!(hex(&[0, 15, 255]), "000fff");
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
//! Desktop D2: offline cache, tool download manifest and rules, job ids.
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use storebound_desktop::cache::{key, Cache};
|
||||
use storebound_desktop::downloads::{self, chrome_download, download_allowed, inside, manifest, unpack};
|
||||
use storebound_desktop::sidecar::valid_job;
|
||||
use url::Url;
|
||||
|
||||
#[test]
|
||||
fn cache_keeps_the_last_read_per_project_method_and_args() {
|
||||
let c = Cache::in_memory().unwrap();
|
||||
assert_eq!(c.get("p1", "health", &json!({})).unwrap(), None);
|
||||
c.put("p1", "health", &json!({}), &json!({ "ok": true, "n": 1 }), "2026-10-10T10:00:00Z").unwrap();
|
||||
c.put("p1", "health", &json!({}), &json!({ "ok": true, "n": 2 }), "2026-10-10T11:00:00Z").unwrap();
|
||||
c.put("p1", "reviews.list", &json!({ "days": 14, "store": "play" }), &json!([1]), "2026-10-10T09:00:00Z").unwrap();
|
||||
c.put("p2", "health", &json!({}), &json!({ "n": 9 }), "2026-10-10T08:00:00Z").unwrap();
|
||||
let e = c.get("p1", "health", &json!({})).unwrap().unwrap();
|
||||
assert_eq!((e.value["n"].clone(), e.at.as_str()), (json!(2), "2026-10-10T11:00:00Z"));
|
||||
// argument order does not matter (canonical JSON), values do
|
||||
assert!(c.get("p1", "reviews.list", &json!({ "store": "play", "days": 14 })).unwrap().is_some());
|
||||
assert!(c.get("p1", "reviews.list", &json!({ "store": "apple", "days": 14 })).unwrap().is_none());
|
||||
assert_eq!(key("a", "m", &json!({ "b": 1, "a": 2 })), key("a", "m", &json!({ "a": 2, "b": 1 })));
|
||||
assert_eq!(c.stamps("p1").unwrap().len(), 2);
|
||||
c.forget_project("p1").unwrap();
|
||||
assert!(c.get("p1", "health", &json!({})).unwrap().is_none());
|
||||
assert!(c.get("p2", "health", &json!({})).unwrap().is_some());
|
||||
c.clear().unwrap();
|
||||
assert!(c.get("p2", "health", &json!({})).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_survives_a_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Cache::open(dir.path()).unwrap().put("p", "m", &json!({}), &json!({ "x": 1 }), "t").unwrap();
|
||||
assert_eq!(Cache::open(dir.path()).unwrap().get("p", "m", &json!({})).unwrap().unwrap().value, json!({ "x": 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_pins_every_desktop_target_with_hashes() {
|
||||
let m = manifest().unwrap();
|
||||
for t in ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] {
|
||||
let list = m.ffmpeg.targets.get(t).unwrap_or_else(|| panic!("no ffmpeg for {t}"));
|
||||
let mut outs = vec![];
|
||||
for a in list {
|
||||
let u = Url::parse(&a.url).unwrap();
|
||||
assert!(download_allowed(&u), "{}", a.url);
|
||||
assert!(!a.url.contains("/latest/"), "pinned URLs only: {}", a.url);
|
||||
assert_eq!(a.sha256.len(), 64);
|
||||
assert!(a.sha256.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert!(a.size > 1_000_000);
|
||||
assert!(a.kind == "zip" || a.kind == "tar.xz");
|
||||
for f in &a.files {
|
||||
inside(std::path::Path::new("/x"), &f.from).unwrap();
|
||||
outs.push(f.to.clone());
|
||||
}
|
||||
}
|
||||
outs.sort();
|
||||
let exe = if t.contains("windows") { ".exe" } else { "" };
|
||||
assert_eq!(outs, vec![format!("ffmpeg{exe}"), format!("ffprobe{exe}")], "{t}");
|
||||
}
|
||||
assert!(download_allowed(&Url::parse(&m.chrome.endpoint).unwrap()));
|
||||
assert_eq!(m.chrome.mac_team_id, "EQHXZ8M8AV");
|
||||
assert!(m.ffmpeg.license.contains("GPL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_only_over_https_from_the_listed_hosts() {
|
||||
for ok in ["https://ffmpeg.martin-riedl.de/download/x.zip", "https://github.com/BtbN/FFmpeg-Builds/releases/download/a/b.zip", "https://storage.googleapis.com/chrome-for-testing-public/1/mac-arm64/chrome-mac-arm64.zip"] {
|
||||
assert!(download_allowed(&Url::parse(ok).unwrap()), "{ok}");
|
||||
}
|
||||
for bad in ["http://ffmpeg.martin-riedl.de/x.zip", "https://evil.test/x.zip", "https://github.com.evil.test/x", "https://storage.googleapis.com:8443/x", "file:///tmp/x.zip"] {
|
||||
assert!(!download_allowed(&Url::parse(bad).unwrap()), "{bad}");
|
||||
}
|
||||
assert!(!download_allowed(&Url::parse(&format!("https://{}@github.com/x", "u")).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_for_testing_endpoint_answer() {
|
||||
let j = json!({ "channels": { "Stable": { "version": "141.0.7390.54", "downloads": { "chrome": [
|
||||
{ "platform": "linux64", "url": "https://storage.googleapis.com/chrome-for-testing-public/141.0.7390.54/linux64/chrome-linux64.zip" },
|
||||
{ "platform": "mac-arm64", "url": "https://storage.googleapis.com/chrome-for-testing-public/141.0.7390.54/mac-arm64/chrome-mac-arm64.zip" }
|
||||
] } } } });
|
||||
let (v, u) = chrome_download(&j, "Stable", "mac-arm64").unwrap();
|
||||
assert_eq!(v, "141.0.7390.54");
|
||||
assert!(u.as_str().ends_with("chrome-mac-arm64.zip"));
|
||||
assert!(chrome_download(&j, "Stable", "win64").is_err());
|
||||
let evil = json!({ "channels": { "Stable": { "version": "1", "downloads": { "chrome": [{ "platform": "win64", "url": "https://evil.test/chrome.zip" }] } } } });
|
||||
assert!(chrome_download(&evil, "Stable", "win64").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_paths_stay_inside() {
|
||||
let base = std::path::Path::new("/data/tools/.staging");
|
||||
assert!(inside(base, "ffmpeg-n9/bin/ffmpeg").is_ok());
|
||||
for bad in ["../ffmpeg", "/usr/bin/ffmpeg", "a/../../b", "./x"] {
|
||||
assert!(inside(base, bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_unpack_refuses_entries_outside_the_folder() {
|
||||
if cfg!(target_os = "macos") {
|
||||
return; // macOS unpacks with ditto, which never writes outside the target folder
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let zip_path = dir.path().join("evil.zip");
|
||||
{
|
||||
let f = std::fs::File::create(&zip_path).unwrap();
|
||||
let mut z = zip::ZipWriter::new(f);
|
||||
z.start_file("../escape.txt", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(b"x").unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
let out = dir.path().join("out");
|
||||
let _ = unpack(&zip_path, "zip", &out);
|
||||
assert!(!dir.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_info_lists_both_tools() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let info = downloads::info(dir.path()).unwrap();
|
||||
assert_eq!(info.iter().map(|i| i.tool).collect::<Vec<_>>(), vec!["ffmpeg", "chrome"]);
|
||||
assert!(info.iter().all(|i| !i.installed));
|
||||
assert!(info[0].available && info[0].size.unwrap_or(0) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_ids_are_short_and_plain() {
|
||||
for ok in ["portfolio-1", "dl:ffmpeg:3", "a.b_c"] {
|
||||
assert!(valid_job(ok), "{ok}");
|
||||
}
|
||||
for bad in ["", "a b", "x\"y", "<script>", &"x".repeat(65)] {
|
||||
assert!(!valid_job(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
//! Security invariants of the desktop shell: capability allow-lists, CSP, the command list, the window / origin
|
||||
//! checks every command runs, what the webview may send to the sidecar, and where links may go.
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use storebound_desktop::policy::*;
|
||||
use storebound_desktop::COMMANDS;
|
||||
use url::Url;
|
||||
|
||||
fn root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
fn read_json(p: &Path) -> Value {
|
||||
serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap_or_else(|e| panic!("{}: {e}", p.display()))
|
||||
}
|
||||
|
||||
fn u(s: &str) -> Url {
|
||||
Url::parse(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
|
||||
let dir = root().join("capabilities");
|
||||
let files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(files.len(), 1, "one capability file expected, found {files:?}");
|
||||
let cap = read_json(&files[0]);
|
||||
assert_eq!(cap["identifier"], "main-window");
|
||||
assert_eq!(cap["windows"], json!(["main"]));
|
||||
assert!(cap.get("webviews").is_none(), "no webview-scoped grants");
|
||||
assert!(cap.get("remote").is_none(), "no remote origins");
|
||||
assert_ne!(cap["local"], json!(false));
|
||||
assert!(cap["description"].as_str().unwrap().len() > 80, "capabilities carry a real description");
|
||||
|
||||
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().expect("plain permission ids only").to_string()).collect();
|
||||
let mut want: BTreeSet<String> = COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
|
||||
want.insert("core:event:allow-listen".into());
|
||||
want.insert("core:event:allow-unlisten".into());
|
||||
assert_eq!(got, want, "capability permissions must be exactly the app commands + event listen");
|
||||
for p in &got {
|
||||
for plugin in ["shell:", "fs:", "dialog:", "opener:", "updater:", "process:", "notification:", "deep-link:", "window-state:", "single-instance:", "http:", "core:webview", "core:window", "core:path", "core:default"] {
|
||||
assert!(!p.starts_with(plugin), "{p}: no plugin or core API is granted to the webview");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_registered_command_is_in_the_list_and_nothing_else() {
|
||||
let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap();
|
||||
let start = lib.find("generate_handler![").unwrap() + "generate_handler![".len();
|
||||
let end = start + lib[start..].find(']').unwrap();
|
||||
let registered: Vec<String> = lib[start..end]
|
||||
.split(',')
|
||||
.map(|s| s.trim().trim_start_matches("commands::").to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
assert_eq!(registered, COMMANDS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
// every command checks the caller first
|
||||
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
||||
for c in COMMANDS {
|
||||
let at = cmds.find(&format!("pub fn {c}(")).or_else(|| cmds.find(&format!("pub async fn {c}("))).unwrap_or_else(|| panic!("{c} not in commands.rs"));
|
||||
let body = &cmds[at..];
|
||||
let open = body.find('{').unwrap();
|
||||
let first = body[open + 1..].trim_start();
|
||||
assert!(first.starts_with("guard(&window)?;"), "{c} must start with guard(&window)?");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csp_is_strict_and_the_config_has_no_backdoors() {
|
||||
let conf = read_json(&root().join("tauri.conf.json"));
|
||||
let csp = &conf["app"]["security"]["csp"];
|
||||
assert_eq!(csp["default-src"], "'self'");
|
||||
assert_eq!(csp["script-src"], "'self'");
|
||||
assert_eq!(csp["style-src"], "'self'");
|
||||
assert_eq!(csp["connect-src"], "ipc: http://ipc.localhost");
|
||||
assert_eq!(csp["object-src"], "'none'");
|
||||
assert_eq!(csp["frame-src"], "'none'");
|
||||
assert_eq!(csp["base-uri"], "'none'");
|
||||
for (k, v) in csp.as_object().unwrap() {
|
||||
let v = v.as_str().unwrap();
|
||||
assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}");
|
||||
assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}");
|
||||
}
|
||||
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window"]));
|
||||
assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true));
|
||||
assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false));
|
||||
let windows = conf["app"]["windows"].as_array().unwrap();
|
||||
assert_eq!(windows.len(), 1);
|
||||
assert_eq!(windows[0]["label"], MAIN);
|
||||
assert!(windows[0].get("url").is_none(), "the main window loads the bundled index only");
|
||||
assert_eq!(conf["bundle"]["externalBin"], json!(["binaries/storebound"]));
|
||||
assert_eq!(conf["plugins"]["deep-link"]["desktop"]["schemes"], json!(["storebound"]));
|
||||
for e in conf["plugins"]["updater"]["endpoints"].as_array().unwrap() {
|
||||
assert!(e.as_str().unwrap().starts_with("https://storebound.dev/"));
|
||||
}
|
||||
assert_eq!(conf["build"]["devUrl"], format!("http://{DEV_HOST}:{DEV_PORT}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_configs_never_replace_the_window_list() {
|
||||
// Tauri merges a platform config's `app.windows` by replacing the whole array (the main window would lose its
|
||||
// label, size or CSP-relevant settings). Keep windows in tauri.conf.json only.
|
||||
for e in fs::read_dir(root()).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
if name.starts_with("tauri.") && name.ends_with(".conf.json") && name != "tauri.conf.json" {
|
||||
let c = read_json(&p);
|
||||
assert!(c["app"].get("windows").is_none(), "{name} must not set app.windows");
|
||||
assert!(c["app"]["security"].get("csp").is_none(), "{name} must not change the CSP");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_main_with_local_pages_may_call() {
|
||||
for ok in ["tauri://localhost/", "tauri://localhost/index.html", "http://tauri.localhost/", "https://tauri.localhost/index.html"] {
|
||||
assert!(require_main("main", &u(ok), false).is_ok(), "{ok}");
|
||||
}
|
||||
assert!(require_main("main", &u("http://127.0.0.1:1420/"), true).is_ok());
|
||||
for bad in [
|
||||
"http://127.0.0.1:1420/",
|
||||
"http://localhost:1420/",
|
||||
"https://storebound.dev/",
|
||||
"tauri://evil/",
|
||||
"tauri://localhost:8080/",
|
||||
"http://tauri.localhost:8080/",
|
||||
"http://tauri.localhost.evil.test/",
|
||||
&format!("http://{}@tauri.localhost/", "user"),
|
||||
"file:///tmp/index.html",
|
||||
"data:text/html,hi",
|
||||
] {
|
||||
assert!(require_main("main", &u(bad), false).is_err(), "accepted {bad}");
|
||||
}
|
||||
assert!(require_main("main", &u("http://127.0.0.1:1421/"), true).is_err());
|
||||
assert!(require_main("http://localhost", &u("tauri://localhost/"), false).is_err());
|
||||
assert!(require_main("popup", &u("tauri://localhost/"), false).is_err());
|
||||
assert!(require_main("", &u("tauri://localhost/"), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webview_calls_to_the_sidecar_are_reads_or_dry_runs() {
|
||||
// a write from the webview always becomes a dry run
|
||||
let p = sanitize_js_call("play.listing", Some(Kind::Write), &json!({ "args": { "only": "en-US" } })).unwrap();
|
||||
assert_eq!(p, json!({ "args": { "only": "en-US" }, "dryRun": true }));
|
||||
// the host's keys are refused, so is anything unknown
|
||||
for key in ["confirmToken", "credentials", "dryRun", "project", "projects", "secret", "env"] {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert(key.into(), json!(false));
|
||||
assert!(sanitize_js_call("play.listing", Some(Kind::Write), &Value::Object(o.clone())).is_err(), "{key}");
|
||||
assert!(sanitize_js_call("check", Some(Kind::Read), &Value::Object(o)).is_err(), "{key}");
|
||||
}
|
||||
assert!(sanitize_js_call("session.hello", Some(Kind::Session), &json!({})).is_err());
|
||||
assert!(sanitize_js_call("portfolio.status", Some(Kind::Portfolio), &json!({})).is_err());
|
||||
assert!(sanitize_js_call("apple.submit", None, &json!({})).is_err());
|
||||
assert!(sanitize_js_call("check", Some(Kind::Read), &json!([1])).is_err());
|
||||
assert_eq!(sanitize_js_call("system.version", Some(Kind::System), &Value::Null).unwrap(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_starts_with_fixed_arguments_from_one_place() {
|
||||
assert_eq!(SIDECAR_ARGS, ["serve", "--stdio"]);
|
||||
assert_eq!(SIDECAR, "storebound");
|
||||
let mut sites = 0;
|
||||
for e in fs::read_dir(root().join("src")).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
let src = fs::read_to_string(&p).unwrap();
|
||||
let n = src.matches(".sidecar(").count();
|
||||
if n > 0 {
|
||||
assert!(p.ends_with("sidecar.rs"), "{} spawns the sidecar", p.display());
|
||||
assert!(src.contains(".args(policy::SIDECAR_ARGS)"));
|
||||
}
|
||||
assert!(!src.contains(".command("), "{}: no other programs are started", p.display());
|
||||
// other programs: only the download checks (unpack, signature, version banner), with fixed program names
|
||||
if src.contains("Command::new(") {
|
||||
assert!(p.ends_with("downloads.rs"), "{} starts a program", p.display());
|
||||
for (i, _) in src.match_indices("Command::new(") {
|
||||
let arg = &src[i + "Command::new(".len()..];
|
||||
let ok = ["\"tar\")", "\"/usr/bin/ditto\")", "\"/usr/bin/codesign\")", "\"powershell\")", "exe)"];
|
||||
assert!(ok.iter().any(|o| arg.starts_with(o)), "unexpected program: {}", &arg[..arg.find(')').unwrap_or(20).min(40)]);
|
||||
}
|
||||
}
|
||||
sites += n;
|
||||
}
|
||||
assert_eq!(sites, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_open_only_for_the_consoles_and_the_site() {
|
||||
for ok in ["https://play.google.com/console/", "https://appstoreconnect.apple.com/apps", "https://storebound.dev/docs"] {
|
||||
assert!(external_allowed(&u(ok)), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"http://play.google.com/console",
|
||||
"https://play.google.com.evil.test/",
|
||||
&format!("https://{}@appstoreconnect.apple.com/", "user"),
|
||||
"https://storebound.dev:8443/",
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://evil.test/?https://storebound.dev",
|
||||
] {
|
||||
assert!(!external_allowed(&u(bad)), "{bad}");
|
||||
}
|
||||
assert_eq!(deep_link_screen(&u("storebound://apps")), Some("apps"));
|
||||
assert_eq!(deep_link_screen(&u("storebound://portfolio?x=1")), Some("portfolio"));
|
||||
assert_eq!(deep_link_screen(&u("storebound://write")), None);
|
||||
assert_eq!(deep_link_screen(&u("https://projects")), None);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
{
|
||||
"generated": "2026-10-10",
|
||||
"ffmpeg": {
|
||||
"version": "9.0.2",
|
||||
"license": "GPL-3.0-or-later (ffmpeg built with GPL components)",
|
||||
"licenseUrl": "https://ffmpeg.org/legal.html",
|
||||
"sources": {
|
||||
"macos": "https://ffmpeg.martin-riedl.de/",
|
||||
"linux": "https://github.com/BtbN/FFmpeg-Builds",
|
||||
"windows": "https://github.com/BtbN/FFmpeg-Builds"
|
||||
},
|
||||
"targets": {
|
||||
"aarch64-apple-darwin": [
|
||||
{
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1789931890_9.0.2/ffmpeg.zip",
|
||||
"sha256": "c8ed4c4e6978a03c485edbfe4e0a5dc2380f8a30bba5150531b31b094492d924",
|
||||
"size": 28395699,
|
||||
"kind": "zip",
|
||||
"files": [
|
||||
{
|
||||
"from": "ffmpeg",
|
||||
"to": "ffmpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1789931890_9.0.2/ffprobe.zip",
|
||||
"sha256": "fcbe839537485eaee7a7a8bc5cbc0f90d53617e80943e8a5b2e31cb851197ea6",
|
||||
"size": 28317701,
|
||||
"kind": "zip",
|
||||
"files": [
|
||||
{
|
||||
"from": "ffprobe",
|
||||
"to": "ffprobe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"x86_64-apple-darwin": [
|
||||
{
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/amd64/1789931006_9.0.2/ffmpeg.zip",
|
||||
"sha256": "7c6b4125b191cbf773832dc51f424cf2b6bb7da43007d1e066f95909e47cacd4",
|
||||
"size": 33816391,
|
||||
"kind": "zip",
|
||||
"files": [
|
||||
{
|
||||
"from": "ffmpeg",
|
||||
"to": "ffmpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"url": "https://ffmpeg.martin-riedl.de/download/macos/amd64/1789931006_9.0.2/ffprobe.zip",
|
||||
"sha256": "2322438ed2f6319a691291b247d09c69dcaa3a982460d1f269a7e1af335cfdfd",
|
||||
"size": 33719233,
|
||||
"kind": "zip",
|
||||
"files": [
|
||||
{
|
||||
"from": "ffprobe",
|
||||
"to": "ffprobe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"x86_64-unknown-linux-gnu": [
|
||||
{
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-10-13-04/ffmpeg-n9.0.2-25-g67b60c310b-linux64-gpl-9.0.tar.xz",
|
||||
"sha256": "7e898ca0a18e8620c0caa9af9a728bc01be006398dd5db5a99d53f14de010a9a",
|
||||
"size": 151141436,
|
||||
"kind": "tar.xz",
|
||||
"files": [
|
||||
{
|
||||
"from": "ffmpeg-n9.0.2-25-g67b60c310b-linux64-gpl-9.0/bin/ffmpeg",
|
||||
"to": "ffmpeg"
|
||||
},
|
||||
{
|
||||
"from": "ffmpeg-n9.0.2-25-g67b60c310b-linux64-gpl-9.0/bin/ffprobe",
|
||||
"to": "ffprobe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aarch64-unknown-linux-gnu": [
|
||||
{
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-10-13-04/ffmpeg-n9.0.2-25-g67b60c310b-linuxarm64-gpl-9.0.tar.xz",
|
||||
"sha256": "8f897563d9a39eb5616ab043f29f9018358d1f8c15d4b0d30df0dba71f3a931f",
|
||||
"size": 127540920,
|
||||
"kind": "tar.xz",
|
||||
"files": [
|
||||
{
|
||||
"from": "ffmpeg-n9.0.2-25-g67b60c310b-linuxarm64-gpl-9.0/bin/ffmpeg",
|
||||
"to": "ffmpeg"
|
||||
},
|
||||
{
|
||||
"from": "ffmpeg-n9.0.2-25-g67b60c310b-linuxarm64-gpl-9.0/bin/ffprobe",
|
||||
"to": "ffprobe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"x86_64-pc-windows-msvc": [
|
||||
{
|
||||
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-10-10-13-04/ffmpeg-n9.0.2-25-g67b60c310b-win64-gpl-9.0.zip",
|
||||
"sha256": "1b196e14bd13b76c0d33411f8ef4511f8708a459f682b58ca4c0285a793138cf",
|
||||
"size": 193972697,
|
||||
"kind": "zip",
|
||||
"files": [
|
||||
{
|
||||
"from": "ffmpeg-n9.0.2-25-g67b60c310b-win64-gpl-9.0/bin/ffmpeg.exe",
|
||||
"to": "ffmpeg.exe"
|
||||
},
|
||||
{
|
||||
"from": "ffmpeg-n9.0.2-25-g67b60c310b-win64-gpl-9.0/bin/ffprobe.exe",
|
||||
"to": "ffprobe.exe"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"chrome": {
|
||||
"endpoint": "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json",
|
||||
"channel": "Stable",
|
||||
"platforms": {
|
||||
"aarch64-apple-darwin": "mac-arm64",
|
||||
"x86_64-apple-darwin": "mac-x64",
|
||||
"x86_64-unknown-linux-gnu": "linux64",
|
||||
"x86_64-pc-windows-msvc": "win64"
|
||||
},
|
||||
"macTeamId": "EQHXZ8M8AV",
|
||||
"windowsSigner": "Google LLC",
|
||||
"license": "Chromium BSD-style license and third-party notices; Google Chrome for Testing terms",
|
||||
"licenseUrl": "https://github.com/GoogleChromeLabs/chrome-for-testing#readme"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
// Shell: sidebar (apps with their icons, workspace), top bar (breadcrumbs, last read, refresh), the screen, the
|
||||
// command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FIXTURES, onDeepLink } from './api';
|
||||
import { devSwitch } from './dev/switches';
|
||||
import { type Key, t } from './i18n';
|
||||
import { ago } from './lib/format';
|
||||
import { APP_TABS, NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { usePrefs } from './prefs-ctx';
|
||||
import { Accounts } from './screens/Accounts';
|
||||
import { AppFolders } from './screens/AppFolders';
|
||||
import { AppView } from './screens/AppView';
|
||||
import { Dependencies } from './screens/Dependencies';
|
||||
import { Onboarding } from './screens/Onboarding';
|
||||
import { Portfolio } from './screens/Portfolio';
|
||||
import { Settings } from './screens/Settings';
|
||||
import { useData } from './store';
|
||||
import { Icon, type IconName, Mark } from './ui/icons';
|
||||
import { AppIcon, Kbd, MOD } from './ui/kit';
|
||||
import { type Command, Palette } from './ui/Palette';
|
||||
import { Toasts } from './ui/Toasts';
|
||||
|
||||
const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'key', dependencies: 'package', settings: 'settings' };
|
||||
const WS_KEY: Record<WorkspaceScreen, Key> = { apps: 'nav.apps', accounts: 'nav.accounts', dependencies: 'nav.dependencies', settings: 'nav.settings' };
|
||||
|
||||
export function App({ onTheme }: { onTheme: () => void }) {
|
||||
const d = useData();
|
||||
const { prefs, update } = usePrefs();
|
||||
const [route, setRoute] = useState<Route>({ screen: 'portfolio' });
|
||||
const [palette, setPalette] = useState(false);
|
||||
const [welcome, setWelcome] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
|
||||
|
||||
// first screen: dev switch (VITE_START), else the welcome guide on a first run
|
||||
useEffect(() => {
|
||||
if (!d.ready || started) return;
|
||||
setStarted(true);
|
||||
const v = devSwitch('start');
|
||||
if (v === 'welcome' || v.startsWith('welcome:')) { setWelcome(true); return; }
|
||||
if (v === 'palette') setPalette(true);
|
||||
const r = parseStart(v, d.projects.map(p => p.id));
|
||||
if (r) { go(r); return; }
|
||||
if (!d.projects.length && !prefs.onboarded) setWelcome(true);
|
||||
}, [d.ready, d.projects, started, go, prefs.onboarded]);
|
||||
|
||||
useEffect(() => {
|
||||
const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); });
|
||||
return () => { un.then(f => f()); };
|
||||
}, [d.projects, go]);
|
||||
|
||||
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-5 workspace, Cmd/Ctrl+Shift+[ ] app tabs
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; }
|
||||
if (palette || welcome) return;
|
||||
if (mod && !e.shiftKey && !e.altKey && /^[1-5]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
const n = Number(e.key);
|
||||
go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! });
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app') {
|
||||
e.preventDefault();
|
||||
const i = APP_TABS.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1);
|
||||
go({ ...route, tab: APP_TABS[(i + APP_TABS.length) % APP_TABS.length]! });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [palette, welcome, route, go]);
|
||||
|
||||
const closeWelcome = useCallback(() => {
|
||||
setWelcome(false);
|
||||
if (!prefs.onboarded) update({ ...prefs, onboarded: true });
|
||||
}, [prefs, update]);
|
||||
|
||||
const app = route.screen === 'app' ? d.projects.find(p => p.id === route.appId) : undefined;
|
||||
useEffect(() => { if (route.screen === 'app' && d.ready && !app) go({ screen: 'portfolio' }); }, [route, app, d.ready, go]);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const c: Command[] = [];
|
||||
const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions') };
|
||||
for (const p of d.projects) c.push({ id: `app-${p.id}`, group: G.apps, title: d.statusOf(p)?.app?.name ?? p.name, hint: t('palette.openApp'), app: { name: p.name, icon: d.icons[p.id] }, run: () => go({ screen: 'app', appId: p.id, tab: route.screen === 'app' ? route.tab : 'releases' }) });
|
||||
c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) });
|
||||
WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) }));
|
||||
if (app) for (const tab of APP_TABS) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.id, tab }) });
|
||||
else if (d.projects[0]) for (const p of d.projects) for (const tab of APP_TABS) c.push({ id: `tab-${p.id}-${tab}`, group: G.tabs, title: `${p.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], keywords: 'tab', run: () => go({ screen: 'app', appId: p.id, tab }) });
|
||||
c.push({ id: 'act-live', group: G.act, title: t('action.readStores'), icon: 'refresh', keywords: 'refresh sync', run: () => { d.refreshPortfolio(true); } });
|
||||
c.push({ id: 'act-check', group: G.act, title: t('action.checkConfigs'), icon: 'shield', run: () => { d.refreshPortfolio(false); } });
|
||||
c.push({ id: 'act-add', group: G.act, title: t('action.addFolder'), icon: 'folderPlus', run: () => go({ screen: 'apps' }) });
|
||||
c.push({ id: 'act-account', group: G.act, title: t('action.addAccount'), icon: 'key', run: () => go({ screen: 'accounts' }) });
|
||||
c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme });
|
||||
c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) });
|
||||
return c;
|
||||
}, [d, app, route, go, onTheme]);
|
||||
|
||||
const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }]
|
||||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.id, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
|
||||
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
|
||||
const lastRead = d.live?.at ?? null;
|
||||
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
||||
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) ? 'page' : undefined);
|
||||
|
||||
return (
|
||||
<NavCtx.Provider value={nav}>
|
||||
<a className="skip" href="#content">{t('a11y.skip')}</a>
|
||||
<div className="shell">
|
||||
<nav className="side" aria-label={t('nav.sections')}>
|
||||
<div className="side-top">
|
||||
<div className="brand"><Mark className="brand-mark" /><span>storebound</span>{d.info?.debug && <small>DEV</small>}</div>
|
||||
<button className="search-btn" onClick={() => setPalette(true)} aria-keyshortcuts="Meta+K Control+K">
|
||||
<Icon name="search" /><span className="lbl">{t('palette.button')}</span><Kbd keys={[MOD, 'K']} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="side-scroll">
|
||||
<button className="nav" aria-current={current({ screen: 'portfolio' })} onClick={() => go({ screen: 'portfolio' })}>
|
||||
<Icon name="portfolio" /><span className="grow">{t('nav.portfolio')}</span>
|
||||
</button>
|
||||
<div className="side-label" id="apps-label"><span>{t('nav.appsLabel')}</span>
|
||||
<button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button>
|
||||
</div>
|
||||
<div role="list" aria-labelledby="apps-label">
|
||||
{d.projects.map(p => {
|
||||
const e = d.statusOf(p);
|
||||
const bad = (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
|
||||
return (
|
||||
<div role="listitem" key={p.id}>
|
||||
<button className="nav nav-app" aria-current={current({ screen: 'app', appId: p.id, tab: 'releases' })} onClick={() => go({ screen: 'app', appId: p.id, tab: route.screen === 'app' ? route.tab : 'releases' })}>
|
||||
<AppIcon name={p.name} src={d.icons[p.id]} />
|
||||
<span className="grow">{e?.app?.name ?? p.name}</span>
|
||||
{bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!d.projects.length && <p className="side-empty">{t('nav.noApps')}</p>}
|
||||
</div>
|
||||
<div className="side-label"><span>{t('nav.workspace')}</span></div>
|
||||
{WORKSPACE.map((s, i) => (
|
||||
<button key={s} className="nav" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${t(WS_KEY[s])} (${MOD} ${i + 2})`}>
|
||||
<Icon name={WS_ICON[s]} /><span className="grow">{t(WS_KEY[s])}</span>
|
||||
{s === 'accounts' && <span className="count">{d.accounts.length}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="side-foot" aria-live="polite">
|
||||
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
|
||||
<span>{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></div>
|
||||
<div className="row"><Icon name="clock" className="sr-icon" /><span>{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}</span></div>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<div className="crumbs" aria-label={t('a11y.breadcrumbs')}>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} className="row" style={{ gap: 6, minWidth: 0 }}>
|
||||
{i > 0 && <Icon name="chevronRight" />}
|
||||
{c.to && i < crumbs.length - 1 ? <button onClick={() => go(c.to!)}>{c.label}</button> : <span className={i === crumbs.length - 1 ? 'here' : ''}>{c.label}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
|
||||
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
|
||||
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy || !d.projects.length} title={t('action.readStoresHint')}>
|
||||
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
|
||||
</button>
|
||||
</header>
|
||||
<main className="content" id="content" tabIndex={-1}>
|
||||
{route.screen === 'portfolio' && <Portfolio />}
|
||||
{route.screen === 'app' && app && <AppView key={app.id} project={app} tab={route.tab} />}
|
||||
{route.screen === 'apps' && <AppFolders />}
|
||||
{route.screen === 'accounts' && <Accounts />}
|
||||
{route.screen === 'dependencies' && <Dependencies />}
|
||||
{route.screen === 'settings' && <Settings onTheme={onTheme} />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{palette && <Palette commands={commands} onClose={() => setPalette(false)} />}
|
||||
{welcome && <Onboarding onClose={closeWelcome} />}
|
||||
<Toasts />
|
||||
</NavCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
// The webview's only way out: the app commands of src-tauri/src/commands.rs. No plugin API is used from here.
|
||||
// Dev builds can run on fixture data instead (VITE_FIXTURES=1, src/dev/fixtures.ts): import.meta.env.DEV is false in
|
||||
// release builds, so that branch and the fixture module are dropped from them (tests/webview.test.ts checks dist/).
|
||||
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||
import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol';
|
||||
|
||||
export type {
|
||||
AppIdentity, AppStatus, CatalogParity, DraftsResult, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult,
|
||||
ProgressParams, ProjectSummary, ReleasePhase, ReleaseTrain, ReplyDraft, StoreRelease, ToolResult,
|
||||
} from '../../../cli/lib/desktop-protocol';
|
||||
|
||||
export interface AppInfo { version: string; identifier: string; platform: string; debug: boolean; home: string | null }
|
||||
export type { ToolStatus } from '../../../cli/lib/tools';
|
||||
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[] }
|
||||
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
|
||||
/** 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 }
|
||||
export type AccountKind = 'play' | 'apple';
|
||||
export interface Account { id: string; label: string; kind: AccountKind; created: string }
|
||||
export interface WriteOutcome { confirmed: boolean; stage: 'dry-run' | 'dialog' | 'done'; dryRun: ToolResult; result?: ToolResult }
|
||||
export interface CacheEntry<T = unknown> { value: T; at: string }
|
||||
export interface DownloadInfo { tool: 'ffmpeg' | 'chrome'; available: boolean; version: string | null; size: number | null; license: string; licenseUrl: string; source: string; installed: boolean; folder: string }
|
||||
export interface DownloadEvent { job: string; tool: string; stage: 'resolve' | 'download' | 'verify' | 'unpack' | 'install' | 'done' | 'error' | 'cancelled'; received: number; total: number | null; message?: string }
|
||||
export type { IconResult as Icon };
|
||||
|
||||
/** What a failed command rejects with (sidecar.rs `Fail`). */
|
||||
export interface Fail { code: number; message: string; data?: unknown }
|
||||
|
||||
export function errorText(e: unknown): string {
|
||||
if (typeof e === 'string') return e;
|
||||
if (e && typeof e === 'object' && 'message' in e && typeof (e as Fail).message === 'string') return (e as Fail).message;
|
||||
return 'Something went wrong';
|
||||
}
|
||||
|
||||
/** Dev-only fixture mode (VITE_FIXTURES=1): every command answered from src/dev/fixtures.ts. */
|
||||
export const FIXTURES: boolean = import.meta.env.DEV && !!import.meta.env.VITE_FIXTURES;
|
||||
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_FIXTURES) return (await import('./dev/fixtures')).mockInvoke<T>(cmd, args ?? {});
|
||||
return tauriInvoke<T>(cmd, args);
|
||||
}
|
||||
|
||||
async function listen<T>(event: string, cb: (payload: T) => void): Promise<UnlistenFn> {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_FIXTURES) return (await import('./dev/fixtures')).mockListen(event, cb as (p: unknown) => void);
|
||||
return tauriListen<T>(event, e => cb(e.payload));
|
||||
}
|
||||
|
||||
let jobSeq = 0;
|
||||
/** A fresh job id for one background run (progress events and cancel refer to it). */
|
||||
export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`;
|
||||
|
||||
export const api = {
|
||||
appInfo: () => invoke<AppInfo>('app_info'),
|
||||
sidecarStatus: () => invoke<SidecarStatus>('sidecar_status'),
|
||||
/** Reads, or dry runs of writes (the host forces dryRun for writes coming from here). */
|
||||
call: <T = ToolResult>(method: string, projectId?: string, params?: { args?: Record<string, unknown> }, job?: string) =>
|
||||
invoke<T>('sidecar_call', { method, projectId: projectId ?? null, params: params ?? null, job: job ?? null }),
|
||||
/** Last cached read (projectId "*" = the whole live portfolio); null when never read. */
|
||||
cached: <T = unknown>(method: string, projectId: string, args: Record<string, unknown> = {}) =>
|
||||
invoke<CacheEntry<T> | null>('cache_get', { method, projectId, args }),
|
||||
clearCache: () => invoke<void>('cache_clear'),
|
||||
cancel: (job: string) => invoke<boolean>('job_cancel', { job }),
|
||||
/** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */
|
||||
writeWithConfirm: (method: string, projectId: string, args: Record<string, unknown> = {}, job?: string) =>
|
||||
invoke<WriteOutcome>('write_with_confirm', { method, projectId, args, job: job ?? null }),
|
||||
projects: () => invoke<Project[]>('projects_list'),
|
||||
addProject: () => invoke<Project | null>('project_add'),
|
||||
initProject: (profile: 'app' | 'game' | 'subscription-app') => invoke<Project | null>('project_init', { profile }),
|
||||
removeProject: (id: string) => invoke<void>('project_remove', { id }),
|
||||
setProjectAccounts: (id: string, play: string | null, apple: string | null) => invoke<Project>('project_set_accounts', { id, play, apple }),
|
||||
accounts: () => invoke<Account[]>('accounts_list'),
|
||||
addPlayAccount: (label: string) => invoke<Account | null>('account_add_play', { label }),
|
||||
addAppleAccount: (label: string, keyId: string, issuerId: string) => invoke<Account | null>('account_add_apple', { label, keyId, issuerId }),
|
||||
removeAccount: (id: string) => invoke<void>('account_remove', { id }),
|
||||
portfolio: (live = false, job?: string) => invoke<PortfolioResult>('portfolio_status', { live, job: job ?? null }),
|
||||
openExternal: (url: string) => invoke<void>('open_external', { url }),
|
||||
toolsInfo: () => invoke<DownloadInfo[]>('tools_info'),
|
||||
installTool: (tool: 'ffmpeg' | 'chrome', job: string) => invoke<string>('tools_install', { tool, job }),
|
||||
uninstallTool: (tool: 'ffmpeg' | 'chrome') => invoke<void>('tools_uninstall', { tool }),
|
||||
};
|
||||
|
||||
/** Typed reads of the project files (offline) and the stores (network). */
|
||||
export const reads = {
|
||||
icon: (projectId: string) => api.call<ToolResult & { data?: IconResult }>('project.icon', projectId),
|
||||
listings: (projectId: string) => api.call<ToolResult & { data?: ListingsResult }>('project.listings', projectId),
|
||||
drafts: (projectId: string) => api.call<ToolResult & { data?: DraftsResult }>('reviews.drafts', projectId),
|
||||
};
|
||||
|
||||
export interface ProgressEvent extends ProgressParams { job?: string }
|
||||
export const onProgress = (cb: (p: ProgressEvent) => void): Promise<UnlistenFn> =>
|
||||
listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job }));
|
||||
export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb);
|
||||
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
// DEV-ONLY FIXTURE MODE (VITE_FIXTURES=1). Answers every app command with made-up data built around the two demo apps
|
||||
// of this repo (examples/demo-game "Prism Puddle", examples/demo-app "Tidy Lists"), so every screen can be shown and
|
||||
// screenshotted without store accounts, in a browser or in `tauri dev`. Nothing here is real store data; the window
|
||||
// shows a "DEV FIXTURES" badge. src/api.ts only imports this module behind import.meta.env.DEV, so release builds do
|
||||
// not contain it (tests/webview.test.ts checks the built files for FIXTURE_MARKER).
|
||||
import type { Account, AppStatus, CacheEntry, DownloadEvent, DraftsResult, ListingField, ListingsResult, PortfolioResult, Project, ProjectSummary, StoreRelease, ToolResult } from '../api';
|
||||
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult } from '../lib/model';
|
||||
|
||||
export const FIXTURE_MARKER = 'storebound-dev-fixtures';
|
||||
|
||||
const HOME = '/home/you';
|
||||
const NOW = Date.now();
|
||||
const iso = (minutesAgo: number) => new Date(NOW - minutesAgo * 60_000).toISOString();
|
||||
const day = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString().slice(0, 10);
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ---- events ----
|
||||
type Cb = (p: unknown) => void;
|
||||
const listeners = new Map<string, Set<Cb>>();
|
||||
export async function mockListen(event: string, cb: Cb): Promise<() => void> {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event)!.add(cb);
|
||||
return () => { listeners.get(event)?.delete(cb); };
|
||||
}
|
||||
const emit = (event: string, payload: unknown) => { for (const cb of listeners.get(event) ?? []) cb(payload); };
|
||||
const cancelled = new Set<string>();
|
||||
|
||||
// ---- icons (fixture art, drawn for the demo apps) ----
|
||||
const svg = (s: string) => `data:image/svg+xml;base64,${btoa(s)}`;
|
||||
const ICONS: Record<string, string> = {
|
||||
'p-prism': svg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c5cff"/><stop offset="1" stop-color="#ff5fa2"/></linearGradient></defs><rect width="64" height="64" fill="url(#g)"/><path d="M32 12 50 44H14z" fill="#fff" fill-opacity=".92"/><path d="M32 12 41 44H32z" fill="#ffd36e"/><path d="M46 46c0 4-3 7-6 7s-6-3-6-7 6-10 6-10 6 6 6 10z" fill="#5ee0ff" stroke="#fff" stroke-width="2"/></svg>`),
|
||||
'p-tidy': svg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2fd18b"/><stop offset="1" stop-color="#0f9d74"/></linearGradient></defs><rect width="64" height="64" fill="url(#g)"/><rect x="14" y="13" width="36" height="40" rx="7" fill="#fff"/><path d="M20 25l3 3 6-6M20 37l3 3 6-6" stroke="#0f9d74" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M33 26h11M33 38h11" stroke="#b6e8d4" stroke-width="3" stroke-linecap="round"/></svg>`),
|
||||
};
|
||||
|
||||
// ---- workspace ----
|
||||
let projects: Project[] = [
|
||||
{ id: 'p-prism', path: `${HOME}/apps/prism-puddle`, name: 'Prism Puddle', playAccount: 'a-play', appleAccount: 'a-apple', added: iso(60 * 24 * 12) },
|
||||
{ id: 'p-tidy', path: `${HOME}/apps/tidy-lists`, name: 'Tidy Lists', playAccount: 'a-play', appleAccount: 'a-apple', added: iso(60 * 24 * 5) },
|
||||
];
|
||||
let accounts: Account[] = [
|
||||
{ id: 'a-play', label: 'Example Studio · Play', kind: 'play', created: iso(60 * 24 * 12) },
|
||||
{ id: 'a-apple', label: 'Example Studio · App Store', kind: 'apple', created: iso(60 * 24 * 12) },
|
||||
];
|
||||
if (import.meta.env.VITE_FIXTURES === 'empty') { projects = []; accounts = []; }
|
||||
|
||||
const SUMMARY: Record<string, ProjectSummary> = {
|
||||
'p-prism': {
|
||||
dir: `${HOME}/apps/prism-puddle`, config: `${HOME}/apps/prism-puddle/storebound.config.ts`, name: 'Prism Puddle', languages: ['en', 'tr', 'ja', 'ar'],
|
||||
play: { packageName: 'com.example.prismpuddle' }, apple: { bundleId: 'com.example.prismpuddle', appId: '6400000001' }, platforms: ['ios', 'android'],
|
||||
products: 4, productIds: ['coins_small', 'coins_large', 'no_ads', 'starter_pack'], subscriptions: 1, subscriptionIds: ['vip_pass_monthly'],
|
||||
catalog: { ios: ['coins_small', 'coins_large', 'no_ads', 'starter_pack', 'vip_pass_monthly'], android: ['coins_small', 'coins_large', 'no_ads', 'starter_pack', 'vip_pass_monthly'] },
|
||||
shots: true, testflightGroups: 3, profile: 'game', icon: `${HOME}/apps/prism-puddle/store/icons/app-store-1024.png`,
|
||||
},
|
||||
'p-tidy': {
|
||||
dir: `${HOME}/apps/tidy-lists`, config: `${HOME}/apps/tidy-lists/storebound.config.ts`, name: 'Tidy Lists', languages: ['en', 'tr'],
|
||||
play: { packageName: 'com.example.tidylists' }, apple: { bundleId: 'com.example.tidylists', appId: '6400000002' }, platforms: ['ios', 'android'],
|
||||
products: 1, productIds: ['tip_jar'], subscriptions: 2, subscriptionIds: ['pro_monthly', 'pro_yearly'],
|
||||
catalog: { ios: ['tip_jar', 'pro_monthly', 'pro_yearly'], android: ['tip_jar', 'pro_monthly', 'pro_yearly'] },
|
||||
shots: true, testflightGroups: 2, profile: 'app', icon: `${HOME}/apps/tidy-lists/store/icons/app-store-1024.png`,
|
||||
},
|
||||
};
|
||||
|
||||
const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial<StoreRelease> = {}): StoreRelease =>
|
||||
({ store, version, build: null, state: phase.toUpperCase(), phase, fraction: null, track: store === 'android' ? 'production' : null, ...o });
|
||||
|
||||
const RELEASES: Record<string, StoreRelease[]> = {
|
||||
'p-prism': [
|
||||
rel('ios', '2.4.0', 'review', { build: '52', state: 'WAITING_FOR_REVIEW', date: iso(60 * 26) }),
|
||||
rel('ios', '2.3.0', 'rollout', { build: '48', state: 'READY_FOR_SALE', fraction: 0.2, phased: { state: 'ACTIVE', day: 5 }, date: iso(60 * 24 * 9) }),
|
||||
rel('ios', '2.2.1', 'other', { build: '44', state: 'REPLACED_WITH_NEW_VERSION', date: iso(60 * 24 * 30) }),
|
||||
rel('android', '2.4.0', 'testing', { build: '240', state: 'completed', track: 'internal' }),
|
||||
rel('android', '2.3.0', 'rollout', { build: '231', state: 'inProgress', fraction: 0.2 }),
|
||||
rel('android', '2.2.1', 'live', { build: '221', state: 'completed' }),
|
||||
],
|
||||
'p-tidy': [
|
||||
rel('ios', '1.5.0', 'draft', { build: '31', state: 'PREPARE_FOR_SUBMISSION', date: iso(60 * 20) }),
|
||||
rel('ios', '1.4.2', 'live', { build: '29', state: 'READY_FOR_SALE', fraction: 1, phased: { state: 'COMPLETE', day: 7 }, date: iso(60 * 24 * 21) }),
|
||||
rel('android', '1.5.0', 'testing', { build: '150', state: 'completed', track: 'beta' }),
|
||||
rel('android', '1.4.2', 'live', { build: '142', state: 'completed' }),
|
||||
],
|
||||
};
|
||||
|
||||
const train = (all: StoreRelease[], v: string) => ({ version: v, ios: all.find(r => r.store === 'ios' && r.version === v) ?? null, android: all.find(r => r.store === 'android' && r.version === v) ?? null });
|
||||
|
||||
function status(id: string, live: boolean): AppStatus {
|
||||
const s = SUMMARY[id]!;
|
||||
const all = RELEASES[id]!;
|
||||
const prism = id === 'p-prism';
|
||||
return {
|
||||
project: s.dir, ok: !prism, summary: s,
|
||||
app: { id: s.dir, name: s.name, platforms: { ios: { bundleId: s.apple!.bundleId, ascAppId: s.apple!.appId }, android: { packageName: s.play!.packageName } } },
|
||||
check: prism ? { ok: true, problems: [], warnings: ['store/listing.md: ja-JP "what\'s new" is empty', 'store/appstore.md: ar-SA subtitle is 31 characters (limit 30)'] } : { ok: true, problems: [], warnings: [] },
|
||||
release: live ? (prism ? { source: 'live', current: train(all, '2.3.0'), next: train(all, '2.4.0'), all } : { source: 'live', current: train(all, '1.4.2'), next: train(all, '1.5.0'), all }) : { source: 'none', current: null, next: null },
|
||||
catalog: live
|
||||
? (prism ? { checked: 'live', products: 4, subscriptions: 1, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: ['starter_pack'], ok: false }
|
||||
: { checked: 'live', products: 1, subscriptions: 2, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true })
|
||||
: { checked: 'config', products: s.products, subscriptions: s.subscriptions, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true },
|
||||
storeErrors: {}, errors: [],
|
||||
};
|
||||
}
|
||||
const portfolio = (live: boolean): PortfolioResult => ({ apps: projects.map(p => status(p.id, live)), at: live ? iso(14) : iso(0) });
|
||||
|
||||
// ---- per-app store reads ----
|
||||
const series = (n: number, base: number, drift: number, seed: number): number[] =>
|
||||
Array.from({ length: n }, (_, i) => Math.max(0.0001, base + drift * (i / n) + Math.sin(i * 1.7 + seed) * base * 0.09 + Math.cos(i * 0.6 + seed) * base * 0.05));
|
||||
function rows(metric: 'Crash' | 'Anr', vals: number[], w28: number): MetricRow[] {
|
||||
return vals.map((v, i) => ({ date: day(vals.length - i), dims: {}, values: {
|
||||
[`userPerceived${metric}Rate`]: v, [`${metric === 'Crash' ? 'crash' : 'anr'}Rate`]: v * 1.4, distinctUsers: 18_000 + i * 140,
|
||||
...(i === vals.length - 1 ? { [`userPerceived${metric}Rate28dUserWeighted`]: w28 } : {}),
|
||||
} }));
|
||||
}
|
||||
const HEALTH: Record<string, HealthSnapshot> = {
|
||||
'p-prism': {
|
||||
date: day(0),
|
||||
play: {
|
||||
window: { start: day(28), end: day(1), days: 28 },
|
||||
crash: rows('Crash', series(28, 0.0068, 0.0032, 1), 0.0091),
|
||||
anr: rows('Anr', series(28, 0.0024, 0.0004, 2), 0.0027),
|
||||
devices: [{ model: 'Galaxy A12', crash: 0.061, users: 1900 }, { model: 'Redmi 9A', crash: 0.042, users: 1400 }, { model: 'Moto G Power (2021)', crash: 0.019, users: 900 }, { model: 'Pixel 6a', crash: 0.008, users: 2100 }, { model: 'Galaxy S23', crash: 0.004, users: 2600 }],
|
||||
issues: [
|
||||
{ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.prismpuddle.combo.ComboMeter.update', reports: 412, users: 268, lastVersion: '231' },
|
||||
{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.prismpuddle.MainActivity', reports: 133, users: 120, lastVersion: '231' },
|
||||
{ type: 'CRASH', cause: 'java.lang.OutOfMemoryError', location: 'android.graphics.Bitmap.createBitmap', reports: 74, users: 61, lastVersion: '221' },
|
||||
],
|
||||
},
|
||||
apple: {
|
||||
metrics: [
|
||||
{ category: 'Hangs', metric: 'Hang rate', unit: 's/h', version: '2.3.0', value: 1.9, goal: 'fair' },
|
||||
{ category: 'Launch', metric: 'Launch time', unit: 's', version: '2.3.0', value: 1.21, goal: 'good' },
|
||||
{ category: 'Memory', metric: 'Peak memory', unit: 'MB', version: '2.3.0', value: 412, goal: 'fair' },
|
||||
{ category: 'Disk', metric: 'Disk writes', unit: 'MB', version: '2.3.0', value: 38, goal: 'good' },
|
||||
{ category: 'Battery', metric: 'Foreground energy', unit: '%/h', version: '2.3.0', value: 9.4, goal: 'poor' },
|
||||
],
|
||||
signatures: [
|
||||
{ build: '48', type: 'HANGS', signature: 'ComboMeter.update(delta:) + 212', weight: 0.31 },
|
||||
{ build: '48', type: 'DISK_WRITES', signature: 'SaveStore.flush() + 88', weight: 0.12 },
|
||||
{ build: '47', type: 'LAUNCHES', signature: 'AssetCache.warmUp() + 64', weight: 0.07 },
|
||||
],
|
||||
betaCrashes: [{ build: '52', count: 3 }, { build: '51', count: 9 }, { build: '50', count: 5 }],
|
||||
},
|
||||
},
|
||||
'p-tidy': {
|
||||
date: day(0),
|
||||
play: { window: { start: day(28), end: day(1), days: 28 }, crash: rows('Crash', series(28, 0.0021, -0.0004, 3), 0.0019), anr: rows('Anr', series(28, 0.0009, 0.0001, 4), 0.001), devices: [{ model: 'Pixel 7', crash: 0.004, users: 800 }, { model: 'Galaxy A54', crash: 0.003, users: 1100 }], issues: [{ type: 'CRASH', cause: 'kotlin.UninitializedPropertyAccessException', location: 'com.example.tidylists.share.ShareSheet', reports: 21, users: 19, lastVersion: '142' }] },
|
||||
apple: { metrics: [{ category: 'Launch', metric: 'Launch time', unit: 's', version: '1.4.2', value: 0.62, goal: 'good' }, { category: 'Hangs', metric: 'Hang rate', unit: 's/h', version: '1.4.2', value: 0.2, goal: 'good' }], signatures: [], betaCrashes: [{ build: '31', count: 1 }] },
|
||||
},
|
||||
};
|
||||
|
||||
const R = (store: 'apple' | 'play', id: string, rating: number, title: string, body: string, where: string, daysAgo: number, reply?: string): Review =>
|
||||
({ store, id, rating, title: title || undefined, body, where, date: new Date(NOW - daysAgo * 86_400_000 - id.length * 3_600_000).toISOString(), appVersion: store === 'play' ? '2.3.0' : undefined, ...(reply ? { reply: { text: reply, date: new Date(NOW - (daysAgo - 1) * 86_400_000).toISOString() } } : {}) });
|
||||
const REVIEWS: Record<string, Review[]> = {
|
||||
'p-prism': [
|
||||
R('apple', 'r-a1', 5, 'Calm and clever', 'The color puzzles are so relaxing. Love that it works offline on the train.', 'USA', 1),
|
||||
R('play', 'r-p1', 2, 'Crashes after combo', 'Game closes when I reach a 20x combo on level 31. Lost my streak twice.', 'en', 1),
|
||||
R('apple', 'r-a2', 4, 'Great, but ads', 'Really pretty game. The ad after every level is a bit much, I bought no ads.', 'GBR', 2, 'Thank you! Remove Ads turns them off for good, and you can restore it on a new device from Settings.'),
|
||||
R('play', 'r-p2', 5, '', 'Çok güzel bir oyun, renkler harika ve müzik çok sakin.', 'tr', 2),
|
||||
R('apple', 'r-a3', 1, 'Lost progress', 'Updated and my map progress is gone. Please fix.', 'DEU', 3),
|
||||
R('play', 'r-p3', 3, 'Okay', 'Nice idea, later levels get repetitive. More star challenges please.', 'en', 4),
|
||||
R('apple', 'r-a4', 5, '最高', '色がきれいで、毎日少しずつ遊んでいます。', 'JPN', 5),
|
||||
R('play', 'r-p4', 4, 'Fun', 'Good time killer. Would love a dark mode for night play.', 'en', 5),
|
||||
R('apple', 'r-a5', 5, 'Perfect puzzle game', 'No timers, no pressure. Exactly what I wanted.', 'CAN', 8, 'Thank you for playing! More calm levels arrive in 2.4.'),
|
||||
R('apple', 'r-a6', 2, 'Hard to read', 'Text on the star map is tiny on my iPad.', 'AUS', 11),
|
||||
R('play', 'r-p5', 5, '', 'Beautiful and offline. Five stars from me.', 'en', 6),
|
||||
R('apple', 'r-a7', 4, 'Very nice', 'I like the new combo meter. Starter pack price feels high in Turkey.', 'TUR', 13),
|
||||
],
|
||||
'p-tidy': [
|
||||
R('apple', 't-a1', 5, 'Finally simple', 'Shared groceries list with my partner in seconds. No clutter.', 'USA', 1),
|
||||
R('play', 't-p1', 4, 'Good', 'Works well. Widgets would be great.', 'en', 2),
|
||||
R('apple', 't-a2', 5, 'Harika', 'Ailecek ortak listeleri burada tutuyoruz, çok pratik.', 'TUR', 3, 'Teşekkürler! Widget\'lar 1.5 ile geliyor.'),
|
||||
R('play', 't-p2', 3, 'Sync delay', 'Sometimes the shared list takes a minute to update.', 'en', 4),
|
||||
R('apple', 't-a3', 5, 'Pro is worth it', 'Unlimited lists and themes. Happy to support a small studio.', 'GBR', 9),
|
||||
],
|
||||
};
|
||||
|
||||
const draft = (store: 'apple' | 'play', reviewId: string, reply: string, approved: boolean, issues: DraftsResult['drafts'][number]['issues'] = []) =>
|
||||
({ file: `${HOME}/apps/prism-puddle/.storebound/replies/${store}-${reviewId}.md`, store, reviewId, approved, sent: null, reply, rating: null, title: null, body: null, date: null, issues });
|
||||
const DRAFTS: Record<string, DraftsResult> = {
|
||||
'p-prism': {
|
||||
dir: `${HOME}/apps/prism-puddle/.storebound/replies`, problems: [], drafts: [
|
||||
draft('play', 'r-p1', 'Sorry about the lost streak! A fix for the crash at high combos is in 2.4.0, now in review. Thank you for the level number, it helped.', true),
|
||||
draft('apple', 'r-a3', 'We are sorry! Your progress is saved in iCloud: open Settings > Restore progress. If it is still missing, write to support@example.com.', false,
|
||||
[{ level: 'warn', msg: 'contains an email address: only your public support address, never a user\'s' }]),
|
||||
draft('play', 'r-p3', '', false),
|
||||
draft('apple', 'r-a6', 'Thanks! Change your rating to 5 stars and we will add bigger text with free coins.', false,
|
||||
[{ level: 'error', msg: 'offers something for a rating or asks to change / give stars: not allowed (no incentives, no rating manipulation)' }]),
|
||||
],
|
||||
},
|
||||
'p-tidy': { dir: `${HOME}/apps/tidy-lists/.storebound/replies`, problems: [], drafts: [] },
|
||||
};
|
||||
|
||||
const TF: Record<string, TestFlightStatus> = {
|
||||
'p-prism': {
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Studio team', internal: true, testers: 6, builds: 'all', publicLink: false },
|
||||
{ id: 'g2', name: 'Friends and family', internal: false, testers: 42, builds: 3, publicLink: true },
|
||||
{ id: 'g3', name: 'Press', internal: false, testers: 8, builds: 1, publicLink: false },
|
||||
],
|
||||
builds: [
|
||||
{ version: '2.4.0', build: '52', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'WAITING_FOR_BETA_REVIEW', uploaded: iso(60 * 28) },
|
||||
{ version: '2.4.0', build: '51', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'BETA_APPROVED', uploaded: iso(60 * 50) },
|
||||
{ version: '2.3.0', build: '48', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'BETA_APPROVED', uploaded: iso(60 * 24 * 10) },
|
||||
{ version: '2.3.0', build: '47', processing: 'VALID', internal: 'EXPIRED', external: 'EXPIRED', uploaded: iso(60 * 24 * 12) },
|
||||
{ version: '2.2.1', build: '44', processing: 'VALID', internal: 'EXPIRED', external: 'EXPIRED', uploaded: iso(60 * 24 * 31) },
|
||||
],
|
||||
missingGroups: [],
|
||||
},
|
||||
'p-tidy': {
|
||||
groups: [{ id: 'g4', name: 'Studio team', internal: true, testers: 4, builds: 'all', publicLink: false }, { id: 'g5', name: 'Beta club', internal: false, testers: 118, builds: 2, publicLink: true }],
|
||||
builds: [{ version: '1.5.0', build: '31', processing: 'VALID', internal: 'IN_BETA_TESTING', external: 'IN_BETA_TESTING', uploaded: iso(60 * 22) }, { version: '1.4.2', build: '29', processing: 'VALID', internal: 'EXPIRED', external: 'EXPIRED', uploaded: iso(60 * 24 * 22) }],
|
||||
missingGroups: ['Accessibility testers'],
|
||||
},
|
||||
};
|
||||
|
||||
const CATALOG: Record<string, CatalogDiff> = {
|
||||
'p-prism': {
|
||||
errors: 1,
|
||||
rows: [
|
||||
{ id: 'starter_pack', store: 'play', level: 'error', code: 'missing', msg: 'not on Google Play (storebound play products)' },
|
||||
{ id: 'vip_pass_monthly', store: 'apple', level: 'warn', code: 'locale', msg: 'no localization for ar-SA' },
|
||||
{ id: 'coins_large', store: 'apple', level: 'warn', code: 'price', msg: 'no US price on the store' },
|
||||
{ id: 'coins_small', store: 'apple', level: 'ok', code: 'ok', msg: 'consumable, 175 region prices' },
|
||||
{ id: 'coins_small', store: 'play', level: 'ok', code: 'ok', msg: 'one_time, 173 region prices' },
|
||||
{ id: 'coins_large', store: 'play', level: 'ok', code: 'ok', msg: 'one_time, 173 region prices' },
|
||||
{ id: 'no_ads', store: 'apple', level: 'ok', code: 'ok', msg: 'non_consumable, 175 region prices' },
|
||||
{ id: 'no_ads', store: 'play', level: 'ok', code: 'ok', msg: 'one_time, 173 region prices' },
|
||||
{ id: 'starter_pack', store: 'apple', level: 'ok', code: 'ok', msg: 'consumable, 175 region prices' },
|
||||
{ id: 'vip_pass_monthly', store: 'play', level: 'ok', code: 'ok', msg: 'subscription P1M, 173 region prices' },
|
||||
{ id: 'legacy_hint_pack', store: 'apple', level: 'warn', code: 'extra', msg: 'on the store but not in the config (consumable, REMOVED_FROM_SALE)' },
|
||||
],
|
||||
parity: [
|
||||
{ id: 'starter_pack', region: 'TR', currency: 'TRY', apple: 149.99, play: 99.99, diffPct: 33.3 },
|
||||
{ id: 'no_ads', region: 'BR', currency: 'BRL', apple: 24.9, play: 19.99, diffPct: 19.7 },
|
||||
{ id: 'coins_large', region: 'IN', currency: 'INR', apple: 899, play: 780, diffPct: 13.2 },
|
||||
],
|
||||
},
|
||||
'p-tidy': { errors: 0, rows: ['tip_jar', 'pro_monthly', 'pro_yearly'].flatMap(id => (['apple', 'play'] as const).map(store => ({ id, store, level: 'ok' as const, code: 'ok', msg: 'matches' }))), parity: [] },
|
||||
};
|
||||
|
||||
const VALIDATE: Record<string, ValidateResult> = {
|
||||
'p-prism': {
|
||||
blockers: 1, findings: [
|
||||
{ store: 'play', level: 'blocker', code: 'product-missing', msg: 'starter_pack is in the config but not on Google Play', fix: 'storebound play products --only starter_pack (dry run first)' },
|
||||
{ store: 'apple', level: 'warn', code: 'screenshots-ipad', msg: 'ar-SA has no 13" iPad screenshots', fix: 'storebound shots render --lang ar, then storebound apple screenshots --only ar-SA' },
|
||||
{ store: 'apple', level: 'warn', code: 'whats-new', msg: '2.4.0 has no "What\'s New" for ja', fix: 'storebound whats-new --since v2.3.0' },
|
||||
],
|
||||
},
|
||||
'p-tidy': { blockers: 0, findings: [] },
|
||||
};
|
||||
|
||||
function field(f: string, length: number, limit: number, required = true): ListingField {
|
||||
return { field: f, length, limit, required, state: !length ? (required ? 'missing' : 'empty-optional') : length > limit ? 'over' : 'ok' };
|
||||
}
|
||||
const LISTINGS: Record<string, ListingsResult> = {
|
||||
'p-prism': {
|
||||
files: { play: `${HOME}/apps/prism-puddle/store/listing.md`, apple: `${HOME}/apps/prism-puddle/store/appstore.md` }, problems: [],
|
||||
locales: [
|
||||
{ lang: 'en', rtl: false, screenshots: 24, complete: 1, play: { code: 'en-US', fields: [field('title', 12, 30), field('short', 74, 80), field('full', 2210, 4000), field('whatsNew', 182, 500, false)] }, apple: { code: 'en-US', fields: [field('name', 12, 30), field('subtitle', 28, 30), field('promotionalText', 141, 170), field('keywords', 97, 100), field('description', 2380, 4000), field('whatsNew', 210, 4000, false)] } },
|
||||
{ lang: 'tr', rtl: false, screenshots: 24, complete: 1, play: { code: 'tr-TR', fields: [field('title', 12, 30), field('short', 79, 80), field('full', 2390, 4000), field('whatsNew', 170, 500, false)] }, apple: { code: 'tr', fields: [field('name', 12, 30), field('subtitle', 26, 30), field('promotionalText', 150, 170), field('keywords', 99, 100), field('description', 2511, 4000), field('whatsNew', 190, 4000, false)] } },
|
||||
{ lang: 'ja', rtl: false, screenshots: 24, complete: 0.89, play: { code: 'ja-JP', fields: [field('title', 9, 30), field('short', 41, 80), field('full', 1310, 4000), field('whatsNew', 0, 500, false)] }, apple: { code: 'ja', fields: [field('name', 9, 30), field('subtitle', 16, 30), field('promotionalText', 0, 170), field('keywords', 88, 100), field('description', 1402, 4000), field('whatsNew', 0, 4000, false)] } },
|
||||
{ lang: 'ar', rtl: true, screenshots: 16, complete: 0.89, play: { code: 'ar', fields: [field('title', 11, 30), field('short', 66, 80), field('full', 1980, 4000), field('whatsNew', 150, 500, false)] }, apple: { code: 'ar-SA', fields: [field('name', 11, 30), field('subtitle', 31, 30), field('promotionalText', 120, 170), field('keywords', 93, 100), field('description', 2005, 4000), field('whatsNew', 160, 4000, false)] } },
|
||||
],
|
||||
},
|
||||
'p-tidy': {
|
||||
files: { play: `${HOME}/apps/tidy-lists/store/listing.md`, apple: `${HOME}/apps/tidy-lists/store/appstore.md` }, problems: [],
|
||||
locales: [
|
||||
{ lang: 'en', rtl: false, screenshots: 16, complete: 1, play: { code: 'en-US', fields: [field('title', 10, 30), field('short', 70, 80), field('full', 1500, 4000), field('whatsNew', 120, 500, false)] }, apple: { code: 'en-US', fields: [field('name', 10, 30), field('subtitle', 24, 30), field('promotionalText', 120, 170), field('keywords', 92, 100), field('description', 1610, 4000), field('whatsNew', 130, 4000, false)] } },
|
||||
{ lang: 'tr', rtl: false, screenshots: 16, complete: 1, play: { code: 'tr-TR', fields: [field('title', 11, 30), field('short', 77, 80), field('full', 1620, 4000), field('whatsNew', 118, 500, false)] }, apple: { code: 'tr', fields: [field('name', 11, 30), field('subtitle', 27, 30), field('promotionalText', 133, 170), field('keywords', 96, 100), field('description', 1700, 4000), field('whatsNew', 128, 4000, false)] } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const TOOLS = [
|
||||
{ id: 'ffmpeg', name: 'ffmpeg', found: true, path: `${HOME}/Library/Application Support/dev.storebound.desktop/tools/bin/ffmpeg`, version: '9.0.2', source: 'app', usedFor: 'icons, screenshot renders, app preview videos, frame crops', install: { kind: 'download', url: 'https://ffmpeg.org/download.html', note: '' } },
|
||||
{ id: 'ffprobe', name: 'ffprobe', found: true, path: `${HOME}/Library/Application Support/dev.storebound.desktop/tools/bin/ffprobe`, version: '9.0.2', source: 'app', usedFor: 'checks of app preview videos and clips', install: { kind: 'download', url: 'https://ffmpeg.org/download.html', note: '' } },
|
||||
{ id: 'chrome', name: 'Google Chrome', found: false, path: null, version: null, source: null, usedFor: 'screenshot renders, captions, web captures and recordings', install: { kind: 'download', url: 'https://googlechromelabs.github.io/chrome-for-testing/', note: '' } },
|
||||
{ id: 'xcode', name: 'Xcode command line tools', found: true, path: '/Applications/Xcode.app/Contents/Developer', version: '27.0', source: 'system', usedFor: 'iOS builds, the iOS Simulator (simctl)', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: '' } },
|
||||
{ id: 'simctl', name: 'simctl (iOS Simulator)', found: true, path: '/Applications/Xcode.app/Contents/Developer/usr/bin/simctl', version: null, source: 'system', usedFor: 'iOS Simulator screenshots and recordings', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: '' } },
|
||||
{ id: 'adb', name: 'adb (Android platform tools)', found: false, path: null, version: null, source: null, usedFor: 'Android emulator / device screenshots and recordings', install: { kind: 'link', url: 'https://developer.android.com/tools/releases/platform-tools', note: '' } },
|
||||
{ id: 'git', name: 'git', found: true, path: '/usr/bin/git', version: '2.55.0', source: 'system', usedFor: 'release notes from history, analytics compare', install: { kind: 'link', url: 'https://git-scm.com/downloads', note: '' } },
|
||||
];
|
||||
let chromeInstalled = false;
|
||||
|
||||
const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data });
|
||||
|
||||
function read(method: string, id: string): ToolResult {
|
||||
switch (method) {
|
||||
case 'project.icon': return ok(method, { path: SUMMARY[id]?.icon ?? null, dataUrl: ICONS[id] ?? null });
|
||||
case 'project.listings': return ok(method, LISTINGS[id]);
|
||||
case 'reviews.drafts': return ok(method, DRAFTS[id]);
|
||||
case 'health': return ok(method, HEALTH[id]);
|
||||
case 'reviews.list': return ok(method, REVIEWS[id]);
|
||||
case 'apple.testflight_status': return ok(method, TF[id]);
|
||||
case 'catalog.diff': return ok(method, CATALOG[id]);
|
||||
case 'validate': return ok(method, VALIDATE[id]);
|
||||
case 'check': return { method, ok: true, exitCode: 0, dryRun: false, command: 'storebound check', output: '12:00:01 [check] config ok: storebound.config.ts\n12:00:01 [check] listing: 4 languages\n12:00:01 [check] 0 problems, 2 warnings' };
|
||||
default: return { method, ok: false, exitCode: 1, dryRun: false, command: `storebound ${method}`, output: `fixture: no data for ${method}` };
|
||||
}
|
||||
}
|
||||
|
||||
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2 };
|
||||
const NETWORK = new Set(['health', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate']);
|
||||
|
||||
async function progressLines(job: string | null, lines: string[]) {
|
||||
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
|
||||
}
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, a: Record<string, unknown>): Promise<T> {
|
||||
const r = await answer(cmd, a);
|
||||
return r as T;
|
||||
}
|
||||
|
||||
async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown> {
|
||||
const job = (a.job as string | null) ?? null;
|
||||
switch (cmd) {
|
||||
case 'app_info': return { version: '0.1.0', identifier: 'dev.storebound.desktop', platform: 'macos', debug: true, home: HOME };
|
||||
case 'sidecar_status': return { running: true, version: '0.5.0', protocol: 'storebound-desktop/1', methods: Array.from({ length: 64 }, (_, i) => ({ method: `m${i}` })), tools: { tools: TOOLS.map(x => (x.id === 'chrome' && chromeInstalled ? { ...x, found: true, name: 'Chrome for Testing', version: '141.0.7390.54', source: 'app' } : x)) } };
|
||||
case 'projects_list': return projects;
|
||||
case 'accounts_list': return accounts;
|
||||
case 'portfolio_status': {
|
||||
if (a.live) {
|
||||
for (let n = 1; n <= projects.length; n++) {
|
||||
await sleep(700);
|
||||
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
||||
emit('sidecar-progress', { params: { requestId: 0, n, total: projects.length, project: projects[n - 1]!.path }, job });
|
||||
}
|
||||
return portfolio(true);
|
||||
}
|
||||
await sleep(120);
|
||||
return portfolio(false);
|
||||
}
|
||||
case 'cache_get': {
|
||||
const method = a.method as string, id = a.projectId as string;
|
||||
if (id === '*' && method === 'portfolio.status') return projects.length ? { value: portfolio(true), at: iso(14) } satisfies CacheEntry<PortfolioResult> : null;
|
||||
if (method === 'project.icon') return ICONS[id] ? { value: read(method, id), at: iso(60) } : null;
|
||||
if (CACHED_AGO[method] !== undefined && import.meta.env.VITE_FIXTURES !== 'cold') return { value: read(method, id), at: iso(CACHED_AGO[method]!) };
|
||||
return null;
|
||||
}
|
||||
case 'cache_clear': return null;
|
||||
case 'sidecar_call': {
|
||||
const method = a.method as string;
|
||||
if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools };
|
||||
const id = a.projectId as string;
|
||||
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
|
||||
else await sleep(150);
|
||||
if (job && cancelled.has(job)) throw { code: -32800, message: 'Request cancelled' };
|
||||
return read(method, id);
|
||||
}
|
||||
case 'job_cancel': cancelled.add(a.job as string); return true;
|
||||
case 'write_with_confirm': {
|
||||
await sleep(500);
|
||||
const method = a.method as string, args = (a.args ?? {}) as Record<string, unknown>;
|
||||
if (method === 'reviews.approve') {
|
||||
const d = DRAFTS[a.projectId as string]?.drafts.find(x => x.file.endsWith(`/${String(args.file)}`));
|
||||
if (d) d.approved = !args.undo;
|
||||
}
|
||||
const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')}`, output: `[${method}] done (fixture)` };
|
||||
return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true }, result: res };
|
||||
}
|
||||
case 'project_add': case 'project_init': await sleep(300); return null;
|
||||
case 'project_remove': projects = projects.filter(p => p.id !== a.id); return null;
|
||||
case 'project_set_accounts': { const p = projects.find(x => x.id === a.id)!; p.playAccount = (a.play as string | null) ?? null; p.appleAccount = (a.apple as string | null) ?? null; return p; }
|
||||
case 'account_add_play': case 'account_add_apple': return null;
|
||||
case 'account_remove': accounts = accounts.filter(x => x.id !== a.id); return null;
|
||||
case 'open_external': return null;
|
||||
case 'tools_info': return [
|
||||
{ tool: 'ffmpeg', available: true, version: '9.0.2', size: 56_713_400, license: 'GPL-3.0-or-later (ffmpeg built with GPL components)', licenseUrl: 'https://ffmpeg.org/legal.html', source: 'https://ffmpeg.martin-riedl.de/', installed: true, folder: 'tools/bin' },
|
||||
{ tool: 'chrome', available: true, version: null, size: null, license: 'Chromium BSD-style license and third-party notices; Google Chrome for Testing terms', licenseUrl: 'https://github.com/GoogleChromeLabs/chrome-for-testing#readme', source: 'https://googlechromelabs.github.io/chrome-for-testing/', installed: chromeInstalled, folder: 'tools/chrome' },
|
||||
];
|
||||
case 'tools_install': {
|
||||
const total = 168_000_000;
|
||||
const ev = (stage: DownloadEvent['stage'], received: number) => emit('tool-download', { job: String(a.job), tool: String(a.tool), stage, received, total } satisfies DownloadEvent);
|
||||
ev('resolve', 0); await sleep(400);
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
if (cancelled.has(a.job as string)) { ev('cancelled', 0); throw 'cancelled'; }
|
||||
ev('download', (total * i) / 20); await sleep(140);
|
||||
}
|
||||
ev('unpack', total); await sleep(400); ev('verify', total); await sleep(400); ev('install', total); await sleep(200);
|
||||
if (a.tool === 'chrome') chromeInstalled = true;
|
||||
return a.tool === 'chrome' ? 'Chrome for Testing 141.0.7390.54' : 'ffmpeg version 9.0.2';
|
||||
}
|
||||
case 'tools_uninstall': if (a.tool === 'chrome') chromeInstalled = false; return null;
|
||||
default: throw { code: -1, message: `fixture: ${cmd} not mocked` };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
// Dev-only switches for screenshots and manual checks: VITE_START / VITE_THEME / VITE_LANG / VITE_AUTORUN, or the same
|
||||
// names as URL query parameters (?start=app:health&theme=dark&lang=tr) in the Vite dev server. Release builds compile
|
||||
// import.meta.env.DEV to false, so this always answers "" there.
|
||||
export type Switch = 'start' | 'theme' | 'lang' | 'autorun';
|
||||
|
||||
export function devSwitch(name: Switch): string {
|
||||
if (!import.meta.env.DEV) return '';
|
||||
const q = typeof location !== 'undefined' ? new URLSearchParams(location.search).get(name) : null;
|
||||
return q ?? String((import.meta.env as Record<string, unknown>)[`VITE_${name.toUpperCase()}`] ?? '');
|
||||
}
|
||||
|
|
@ -1,560 +0,0 @@
|
|||
// English UI strings (the source catalog). Placeholders: {name}. Plural: a "<key>_one" entry for a count of 1.
|
||||
export const en = {
|
||||
'a11y.skip': 'Skip to content',
|
||||
'a11y.breadcrumbs': 'You are here',
|
||||
|
||||
'nav.sections': 'Sections',
|
||||
'nav.portfolio': 'Portfolio',
|
||||
'nav.appsLabel': 'Apps',
|
||||
'nav.workspace': 'Workspace',
|
||||
'nav.apps': 'App folders',
|
||||
'nav.accounts': 'Accounts',
|
||||
'nav.dependencies': 'Dependencies',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.noApps': 'No apps yet',
|
||||
'nav.problems': '{count} problems',
|
||||
'nav.problems_one': '1 problem',
|
||||
|
||||
'tab.releases': 'Releases',
|
||||
'tab.catalog': 'Catalog',
|
||||
'tab.listings': 'Listings',
|
||||
'tab.health': 'Health',
|
||||
'tab.reviews': 'Reviews',
|
||||
'tab.testflight': 'TestFlight',
|
||||
'tab.checks': 'Checks',
|
||||
|
||||
'profile.app': 'App',
|
||||
'profile.game': 'Game',
|
||||
'profile.subscription-app': 'Subscription app',
|
||||
|
||||
'foot.cli': 'CLI {version} running',
|
||||
'foot.cliDown': 'CLI not running',
|
||||
'foot.cliStarting': 'Starting the CLI',
|
||||
'foot.lastRead': 'Stores read {when}',
|
||||
'foot.neverRead': 'Stores not read yet',
|
||||
|
||||
'top.storesRead': 'Stores read {when}',
|
||||
'top.storesNotRead': 'Stores not read',
|
||||
'dev.fixtures': 'DEV FIXTURES',
|
||||
'dev.fixturesHint': 'Development build on fixture data: nothing here comes from a store. Release builds have no fixture mode.',
|
||||
|
||||
'action.readStores': 'Read the stores',
|
||||
'action.reading': 'Reading',
|
||||
'action.readStoresHint': 'Read-only: App Store Connect and Google Play status of every app, with each app\'s own accounts',
|
||||
'action.checkConfigs': 'Check configs',
|
||||
'action.addFolder': 'Add app folder',
|
||||
'action.addAccount': 'Add a store account',
|
||||
'action.toggleTheme': 'Switch light / dark',
|
||||
'action.welcome': 'Open the welcome guide',
|
||||
'action.refresh': 'Refresh',
|
||||
|
||||
'palette.title': 'Command palette',
|
||||
'palette.button': 'Search or jump to',
|
||||
'palette.placeholder': 'Jump to an app, a tab or a screen, or run an action',
|
||||
'palette.results': 'Results',
|
||||
'palette.none': 'Nothing matches "{q}"',
|
||||
'palette.move': 'move',
|
||||
'palette.open': 'open',
|
||||
'palette.toggle': 'open / close',
|
||||
'palette.gApps': 'Apps',
|
||||
'palette.gGo': 'Go to',
|
||||
'palette.gTabs': 'App tabs',
|
||||
'palette.gActions': 'Actions',
|
||||
'palette.openApp': 'Open',
|
||||
|
||||
'common.loading': 'Loading',
|
||||
'common.unknown': 'Unknown',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.exit': 'Exit code {code}',
|
||||
'common.cachedFrom': 'Last read {when} (saved copy)',
|
||||
'common.readWhen': 'Read {when}',
|
||||
'common.readFailedKeep': 'The last read failed, so this shows the previous one: {error}',
|
||||
|
||||
'time.justNow': 'just now',
|
||||
'time.never': 'never',
|
||||
|
||||
'toast.readingStores': 'Reading the stores',
|
||||
'toast.readingStoresDetail': 'App Store Connect and Google Play, read-only',
|
||||
'toast.readDone': 'Stores read',
|
||||
'toast.readDoneDetail': '{count} apps up to date',
|
||||
'toast.readDoneDetail_one': '1 app up to date',
|
||||
'toast.readDonePartial': 'Stores read, with gaps',
|
||||
'toast.readDonePartialDetail': '{count} apps could not be read on a store; they show as unknown',
|
||||
'toast.readDonePartialDetail_one': '1 app could not be read on a store; it shows as unknown',
|
||||
'toast.readFailed': 'Reading the stores failed',
|
||||
'toast.cancelled': 'Cancelled',
|
||||
'toast.reading': 'Reading',
|
||||
'toast.readingDetail': 'Read-only',
|
||||
'toast.readOk': '{what} read',
|
||||
'toast.readFailedWhat': 'Reading {what} failed',
|
||||
|
||||
'write.dryFailed': 'The dry run failed, nothing was written',
|
||||
'write.failed': 'Not applied',
|
||||
|
||||
'phase.none': 'Not on this store',
|
||||
'phase.live': 'Live',
|
||||
'phase.rollout': 'Rollout',
|
||||
'phase.phased': 'Phased',
|
||||
'phase.halted': 'Halted',
|
||||
'phase.paused': 'Paused',
|
||||
'phase.approved': 'Approved',
|
||||
'phase.review': 'In review',
|
||||
'phase.rejected': 'Rejected',
|
||||
'phase.testing': 'Testing',
|
||||
'phase.draft': 'Draft',
|
||||
'phase.other': 'Other',
|
||||
|
||||
'stage.build': 'Build',
|
||||
'stage.testing': 'Testing',
|
||||
'stage.review': 'Review',
|
||||
'stage.live': 'Live',
|
||||
'stage.rollout': 'Rollout',
|
||||
|
||||
'check.notChecked': 'Not checked',
|
||||
'check.notLoaded': 'Config not loaded',
|
||||
'check.problems': '{count} problems',
|
||||
'check.problems_one': '1 problem',
|
||||
'check.warnings': '{count} warnings',
|
||||
'check.warnings_one': '1 warning',
|
||||
'check.ok': 'Config OK',
|
||||
|
||||
'app.notSetUp': 'not set up',
|
||||
'app.notOnStore': 'Not on this store',
|
||||
'app.sections': '{name} sections',
|
||||
'app.consoleHint': 'Submit, release and publish happen in the store console',
|
||||
|
||||
'portfolio.lead': 'Every app in this workspace on the App Store and Google Play.',
|
||||
'portfolio.sub': '{count} apps · {stores} store listings',
|
||||
'portfolio.sub_one': '1 app · {stores} store listings',
|
||||
'portfolio.emptyTitle': 'Add your first app',
|
||||
'portfolio.emptyText': 'storebound works on app folders: one storebound.config.ts per app, for iOS and Android. Add a folder that has one, or start a new one from the templates.',
|
||||
'portfolio.notReadNotice': 'Releases, rollouts and catalog parity need one read of the stores. It is read-only and uses each app\'s own accounts.',
|
||||
'portfolio.apps': 'Apps',
|
||||
'portfolio.appsSub': 'Release state from the stores, read {when}',
|
||||
'portfolio.appsSubOffline': 'Config only: read the stores for releases',
|
||||
'portfolio.unreadable': 'Unreadable',
|
||||
'portfolio.noVersion': 'No version yet',
|
||||
'portfolio.noNext': 'Nothing in flight',
|
||||
'portfolio.thenNext': 'next {version} · {phase}',
|
||||
'portfolio.products': '{count} products',
|
||||
'portfolio.products_one': '1 product',
|
||||
'portfolio.subs': '{count} subs',
|
||||
'portfolio.subs_one': '1 sub',
|
||||
'portfolio.inParity': 'In parity',
|
||||
'portfolio.differs': '{count} differ',
|
||||
'portfolio.differs_one': '1 differs',
|
||||
'portfolio.configOnly': 'Not compared yet',
|
||||
'portfolio.attention': 'Needs attention',
|
||||
'portfolio.attentionSub': 'Problems first, then warnings',
|
||||
'portfolio.allClear': 'Nothing needs attention',
|
||||
'portfolio.allClearText': 'Configs check out and no store reported a problem in the last read.',
|
||||
'portfolio.workspace': 'Workspace',
|
||||
'portfolio.healthNotRead': 'Not read',
|
||||
'portfolio.healthNoPlay': 'iOS only',
|
||||
'portfolio.healthTitle': 'Last 14 days, user-perceived, read {when}',
|
||||
'portfolio.accounts': '{play} Google Play · {apple} App Store Connect',
|
||||
'portfolio.storesRead': 'Stores read',
|
||||
'portfolio.tools': 'Local tools',
|
||||
'portfolio.toolsFound': '{found} of {total} found',
|
||||
|
||||
'kpi.apps': 'Apps',
|
||||
'kpi.appsSub': '{ios} on iOS · {android} on Android',
|
||||
'kpi.live': 'Live versions',
|
||||
'kpi.liveSub': 'across {count} store listings',
|
||||
'kpi.rolling': 'Rolling out',
|
||||
'kpi.review': 'In review',
|
||||
'kpi.attention': 'Needs attention',
|
||||
'kpi.blockers': '{count} blockers',
|
||||
'kpi.blockers_one': '1 blocker',
|
||||
'kpi.noBlockers': 'No blockers',
|
||||
'kpi.none': 'None',
|
||||
'kpi.notRead': 'Read the stores to see this',
|
||||
|
||||
'legend.done': 'done',
|
||||
'legend.stages': 'Build · Testing · Review · Live · Rollout',
|
||||
|
||||
'col.app': 'App',
|
||||
'col.next': 'Next version',
|
||||
'col.catalog': 'Catalog',
|
||||
'col.crash': 'Crash rate',
|
||||
'col.config': 'Config',
|
||||
'col.version': 'Version',
|
||||
'col.build': 'Build',
|
||||
'col.versionCode': 'Version code',
|
||||
'col.track': 'Track',
|
||||
'col.item': 'Item',
|
||||
'col.type': 'Type',
|
||||
'col.priceGap': 'Price gap',
|
||||
'col.region': 'Region',
|
||||
'col.diff': 'Difference',
|
||||
'col.language': 'Language',
|
||||
'col.issue': 'Issue',
|
||||
'col.reports': 'Reports',
|
||||
'col.users': 'Users',
|
||||
'col.metric': 'Metric',
|
||||
'col.value': 'Value',
|
||||
'col.goal': 'Goal',
|
||||
'col.name': 'Name',
|
||||
'col.store': 'Store',
|
||||
'col.usedBy': 'Used by',
|
||||
'col.added': 'Added',
|
||||
'col.actions': 'Actions',
|
||||
|
||||
'att.storeError': '{store} could not be read: {error}',
|
||||
'att.rejected': '{store} {version} was rejected',
|
||||
'att.halted': '{store} {version}: rollout halted',
|
||||
'att.catalog': '{count} catalog items differ between the stores',
|
||||
'att.catalog_one': '1 catalog item differs between the stores',
|
||||
'att.crash': 'Crash rate near or over the Google Play threshold',
|
||||
'att.anr': 'ANR rate near or over the Google Play threshold',
|
||||
|
||||
'tl.next': 'Next',
|
||||
'tl.live': 'Live',
|
||||
'tl.store': 'Store',
|
||||
'tl.created': 'Created {date}',
|
||||
'tl.notOnStore': 'Not on this store',
|
||||
'tl.noVersion': '{version} is not on {store} yet',
|
||||
'tl.everyone': 'Everyone',
|
||||
'tl.day': 'day {day} of 7',
|
||||
'tl.rolloutAria': 'Share of users with this version',
|
||||
|
||||
'releases.notReadTitle': 'Release state is not read yet',
|
||||
'releases.notReadText': 'The timeline shows each version on both stores: build, TestFlight or internal testing, review, live and the rollout share. Read the stores once to fill it.',
|
||||
'releases.noAccounts': 'Tip: map store accounts to this app in App folders first.',
|
||||
'releases.storeUnreadable': '{store} could not be read, so its lane shows as unknown: {error}',
|
||||
'releases.noVersions': 'No versions on the stores yet',
|
||||
'releases.noVersionsText': 'When the first build is uploaded it shows here.',
|
||||
'releases.history': 'Versions on the stores',
|
||||
'releases.historySub': 'Read {when}',
|
||||
'releases.consoleNote': 'storebound never submits for review, releases or completes a rollout. Do those in the store console.',
|
||||
|
||||
'cat.products': 'One-time products',
|
||||
'cat.productsSub': 'In the config',
|
||||
'cat.subscriptions': 'Subscriptions',
|
||||
'cat.subsSub': 'All groups',
|
||||
'cat.parity': 'In parity',
|
||||
'cat.parityRead': 'Compared {when}',
|
||||
'cat.gaps': 'Price gaps',
|
||||
'cat.gapsSub': 'Same currency, over 10 %',
|
||||
'cat.matrix': 'Parity matrix',
|
||||
'cat.configOnly': 'From the config only: compare with both stores to fill the store columns',
|
||||
'cat.compare': 'Compare with the stores',
|
||||
'cat.ok': 'Matches',
|
||||
'cat.warn': 'Differs',
|
||||
'cat.error': 'Missing or wrong',
|
||||
'cat.na': 'Not sold here',
|
||||
'cat.unknown': 'Not compared',
|
||||
'cat.extra': 'Store only',
|
||||
'cat.sub': 'Subscription',
|
||||
'cat.product': 'Product',
|
||||
'cat.notInConfig': 'Not in the config',
|
||||
'cat.noGap': 'None',
|
||||
'cat.gapsTitle': 'Price gaps between the stores',
|
||||
'cat.gapsTitleSub': 'Stores round to their own price tiers, so small gaps are normal',
|
||||
'cat.emptyTitle': 'No in-app products or subscriptions',
|
||||
'cat.emptyText': 'Products and subscriptions are defined once in storebound.config.ts and created on both stores. This app has none in its config.',
|
||||
|
||||
'lst.languages': 'Languages',
|
||||
'lst.completeKpi': 'Complete',
|
||||
'lst.completeSub': 'Required fields, both stores',
|
||||
'lst.toFix': 'To fix',
|
||||
'lst.toFixSub': 'Missing or over the limit',
|
||||
'lst.screenshots': 'Screenshots',
|
||||
'lst.screenshotsSub': 'Rendered, all languages',
|
||||
'lst.noRenders': 'No renders folder yet',
|
||||
'lst.playSub': 'store/listing.md',
|
||||
'lst.appleSub': 'store/appstore.md',
|
||||
'lst.complete': 'Complete',
|
||||
'lst.completeAria': '{pct} % complete',
|
||||
'lst.missing': 'Missing',
|
||||
'lst.optional': 'Optional',
|
||||
'lst.over': '{n} over',
|
||||
'lst.title': 'Title',
|
||||
'lst.short': 'Short',
|
||||
'lst.full': 'Full',
|
||||
'lst.whatsNew': 'What\'s new',
|
||||
'lst.name': 'Name',
|
||||
'lst.subtitle': 'Subtitle',
|
||||
'lst.promo': 'Promo text',
|
||||
'lst.keywords': 'Keywords',
|
||||
'lst.description': 'Description',
|
||||
'lst.noFilesTitle': 'No listing files yet',
|
||||
'lst.noFilesText': 'Listing text lives in store/listing.md (Google Play) and store/appstore.md (App Store) in the app folder, one section per language.',
|
||||
'lst.noFilesHint': 'writes both templates into the app folder',
|
||||
'lst.foot': 'From the app folder\'s files, read {when}. Nothing is uploaded from here.',
|
||||
|
||||
'health.read': 'Read health',
|
||||
'health.emptyTitle': 'Health is not read yet',
|
||||
'health.emptyText': 'Crash and ANR rates against the Google Play thresholds, worst devices and top issues, plus App Store diagnostics. Read-only.',
|
||||
'health.crash': 'User-perceived crash rate',
|
||||
'health.anr': 'User-perceived ANR rate',
|
||||
'health.window': '{from} to {to}',
|
||||
'health.over': 'Over threshold',
|
||||
'health.near': 'Near threshold',
|
||||
'health.under': 'Under threshold',
|
||||
'health.current28': '28-day average, as Google Play counts it',
|
||||
'health.daily': 'Daily',
|
||||
'health.threshold': 'Bad behavior threshold {value}',
|
||||
'health.chartAria': '{metric}: {value}',
|
||||
'health.devices': 'Worst devices',
|
||||
'health.devicesSub': 'Per-device threshold {value}',
|
||||
'health.noDevices': 'No device has enough users to compare.',
|
||||
'health.issues': 'Top issues',
|
||||
'health.issuesSub': 'Most reports first',
|
||||
'health.noIssues': 'No issues reported',
|
||||
'health.crashShort': 'Crash',
|
||||
'health.appleMetrics': 'Performance metrics',
|
||||
'health.appleMetricsSub': 'Latest values per metric',
|
||||
'health.noMetrics': 'No metrics yet',
|
||||
'health.signatures': 'Diagnostic signatures',
|
||||
'health.signaturesSub': 'Heaviest first, latest builds',
|
||||
'health.noSignatures': 'No diagnostic signatures',
|
||||
'health.betaCrashes': 'TestFlight crashes',
|
||||
'health.noBetaCrashes': 'No TestFlight crash reports in the window.',
|
||||
'health.build': 'build {build}',
|
||||
'health.noPlay': 'This app is not on Google Play.',
|
||||
'health.noApple': 'This app is not on the App Store.',
|
||||
'health.unreadable': '{store} could not be read, shown as unknown: {error}',
|
||||
|
||||
'reviews.read': 'Read reviews',
|
||||
'reviews.emptyTitle': 'Reviews are not read yet',
|
||||
'reviews.emptyText': 'The last 30 days on the App Store, the last 7 on Google Play (its API keeps no more), with your local reply drafts. Read-only.',
|
||||
'reviews.ratings': 'Ratings',
|
||||
'reviews.count': '{count} reviews',
|
||||
'reviews.count_one': '1 review',
|
||||
'reviews.unansweredN': '{count} without a reply',
|
||||
'reviews.unansweredN_one': '1 without a reply',
|
||||
'reviews.filter': 'Filter',
|
||||
'reviews.store': 'Store',
|
||||
'reviews.all': 'All',
|
||||
'reviews.onlyUnanswered': 'Only without a reply',
|
||||
'reviews.search': 'Search reviews',
|
||||
'reviews.inbox': 'Inbox',
|
||||
'reviews.shown': '{count} shown',
|
||||
'reviews.noMatch': 'No review matches the filter',
|
||||
'reviews.pick': 'Pick a review',
|
||||
'reviews.answered': 'Answered',
|
||||
'reviews.drafted': 'Draft',
|
||||
'reviews.approved': 'Approved',
|
||||
'reviews.notApproved': 'Not approved',
|
||||
'reviews.noTitle': 'No title',
|
||||
'reviews.noText': 'No text',
|
||||
'reviews.version': 'version {v}',
|
||||
'reviews.currentReply': 'Reply on the store',
|
||||
'reviews.draft': 'Your reply draft',
|
||||
'reviews.noDraft': 'No draft for this review. "Make drafts" writes one file per unanswered review into .storebound/replies.',
|
||||
'reviews.emptyDraft': 'The draft {file} has no reply yet: write it under "## Reply".',
|
||||
'reviews.chars': '{n} of {limit} characters',
|
||||
'reviews.approve': 'Approve',
|
||||
'reviews.unapprove': 'Remove approval',
|
||||
'reviews.approveHint': 'Marks the draft approved (local file, confirmed in a dialog). Nothing is sent.',
|
||||
'reviews.fixFirst': 'Fix the errors first',
|
||||
'reviews.sentOn': 'Sent {date}',
|
||||
'reviews.sendNote': 'Replies are public, so they go out from a terminal:',
|
||||
'reviews.makeDrafts': 'Make drafts',
|
||||
'reviews.makeDraftsHint': 'One local draft per unanswered review (dry run first, then a confirmation)',
|
||||
'reviews.draftsMade': 'Reply drafts written',
|
||||
'reviews.approvedToast': 'Draft approved',
|
||||
'reviews.unapproved': 'Approval removed',
|
||||
'reviews.starsAria': '{count} of 5 stars',
|
||||
'reviews.starsAria_one': '1 of 5 stars',
|
||||
|
||||
'tf.read': 'Read TestFlight',
|
||||
'tf.emptyTitle': 'TestFlight is not read yet',
|
||||
'tf.emptyText': 'Beta groups, testers and recent builds with their processing and beta review states. Read-only.',
|
||||
'tf.noIos': 'This app is not on iOS',
|
||||
'tf.noIosText': 'TestFlight is for apps with an App Store record. Add the apple section to the config to use it.',
|
||||
'tf.internal': 'Internal',
|
||||
'tf.external': 'External',
|
||||
'tf.testers': 'testers',
|
||||
'tf.builds': 'builds',
|
||||
'tf.all': 'All',
|
||||
'tf.publicOn': 'Public link on',
|
||||
'tf.publicOff': 'Invite only',
|
||||
'tf.noGroups': 'No beta groups',
|
||||
'tf.buildsTitle': 'Recent builds',
|
||||
'tf.buildsSub': 'Newest first',
|
||||
'tf.processing': 'Processing',
|
||||
'tf.internalTesting': 'Internal testing',
|
||||
'tf.externalTesting': 'External testing',
|
||||
'tf.uploaded': 'Uploaded',
|
||||
'tf.noBuilds': 'No builds uploaded yet',
|
||||
'tf.missingGroups': 'In the config but not on App Store Connect: {groups}',
|
||||
|
||||
'checks.offline': 'Config check',
|
||||
'checks.offlineSub': 'Offline: config, listing files, copy, declarations, products',
|
||||
'checks.again': 'Check again',
|
||||
'checks.clean': 'The config checks out',
|
||||
'checks.cleanText': 'No problems or warnings in the app folder.',
|
||||
'checks.problem': 'Problem',
|
||||
'checks.warning': 'Warning',
|
||||
'checks.validate': 'Store readiness',
|
||||
'checks.validateSub': 'Read-only report from both stores, blockers in order',
|
||||
'checks.run': 'Run the report',
|
||||
'checks.validateEmpty': 'Readiness not checked yet',
|
||||
'checks.validateEmptyText': 'What is still missing on the App Store and Google Play before a submission, in the order to fix it.',
|
||||
'checks.ready': 'No blockers',
|
||||
'checks.readyText': 'Both stores have what a submission needs.',
|
||||
|
||||
'folders.lead': 'One folder per app with its storebound.config.ts (iOS and Android in one config). Pick the store accounts each app uses; the keys stay in the keychain.',
|
||||
'folders.list': 'App folders',
|
||||
'folders.listSub': '{count} folders',
|
||||
'folders.listSub_one': '1 folder',
|
||||
'folders.emptyTitle': 'No app folders yet',
|
||||
'folders.emptyText': 'Add a folder that has a storebound.config.ts, or start a new one below.',
|
||||
'folders.added': '{name} added',
|
||||
'folders.started': '{name} started',
|
||||
'folders.startedDetail': 'Templates written. Edit storebound.config.ts and the store files next.',
|
||||
'folders.playFor': 'Google Play account for {name}',
|
||||
'folders.appleFor': 'App Store Connect account for {name}',
|
||||
'folders.noPlay': 'No Google Play account',
|
||||
'folders.noApple': 'No App Store account',
|
||||
'folders.check': 'Check',
|
||||
'folders.gallery': 'Gallery',
|
||||
'folders.galleryHint': 'Writes the local screenshot review page (dry run first, then a confirmation)',
|
||||
'folders.remove': 'Remove {name} from the list (the folder stays)',
|
||||
'folders.checkTitle': 'Config check',
|
||||
'folders.galleryTitle': 'Screenshot review page',
|
||||
'folders.newTitle': 'Start a new app folder',
|
||||
'folders.newSub': 'storebound init: config, listing, screenshot copy, declarations and privacy policy templates',
|
||||
'folders.profile': 'Profile',
|
||||
'folders.profileHint.app': 'Utility or productivity app: optional account and analytics, no ads.',
|
||||
'folders.profileHint.game': 'Free-to-play game with ads and in-app purchases.',
|
||||
'folders.profileHint.subscription-app': 'App with accounts and auto-renewable subscriptions.',
|
||||
'folders.chooseAndStart': 'Choose a folder and start',
|
||||
|
||||
'run.failed': 'Failed',
|
||||
'run.running': 'Running',
|
||||
'run.applied': 'Applied after your confirmation',
|
||||
'run.cancelled': 'Cancelled in the dialog: nothing was written',
|
||||
'run.dryFailed': 'The dry run failed: nothing was written',
|
||||
'run.done': 'Done',
|
||||
|
||||
'accounts.lead': 'Store API keys live in the system keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). They are never shown again, never written to your app folders, and reach storebound in memory for one call.',
|
||||
'accounts.list': 'Store accounts',
|
||||
'accounts.listSub': 'Labels only: the keys are in the keychain',
|
||||
'accounts.emptyTitle': 'No store accounts yet',
|
||||
'accounts.emptyText': 'Add a Google Play service account or an App Store Connect API key below. Store reads need them; app folders work without.',
|
||||
'accounts.unused': 'Not used',
|
||||
'accounts.remove': 'Remove',
|
||||
'accounts.removeAria': 'Remove {label} and delete its key from the keychain',
|
||||
'accounts.saved': '"{label}" saved',
|
||||
'accounts.savedDetail': 'The key is in the system keychain.',
|
||||
'accounts.playSub': 'Service account JSON key',
|
||||
'accounts.appleSub': 'Team API key (.p8) from Users and Access, Integrations',
|
||||
'accounts.label': 'Name',
|
||||
'accounts.playPlaceholder': 'Studio Play account',
|
||||
'accounts.applePlaceholder': 'Studio App Store key',
|
||||
'accounts.playHow': 'A service account with access to your apps in Play Console (Users and permissions).',
|
||||
'accounts.keyId': 'Key ID',
|
||||
'accounts.issuerId': 'Issuer ID',
|
||||
'accounts.choosePlay': 'Choose the JSON key',
|
||||
'accounts.chooseApple': 'Choose the .p8 key',
|
||||
'accounts.safety': 'Keys are picked in the system file dialog and go straight to the keychain. The window never sees them.',
|
||||
|
||||
'deps.lead': 'Local tools for screenshots, app previews, icons and simulator or emulator captures. Store work (listings, products, releases, reviews) needs none of them.',
|
||||
'deps.checkAgain': 'Check again',
|
||||
'deps.tools': 'Tools on this computer',
|
||||
'deps.found': '{found} of {total} found',
|
||||
'deps.missing': 'Missing',
|
||||
'deps.appCopy': 'App copy',
|
||||
'deps.installed': 'Installed',
|
||||
'deps.download': 'Download',
|
||||
'deps.remove': 'Remove',
|
||||
'deps.getIt': 'Get it',
|
||||
'deps.unavailable': 'Not for this platform',
|
||||
'deps.downloading': 'Downloading {tool}',
|
||||
'deps.installedToast': '{tool} installed',
|
||||
'deps.failed': 'Download failed',
|
||||
'deps.cancelledDetail': 'The partial download was deleted.',
|
||||
'deps.removed': 'Removed from the app data folder',
|
||||
'deps.inAppData': 'In the app data folder ({folder})',
|
||||
'deps.notDownloaded': 'Not downloaded',
|
||||
'deps.source': 'Source',
|
||||
'deps.license': 'License',
|
||||
'deps.verify': 'Verified by',
|
||||
'deps.verifyFfmpeg': 'SHA-256 of the exact archive, pinned in this app version',
|
||||
'deps.verifyChrome': 'Google\'s code signature (macOS, Windows) or the version banner (Linux)',
|
||||
'deps.licenseLink': 'License and source',
|
||||
'deps.terminal': 'From a terminal',
|
||||
'deps.terminalSub': 'The same check, and the usual install commands',
|
||||
'deps.copy': 'Copy "{cmd}"',
|
||||
'deps.copied': 'Copied',
|
||||
|
||||
'dl.resolve': 'Finding the download',
|
||||
'dl.download': 'Downloading',
|
||||
'dl.verify': 'Verifying',
|
||||
'dl.unpack': 'Unpacking',
|
||||
'dl.install': 'Installing',
|
||||
'dl.done': 'Done',
|
||||
'dl.error': 'Failed',
|
||||
'dl.cancelled': 'Cancelled',
|
||||
|
||||
'settings.lead': 'Appearance, language, shortcuts and what this app runs on.',
|
||||
'settings.appearance': 'Appearance',
|
||||
'settings.theme': 'Theme',
|
||||
'settings.system': 'System',
|
||||
'settings.light': 'Light',
|
||||
'settings.dark': 'Dark',
|
||||
'settings.motion': 'Motion',
|
||||
'settings.followSystem': 'Follow the system',
|
||||
'settings.reduce': 'Reduce',
|
||||
'settings.language': 'Language',
|
||||
'settings.shortcuts': 'Keyboard shortcuts',
|
||||
'settings.scPalette': 'Command palette',
|
||||
'settings.scWorkspace': 'App folders, Accounts, Dependencies, Settings',
|
||||
'settings.scTabs': 'Previous / next app tab',
|
||||
'settings.scTabArrows': 'Move between tabs (tab list focused)',
|
||||
'settings.scClose': 'Close a dialog',
|
||||
'settings.data': 'Saved reads',
|
||||
'settings.dataText': 'The last store reads are kept in the app data folder so screens open with them offline, always marked with their time. No keys are kept there.',
|
||||
'settings.clearCache': 'Clear saved reads',
|
||||
'settings.cacheCleared': 'Saved reads cleared',
|
||||
'settings.about': 'About',
|
||||
'settings.app': 'App',
|
||||
'settings.debug': 'debug build',
|
||||
'settings.methods': '{count} methods',
|
||||
'settings.tools': 'Local tools',
|
||||
'settings.telemetry': 'Telemetry',
|
||||
'settings.telemetryNone': 'None. The app only talks to the store APIs through storebound, with your keys.',
|
||||
|
||||
'wz.s1': 'Welcome',
|
||||
'wz.s2': 'Your apps',
|
||||
'wz.s3': 'Store keys',
|
||||
'wz.s4': 'Local tools',
|
||||
'wz.s5': 'First read',
|
||||
'wz.footer': 'You can open this guide again from Settings or the command palette.',
|
||||
'wz.footerSafe': 'Nothing is submitted or released from here.',
|
||||
'wz.welcomeTitle': 'Every app, both stores, one window',
|
||||
'wz.welcomeLead': 'storebound desktop shows where each of your apps stands on the App Store and Google Play, and runs the storebound CLI for you. Five short steps get you there.',
|
||||
'wz.p1t': 'One portfolio',
|
||||
'wz.p1s': 'Releases, rollouts, catalog parity, health and reviews of every app.',
|
||||
'wz.p2t': 'Safe by design',
|
||||
'wz.p2s': 'Reads are read-only. Every change shows a dry run and a native confirmation first.',
|
||||
'wz.p3t': 'Your keys stay yours',
|
||||
'wz.p3s': 'Store keys live in the system keychain. No account, no telemetry.',
|
||||
'wz.appsTitle': 'Add your apps',
|
||||
'wz.appsLead': 'Each app is a folder with a storebound.config.ts that describes it on both stores.',
|
||||
'wz.addTitle': 'Add an existing folder',
|
||||
'wz.addText': 'The folder already has a storebound.config.ts (from storebound init or the CLI).',
|
||||
'wz.initTitle': 'Start a new app folder',
|
||||
'wz.initText': 'Writes the templates for a profile into a folder you choose. Existing files are kept.',
|
||||
'wz.added': 'Added',
|
||||
'wz.ready': 'Ready',
|
||||
'wz.keysTitle': 'Add your store keys',
|
||||
'wz.keysLead': 'Store reads need an API key per store. You can skip this and add them later in Accounts.',
|
||||
'wz.mapHint': 'Which accounts this app uses',
|
||||
'wz.toolsTitle': 'Check the local tools',
|
||||
'wz.toolsLead': 'Screenshots, previews and icons use ffmpeg and Chrome. The app can download its own copies; store work needs none of them.',
|
||||
'wz.toolsNote': 'The same check in a terminal:',
|
||||
'wz.readTitle': 'Read the stores',
|
||||
'wz.readLead': 'One read-only pass over App Store Connect and Google Play fills the portfolio: versions, review state, rollouts and catalog parity.',
|
||||
'wz.readNow': 'Read the stores now',
|
||||
'wz.readDone': '{count} apps read {when}',
|
||||
'wz.needApp': 'Add an app folder first (step 2).',
|
||||
'wz.skip': 'Skip for now',
|
||||
'wz.back': 'Back',
|
||||
'wz.next': 'Next',
|
||||
'wz.finish': 'Finish',
|
||||
} as const;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
// UI strings: English and Turkish catalogs with the same keys (tr.ts is typed against en.ts; tools/i18n-check.ts also
|
||||
// checks empty strings and placeholder parity in CI). `t(key, vars)`: "{name}" placeholders; with a numeric `count`,
|
||||
// a "<key>_one" entry is used for 1 when the catalog has one.
|
||||
import { en } from './en';
|
||||
import { tr } from './tr';
|
||||
|
||||
export type Key = keyof typeof en;
|
||||
export type Vars = Record<string, string | number>;
|
||||
export type UiLang = 'en' | 'tr';
|
||||
|
||||
export const CATALOGS: Record<UiLang, Record<Key, string>> = { en, tr };
|
||||
let current: UiLang = 'en';
|
||||
|
||||
export function setLang(l: UiLang): void { current = l; }
|
||||
export function lang(): UiLang { return current; }
|
||||
|
||||
export function format(s: string, vars?: Vars): string {
|
||||
if (!vars) return s;
|
||||
return s.replace(/\{(\w+)\}/g, (m, k: string) => (vars[k] === undefined ? m : String(vars[k])));
|
||||
}
|
||||
|
||||
export function t(key: Key, vars?: Vars): string {
|
||||
const cat = CATALOGS[current];
|
||||
let k: Key = key;
|
||||
if (vars && vars.count === 1) {
|
||||
const one = `${key}_one` as Key;
|
||||
if (one in cat) k = one;
|
||||
}
|
||||
return format(cat[k] ?? en[k] ?? key, vars);
|
||||
}
|
||||
|
||||
/** Placeholders of a message ("{count} apps" -> ["count"]), sorted, for the parity check. */
|
||||
export const placeholders = (s: string): string[] => [...s.matchAll(/\{(\w+)\}/g)].map(m => m[1]!).sort();
|
||||
|
|
@ -1,562 +0,0 @@
|
|||
// Türkçe arayüz metinleri. Anahtarlar en.ts ile aynı (tip kontrolü); yer tutucular aynı kalmalı (tools/i18n-check.ts).
|
||||
import type { en } from './en';
|
||||
|
||||
export const tr: Record<keyof typeof en, string> = {
|
||||
'a11y.skip': 'İçeriğe geç',
|
||||
'a11y.breadcrumbs': 'Buradasınız',
|
||||
|
||||
'nav.sections': 'Bölümler',
|
||||
'nav.portfolio': 'Portföy',
|
||||
'nav.appsLabel': 'Uygulamalar',
|
||||
'nav.workspace': 'Çalışma alanı',
|
||||
'nav.apps': 'Uygulama klasörleri',
|
||||
'nav.accounts': 'Hesaplar',
|
||||
'nav.dependencies': 'Bağımlılıklar',
|
||||
'nav.settings': 'Ayarlar',
|
||||
'nav.noApps': 'Henüz uygulama yok',
|
||||
'nav.problems': '{count} sorun',
|
||||
'nav.problems_one': '1 sorun',
|
||||
|
||||
'tab.releases': 'Sürümler',
|
||||
'tab.catalog': 'Katalog',
|
||||
'tab.listings': 'Mağaza metinleri',
|
||||
'tab.health': 'Sağlık',
|
||||
'tab.reviews': 'Yorumlar',
|
||||
'tab.testflight': 'TestFlight',
|
||||
'tab.checks': 'Kontroller',
|
||||
|
||||
'profile.app': 'Uygulama',
|
||||
'profile.game': 'Oyun',
|
||||
'profile.subscription-app': 'Abonelikli uygulama',
|
||||
|
||||
'foot.cli': 'CLI {version} çalışıyor',
|
||||
'foot.cliDown': 'CLI çalışmıyor',
|
||||
'foot.cliStarting': 'CLI başlatılıyor',
|
||||
'foot.lastRead': 'Mağazalar {when} okundu',
|
||||
'foot.neverRead': 'Mağazalar henüz okunmadı',
|
||||
|
||||
'top.storesRead': 'Mağazalar {when} okundu',
|
||||
'top.storesNotRead': 'Mağazalar okunmadı',
|
||||
'dev.fixtures': 'GELİŞTİRME VERİSİ',
|
||||
'dev.fixturesHint': 'Geliştirme sürümü örnek verilerle çalışıyor: buradaki hiçbir şey bir mağazadan gelmiyor. Yayın sürümlerinde bu mod yok.',
|
||||
|
||||
'action.readStores': 'Mağazaları oku',
|
||||
'action.reading': 'Okunuyor',
|
||||
'action.readStoresHint': 'Salt okunur: her uygulamanın App Store Connect ve Google Play durumu, kendi hesaplarıyla',
|
||||
'action.checkConfigs': 'Ayarları kontrol et',
|
||||
'action.addFolder': 'Uygulama klasörü ekle',
|
||||
'action.addAccount': 'Mağaza hesabı ekle',
|
||||
'action.toggleTheme': 'Açık / koyu tema',
|
||||
'action.welcome': 'Karşılama rehberini aç',
|
||||
'action.refresh': 'Yenile',
|
||||
|
||||
'palette.title': 'Komut paleti',
|
||||
'palette.button': 'Ara veya git',
|
||||
'palette.placeholder': 'Bir uygulamaya, sekmeye veya ekrana git ya da bir işlem çalıştır',
|
||||
'palette.results': 'Sonuçlar',
|
||||
'palette.none': '"{q}" ile eşleşen bir şey yok',
|
||||
'palette.move': 'gezin',
|
||||
'palette.open': 'aç',
|
||||
'palette.toggle': 'aç / kapat',
|
||||
'palette.gApps': 'Uygulamalar',
|
||||
'palette.gGo': 'Git',
|
||||
'palette.gTabs': 'Uygulama sekmeleri',
|
||||
'palette.gActions': 'İşlemler',
|
||||
'palette.openApp': 'Aç',
|
||||
|
||||
'common.loading': 'Yükleniyor',
|
||||
'common.unknown': 'Bilinmiyor',
|
||||
'common.cancel': 'İptal',
|
||||
'common.close': 'Kapat',
|
||||
'common.exit': 'Çıkış kodu {code}',
|
||||
'common.cachedFrom': 'Son okuma {when} (kayıtlı kopya)',
|
||||
'common.readWhen': '{when} okundu',
|
||||
'common.readFailedKeep': 'Son okuma başarısız oldu, bu yüzden bir öncekini gösteriyor: {error}',
|
||||
|
||||
'time.justNow': 'az önce',
|
||||
'time.never': 'hiç',
|
||||
|
||||
'toast.readingStores': 'Mağazalar okunuyor',
|
||||
'toast.readingStoresDetail': 'App Store Connect ve Google Play, salt okunur',
|
||||
'toast.readDone': 'Mağazalar okundu',
|
||||
'toast.readDoneDetail': '{count} uygulama güncel',
|
||||
'toast.readDoneDetail_one': '1 uygulama güncel',
|
||||
'toast.readDonePartial': 'Mağazalar eksik okundu',
|
||||
'toast.readDonePartialDetail': '{count} uygulama bir mağazada okunamadı; bilinmiyor olarak gösteriliyor',
|
||||
'toast.readDonePartialDetail_one': '1 uygulama bir mağazada okunamadı; bilinmiyor olarak gösteriliyor',
|
||||
'toast.readFailed': 'Mağazalar okunamadı',
|
||||
'toast.cancelled': 'İptal edildi',
|
||||
'toast.reading': 'Okunuyor',
|
||||
'toast.readingDetail': 'Salt okunur',
|
||||
'toast.readOk': '{what} okundu',
|
||||
'toast.readFailedWhat': '{what} okunamadı',
|
||||
|
||||
'write.dryFailed': 'Deneme çalıştırması başarısız, hiçbir şey yazılmadı',
|
||||
'write.failed': 'Uygulanmadı',
|
||||
|
||||
'phase.none': 'Bu mağazada yok',
|
||||
'phase.live': 'Yayında',
|
||||
'phase.rollout': 'Kademeli',
|
||||
'phase.phased': 'Aşamalı',
|
||||
'phase.halted': 'Durduruldu',
|
||||
'phase.paused': 'Duraklatıldı',
|
||||
'phase.approved': 'Onaylandı',
|
||||
'phase.review': 'İncelemede',
|
||||
'phase.rejected': 'Reddedildi',
|
||||
'phase.testing': 'Testte',
|
||||
'phase.draft': 'Taslak',
|
||||
'phase.other': 'Diğer',
|
||||
|
||||
'stage.build': 'Derleme',
|
||||
'stage.testing': 'Test',
|
||||
'stage.review': 'İnceleme',
|
||||
'stage.live': 'Yayın',
|
||||
'stage.rollout': 'Dağıtım',
|
||||
|
||||
'check.notChecked': 'Kontrol edilmedi',
|
||||
'check.notLoaded': 'Ayar yüklenemedi',
|
||||
'check.problems': '{count} sorun',
|
||||
'check.problems_one': '1 sorun',
|
||||
'check.warnings': '{count} uyarı',
|
||||
'check.warnings_one': '1 uyarı',
|
||||
'check.ok': 'Ayar tamam',
|
||||
|
||||
'app.notSetUp': 'kurulmadı',
|
||||
'app.notOnStore': 'Bu mağazada yok',
|
||||
'app.sections': '{name} bölümleri',
|
||||
'app.consoleHint': 'İncelemeye gönderme, yayınlama ve dağıtım mağaza konsolunda yapılır',
|
||||
|
||||
'portfolio.lead': 'Bu çalışma alanındaki her uygulama, App Store ve Google Play\'de.',
|
||||
'portfolio.sub': '{count} uygulama · {stores} mağaza kaydı',
|
||||
'portfolio.sub_one': '1 uygulama · {stores} mağaza kaydı',
|
||||
'portfolio.emptyTitle': 'İlk uygulamanızı ekleyin',
|
||||
'portfolio.emptyText': 'storebound uygulama klasörleriyle çalışır: her uygulama için iOS ve Android\'i anlatan bir storebound.config.ts. Bu dosyası olan bir klasör ekleyin ya da şablonlardan yenisini başlatın.',
|
||||
'portfolio.notReadNotice': 'Sürümler, kademeli dağıtımlar ve katalog eşleşmesi için mağazaların bir kez okunması gerekir. Salt okunurdur ve her uygulamanın kendi hesaplarını kullanır.',
|
||||
'portfolio.apps': 'Uygulamalar',
|
||||
'portfolio.appsSub': 'Mağazalardan sürüm durumu, {when} okundu',
|
||||
'portfolio.appsSubOffline': 'Yalnızca ayar: sürümler için mağazaları okuyun',
|
||||
'portfolio.unreadable': 'Okunamadı',
|
||||
'portfolio.noVersion': 'Henüz sürüm yok',
|
||||
'portfolio.noNext': 'Bekleyen sürüm yok',
|
||||
'portfolio.thenNext': 'sonra {version} · {phase}',
|
||||
'portfolio.products': '{count} ürün',
|
||||
'portfolio.products_one': '1 ürün',
|
||||
'portfolio.subs': '{count} abonelik',
|
||||
'portfolio.subs_one': '1 abonelik',
|
||||
'portfolio.inParity': 'Eşleşiyor',
|
||||
'portfolio.differs': '{count} fark',
|
||||
'portfolio.differs_one': '1 fark',
|
||||
'portfolio.configOnly': 'Henüz karşılaştırılmadı',
|
||||
'portfolio.attention': 'İlgilenilmesi gerekenler',
|
||||
'portfolio.attentionSub': 'Önce sorunlar, sonra uyarılar',
|
||||
'portfolio.allClear': 'İlgilenilecek bir şey yok',
|
||||
'portfolio.allClearText': 'Ayarlar sorunsuz ve son okumada hiçbir mağaza sorun bildirmedi.',
|
||||
'portfolio.workspace': 'Çalışma alanı',
|
||||
'portfolio.healthNotRead': 'Okunmadı',
|
||||
'portfolio.healthNoPlay': 'Yalnız iOS',
|
||||
'portfolio.healthTitle': 'Son 14 gün, kullanıcının fark ettiği, {when} okundu',
|
||||
'portfolio.accounts': '{play} Google Play · {apple} App Store Connect',
|
||||
'portfolio.storesRead': 'Mağaza okuması',
|
||||
'portfolio.tools': 'Yerel araçlar',
|
||||
'portfolio.toolsFound': '{total} araçtan {found} tanesi var',
|
||||
|
||||
'kpi.apps': 'Uygulamalar',
|
||||
'kpi.appsSub': '{ios} iOS · {android} Android',
|
||||
'kpi.live': 'Yayındaki sürümler',
|
||||
'kpi.liveSub': '{count} mağaza kaydında',
|
||||
'kpi.rolling': 'Dağıtımda',
|
||||
'kpi.review': 'İncelemede',
|
||||
'kpi.attention': 'İlgi bekleyen',
|
||||
'kpi.blockers': '{count} engel',
|
||||
'kpi.blockers_one': '1 engel',
|
||||
'kpi.noBlockers': 'Engel yok',
|
||||
'kpi.none': 'Yok',
|
||||
'kpi.notRead': 'Görmek için mağazaları okuyun',
|
||||
|
||||
'legend.done': 'tamam',
|
||||
'legend.stages': 'Derleme · Test · İnceleme · Yayın · Dağıtım',
|
||||
|
||||
'col.app': 'Uygulama',
|
||||
'col.next': 'Sonraki sürüm',
|
||||
'col.catalog': 'Katalog',
|
||||
'col.crash': 'Çökme oranı',
|
||||
'col.config': 'Ayar',
|
||||
'col.version': 'Sürüm',
|
||||
'col.build': 'Derleme',
|
||||
'col.versionCode': 'Sürüm kodu',
|
||||
'col.track': 'Kanal',
|
||||
'col.item': 'Öğe',
|
||||
'col.type': 'Tür',
|
||||
'col.priceGap': 'Fiyat farkı',
|
||||
'col.region': 'Bölge',
|
||||
'col.diff': 'Fark',
|
||||
'col.language': 'Dil',
|
||||
'col.issue': 'Sorun',
|
||||
'col.reports': 'Rapor',
|
||||
'col.users': 'Kullanıcı',
|
||||
'col.metric': 'Ölçüm',
|
||||
'col.value': 'Değer',
|
||||
'col.goal': 'Hedef',
|
||||
'col.name': 'Ad',
|
||||
'col.store': 'Mağaza',
|
||||
'col.usedBy': 'Kullanan',
|
||||
'col.added': 'Eklendi',
|
||||
'col.actions': 'İşlemler',
|
||||
|
||||
'att.storeError': '{store} okunamadı: {error}',
|
||||
'att.rejected': '{store} {version} reddedildi',
|
||||
'att.halted': '{store} {version}: dağıtım durduruldu',
|
||||
'att.catalog': '{count} katalog öğesi mağazalar arasında farklı',
|
||||
'att.catalog_one': '1 katalog öğesi mağazalar arasında farklı',
|
||||
'att.crash': 'Çökme oranı Google Play eşiğine yakın veya üstünde',
|
||||
'att.anr': 'ANR oranı Google Play eşiğine yakın veya üstünde',
|
||||
|
||||
'tl.next': 'Sonraki',
|
||||
'tl.live': 'Yayında',
|
||||
'tl.store': 'Mağaza',
|
||||
'tl.created': 'Oluşturma: {date}',
|
||||
'tl.notOnStore': 'Bu mağazada yok',
|
||||
'tl.noVersion': '{version} henüz {store} mağazasında yok',
|
||||
'tl.everyone': 'Herkes',
|
||||
'tl.day': '7 günün {day}. günü',
|
||||
'tl.rolloutAria': 'Bu sürümü alan kullanıcı payı',
|
||||
|
||||
'releases.notReadTitle': 'Sürüm durumu henüz okunmadı',
|
||||
'releases.notReadText': 'Zaman çizelgesi her sürümü iki mağazada birlikte gösterir: derleme, TestFlight ya da dahili test, inceleme, yayın ve dağıtım payı. Doldurmak için mağazaları bir kez okuyun.',
|
||||
'releases.noAccounts': 'İpucu: önce Uygulama klasörleri ekranında bu uygulamaya mağaza hesaplarını bağlayın.',
|
||||
'releases.storeUnreadable': '{store} okunamadı, bu yüzden şeridi bilinmiyor olarak gösteriliyor: {error}',
|
||||
'releases.noVersions': 'Mağazalarda henüz sürüm yok',
|
||||
'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.',
|
||||
'releases.history': 'Mağazalardaki sürümler',
|
||||
'releases.historySub': '{when} okundu',
|
||||
'releases.consoleNote': 'storebound hiçbir zaman incelemeye göndermez, yayınlamaz veya dağıtımı tamamlamaz. Bunları mağaza konsolunda yapın.',
|
||||
|
||||
'cat.products': 'Tek seferlik ürünler',
|
||||
'cat.productsSub': 'Ayar dosyasında',
|
||||
'cat.subscriptions': 'Abonelikler',
|
||||
'cat.subsSub': 'Tüm gruplar',
|
||||
'cat.parity': 'Eşleşen',
|
||||
'cat.parityRead': '{when} karşılaştırıldı',
|
||||
'cat.gaps': 'Fiyat farkları',
|
||||
'cat.gapsSub': 'Aynı para birimi, %10 üstü',
|
||||
'cat.matrix': 'Eşleşme tablosu',
|
||||
'cat.configOnly': 'Yalnızca ayar dosyasından: mağaza sütunları için iki mağazayla karşılaştırın',
|
||||
'cat.compare': 'Mağazalarla karşılaştır',
|
||||
'cat.ok': 'Eşleşiyor',
|
||||
'cat.warn': 'Farklı',
|
||||
'cat.error': 'Eksik veya hatalı',
|
||||
'cat.na': 'Burada satılmıyor',
|
||||
'cat.unknown': 'Karşılaştırılmadı',
|
||||
'cat.extra': 'Yalnız mağazada',
|
||||
'cat.sub': 'Abonelik',
|
||||
'cat.product': 'Ürün',
|
||||
'cat.notInConfig': 'Ayar dosyasında yok',
|
||||
'cat.noGap': 'Yok',
|
||||
'cat.gapsTitle': 'Mağazalar arası fiyat farkları',
|
||||
'cat.gapsTitleSub': 'Mağazalar kendi fiyat basamaklarına yuvarlar, küçük farklar normaldir',
|
||||
'cat.emptyTitle': 'Uygulama içi ürün veya abonelik yok',
|
||||
'cat.emptyText': 'Ürünler ve abonelikler storebound.config.ts içinde bir kez tanımlanır ve iki mağazada oluşturulur. Bu uygulamanın ayarında hiç yok.',
|
||||
|
||||
'lst.languages': 'Diller',
|
||||
'lst.completeKpi': 'Tamamlanan',
|
||||
'lst.completeSub': 'Zorunlu alanlar, iki mağaza',
|
||||
'lst.toFix': 'Düzeltilecek',
|
||||
'lst.toFixSub': 'Eksik veya sınırı aşan',
|
||||
'lst.screenshots': 'Ekran görüntüleri',
|
||||
'lst.screenshotsSub': 'Üretilmiş, tüm diller',
|
||||
'lst.noRenders': 'Henüz görsel klasörü yok',
|
||||
'lst.playSub': 'store/listing.md',
|
||||
'lst.appleSub': 'store/appstore.md',
|
||||
'lst.complete': 'Tamamlanan',
|
||||
'lst.completeAria': '%{pct} tamamlandı',
|
||||
'lst.missing': 'Eksik',
|
||||
'lst.optional': 'İsteğe bağlı',
|
||||
'lst.over': '{n} fazla',
|
||||
'lst.title': 'Başlık',
|
||||
'lst.short': 'Kısa',
|
||||
'lst.full': 'Uzun',
|
||||
'lst.whatsNew': 'Yenilikler',
|
||||
'lst.name': 'Ad',
|
||||
'lst.subtitle': 'Alt başlık',
|
||||
'lst.promo': 'Tanıtım metni',
|
||||
'lst.keywords': 'Anahtar kelimeler',
|
||||
'lst.description': 'Açıklama',
|
||||
'lst.noFilesTitle': 'Henüz mağaza metni dosyası yok',
|
||||
'lst.noFilesText': 'Mağaza metinleri uygulama klasöründe store/listing.md (Google Play) ve store/appstore.md (App Store) dosyalarında, her dil için bir bölüm olarak durur.',
|
||||
'lst.noFilesHint': 'iki şablonu da uygulama klasörüne yazar',
|
||||
'lst.foot': 'Uygulama klasöründeki dosyalardan, {when} okundu. Buradan hiçbir şey yüklenmez.',
|
||||
|
||||
'health.read': 'Sağlığı oku',
|
||||
'health.emptyTitle': 'Sağlık henüz okunmadı',
|
||||
'health.emptyText': 'Google Play eşiklerine göre çökme ve ANR oranları, en kötü cihazlar ve öne çıkan sorunlar, ayrıca App Store tanılamaları. Salt okunur.',
|
||||
'health.crash': 'Kullanıcının fark ettiği çökme oranı',
|
||||
'health.anr': 'Kullanıcının fark ettiği ANR oranı',
|
||||
'health.window': '{from} ile {to} arası',
|
||||
'health.over': 'Eşiğin üstünde',
|
||||
'health.near': 'Eşiğe yakın',
|
||||
'health.under': 'Eşiğin altında',
|
||||
'health.current28': '28 günlük ortalama, Google Play\'in saydığı gibi',
|
||||
'health.daily': 'Günlük',
|
||||
'health.threshold': 'Kötü davranış eşiği {value}',
|
||||
'health.chartAria': '{metric}: {value}',
|
||||
'health.devices': 'En kötü cihazlar',
|
||||
'health.devicesSub': 'Cihaz başına eşik {value}',
|
||||
'health.noDevices': 'Karşılaştırmaya yetecek kullanıcısı olan cihaz yok.',
|
||||
'health.issues': 'Öne çıkan sorunlar',
|
||||
'health.issuesSub': 'En çok rapor önce',
|
||||
'health.noIssues': 'Bildirilen sorun yok',
|
||||
'health.crashShort': 'Çökme',
|
||||
'health.appleMetrics': 'Performans ölçümleri',
|
||||
'health.appleMetricsSub': 'Her ölçümün son değeri',
|
||||
'health.noMetrics': 'Henüz ölçüm yok',
|
||||
'health.signatures': 'Tanılama imzaları',
|
||||
'health.signaturesSub': 'En ağır önce, son derlemeler',
|
||||
'health.noSignatures': 'Tanılama imzası yok',
|
||||
'health.betaCrashes': 'TestFlight çökmeleri',
|
||||
'health.noBetaCrashes': 'Bu aralıkta TestFlight çökme raporu yok.',
|
||||
'health.build': 'derleme {build}',
|
||||
'health.noPlay': 'Bu uygulama Google Play\'de değil.',
|
||||
'health.noApple': 'Bu uygulama App Store\'da değil.',
|
||||
'health.unreadable': '{store} okunamadı, bilinmiyor olarak gösteriliyor: {error}',
|
||||
|
||||
'reviews.read': 'Yorumları oku',
|
||||
'reviews.emptyTitle': 'Yorumlar henüz okunmadı',
|
||||
'reviews.emptyText': 'App Store\'da son 30 gün, Google Play\'de son 7 gün (API\'si fazlasını tutmuyor), yerel yanıt taslaklarınızla birlikte. Salt okunur.',
|
||||
'reviews.ratings': 'Puanlar',
|
||||
'reviews.count': '{count} yorum',
|
||||
'reviews.count_one': '1 yorum',
|
||||
'reviews.unansweredN': '{count} yanıtsız',
|
||||
'reviews.unansweredN_one': '1 yanıtsız',
|
||||
'reviews.filter': 'Süzgeç',
|
||||
'reviews.store': 'Mağaza',
|
||||
'reviews.all': 'Tümü',
|
||||
'reviews.onlyUnanswered': 'Yalnızca yanıtsız olanlar',
|
||||
'reviews.search': 'Yorumlarda ara',
|
||||
'reviews.inbox': 'Gelen kutusu',
|
||||
'reviews.shown': '{count} gösteriliyor',
|
||||
'reviews.noMatch': 'Süzgece uyan yorum yok',
|
||||
'reviews.pick': 'Bir yorum seçin',
|
||||
'reviews.answered': 'Yanıtlandı',
|
||||
'reviews.drafted': 'Taslak',
|
||||
'reviews.approved': 'Onaylı',
|
||||
'reviews.notApproved': 'Onaylanmadı',
|
||||
'reviews.noTitle': 'Başlık yok',
|
||||
'reviews.noText': 'Metin yok',
|
||||
'reviews.version': 'sürüm {v}',
|
||||
'reviews.currentReply': 'Mağazadaki yanıt',
|
||||
'reviews.draft': 'Yanıt taslağınız',
|
||||
'reviews.noDraft': 'Bu yorum için taslak yok. "Taslak oluştur" her yanıtsız yorum için .storebound/replies içine bir dosya yazar.',
|
||||
'reviews.emptyDraft': '{file} taslağında henüz yanıt yok: "## Reply" altına yazın.',
|
||||
'reviews.chars': '{limit} karakterden {n}',
|
||||
'reviews.approve': 'Onayla',
|
||||
'reviews.unapprove': 'Onayı kaldır',
|
||||
'reviews.approveHint': 'Taslağı onaylı olarak işaretler (yerel dosya, bir pencerede onaylanır). Hiçbir şey gönderilmez.',
|
||||
'reviews.fixFirst': 'Önce hataları düzeltin',
|
||||
'reviews.sentOn': '{date} gönderildi',
|
||||
'reviews.sendNote': 'Yanıtlar herkese açıktır, bu yüzden terminalden gönderilir:',
|
||||
'reviews.makeDrafts': 'Taslak oluştur',
|
||||
'reviews.makeDraftsHint': 'Her yanıtsız yorum için bir yerel taslak (önce deneme, sonra onay)',
|
||||
'reviews.draftsMade': 'Yanıt taslakları yazıldı',
|
||||
'reviews.approvedToast': 'Taslak onaylandı',
|
||||
'reviews.unapproved': 'Onay kaldırıldı',
|
||||
'reviews.starsAria': '5 üzerinden {count} yıldız',
|
||||
'reviews.starsAria_one': '5 üzerinden 1 yıldız',
|
||||
|
||||
'tf.read': 'TestFlight\'ı oku',
|
||||
'tf.emptyTitle': 'TestFlight henüz okunmadı',
|
||||
'tf.emptyText': 'Beta grupları, test kullanıcıları ve son derlemeler, işlenme ve beta inceleme durumlarıyla. Salt okunur.',
|
||||
'tf.noIos': 'Bu uygulama iOS\'ta değil',
|
||||
'tf.noIosText': 'TestFlight, App Store kaydı olan uygulamalar içindir. Kullanmak için ayar dosyasına apple bölümünü ekleyin.',
|
||||
'tf.internal': 'Dahili',
|
||||
'tf.external': 'Harici',
|
||||
'tf.testers': 'test kullanıcısı',
|
||||
'tf.builds': 'derleme',
|
||||
'tf.all': 'Tümü',
|
||||
'tf.publicOn': 'Herkese açık bağlantı açık',
|
||||
'tf.publicOff': 'Yalnızca davetle',
|
||||
'tf.noGroups': 'Beta grubu yok',
|
||||
'tf.buildsTitle': 'Son derlemeler',
|
||||
'tf.buildsSub': 'En yeni önce',
|
||||
'tf.processing': 'İşlenme',
|
||||
'tf.internalTesting': 'Dahili test',
|
||||
'tf.externalTesting': 'Harici test',
|
||||
'tf.uploaded': 'Yüklendi',
|
||||
'tf.noBuilds': 'Henüz derleme yüklenmedi',
|
||||
'tf.missingGroups': 'Ayar dosyasında var ama App Store Connect\'te yok: {groups}',
|
||||
|
||||
'checks.offline': 'Ayar kontrolü',
|
||||
'checks.offlineSub': 'Çevrimdışı: ayar, metin dosyaları, metinler, beyanlar, ürünler',
|
||||
'checks.again': 'Yeniden kontrol et',
|
||||
'checks.clean': 'Ayar sorunsuz',
|
||||
'checks.cleanText': 'Uygulama klasöründe sorun veya uyarı yok.',
|
||||
'checks.problem': 'Sorun',
|
||||
'checks.warning': 'Uyarı',
|
||||
'checks.validate': 'Mağaza hazırlığı',
|
||||
'checks.validateSub': 'İki mağazadan salt okunur rapor, engeller sırayla',
|
||||
'checks.run': 'Raporu çalıştır',
|
||||
'checks.validateEmpty': 'Hazırlık henüz kontrol edilmedi',
|
||||
'checks.validateEmptyText': 'Göndermeden önce App Store ve Google Play\'de hâlâ eksik olanlar, düzeltme sırasıyla.',
|
||||
'checks.ready': 'Engel yok',
|
||||
'checks.readyText': 'İki mağazada da gönderim için gerekenler tamam.',
|
||||
|
||||
'folders.lead': 'Her uygulama için storebound.config.ts dosyası olan bir klasör (iOS ve Android tek ayarda). Her uygulamanın kullandığı mağaza hesaplarını seçin; anahtarlar anahtar zincirinde kalır.',
|
||||
'folders.list': 'Uygulama klasörleri',
|
||||
'folders.listSub': '{count} klasör',
|
||||
'folders.listSub_one': '1 klasör',
|
||||
'folders.emptyTitle': 'Henüz uygulama klasörü yok',
|
||||
'folders.emptyText': 'storebound.config.ts dosyası olan bir klasör ekleyin ya da aşağıdan yenisini başlatın.',
|
||||
'folders.added': '{name} eklendi',
|
||||
'folders.started': '{name} başlatıldı',
|
||||
'folders.startedDetail': 'Şablonlar yazıldı. Sırada storebound.config.ts ve mağaza dosyalarını düzenlemek var.',
|
||||
'folders.playFor': '{name} için Google Play hesabı',
|
||||
'folders.appleFor': '{name} için App Store Connect hesabı',
|
||||
'folders.noPlay': 'Google Play hesabı yok',
|
||||
'folders.noApple': 'App Store hesabı yok',
|
||||
'folders.check': 'Kontrol et',
|
||||
'folders.gallery': 'Galeri',
|
||||
'folders.galleryHint': 'Yerel ekran görüntüsü inceleme sayfasını yazar (önce deneme, sonra onay)',
|
||||
'folders.remove': '{name} listeden kaldır (klasör yerinde kalır)',
|
||||
'folders.checkTitle': 'Ayar kontrolü',
|
||||
'folders.galleryTitle': 'Ekran görüntüsü inceleme sayfası',
|
||||
'folders.newTitle': 'Yeni uygulama klasörü başlat',
|
||||
'folders.newSub': 'storebound init: ayar, mağaza metni, ekran görüntüsü metni, beyan ve gizlilik politikası şablonları',
|
||||
'folders.profile': 'Profil',
|
||||
'folders.profileHint.app': 'Araç veya verimlilik uygulaması: isteğe bağlı hesap ve analiz, reklam yok.',
|
||||
'folders.profileHint.game': 'Reklamlı ve uygulama içi satın almalı ücretsiz oyun.',
|
||||
'folders.profileHint.subscription-app': 'Hesaplı ve otomatik yenilenen abonelikli uygulama.',
|
||||
'folders.chooseAndStart': 'Klasör seç ve başlat',
|
||||
|
||||
'run.failed': 'Başarısız',
|
||||
'run.running': 'Çalışıyor',
|
||||
'run.applied': 'Onayınızdan sonra uygulandı',
|
||||
'run.cancelled': 'Pencerede iptal edildi: hiçbir şey yazılmadı',
|
||||
'run.dryFailed': 'Deneme çalıştırması başarısız: hiçbir şey yazılmadı',
|
||||
'run.done': 'Bitti',
|
||||
|
||||
'accounts.lead': 'Mağaza API anahtarları sistemin anahtar zincirinde durur (macOS\'ta Anahtar Zinciri, Windows\'ta Kimlik Bilgisi Yöneticisi, Linux\'ta Secret Service). Bir daha gösterilmez, uygulama klasörlerinize yazılmaz ve storebound\'a yalnızca bir çağrı için bellekte verilir.',
|
||||
'accounts.list': 'Mağaza hesapları',
|
||||
'accounts.listSub': 'Yalnızca etiketler: anahtarlar anahtar zincirinde',
|
||||
'accounts.emptyTitle': 'Henüz mağaza hesabı yok',
|
||||
'accounts.emptyText': 'Aşağıdan bir Google Play hizmet hesabı veya App Store Connect API anahtarı ekleyin. Mağaza okumaları bunlara ihtiyaç duyar; uygulama klasörleri onlarsız da çalışır.',
|
||||
'accounts.unused': 'Kullanılmıyor',
|
||||
'accounts.remove': 'Kaldır',
|
||||
'accounts.removeAria': '{label} hesabını kaldır ve anahtarını anahtar zincirinden sil',
|
||||
'accounts.saved': '"{label}" kaydedildi',
|
||||
'accounts.savedDetail': 'Anahtar sistemin anahtar zincirinde.',
|
||||
'accounts.playSub': 'Hizmet hesabı JSON anahtarı',
|
||||
'accounts.appleSub': 'Kullanıcılar ve Erişim, Entegrasyonlar altındaki ekip API anahtarı (.p8)',
|
||||
'accounts.label': 'Ad',
|
||||
'accounts.playPlaceholder': 'Stüdyo Play hesabı',
|
||||
'accounts.applePlaceholder': 'Stüdyo App Store anahtarı',
|
||||
'accounts.playHow': 'Play Console\'da uygulamalarınıza erişimi olan bir hizmet hesabı (Kullanıcılar ve izinler).',
|
||||
'accounts.keyId': 'Anahtar kimliği',
|
||||
'accounts.issuerId': 'Veren kimliği',
|
||||
'accounts.choosePlay': 'JSON anahtarını seç',
|
||||
'accounts.chooseApple': '.p8 anahtarını seç',
|
||||
'accounts.safety': 'Anahtarlar sistemin dosya penceresinde seçilir ve doğrudan anahtar zincirine gider. Uygulama penceresi onları hiç görmez.',
|
||||
|
||||
'deps.lead': 'Ekran görüntüleri, uygulama önizlemeleri, simgeler ve simülatör ya da emülatör kayıtları için yerel araçlar. Mağaza işleri (metinler, ürünler, sürümler, yorumlar) hiçbirine ihtiyaç duymaz.',
|
||||
'deps.checkAgain': 'Yeniden kontrol et',
|
||||
'deps.tools': 'Bu bilgisayardaki araçlar',
|
||||
'deps.found': '{total} araçtan {found} tanesi var',
|
||||
'deps.missing': 'Eksik',
|
||||
'deps.appCopy': 'Uygulamanın kopyası',
|
||||
'deps.installed': 'Kurulu',
|
||||
'deps.download': 'İndir',
|
||||
'deps.remove': 'Kaldır',
|
||||
'deps.getIt': 'Edin',
|
||||
'deps.unavailable': 'Bu platform için yok',
|
||||
'deps.downloading': '{tool} indiriliyor',
|
||||
'deps.installedToast': '{tool} kuruldu',
|
||||
'deps.failed': 'İndirme başarısız',
|
||||
'deps.cancelledDetail': 'Yarım kalan indirme silindi.',
|
||||
'deps.removed': 'Uygulama veri klasöründen kaldırıldı',
|
||||
'deps.inAppData': 'Uygulama veri klasöründe ({folder})',
|
||||
'deps.notDownloaded': 'İndirilmedi',
|
||||
'deps.source': 'Kaynak',
|
||||
'deps.license': 'Lisans',
|
||||
'deps.verify': 'Doğrulama',
|
||||
'deps.verifyFfmpeg': 'Bu uygulama sürümüne sabitlenmiş arşivin SHA-256 özeti',
|
||||
'deps.verifyChrome': 'Google\'ın kod imzası (macOS, Windows) veya sürüm satırı (Linux)',
|
||||
'deps.licenseLink': 'Lisans ve kaynak',
|
||||
'deps.terminal': 'Terminalden',
|
||||
'deps.terminalSub': 'Aynı kontrol ve alışılmış kurulum komutları',
|
||||
'deps.copy': '"{cmd}" komutunu kopyala',
|
||||
'deps.copied': 'Kopyalandı',
|
||||
|
||||
'dl.resolve': 'İndirme aranıyor',
|
||||
'dl.download': 'İndiriliyor',
|
||||
'dl.verify': 'Doğrulanıyor',
|
||||
'dl.unpack': 'Açılıyor',
|
||||
'dl.install': 'Kuruluyor',
|
||||
'dl.done': 'Bitti',
|
||||
'dl.error': 'Başarısız',
|
||||
'dl.cancelled': 'İptal edildi',
|
||||
|
||||
'settings.lead': 'Görünüm, dil, kısayollar ve bu uygulamanın üzerinde çalıştığı şeyler.',
|
||||
'settings.appearance': 'Görünüm',
|
||||
'settings.theme': 'Tema',
|
||||
'settings.system': 'Sistem',
|
||||
'settings.light': 'Açık',
|
||||
'settings.dark': 'Koyu',
|
||||
'settings.motion': 'Hareket',
|
||||
'settings.followSystem': 'Sistemi izle',
|
||||
'settings.reduce': 'Azalt',
|
||||
'settings.language': 'Dil',
|
||||
'settings.shortcuts': 'Klavye kısayolları',
|
||||
'settings.scPalette': 'Komut paleti',
|
||||
'settings.scWorkspace': 'Uygulama klasörleri, Hesaplar, Bağımlılıklar, Ayarlar',
|
||||
'settings.scTabs': 'Önceki / sonraki uygulama sekmesi',
|
||||
'settings.scTabArrows': 'Sekmeler arasında gezin (sekme listesi odaktayken)',
|
||||
'settings.scClose': 'Pencereyi kapat',
|
||||
'settings.data': 'Kayıtlı okumalar',
|
||||
'settings.dataText': 'Son mağaza okumaları uygulama veri klasöründe saklanır; böylece ekranlar çevrimdışıyken de açılır, her zaman okuma zamanıyla işaretli. Orada anahtar tutulmaz.',
|
||||
'settings.clearCache': 'Kayıtlı okumaları temizle',
|
||||
'settings.cacheCleared': 'Kayıtlı okumalar temizlendi',
|
||||
'settings.about': 'Hakkında',
|
||||
'settings.app': 'Uygulama',
|
||||
'settings.debug': 'hata ayıklama sürümü',
|
||||
'settings.methods': '{count} yöntem',
|
||||
'settings.tools': 'Yerel araçlar',
|
||||
'settings.telemetry': 'Telemetri',
|
||||
'settings.telemetryNone': 'Yok. Uygulama mağaza API\'leriyle yalnızca storebound üzerinden, sizin anahtarlarınızla konuşur.',
|
||||
|
||||
'wz.s1': 'Hoş geldiniz',
|
||||
'wz.s2': 'Uygulamalarınız',
|
||||
'wz.s3': 'Mağaza anahtarları',
|
||||
'wz.s4': 'Yerel araçlar',
|
||||
'wz.s5': 'İlk okuma',
|
||||
'wz.footer': 'Bu rehberi Ayarlar\'dan veya komut paletinden yeniden açabilirsiniz.',
|
||||
'wz.footerSafe': 'Buradan hiçbir şey gönderilmez veya yayınlanmaz.',
|
||||
'wz.welcomeTitle': 'Her uygulama, iki mağaza, tek pencere',
|
||||
'wz.welcomeLead': 'storebound desktop her uygulamanızın App Store ve Google Play\'deki durumunu gösterir ve storebound CLI\'yi sizin için çalıştırır. Beş kısa adımda hazır.',
|
||||
'wz.p1t': 'Tek portföy',
|
||||
'wz.p1s': 'Her uygulamanın sürümleri, dağıtımları, katalog eşleşmesi, sağlığı ve yorumları.',
|
||||
'wz.p2t': 'Tasarımı gereği güvenli',
|
||||
'wz.p2s': 'Okumalar salt okunurdur. Her değişiklik önce bir deneme çalıştırması ve sistem onayı gösterir.',
|
||||
'wz.p3t': 'Anahtarlarınız sizde kalır',
|
||||
'wz.p3s': 'Mağaza anahtarları sistemin anahtar zincirinde durur. Hesap yok, telemetri yok.',
|
||||
'wz.appsTitle': 'Uygulamalarınızı ekleyin',
|
||||
'wz.appsLead': 'Her uygulama, onu iki mağazada anlatan bir storebound.config.ts dosyası olan bir klasördür.',
|
||||
'wz.addTitle': 'Var olan klasörü ekle',
|
||||
'wz.addText': 'Klasörde zaten bir storebound.config.ts var (storebound init veya CLI ile).',
|
||||
'wz.initTitle': 'Yeni uygulama klasörü başlat',
|
||||
'wz.initText': 'Seçtiğiniz klasöre bir profilin şablonlarını yazar. Var olan dosyalar korunur.',
|
||||
'wz.added': 'Eklendi',
|
||||
'wz.ready': 'Hazır',
|
||||
'wz.keysTitle': 'Mağaza anahtarlarınızı ekleyin',
|
||||
'wz.keysLead': 'Mağaza okumaları her mağaza için bir API anahtarı ister. Bu adımı atlayıp daha sonra Hesaplar\'dan ekleyebilirsiniz.',
|
||||
'wz.mapHint': 'Bu uygulamanın kullandığı hesaplar',
|
||||
'wz.toolsTitle': 'Yerel araçları kontrol edin',
|
||||
'wz.toolsLead': 'Ekran görüntüleri, önizlemeler ve simgeler ffmpeg ve Chrome kullanır. Uygulama kendi kopyalarını indirebilir; mağaza işleri hiçbirine ihtiyaç duymaz.',
|
||||
'wz.toolsNote': 'Aynı kontrol terminalde:',
|
||||
'wz.readTitle': 'Mağazaları okuyun',
|
||||
'wz.readLead': 'App Store Connect ve Google Play üzerinde salt okunur bir tur portföyü doldurur: sürümler, inceleme durumu, dağıtımlar ve katalog eşleşmesi.',
|
||||
'wz.readNow': 'Mağazaları şimdi oku',
|
||||
'wz.readDone': '{count} uygulama {when} okundu',
|
||||
'wz.needApp': 'Önce bir uygulama klasörü ekleyin (2. adım).',
|
||||
'wz.skip': 'Şimdilik geç',
|
||||
'wz.back': 'Geri',
|
||||
'wz.next': 'İleri',
|
||||
'wz.finish': 'Bitir',
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// Locale-aware formatting for the UI (numbers, percentages, dates, "3 min ago"). Pure apart from Intl.
|
||||
import { lang, t } from '../i18n';
|
||||
|
||||
const loc = (): string => (lang() === 'tr' ? 'tr-TR' : 'en-US');
|
||||
|
||||
/** 0.0109 -> "1.09%" (Turkish: "%1,09"). */
|
||||
export function pct(x: number | null | undefined, digits = 2): string {
|
||||
if (x === null || x === undefined || Number.isNaN(x)) return '–';
|
||||
return new Intl.NumberFormat(loc(), { style: 'percent', minimumFractionDigits: digits, maximumFractionDigits: digits }).format(x);
|
||||
}
|
||||
|
||||
/** Rollout share: 0.2 -> "20%", 0.05 -> "5%". */
|
||||
export function share(x: number): string {
|
||||
return new Intl.NumberFormat(loc(), { style: 'percent', maximumFractionDigits: x < 0.1 ? 1 : 0 }).format(x);
|
||||
}
|
||||
|
||||
export const num = (n: number, digits = 0): string => new Intl.NumberFormat(loc(), { maximumFractionDigits: digits, minimumFractionDigits: digits }).format(n);
|
||||
|
||||
export function bytes(n: number | null | undefined): string {
|
||||
if (!n) return '–';
|
||||
const mb = n / 1e6;
|
||||
return mb >= 1 ? `${num(mb, mb < 10 ? 1 : 0)} MB` : `${num(n / 1e3, 0)} KB`;
|
||||
}
|
||||
|
||||
export function date(iso: string | null | undefined): string {
|
||||
if (!iso) return '–';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '–';
|
||||
return d.toLocaleDateString(loc(), { day: 'numeric', month: 'short', year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
||||
}
|
||||
|
||||
export function dateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '–';
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? '–' : d.toLocaleString(loc(), { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** "just now", "4 min ago", "3 h ago", "2 d ago" (and the Turkish forms). */
|
||||
export function ago(iso: string | null | undefined, now: number = Date.now()): string {
|
||||
if (!iso) return t('time.never');
|
||||
const s = Math.round((now - new Date(iso).getTime()) / 1000);
|
||||
if (!Number.isFinite(s)) return t('time.never');
|
||||
if (s < 45) return t('time.justNow');
|
||||
const rtf = new Intl.RelativeTimeFormat(loc(), { numeric: 'auto', style: 'short' });
|
||||
if (s < 3600) return rtf.format(-Math.round(s / 60), 'minute');
|
||||
if (s < 86_400) return rtf.format(-Math.round(s / 3600), 'hour');
|
||||
if (s < 86_400 * 30) return rtf.format(-Math.round(s / 86_400), 'day');
|
||||
return date(iso);
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
// Pure view models for the screens: release timeline stages, health series against the store thresholds, review
|
||||
// inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher.
|
||||
// "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok".
|
||||
import type { AppStatus, ProjectSummary, ReleaseTrain, StoreRelease } from '../api';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'neutral' | 'accent';
|
||||
|
||||
// ---- CLI JSON shapes the screens read (the commands' --json output) ----
|
||||
|
||||
export interface MetricRow { date: string; dims: Record<string, string>; values: Record<string, number> }
|
||||
export interface PlayHealth {
|
||||
window: { start: string; end: string; days: number };
|
||||
crash: MetricRow[];
|
||||
anr: MetricRow[];
|
||||
devices?: { model: string; crash?: number; anr?: number; users?: number }[];
|
||||
issues: { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string }[];
|
||||
}
|
||||
export interface AppleHealth {
|
||||
metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[];
|
||||
signatures: { build: string; type: string; signature: string; weight: number }[];
|
||||
betaCrashes: { build: string; count: number }[];
|
||||
}
|
||||
export interface HealthSnapshot { date: string; play?: PlayHealth | { error: string }; apple?: AppleHealth | { error: string } }
|
||||
export interface Review { store: 'apple' | 'play'; id: string; rating: number; title?: string; body: string; where?: string; date: string; appVersion?: string; reply?: { text: string; date?: string; state?: string } }
|
||||
export interface TfGroup { id: string; name: string; internal: boolean; testers: number; builds: number | 'all'; publicLink: boolean }
|
||||
export interface TfBuild { version: string | null; build: string | null; processing: string | null; internal: string | null; external: string | null; uploaded: string | null }
|
||||
export interface TestFlightStatus { groups: TfGroup[]; builds: TfBuild[]; missingGroups: string[] }
|
||||
export interface CatalogRow { id: string; store: 'apple' | 'play'; level: 'error' | 'warn' | 'ok'; code: string; msg: string }
|
||||
export interface ParityRow { id: string; region: string; currency: string; apple: number; play: number; diffPct: number }
|
||||
export interface CatalogDiff { errors: number; rows: CatalogRow[]; parity: ParityRow[] }
|
||||
export interface Finding { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string }
|
||||
export interface ValidateResult { blockers: number; findings: Finding[] }
|
||||
|
||||
export const isError = (x: unknown): x is { error: string } => !!x && typeof x === 'object' && typeof (x as { error?: unknown }).error === 'string';
|
||||
|
||||
// ---- Release timeline ----
|
||||
|
||||
/** Stages of one version on one store, left to right. */
|
||||
export const STAGES = ['build', 'testing', 'review', 'live', 'rollout'] as const;
|
||||
export type Stage = (typeof STAGES)[number];
|
||||
|
||||
export interface Lane {
|
||||
store: 'ios' | 'android';
|
||||
release: StoreRelease | null;
|
||||
/** Index of the stage it is at (-1: not on this store). */
|
||||
at: number;
|
||||
tone: Tone;
|
||||
/** Rollout share 0-1 when live or rolling out (1 = everyone). */
|
||||
share: number | null;
|
||||
/** The store's state word, for a tooltip. */
|
||||
state: string;
|
||||
}
|
||||
|
||||
/** Where a store release is on the track. Rollout counts as the last stage; a full release fills it. */
|
||||
export function laneOf(store: 'ios' | 'android', r: StoreRelease | null, testflightReached = false): Lane {
|
||||
if (!r) return { store, release: null, at: -1, tone: 'neutral', share: null, state: '' };
|
||||
const base = { store, release: r, state: r.state };
|
||||
switch (r.phase) {
|
||||
case 'draft': return { ...base, at: testflightReached ? 1 : 0, tone: testflightReached ? 'info' : 'neutral', share: null };
|
||||
case 'testing': return { ...base, at: 1, tone: 'info', share: null };
|
||||
case 'review': return { ...base, at: 2, tone: 'warn', share: null };
|
||||
case 'approved': return { ...base, at: 2, tone: 'ok', share: null };
|
||||
case 'rejected': return { ...base, at: 2, tone: 'bad', share: null };
|
||||
case 'live': return { ...base, at: 4, tone: 'ok', share: 1 };
|
||||
case 'rollout': return { ...base, at: 4, tone: 'accent', share: r.fraction ?? null };
|
||||
case 'halted': return { ...base, at: 4, tone: 'bad', share: r.fraction ?? null };
|
||||
default: return { ...base, at: 0, tone: 'neutral', share: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Both lanes of a train; iOS counts TestFlight as reached when a build of that version is in beta testing. */
|
||||
export function trainLanes(t: ReleaseTrain, tf?: TestFlightStatus | null): Lane[] {
|
||||
const inBeta = !!tf?.builds.some(b => b.version === t.version && /IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING/.test(`${b.internal ?? ''} ${b.external ?? ''}`));
|
||||
return [laneOf('ios', t.ios, inBeta), laneOf('android', t.android)];
|
||||
}
|
||||
|
||||
/** Every distinct version of the store releases, newest first, with both stores' release (best phase per store). */
|
||||
export function versionHistory(all: StoreRelease[]): ReleaseTrain[] {
|
||||
const rank: Record<string, number> = { live: 0, rollout: 1, halted: 2, approved: 3, review: 4, rejected: 5, testing: 6, draft: 7, other: 8 };
|
||||
const versions = [...new Set(all.map(r => r.version).filter((v): v is string => !!v))].sort(cmpVersion).reverse();
|
||||
const best = (store: 'ios' | 'android', v: string) => all.filter(r => r.store === store && r.version === v).sort((a, b) => (rank[a.phase] ?? 9) - (rank[b.phase] ?? 9))[0] ?? null;
|
||||
return versions.map(v => ({ version: v, ios: best('ios', v), android: best('android', v) }));
|
||||
}
|
||||
|
||||
/** On one store: the highest version reaching users (live or rolling out) and the highest version above it. */
|
||||
export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now: StoreRelease | null; next: StoreRelease | null } {
|
||||
const rs = all.filter((r): r is StoreRelease & { version: string } => r.store === store && !!r.version);
|
||||
const desc = (a: { version: string }, b: { version: string }) => cmpVersion(b.version, a.version);
|
||||
const now = rs.filter(r => r.phase === 'live' || r.phase === 'rollout' || r.phase === 'halted').sort(desc)[0] ?? null;
|
||||
const next = rs.filter(r => !now || cmpVersion(r.version, now.version) > 0).sort(desc)[0] ?? null;
|
||||
return { now, next };
|
||||
}
|
||||
|
||||
export function cmpVersion(a: string, b: string): number {
|
||||
const pa = a.split('.').map(x => parseInt(x, 10) || 0), pb = b.split('.').map(x => parseInt(x, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- Health ----
|
||||
|
||||
export const PLAY_THRESHOLDS = { crash: 0.0109, anr: 0.0047, perDevice: 0.08 } as const;
|
||||
|
||||
export interface Series { dates: string[]; values: number[]; current: number | null; threshold: number; tone: Tone }
|
||||
|
||||
/** Daily user-perceived rates (fractions) and the current 28-day value against the bad behavior threshold. */
|
||||
export function healthSeries(rows: MetricRow[], metric: 'userPerceivedCrashRate' | 'userPerceivedAnrRate', threshold: number): Series {
|
||||
const pts = rows.filter(r => typeof r.values[metric] === 'number' && !r.dims.deviceModel).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const w28 = metric === 'userPerceivedCrashRate' ? 'userPerceivedCrashRate28dUserWeighted' : 'userPerceivedAnrRate28dUserWeighted';
|
||||
let current: number | null = null;
|
||||
for (let i = pts.length - 1; i >= 0 && current === null; i--) { const v = pts[i]!.values[w28]; if (typeof v === 'number') current = v; }
|
||||
if (current === null && pts.length) current = pts.at(-1)!.values[metric]!;
|
||||
return { dates: pts.map(p => p.date), values: pts.map(p => p.values[metric]!), current, threshold, tone: toneFor(current, threshold) };
|
||||
}
|
||||
|
||||
/** Over the threshold: bad; from 80 % of it: warn; unknown: neutral. */
|
||||
export function toneFor(v: number | null, threshold: number): Tone {
|
||||
if (v === null) return 'neutral';
|
||||
return v >= threshold ? 'bad' : v >= threshold * 0.8 ? 'warn' : 'ok';
|
||||
}
|
||||
|
||||
// ---- Reviews ----
|
||||
|
||||
export interface StarStats { count: number; average: number | null; byStar: [number, number, number, number, number]; unanswered: number }
|
||||
|
||||
export function starStats(rs: Review[]): StarStats {
|
||||
const byStar: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
||||
let sum = 0, n = 0;
|
||||
for (const r of rs) if (r.rating >= 1 && r.rating <= 5) { byStar[r.rating - 1]!++; sum += r.rating; n++; }
|
||||
return { count: rs.length, average: n ? sum / n : null, byStar, unanswered: rs.filter(r => !r.reply).length };
|
||||
}
|
||||
|
||||
export interface ReviewFilter { store: 'all' | 'apple' | 'play'; stars: number[]; unanswered: boolean; query: string }
|
||||
|
||||
export function filterReviews(rs: Review[], f: ReviewFilter): Review[] {
|
||||
const q = f.query.trim().toLowerCase();
|
||||
return rs.filter(r => (f.store === 'all' || r.store === f.store) && (!f.stars.length || f.stars.includes(r.rating)) && (!f.unanswered || !r.reply)
|
||||
&& (!q || `${r.title ?? ''} ${r.body}`.toLowerCase().includes(q))).sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
/** Draft file name of a review (cli/lib/reviews.ts draftName). */
|
||||
export const draftName = (r: Pick<Review, 'store' | 'id'>): string => `${r.store}-${r.id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)}.md`;
|
||||
|
||||
// ---- Catalog parity matrix ----
|
||||
|
||||
export type CellState = 'ok' | 'warn' | 'error' | 'na' | 'unknown';
|
||||
export interface MatrixRow { id: string; kind: 'product' | 'subscription' | 'extra'; config: boolean; ios: { state: CellState; notes: string[] }; android: { state: CellState; notes: string[] }; maxDiff: number | null }
|
||||
|
||||
export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | null): MatrixRow[] {
|
||||
const ids = new Map<string, MatrixRow['kind']>();
|
||||
for (const id of s?.productIds ?? []) ids.set(id, 'product');
|
||||
for (const id of s?.subscriptionIds ?? []) ids.set(id, 'subscription');
|
||||
for (const r of diff?.rows ?? []) if (!ids.has(r.id)) ids.set(r.id, 'extra');
|
||||
const cell = (id: string, store: 'apple' | 'play', sold: boolean): MatrixRow['ios'] => {
|
||||
const rs = (diff?.rows ?? []).filter(r => r.id === id && r.store === store);
|
||||
if (!diff) return { state: sold ? 'unknown' : 'na', notes: [] };
|
||||
if (!rs.length) return { state: sold ? 'unknown' : 'na', notes: [] };
|
||||
const state: CellState = rs.some(r => r.level === 'error') ? 'error' : rs.some(r => r.level === 'warn') ? 'warn' : 'ok';
|
||||
return { state, notes: rs.filter(r => r.level !== 'ok').map(r => r.msg) };
|
||||
};
|
||||
return [...ids].map(([id, kind]) => {
|
||||
const diffs = (diff?.parity ?? []).filter(p => p.id === id).map(p => p.diffPct);
|
||||
return {
|
||||
id, kind, config: kind !== 'extra',
|
||||
ios: cell(id, 'apple', kind === 'extra' ? (diff?.rows ?? []).some(r => r.id === id && r.store === 'apple') : !!s?.catalog.ios.includes(id)),
|
||||
android: cell(id, 'play', kind === 'extra' ? (diff?.rows ?? []).some(r => r.id === id && r.store === 'play') : !!s?.catalog.android.includes(id)),
|
||||
maxDiff: diffs.length ? Math.max(...diffs) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Portfolio: needs attention ----
|
||||
|
||||
export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' }
|
||||
|
||||
export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] {
|
||||
const out: Attention[] = [];
|
||||
if (!e) return out;
|
||||
const base = { app: name, projectId };
|
||||
for (const x of e.errors) out.push({ ...base, tone: 'bad', text: x, tab: 'checks' });
|
||||
for (const p of e.check?.problems ?? []) out.push({ ...base, tone: 'bad', text: p, tab: 'checks' });
|
||||
for (const w of (e.check?.warnings ?? []).slice(0, 3)) out.push({ ...base, tone: 'warn', text: w, tab: 'checks' });
|
||||
const l = live ?? (e.release.source === 'live' ? e : undefined);
|
||||
if (l) {
|
||||
if (l.storeErrors.ios) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'App Store', error: l.storeErrors.ios }), tab: 'releases' });
|
||||
if (l.storeErrors.android) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'Google Play', error: l.storeErrors.android }), tab: 'releases' });
|
||||
for (const r of l.release.all ?? []) {
|
||||
if (r.phase === 'rejected') out.push({ ...base, tone: 'bad', text: t('att.rejected', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' });
|
||||
if (r.phase === 'halted') out.push({ ...base, tone: 'bad', text: t('att.halted', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' });
|
||||
}
|
||||
const c = l.catalog;
|
||||
if (c && c.checked === 'live' && !c.ok) {
|
||||
const n = c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length;
|
||||
out.push({ ...base, tone: 'warn', text: t('att.catalog', { count: n }), tab: 'catalog' });
|
||||
}
|
||||
}
|
||||
if (health && health.play && !isError(health.play)) {
|
||||
const c = healthSeries(health.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
const a = healthSeries(health.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr);
|
||||
if (c.tone === 'bad' || c.tone === 'warn') out.push({ ...base, tone: c.tone, text: t('att.crash'), tab: 'health' });
|
||||
if (a.tone === 'bad' || a.tone === 'warn') out.push({ ...base, tone: a.tone, text: t('att.anr'), tab: 'health' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Command palette ----
|
||||
|
||||
/** Subsequence match score (higher is better), or -1. Word starts and consecutive letters score more. */
|
||||
export function fuzzy(query: string, text: string): number {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return 0;
|
||||
const s = text.toLowerCase();
|
||||
let score = 0, at = -1, run = 0;
|
||||
for (const ch of q) {
|
||||
const i = s.indexOf(ch, at + 1);
|
||||
if (i < 0) return -1;
|
||||
run = i === at + 1 ? run + 1 : 0;
|
||||
score += 1 + run * 2 + (i === 0 || /[\s/·.-]/.test(s[i - 1] ?? '') ? 3 : 0);
|
||||
at = i;
|
||||
}
|
||||
return score - s.length * 0.01;
|
||||
}
|
||||
|
||||
// ---- Identity ----
|
||||
|
||||
/** Two letters for a monogram ("Prism Puddle" -> "PP", "tidy" -> "TI"). */
|
||||
export function monogram(name: string): string {
|
||||
const words = name.trim().split(/[\s_-]+/).filter(Boolean);
|
||||
const m = words.length >= 2 ? words[0]![0]! + words[1]![0]! : (words[0] ?? '?').slice(0, 2);
|
||||
return m.toUpperCase();
|
||||
}
|
||||
|
||||
/** Stable hue (0-359) from a name, for the monogram gradient. */
|
||||
export function hueOf(name: string): number {
|
||||
let h = 0;
|
||||
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { StrictMode, useCallback, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { devSwitch } from './dev/switches';
|
||||
import { setLang } from './i18n';
|
||||
import { applyPrefs, loadPrefs, type Prefs, resolveLang, savePrefs } from './prefs';
|
||||
import { PrefsContext } from './prefs-ctx';
|
||||
import { DataProvider } from './store';
|
||||
import './styles.css';
|
||||
|
||||
const systemLang = (): string => (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
|
||||
|
||||
function Root() {
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => {
|
||||
const p = loadPrefs();
|
||||
// Dev builds only: theme=light|dark and lang=en|tr force them (screenshots without clicking).
|
||||
const th = devSwitch('theme'), lg = devSwitch('lang');
|
||||
if (th === 'light' || th === 'dark') p.theme = th;
|
||||
if (lg === 'en' || lg === 'tr') p.lang = lg;
|
||||
if (devSwitch('start')) p.onboarded = true;
|
||||
return p;
|
||||
});
|
||||
const lang = resolveLang(prefs.lang, systemLang());
|
||||
setLang(lang);
|
||||
useEffect(() => { applyPrefs(prefs, document.documentElement, systemLang()); }, [prefs]);
|
||||
const update = useCallback((p: Prefs) => { setPrefs(p); savePrefs(p); }, []);
|
||||
const onTheme = useCallback(() => {
|
||||
const dark = prefs.theme === 'dark' || (prefs.theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
update({ ...prefs, theme: dark ? 'light' : 'dark' });
|
||||
}, [prefs, update]);
|
||||
// a language change re-renders the whole tree in the new language
|
||||
return <PrefsContext.Provider value={{ prefs, update }}><DataProvider><App key={lang} onTheme={onTheme} /></DataProvider></PrefsContext.Provider>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<StrictMode><Root /></StrictMode>);
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
// Routes: Portfolio -> one App (tabs) plus the workspace screens. Pure, so tests can check the deep links.
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Key } from './i18n';
|
||||
import type { IconName } from './ui/icons';
|
||||
|
||||
export const APP_TABS = ['releases', 'catalog', 'listings', 'health', 'reviews', 'testflight', 'checks'] as const;
|
||||
export type AppTab = (typeof APP_TABS)[number];
|
||||
export const TAB_KEY: Record<AppTab, Key> = { releases: 'tab.releases', catalog: 'tab.catalog', listings: 'tab.listings', health: 'tab.health', reviews: 'tab.reviews', testflight: 'tab.testflight', checks: 'tab.checks' };
|
||||
export const TAB_ICON: Record<AppTab, IconName> = { releases: 'rocket', catalog: 'tag', listings: 'text', health: 'activity', reviews: 'message', testflight: 'plane', checks: 'shield' };
|
||||
export const WORKSPACE = ['apps', 'accounts', 'dependencies', 'settings'] as const;
|
||||
export type WorkspaceScreen = (typeof WORKSPACE)[number];
|
||||
|
||||
export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen };
|
||||
|
||||
/** storebound://<screen> deep links and the dev-only start switch ("app:health" = first app, Health tab;
|
||||
* "app:health:2" = second app). `apps` are the project ids in list order. */
|
||||
export function parseStart(v: string, apps: string[]): Route | null {
|
||||
const [screen, tab, n] = v.split(':');
|
||||
const app = apps[Math.max(0, (Number(n) || 1) - 1)];
|
||||
if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' };
|
||||
if (screen === 'portfolio') return { screen: 'portfolio' };
|
||||
return (WORKSPACE as readonly string[]).includes(screen ?? '') ? { screen: screen as WorkspaceScreen } : null;
|
||||
}
|
||||
|
||||
export const sameRoute = (a: Route, b: Route): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
export interface Nav { route: Route; go: (r: Route) => void; openPalette: () => void; openWelcome: () => void }
|
||||
export const NavCtx = createContext<Nav | null>(null);
|
||||
export const useNav = (): Nav => {
|
||||
const n = useContext(NavCtx);
|
||||
if (!n) throw new Error('useNav outside NavCtx');
|
||||
return n;
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/** Home folder shown as "~", so screenshots and screen shares do not show the account name. */
|
||||
export function shortPath(p: string, home: string | null | undefined): string {
|
||||
if (!home) return p;
|
||||
const h = home.replace(/[\\/]+$/, '');
|
||||
if (p === h) return '~';
|
||||
return p.startsWith(h + '/') || p.startsWith(h + '\\') ? `~${p.slice(h.length)}` : p;
|
||||
}
|
||||
|
||||
/** Every occurrence of the home folder in a log text as "~". */
|
||||
export function tildeText(text: string, home: string | null | undefined): string {
|
||||
const h = home?.replace(/[\\/]+$/, '');
|
||||
return h && h.length > 1 ? text.split(h).join('~') : text;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type { Prefs } from './prefs';
|
||||
|
||||
export interface PrefsApi { prefs: Prefs; update: (p: Prefs) => void }
|
||||
export const PrefsContext = createContext<PrefsApi | null>(null);
|
||||
export const usePrefs = (): PrefsApi => {
|
||||
const p = useContext(PrefsContext);
|
||||
if (!p) throw new Error('usePrefs outside PrefsContext');
|
||||
return p;
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// Per-device UI preferences (theme, motion, language, onboarding seen). Only conveniences live in localStorage;
|
||||
// nothing about projects or accounts.
|
||||
export type Theme = 'system' | 'light' | 'dark';
|
||||
export type Motion = 'system' | 'reduce';
|
||||
export type Lang = 'system' | 'en' | 'tr';
|
||||
export interface Prefs { theme: Theme; motion: Motion; lang: Lang; onboarded: boolean }
|
||||
|
||||
export const DEFAULT_PREFS: Prefs = { theme: 'system', motion: 'system', lang: 'system', onboarded: false };
|
||||
const KEY = 'storebound.prefs.v1';
|
||||
|
||||
export function parsePrefs(raw: string | null): Prefs {
|
||||
try {
|
||||
const v = JSON.parse(raw ?? '{}') as Partial<Prefs>;
|
||||
return {
|
||||
theme: v.theme === 'light' || v.theme === 'dark' ? v.theme : 'system',
|
||||
motion: v.motion === 'reduce' ? 'reduce' : 'system',
|
||||
lang: v.lang === 'en' || v.lang === 'tr' ? v.lang : 'system',
|
||||
onboarded: v.onboarded === true,
|
||||
};
|
||||
} catch { return { ...DEFAULT_PREFS }; }
|
||||
}
|
||||
|
||||
export function loadPrefs(): Prefs {
|
||||
try { return parsePrefs(localStorage.getItem(KEY)); } catch { return { ...DEFAULT_PREFS }; }
|
||||
}
|
||||
|
||||
export function savePrefs(p: Prefs): void {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(p)); } catch { /* private mode: keep it for this run only */ }
|
||||
}
|
||||
|
||||
/** Puts the preferences on <html>: data-theme (light / dark, absent = follow the system), data-motion and lang. */
|
||||
export function applyPrefs(p: Prefs, root: { dataset: DOMStringMap; lang?: string } = document.documentElement, systemLang = 'en'): void {
|
||||
if (p.theme === 'system') delete root.dataset.theme; else root.dataset.theme = p.theme;
|
||||
if (p.motion === 'reduce') root.dataset.motion = 'reduce'; else delete root.dataset.motion;
|
||||
root.lang = resolveLang(p.lang, systemLang);
|
||||
}
|
||||
|
||||
/** The language to show: the preference, else the system's when we have it (tr), else English. */
|
||||
export function resolveLang(l: Lang, systemLang: string): 'en' | 'tr' {
|
||||
if (l !== 'system') return l;
|
||||
return /^tr\b/i.test(systemLang) ? 'tr' : 'en';
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
// Store accounts: the keys live in the system keychain; the app keeps a label only and hands a key to storebound in
|
||||
// memory for one call.
|
||||
import { useState } from 'react';
|
||||
import { type Account, api, errorText } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { date } from '../lib/format';
|
||||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { Card, Chip, Empty, Notice } from '../ui/kit';
|
||||
|
||||
export function Accounts() {
|
||||
const d = useData();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [playLabel, setPlayLabel] = useState('');
|
||||
const [appleLabel, setAppleLabel] = useState('');
|
||||
const [keyId, setKeyId] = useState('');
|
||||
const [issuerId, setIssuerId] = useState('');
|
||||
|
||||
const act = async (key: string, fn: () => Promise<Account | null | void>) => {
|
||||
setBusy(key); setError(null);
|
||||
try {
|
||||
const r = await fn();
|
||||
if (r) d.toast({ kind: 'ok', title: t('accounts.saved', { label: r.label }), detail: t('accounts.savedDetail') });
|
||||
await d.reload();
|
||||
} catch (e) { setError(errorText(e)); } finally { setBusy(null); }
|
||||
};
|
||||
const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel); if (a) setPlayLabel(''); return a; });
|
||||
const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; });
|
||||
const used = (a: Account) => d.projects.filter(p => p.playAccount === a.id || p.appleAccount === a.id).map(p => p.name);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph"><div><h1>{t('nav.accounts')}</h1><p>{t('accounts.lead')}</p></div></div>
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
<Card title={t('accounts.list')} icon="key" sub={t('accounts.listSub')} pad={false}>
|
||||
{!d.accounts.length ? <Empty illo="key" title={t('accounts.emptyTitle')}>{t('accounts.emptyText')}</Empty> : (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.name')}</th><th>{t('col.store')}</th><th>{t('col.usedBy')}</th><th>{t('col.added')}</th><th><span className="sr">{t('col.actions')}</span></th></tr></thead>
|
||||
<tbody>
|
||||
{d.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td className="strong">{a.label}</td>
|
||||
<td><span className="row" style={{ gap: 8 }}><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} className="ic14" />{a.kind === 'play' ? 'Google Play' : 'App Store Connect'}</span></td>
|
||||
<td>{used(a).length ? used(a).join(', ') : <Chip tone="neutral">{t('accounts.unused')}</Chip>}</td>
|
||||
<td className="muted">{date(a.created)}</td>
|
||||
<td className="right"><button className="btn btn-sm btn-ghost btn-danger" disabled={!!busy} onClick={() => act(`rm-${a.id}`, () => api.removeAccount(a.id))} aria-label={t('accounts.removeAria', { label: a.label })}><Icon name="trash" />{t('accounts.remove')}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
<div className="grid g2">
|
||||
<Card title="Google Play" icon="key" sub={t('accounts.playSub')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addPlay(); }}>
|
||||
<div className="field">
|
||||
<label htmlFor="play-label">{t('accounts.label')}</label>
|
||||
<input id="play-label" className="input" type="text" value={playLabel} onChange={e => setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" />
|
||||
</div>
|
||||
<p className="small muted">{t('accounts.playHow')}</p>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!playLabel.trim() || !!busy}><Icon name="key" />{t('accounts.choosePlay')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title="App Store Connect" icon="key" sub={t('accounts.appleSub')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addApple(); }}>
|
||||
<div className="field">
|
||||
<label htmlFor="apple-label">{t('accounts.label')}</label>
|
||||
<input id="apple-label" className="input" type="text" value={appleLabel} onChange={e => setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" />
|
||||
</div>
|
||||
<div className="grid g2" style={{ gap: 12 }}>
|
||||
<div className="field">
|
||||
<label htmlFor="key-id">{t('accounts.keyId')}</label>
|
||||
<input id="key-id" className="input mono" type="text" value={keyId} onChange={e => setKeyId(e.target.value.toUpperCase())} placeholder="ABC123DEFG" maxLength={10} autoComplete="off" spellCheck={false} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="issuer-id">{t('accounts.issuerId')}</label>
|
||||
<input id="issuer-id" className="input mono" type="text" value={issuerId} onChange={e => setIssuerId(e.target.value)} placeholder="UUID" maxLength={36} autoComplete="off" spellCheck={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!appleLabel.trim() || keyId.length !== 10 || issuerId.length < 36 || !!busy}><Icon name="key" />{t('accounts.chooseApple')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
<Notice tone="info" icon="shield">{t('accounts.safety')}</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
|
||||
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
|
||||
import { devSwitch } from '../dev/switches';
|
||||
import { t } from '../i18n';
|
||||
import { shortPath, tildeText } from '../paths';
|
||||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { AppIcon, Card, Chip, Empty, Notice, Seg, StoreBadge } from '../ui/kit';
|
||||
import { checkState } from './Portfolio';
|
||||
|
||||
type Profile = 'app' | 'game' | 'subscription-app';
|
||||
interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string }
|
||||
|
||||
export function AppFolders() {
|
||||
const d = useData();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<Run | null>(null);
|
||||
const [profile, setProfile] = useState<Profile>('app');
|
||||
const logRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const un = onProgress(p => { if (p.line !== undefined) setRun(r => (r && p.job === r.job ? { ...r, lines: [...r.lines.slice(-400), p.line!] } : r)); });
|
||||
return () => { un.then(f => f()); };
|
||||
}, []);
|
||||
useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); }, [run?.lines.length]);
|
||||
|
||||
const act = async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key); setError(null);
|
||||
try { await fn(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
|
||||
};
|
||||
const add = () => act('add', async () => { const p = await api.addProject(); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.added', { name: p.name }) }); } });
|
||||
const init = () => act('init', async () => { const p = await api.initProject(profile); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.started', { name: p.name }), detail: t('folders.startedDetail') }); } });
|
||||
const remove = (p: Project) => act(`rm-${p.id}`, async () => { await api.removeProject(p.id); await d.reload(); });
|
||||
const setAcc = (p: Project, play: string | null, apple: string | null) => act(`acc-${p.id}`, async () => { await api.setProjectAccounts(p.id, play, apple); await d.reload(); });
|
||||
|
||||
const check = (p: Project) => act(`check-${p.id}`, async () => {
|
||||
const job = newJob('check');
|
||||
setRun({ title: t('folders.checkTitle'), project: p.name, job, lines: [] });
|
||||
try { const r = await api.call('check', p.id, undefined, job); setRun(x => x && { ...x, result: r }); } catch (e) { setRun(x => x && { ...x, error: errorText(e) }); }
|
||||
});
|
||||
const gallery = (p: Project) => act(`gallery-${p.id}`, async () => {
|
||||
const job = newJob('gallery');
|
||||
setRun({ title: t('folders.galleryTitle'), project: p.name, job, lines: [] });
|
||||
try { const o = await api.writeWithConfirm('shots.gallery', p.id, {}, job); setRun(x => x && { ...x, outcome: o, result: o.result ?? o.dryRun }); } catch (e) { setRun(x => x && { ...x, error: errorText(e) }); }
|
||||
});
|
||||
|
||||
// Dev builds only: VITE_AUTORUN=gallery starts the confirm flow on the first app once (checks of the native dialog).
|
||||
const autorun = useRef(false);
|
||||
useEffect(() => {
|
||||
const first = d.projects[0];
|
||||
if (devSwitch('autorun') !== 'gallery' || !first || autorun.current) return;
|
||||
autorun.current = true;
|
||||
gallery(first);
|
||||
}, [d.projects]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const play = d.accounts.filter(a => a.kind === 'play');
|
||||
const apple = d.accounts.filter(a => a.kind === 'apple');
|
||||
const status = !run ? '' : run.error ? t('run.failed') : !run.result ? t('run.running') : run.outcome
|
||||
? (run.outcome.confirmed ? t('run.applied') : run.outcome.stage === 'dialog' ? t('run.cancelled') : t('run.dryFailed'))
|
||||
: run.result.ok ? t('run.done') : t('common.exit', { code: run.result.exitCode });
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.apps')}</h1><p>{t('folders.lead')}</p></div>
|
||||
<div className="actions"><button className="btn btn-primary" onClick={add} disabled={!!busy}><Icon name="folderPlus" />{t('action.addFolder')}</button></div>
|
||||
</div>
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
|
||||
{!d.projects.length ? (
|
||||
<Card pad={false}><Empty illo="apps" title={t('folders.emptyTitle')}>{t('folders.emptyText')}</Empty></Card>
|
||||
) : (
|
||||
<Card title={t('folders.list')} icon="folder" sub={t('folders.listSub', { count: d.projects.length })} pad={false}>
|
||||
<div className="list">
|
||||
{d.projects.map(p => {
|
||||
const e = d.statusOf(p);
|
||||
const st = checkState(e);
|
||||
return (
|
||||
<div className="li" key={p.id} style={{ alignItems: 'center' }}>
|
||||
<AppIcon name={p.name} src={d.icons[p.id]} />
|
||||
<div className="li-main">
|
||||
<span className="row" style={{ gap: 8 }}><span className="li-title">{e?.app?.name ?? p.name}</span><Chip tone={st.tone}>{st.text}</Chip></span>
|
||||
<span className="li-sub mono-id selectable" title={p.path}>{shortPath(p.path, d.info?.home)}</span>
|
||||
<span className="row" style={{ gap: 6, marginTop: 6 }}>
|
||||
<StoreBadge store="ios" id={e?.app?.platforms.ios?.bundleId} off={!!e && !e.app?.platforms.ios} />
|
||||
<StoreBadge store="android" id={e?.app?.platforms.android?.packageName} off={!!e && !e.app?.platforms.android} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6, width: 270 }}>
|
||||
<label className="acc-pick" title="Google Play"><StoreGlyph store="android" />
|
||||
<select className="input" aria-label={t('folders.playFor', { name: p.name })} value={p.playAccount ?? ''} onChange={ev => setAcc(p, ev.target.value || null, p.appleAccount)} disabled={!!busy}>
|
||||
<option value="">{t('folders.noPlay')}</option>
|
||||
{play.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select></label>
|
||||
<label className="acc-pick" title="App Store Connect"><StoreGlyph store="ios" />
|
||||
<select className="input" aria-label={t('folders.appleFor', { name: p.name })} value={p.appleAccount ?? ''} onChange={ev => setAcc(p, p.playAccount, ev.target.value || null)} disabled={!!busy}>
|
||||
<option value="">{t('folders.noApple')}</option>
|
||||
{apple.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<div className="actions" style={{ flexWrap: 'nowrap' }}>
|
||||
<button className="btn btn-sm" onClick={() => check(p)} disabled={!!busy}><Icon name="shield" />{t('folders.check')}</button>
|
||||
<button className="btn btn-sm" onClick={() => gallery(p)} disabled={!!busy} title={t('folders.galleryHint')}><Icon name="image" />{t('folders.gallery')}</button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => remove(p)} disabled={!!busy} aria-label={t('folders.remove', { name: p.name })} title={t('folders.remove', { name: p.name })}><Icon name="trash" /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title={t('folders.newTitle')} icon="sparkle" sub={t('folders.newSub')}>
|
||||
<div className="row between" style={{ gap: 16, flexWrap: 'wrap' }}>
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<Seg value={profile} onChange={setProfile} label={t('folders.profile')} options={[['app', t('profile.app')], ['game', t('profile.game')], ['subscription-app', t('profile.subscription-app')]]} />
|
||||
<span className="small muted">{t(`folders.profileHint.${profile}` as 'folders.profileHint.app')}</span>
|
||||
</div>
|
||||
<button className="btn" onClick={init} disabled={!!busy}><Icon name="folderPlus" />{t('folders.chooseAndStart')}</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{run && (
|
||||
<Card title={`${run.title} · ${run.project}`} icon="terminal" sub={<>{status}{run.result && <> · <code className="selectable">{run.result.command}</code></>}</>}
|
||||
actions={<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setRun(null)} aria-label={t('common.close')}><Icon name="x" /></button>}>
|
||||
<div className="stack">
|
||||
{run.error && <Notice tone="bad">{run.error}</Notice>}
|
||||
<pre className="log selectable" ref={logRef} aria-live="polite">{tildeText(run.result?.output ?? run.lines.join('\n'), d.info?.home) || ' '}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// One app across both stores: header (icon, ids, store state), then the tabs.
|
||||
import { type KeyboardEvent, useRef } from 'react';
|
||||
import { api, type Project } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { APP_TABS, type AppTab, TAB_ICON, TAB_KEY, useNav } from '../nav';
|
||||
import { storeNow } from '../lib/model';
|
||||
import { useData } from '../store';
|
||||
import { Icon } from '../ui/icons';
|
||||
import { AppIcon, Chip, PhaseChip, StoreBadge } from '../ui/kit';
|
||||
import { CatalogTab } from './tabs/Catalog';
|
||||
import { ChecksTab } from './tabs/Checks';
|
||||
import { HealthTab } from './tabs/Health';
|
||||
import { ListingsTab } from './tabs/Listings';
|
||||
import { ReleasesTab } from './tabs/Releases';
|
||||
import { ReviewsTab } from './tabs/Reviews';
|
||||
import { TestFlightTab } from './tabs/TestFlight';
|
||||
|
||||
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/';
|
||||
|
||||
export function AppView({ project, tab }: { project: Project; tab: AppTab }) {
|
||||
const d = useData();
|
||||
const nav = useNav();
|
||||
const e = d.statusOf(project);
|
||||
const l = d.liveOf(project);
|
||||
const ios = e?.app?.platforms.ios, android = e?.app?.platforms.android;
|
||||
const name = e?.app?.name ?? project.name;
|
||||
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: project.id, tab: x });
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
const i = APP_TABS.indexOf(tab);
|
||||
const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? APP_TABS.length - 1 : null;
|
||||
if (j === null) return;
|
||||
ev.preventDefault();
|
||||
const n = (j + APP_TABS.length) % APP_TABS.length;
|
||||
setTab(APP_TABS[n]!);
|
||||
tabs.current[n]?.focus();
|
||||
};
|
||||
const all = l?.release.all ?? [];
|
||||
const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null;
|
||||
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div className="app-head">
|
||||
<AppIcon name={name} src={d.icons[project.id]} />
|
||||
<div>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<h1>{name}</h1>
|
||||
{e?.summary?.profile && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
|
||||
</div>
|
||||
<div className="ids">
|
||||
<StoreBadge store="ios" id={ios?.bundleId} off={!!e && !ios}>{iosNow && <><span className="num strong">{iosNow.version}</span><PhaseChip r={iosNow} /></>}</StoreBadge>
|
||||
<StoreBadge store="android" id={android?.packageName} off={!!e && !android}>{androidNow && <><span className="num strong">{androidNow.version}</span><PhaseChip r={androidNow} /></>}</StoreBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{ios && <button className="btn btn-ghost" onClick={() => open(ascUrl(ios.ascAppId))} title={t('app.consoleHint')}><Icon name="external" />App Store Connect</button>}
|
||||
{android && <button className="btn btn-ghost" onClick={() => open(PLAY_CONSOLE)} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
|
||||
{APP_TABS.map((x, i) => (
|
||||
<button key={x} ref={el => { tabs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}>
|
||||
<Icon name={TAB_ICON[x]} />{t(TAB_KEY[x])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${tab}`} className="stack" style={{ gap: 16 }}>
|
||||
{tab === 'releases' && <ReleasesTab project={project} />}
|
||||
{tab === 'catalog' && <CatalogTab project={project} />}
|
||||
{tab === 'listings' && <ListingsTab project={project} />}
|
||||
{tab === 'health' && <HealthTab project={project} />}
|
||||
{tab === 'reviews' && <ReviewsTab project={project} />}
|
||||
{tab === 'testflight' && <TestFlightTab project={project} />}
|
||||
{tab === 'checks' && <ChecksTab project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
// Dependencies: local tools for screenshots, previews, icons and captures. ffmpeg and Chrome for Testing download
|
||||
// into the app data folder in one click (checksum or signature verified, progress, cancel); the rest link out.
|
||||
// Store work needs none of them. Sources and licenses: docs/desktop.md.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, type DownloadInfo, errorText, newJob, onDownload, type ToolStatus } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { bytes } from '../lib/format';
|
||||
import { shortPath } from '../paths';
|
||||
import { useData } from '../store';
|
||||
import { Icon, type IconName } from '../ui/icons';
|
||||
import { Card, Chip, Notice, Skeleton } from '../ui/kit';
|
||||
|
||||
const TOOL_ICON: Record<string, IconName> = { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' };
|
||||
|
||||
/** Status chip of one tool. */
|
||||
export function toolState(x: ToolStatus): { text: string; tone: 'ok' | 'warn' | 'accent' } {
|
||||
if (!x.found) return { text: t('deps.missing'), tone: 'warn' };
|
||||
return x.source === 'app' ? { text: t('deps.appCopy'), tone: 'accent' } : { text: t('deps.installed'), tone: 'ok' };
|
||||
}
|
||||
|
||||
interface Dl { job: string; stage: string; received: number; total: number | null }
|
||||
|
||||
export function Dependencies() {
|
||||
const d = useData();
|
||||
const [tools, setTools] = useState<ToolStatus[] | null>(d.sidecar?.tools?.tools ?? null);
|
||||
const [info, setInfo] = useState<DownloadInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dl, setDl] = useState<Record<string, Dl>>({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const [s, i] = await Promise.all([api.call<{ tools: ToolStatus[] }>('system.tools'), api.toolsInfo()]);
|
||||
setTools(s.tools); setInfo(i);
|
||||
} catch (e) { setError(errorText(e)); } finally { setBusy(false); }
|
||||
}, []);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
useEffect(() => {
|
||||
const un = onDownload(e => setDl(m => {
|
||||
const k = Object.keys(m).find(x => m[x]!.job === e.job);
|
||||
return k ? { ...m, [k]: { ...m[k]!, stage: e.stage, received: e.received, total: e.total } } : m;
|
||||
}));
|
||||
return () => { un.then(f => f()); };
|
||||
}, []);
|
||||
|
||||
const install = async (tool: 'ffmpeg' | 'chrome') => {
|
||||
const job = newJob(`dl-${tool}`);
|
||||
setDl(m => ({ ...m, [tool]: { job, stage: 'resolve', received: 0, total: null } }));
|
||||
const tid = d.toast({ id: job, kind: 'job', title: t('deps.downloading', { tool: tool === 'chrome' ? 'Chrome for Testing' : 'ffmpeg' }), detail: t('dl.resolve'), progress: null, job });
|
||||
try {
|
||||
const v = await api.installTool(tool, job);
|
||||
d.updateToast(tid, { kind: 'ok', title: t('deps.installedToast', { tool: tool === 'chrome' ? 'Chrome for Testing' : 'ffmpeg' }), detail: v, progress: undefined, job: undefined });
|
||||
} catch (e) {
|
||||
const msg = errorText(e);
|
||||
d.updateToast(tid, { kind: msg === 'cancelled' ? 'info' : 'bad', title: msg === 'cancelled' ? t('toast.cancelled') : t('deps.failed'), detail: msg === 'cancelled' ? t('deps.cancelledDetail') : msg, progress: undefined, job: undefined });
|
||||
} finally {
|
||||
setDl(m => { const { [tool]: _gone, ...rest } = m; return rest; });
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
const uninstall = async (tool: 'ffmpeg' | 'chrome') => {
|
||||
try { await api.uninstallTool(tool); d.toast({ kind: 'ok', title: t('deps.removed') }); } catch (e) { setError(errorText(e)); }
|
||||
refresh();
|
||||
};
|
||||
const open = (url: string) => api.openExternal(url).catch(e => setError(errorText(e)));
|
||||
const copy = (s: string) => { navigator.clipboard?.writeText(s).then(() => d.toast({ kind: 'ok', title: t('deps.copied') }), () => {}); };
|
||||
|
||||
const found = tools?.filter(x => x.found).length ?? 0;
|
||||
const managed = (id: string): DownloadInfo | undefined => info.find(i => i.tool === (id === 'ffprobe' ? 'ffmpeg' : id));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.dependencies')}</h1><p>{t('deps.lead')}</p></div>
|
||||
<div className="actions"><button className="btn" onClick={refresh} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('deps.checkAgain')}</button></div>
|
||||
</div>
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
<Card title={t('deps.tools')} icon="package" sub={tools ? t('deps.found', { found, total: tools.length }) : t('common.loading')} pad={false}>
|
||||
{!tools ? <div className="card-body"><Skeleton lines={6} /></div> : (
|
||||
<div className="list">
|
||||
{tools.map(x => {
|
||||
const st = toolState(x);
|
||||
const m = x.install.kind === 'download' ? managed(x.id) : undefined;
|
||||
const running = m ? dl[m.tool] : undefined;
|
||||
const pct = running?.total ? running.received / running.total : null;
|
||||
return (
|
||||
<div className="li tool-row" key={x.id} style={{ alignItems: 'center' }}>
|
||||
<span className="ti"><Icon name={TOOL_ICON[x.id] ?? 'package'} /></span>
|
||||
<div className="li-main">
|
||||
<span className="row" style={{ gap: 8 }}><span className="li-title">{x.name}</span><Chip tone={st.tone}>{st.text}</Chip>{x.version && <span className="small muted num">{x.version}</span>}</span>
|
||||
<span className="li-sub">{x.usedFor}</span>
|
||||
{x.path && <span className="li-sub mono-id selectable" title={x.path}>{shortPath(x.path, d.info?.home)}</span>}
|
||||
</div>
|
||||
<div className="actions" style={{ flexWrap: 'nowrap' }}>
|
||||
{running ? (
|
||||
<div className="prog" aria-live="polite">
|
||||
<div className={`bar${pct === null ? ' indet' : ''}`}><span style={{ width: `${(pct ?? 0.35) * 100}%` }} /></div>
|
||||
<div className="c"><span>{t(`dl.${running.stage}` as 'dl.download')}</span><span>{running.total ? `${bytes(running.received)} / ${bytes(running.total)}` : ''}</span></div>
|
||||
</div>
|
||||
) : null}
|
||||
{running ? <button className="btn btn-sm" onClick={() => d.cancelJob(running.job)}>{t('common.cancel')}</button>
|
||||
: m && x.id !== 'ffprobe' ? (
|
||||
m.available ? (
|
||||
m.installed ? <button className="btn btn-sm btn-ghost" onClick={() => uninstall(m.tool)}><Icon name="trash" />{t('deps.remove')}</button>
|
||||
: <button className="btn btn-sm btn-primary" onClick={() => install(m.tool)} title={`${m.license} · ${m.source}`}><Icon name="download" />{t('deps.download')}{m.size ? ` · ${bytes(m.size)}` : ''}</button>
|
||||
) : <Chip tone="neutral">{t('deps.unavailable')}</Chip>
|
||||
) : x.install.kind === 'link' && !x.found ? <button className="btn btn-sm" onClick={() => open(x.install.url)}><Icon name="external" />{t('deps.getIt')}</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<div className="grid g2">
|
||||
{info.map(m => (
|
||||
<Card key={m.tool} title={m.tool === 'chrome' ? 'Chrome for Testing' : `ffmpeg ${m.version ?? ''}`} icon={m.tool === 'chrome' ? 'globe' : 'image'} sub={m.installed ? t('deps.inAppData', { folder: m.folder }) : t('deps.notDownloaded')}>
|
||||
<dl className="kv">
|
||||
<dt>{t('deps.source')}</dt><dd className="selectable small">{m.source}</dd>
|
||||
<dt>{t('deps.license')}</dt><dd className="small">{m.license}</dd>
|
||||
<dt>{t('deps.verify')}</dt><dd className="small">{m.tool === 'chrome' ? t('deps.verifyChrome') : t('deps.verifyFfmpeg')}</dd>
|
||||
</dl>
|
||||
<div className="actions" style={{ marginTop: 12 }}><button className="btn btn-sm btn-ghost" onClick={() => open(m.licenseUrl)}><Icon name="external" />{t('deps.licenseLink')}</button></div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card title={t('deps.terminal')} icon="terminal" sub={t('deps.terminalSub')}>
|
||||
<div className="stack">
|
||||
{['storebound doctor', 'bun add -d playwright', 'brew install ffmpeg'].map(c => (
|
||||
<div className="cmd" key={c}><code className="selectable">{c}</code><button className="btn btn-sm btn-ghost btn-icon" onClick={() => copy(c)} aria-label={t('deps.copy', { cmd: c })}><Icon name="copy" /></button></div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
// First-run guide: what the app does, add or start an app folder, store keys into the keychain, local tools, and the
|
||||
// first read of the stores. Every step can be skipped; it opens again from Settings or the command palette.
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { api, errorText, newJob } from '../api';
|
||||
import { devSwitch } from '../dev/switches';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago } from '../lib/format';
|
||||
import { useData } from '../store';
|
||||
import { Icon, type IconName, Mark } from '../ui/icons';
|
||||
import { Illo } from '../ui/illustrations';
|
||||
import { AppIcon, Chip, Notice, Seg } from '../ui/kit';
|
||||
|
||||
const STEPS: [Key, IconName][] = [['wz.s1', 'sparkle'], ['wz.s2', 'folder'], ['wz.s3', 'key'], ['wz.s4', 'package'], ['wz.s5', 'refresh']];
|
||||
type Profile = 'app' | 'game' | 'subscription-app';
|
||||
|
||||
export function Onboarding({ onClose }: { onClose: () => void }) {
|
||||
const d = useData();
|
||||
const v = devSwitch('start');
|
||||
const [step, setStep] = useState(() => (v.startsWith('welcome:') ? Math.max(0, Math.min(4, Number(v.split(':')[1]) - 1)) : 0));
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<'add' | 'init'>('add');
|
||||
const [profile, setProfile] = useState<Profile>('app');
|
||||
const [playLabel, setPlayLabel] = useState('');
|
||||
const [appleLabel, setAppleLabel] = useState('');
|
||||
const [keyId, setKeyId] = useState('');
|
||||
const [issuerId, setIssuerId] = useState('');
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const before = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
before.current = document.activeElement;
|
||||
root.current?.querySelector<HTMLElement>('.wz-body h2')?.focus();
|
||||
return () => { (before.current as HTMLElement | null)?.focus?.(); };
|
||||
}, []);
|
||||
useEffect(() => { root.current?.querySelector<HTMLElement>('.wz-body h2')?.focus(); setError(null); }, [step]);
|
||||
|
||||
const act = async (key: string, fn: () => Promise<unknown>) => {
|
||||
setBusy(key); setError(null);
|
||||
try { await fn(); await d.reload(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
|
||||
};
|
||||
// focus stays inside the dialog; Esc closes it
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); onClose(); return; }
|
||||
if (e.key !== 'Tab' || !root.current) return;
|
||||
const f = [...root.current.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex="0"], h2[tabindex]')];
|
||||
if (!f.length) return;
|
||||
const first = f[0]!, last = f.at(-1)!;
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
|
||||
const tools = d.sidecar?.tools?.tools ?? [];
|
||||
const install = (tool: 'ffmpeg' | 'chrome') => act(`dl-${tool}`, async () => {
|
||||
const job = newJob(`dl-${tool}`);
|
||||
const tid = d.toast({ id: job, kind: 'job', title: t('deps.downloading', { tool: tool === 'chrome' ? 'Chrome for Testing' : 'ffmpeg' }), progress: null, job });
|
||||
try { const r = await api.installTool(tool, job); d.updateToast(tid, { kind: 'ok', title: t('deps.installedToast', { tool }), detail: r, job: undefined, progress: undefined }); }
|
||||
catch (e) { d.updateToast(tid, { kind: 'bad', title: t('deps.failed'), detail: errorText(e), job: undefined, progress: undefined }); }
|
||||
});
|
||||
const done = [d.projects.length > 0 || step > 0, d.projects.length > 0, d.accounts.length > 0, tools.length > 0 && tools.filter(x => x.id === 'ffmpeg' || x.id === 'chrome').every(x => x.found), !!d.live];
|
||||
const liveApps = d.live?.value.apps.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="wizard-scrim">
|
||||
<div className="wizard" role="dialog" aria-modal="true" aria-labelledby="wz-title" ref={root} onKeyDown={onKey}>
|
||||
<aside className="wz-side">
|
||||
<div className="brand"><Mark className="brand-mark" /><span>storebound</span></div>
|
||||
<ol className="wz-steps">
|
||||
{STEPS.map(([k, icon], i) => (
|
||||
<li key={k}>
|
||||
<button className={`wz-step${done[i] && i !== step ? ' done' : ''}`} aria-current={i === step ? 'step' : undefined} onClick={() => setStep(i)}>
|
||||
<span className="wz-num">{done[i] && i !== step ? <Icon name="check" /> : i + 1}</span><span className="grow">{t(k)}</span><Icon name={icon} className="ic14 muted" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="foot"><span>{t('wz.footer')}</span><span className="row" style={{ gap: 6 }}><Icon name="shield" className="ic13" />{t('wz.footerSafe')}</span></div>
|
||||
</aside>
|
||||
<div className="wz-main">
|
||||
<div className="wz-body">
|
||||
{step === 0 && (
|
||||
<>
|
||||
<Illo name="apps" />
|
||||
<h2 id="wz-title" tabIndex={-1}>{t('wz.welcomeTitle')}</h2>
|
||||
<p className="lead">{t('wz.welcomeLead')}</p>
|
||||
<div className="checklist">
|
||||
{([['portfolio', 'wz.p1t', 'wz.p1s'], ['shield', 'wz.p2t', 'wz.p2s'], ['key', 'wz.p3t', 'wz.p3s']] as [IconName, Key, Key][]).map(([ic, a, b]) => (
|
||||
<div className="ck" key={a}><span className="ck-ic tone-bg-accent"><Icon name={ic} /></span><div className="grow"><div className="t">{t(a)}</div><div className="s">{t(b)}</div></div></div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<h2 id="wz-title" tabIndex={-1}>{t('wz.appsTitle')}</h2>
|
||||
<p className="lead">{t('wz.appsLead')}</p>
|
||||
<div className="choice-grid">
|
||||
<button className="choice" aria-pressed={mode === 'add'} onClick={() => setMode('add')}>
|
||||
<span className="ch-icon"><Icon name="folder" /></span><b>{t('wz.addTitle')}</b><span>{t('wz.addText')}</span>
|
||||
</button>
|
||||
<button className="choice" aria-pressed={mode === 'init'} onClick={() => setMode('init')}>
|
||||
<span className="ch-icon"><Icon name="sparkle" /></span><b>{t('wz.initTitle')}</b><span>{t('wz.initText')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'init' && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<Seg value={profile} onChange={setProfile} label={t('folders.profile')} options={[['app', t('profile.app')], ['game', t('profile.game')], ['subscription-app', t('profile.subscription-app')]]} />
|
||||
<span className="small muted">{t(`folders.profileHint.${profile}` as Key)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
{mode === 'add'
|
||||
? <button className="btn btn-primary" onClick={() => act('add', () => api.addProject())} disabled={!!busy}><Icon name="folderPlus" />{t('action.addFolder')}</button>
|
||||
: <button className="btn btn-primary" onClick={() => act('init', () => api.initProject(profile))} disabled={!!busy}><Icon name="folderPlus" />{t('folders.chooseAndStart')}</button>}
|
||||
</div>
|
||||
{d.projects.length > 0 && (
|
||||
<div className="checklist">
|
||||
{d.projects.map(p => <div className="ck" key={p.id}><AppIcon name={p.name} src={d.icons[p.id]} /><div className="grow"><div className="t">{d.statusOf(p)?.app?.name ?? p.name}</div><div className="s">{t('wz.added')}</div></div><Chip tone="ok" icon="check">{t('wz.ready')}</Chip></div>)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<h2 id="wz-title" tabIndex={-1}>{t('wz.keysTitle')}</h2>
|
||||
<p className="lead">{t('wz.keysLead')}</p>
|
||||
<div className="choice-grid">
|
||||
<form className="choice" onSubmit={e => { e.preventDefault(); act('play', async () => { if (await api.addPlayAccount(playLabel)) setPlayLabel(''); }); }}>
|
||||
<span className="ch-icon"><Icon name="key" /></span><b>Google Play</b><span>{t('accounts.playHow')}</span>
|
||||
<input className="input" value={playLabel} onChange={e => setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} aria-label={t('accounts.label')} maxLength={60} />
|
||||
<div><button className="btn btn-sm btn-primary" type="submit" disabled={!playLabel.trim() || !!busy}>{t('accounts.choosePlay')}</button></div>
|
||||
</form>
|
||||
<form className="choice" onSubmit={e => { e.preventDefault(); act('apple', async () => { if (await api.addAppleAccount(appleLabel, keyId, issuerId)) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } }); }}>
|
||||
<span className="ch-icon"><Icon name="key" /></span><b>App Store Connect</b><span>{t('accounts.appleSub')}</span>
|
||||
<input className="input" value={appleLabel} onChange={e => setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} aria-label={t('accounts.label')} maxLength={60} />
|
||||
<div className="row"><input className="input mono" value={keyId} onChange={e => setKeyId(e.target.value.toUpperCase())} placeholder={t('accounts.keyId')} aria-label={t('accounts.keyId')} maxLength={10} />
|
||||
<input className="input mono" value={issuerId} onChange={e => setIssuerId(e.target.value)} placeholder={t('accounts.issuerId')} aria-label={t('accounts.issuerId')} maxLength={36} /></div>
|
||||
<div><button className="btn btn-sm btn-primary" type="submit" disabled={!appleLabel.trim() || keyId.length !== 10 || issuerId.length < 36 || !!busy}>{t('accounts.chooseApple')}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{d.accounts.length > 0 && d.projects.length > 0 && (
|
||||
<div className="checklist">
|
||||
{d.projects.map(p => (
|
||||
<div className="ck" key={p.id}>
|
||||
<AppIcon name={p.name} src={d.icons[p.id]} />
|
||||
<div className="grow"><div className="t">{p.name}</div><div className="s">{t('wz.mapHint')}</div></div>
|
||||
<select className="input" style={{ width: 180 }} aria-label={t('folders.playFor', { name: p.name })} value={p.playAccount ?? ''} onChange={e => act('map', () => api.setProjectAccounts(p.id, e.target.value || null, p.appleAccount))}>
|
||||
<option value="">{t('folders.noPlay')}</option>{d.accounts.filter(a => a.kind === 'play').map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
<select className="input" style={{ width: 180 }} aria-label={t('folders.appleFor', { name: p.name })} value={p.appleAccount ?? ''} onChange={e => act('map', () => api.setProjectAccounts(p.id, p.playAccount, e.target.value || null))}>
|
||||
<option value="">{t('folders.noApple')}</option>{d.accounts.filter(a => a.kind === 'apple').map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Notice tone="info" icon="shield">{t('accounts.safety')}</Notice>
|
||||
</>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<h2 id="wz-title" tabIndex={-1}>{t('wz.toolsTitle')}</h2>
|
||||
<p className="lead">{t('wz.toolsLead')}</p>
|
||||
<div className="checklist">
|
||||
{tools.map(x => (
|
||||
<div className="ck" key={x.id}>
|
||||
<span className={`ck-ic ${x.found ? 'tone-bg-ok' : 'tone-bg-warn'}`}><Icon name={x.found ? 'check' : 'alert'} /></span>
|
||||
<div className="grow"><div className="t">{x.name}{x.version ? <span className="muted small num"> · {x.version}</span> : null}</div><div className="s">{x.usedFor}</div></div>
|
||||
{!x.found && (x.id === 'ffmpeg' || x.id === 'chrome') && <button className="btn btn-sm btn-primary" onClick={() => install(x.id as 'ffmpeg' | 'chrome')} disabled={!!busy}><Icon name="download" />{t('deps.download')}</button>}
|
||||
{!x.found && x.install.kind === 'link' && <button className="btn btn-sm" onClick={() => api.openExternal(x.install.url).catch(() => {})}><Icon name="external" />{t('deps.getIt')}</button>}
|
||||
{x.found && <Chip tone={x.source === 'app' ? 'accent' : 'ok'}>{x.source === 'app' ? t('deps.appCopy') : t('deps.installed')}</Chip>}
|
||||
</div>
|
||||
))}
|
||||
{!tools.length && <p className="muted">{t('common.loading')}</p>}
|
||||
</div>
|
||||
<p className="small muted">{t('wz.toolsNote')} <code className="selectable">storebound doctor</code></p>
|
||||
</>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<>
|
||||
<Illo name="cloud" />
|
||||
<h2 id="wz-title" tabIndex={-1}>{t('wz.readTitle')}</h2>
|
||||
<p className="lead">{t('wz.readLead')}</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy || !d.projects.length}><Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('wz.readNow')}</button>
|
||||
{d.live && <span className="small muted">{t('wz.readDone', { count: liveApps, when: ago(d.live.at) })}</span>}
|
||||
</div>
|
||||
{!d.projects.length && <Notice tone="warn">{t('wz.needApp')}</Notice>}
|
||||
</>
|
||||
)}
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
</div>
|
||||
<div className="wz-foot">
|
||||
<button className="btn btn-ghost" onClick={onClose}>{step === 4 ? t('common.close') : t('wz.skip')}</button>
|
||||
<span className="grow" />
|
||||
{step > 0 && <button className="btn" onClick={() => setStep(s => s - 1)}><Icon name="arrowLeft" />{t('wz.back')}</button>}
|
||||
{step < 4 ? <button className="btn btn-primary" onClick={() => setStep(s => s + 1)}>{t('wz.next')}<Icon name="arrowRight" /></button>
|
||||
: <button className="btn btn-primary" onClick={onClose}><Icon name="check" />{t('wz.finish')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
// Portfolio: every app on both stores. KPIs, one row per app (live version and state per store with its place on
|
||||
// the release track, next version, catalog parity, config check), what needs attention, and the workspace state.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type AppStatus, type Project, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago, pct, share } from '../lib/format';
|
||||
import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model';
|
||||
import { useNav } from '../nav';
|
||||
import { useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, Skeleton, Sparkline } from '../ui/kit';
|
||||
|
||||
/** Tag of one app's offline config check. */
|
||||
export function checkState(e: Pick<AppStatus, 'errors' | 'check'> | undefined): { text: string; tone: Tone } {
|
||||
if (!e) return { text: t('check.notChecked'), tone: 'neutral' };
|
||||
if (e.errors.length || !e.check) return { text: t('check.notLoaded'), tone: 'bad' };
|
||||
if (e.check.problems.length) return { text: t('check.problems', { count: e.check.problems.length }), tone: 'bad' };
|
||||
if (e.check.warnings.length) return { text: t('check.warnings', { count: e.check.warnings.length }), tone: 'warn' };
|
||||
return { text: t('check.ok'), tone: 'ok' };
|
||||
}
|
||||
|
||||
export function Pips({ r, store }: { r: StoreRelease | null; store: 'ios' | 'android' }) {
|
||||
const lane = laneOf(store, r);
|
||||
return (
|
||||
<span className="pips" aria-hidden="true">
|
||||
{STAGES.map((s, i) => <b key={s} className={i < lane.at ? 'on' : i === lane.at ? (lane.tone === 'neutral' ? 'on' : lane.tone === 'accent' ? 'on' : lane.tone) : ''} />)}
|
||||
{lane.share !== null && lane.share < 1 && <span className="pct">{share(lane.share)}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreCell({ e, store, project }: { e: AppStatus | undefined; store: 'ios' | 'android'; project: AppStatus | undefined }) {
|
||||
const on = store === 'ios' ? project?.app?.platforms.ios : project?.app?.platforms.android;
|
||||
if (project && !on) return <span className="muted small">{t('app.notOnStore')}</span>;
|
||||
if (!e) return <span className="muted small">{t('common.unknown')}</span>;
|
||||
const err = e.storeErrors[store];
|
||||
if (err) return <span title={err}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
|
||||
const all = e.release.all ?? [e.release.current?.ios, e.release.current?.android, e.release.next?.ios, e.release.next?.android].filter((x): x is StoreRelease => !!x);
|
||||
const { now, next } = storeNow(all, store);
|
||||
const r = now ?? next;
|
||||
if (!r) return <span className="muted small">{t('portfolio.noVersion')}</span>;
|
||||
return (
|
||||
<div className="mini">
|
||||
<div className="row" style={{ gap: 8 }}><span className="strong num">{r.version}</span><PhaseChip r={r} /></div>
|
||||
<Pips r={r} store={store} />
|
||||
{now && next && <span className="small muted">{t('portfolio.thenNext', { version: next.version ?? '', phase: t(`phase.${next.phase}` as Key) })}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextCell({ e }: { e: AppStatus | undefined }) {
|
||||
const n = e?.release.next;
|
||||
if (!e) return <span className="muted small">{t('common.unknown')}</span>;
|
||||
if (!n) return <span className="muted small">{t('portfolio.noNext')}</span>;
|
||||
return (
|
||||
<div className="mini">
|
||||
<span className="strong num">{n.version}</span>
|
||||
<div className="row small muted" style={{ gap: 10 }}>
|
||||
{n.ios && <span className="row" style={{ gap: 4 }}><StoreGlyph store="ios" className="ic13" />{t(`phase.${n.ios.phase}`)}</span>}
|
||||
{n.android && <span className="row" style={{ gap: 4 }}><StoreGlyph store="android" className="ic13" />{t(`phase.${n.android.phase}`)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Last cached health read of every app (the Health tab reads it; the portfolio only shows what is saved). */
|
||||
function useCachedHealth(projects: Project[]): Record<string, { h: HealthSnapshot; at: string } | null> {
|
||||
const [m, set] = useState<Record<string, { h: HealthSnapshot; at: string } | null>>({});
|
||||
const key = projects.map(p => p.id).join();
|
||||
useEffect(() => {
|
||||
for (const p of projects) {
|
||||
api.cached<ToolResult>('health', p.id).then(c => set(x => ({ ...x, [p.id]: c?.value.data ? { h: c.value.data as HealthSnapshot, at: c.at } : null })), () => {});
|
||||
}
|
||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return m;
|
||||
}
|
||||
|
||||
function HealthCell({ c }: { c: { h: HealthSnapshot; at: string } | null | undefined }) {
|
||||
if (!c) return <span className="muted small">{t('portfolio.healthNotRead')}</span>;
|
||||
const play = c.h.play;
|
||||
if (!play) return <span className="muted small">{t('portfolio.healthNoPlay')}</span>;
|
||||
if (isError(play)) return <Chip tone="warn">{t('portfolio.unreadable')}</Chip>;
|
||||
const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
return (
|
||||
<div className="mini-health" title={t('portfolio.healthTitle', { when: ago(c.at) })}>
|
||||
<Sparkline values={s.values.slice(-14)} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: t('health.crash'), value: pct(s.current) })} />
|
||||
<span className={`num small strong ${s.tone === 'bad' ? 'tbad' : s.tone === 'warn' ? 'twarn' : ''}`}>{pct(s.current)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionList({ items }: { items: Attention[] }) {
|
||||
const nav = useNav();
|
||||
if (!items.length) return <Empty compact illo="shield" title={t('portfolio.allClear')}>{t('portfolio.allClearText')}</Empty>;
|
||||
return (
|
||||
<div className="list">
|
||||
{items.slice(0, 8).map((a, i) => (
|
||||
<button key={i} className="li" onClick={() => nav.go({ screen: 'app', appId: a.projectId, tab: a.tab ?? 'checks' })}>
|
||||
<span className={`li-icon tone-bg-${a.tone}`}><Icon name={a.tone === 'bad' ? 'alertCircle' : a.tone === 'warn' ? 'alert' : 'info'} /></span>
|
||||
<span className="li-main"><span className="li-title">{a.text}</span><span className="li-sub">{a.app}{a.tab ? ` · ${t(`tab.${a.tab}`)}` : ''}</span></span>
|
||||
<Icon name="chevronRight" className="ic14 muted" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Portfolio() {
|
||||
const d = useData();
|
||||
const nav = useNav();
|
||||
const open = (p: Project) => nav.go({ screen: 'app', appId: p.id, tab: 'releases' });
|
||||
|
||||
if (d.ready && !d.projects.length) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph"><div><h1>{t('nav.portfolio')}</h1><p>{t('portfolio.lead')}</p></div></div>
|
||||
<Card pad={false}>
|
||||
<Empty illo="apps" title={t('portfolio.emptyTitle')} actions={<>
|
||||
<button className="btn btn-primary" onClick={() => nav.go({ screen: 'apps' })}><Icon name="folderPlus" />{t('action.addFolder')}</button>
|
||||
<button className="btn" onClick={nav.openWelcome}><Icon name="sparkle" />{t('action.welcome')}</button>
|
||||
</>}>{t('portfolio.emptyText')}</Empty>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const health = useCachedHealth(d.projects);
|
||||
const rows = d.projects.map(p => ({ p, e: d.statusOf(p), l: d.liveOf(p) }));
|
||||
const lives = rows.map(r => r.l).filter((x): x is AppStatus => !!x);
|
||||
const all = lives.flatMap(l => l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x));
|
||||
const stores = rows.reduce((n, r) => n + (r.e?.app?.platforms.ios ? 1 : 0) + (r.e?.app?.platforms.android ? 1 : 0), 0);
|
||||
const liveCount = lives.reduce((n, l) => n + (l.release.current?.ios ? 1 : 0) + (l.release.current?.android ? 1 : 0), 0);
|
||||
const rolling = all.filter(r => r.phase === 'rollout');
|
||||
const review = all.filter(r => r.phase === 'review');
|
||||
const att = rows.flatMap(r => attention(r.p.id, r.e?.app?.name ?? r.p.name, r.e, r.l, health[r.p.id]?.h));
|
||||
const blockers = att.filter(a => a.tone === 'bad').length;
|
||||
const known = lives.length > 0;
|
||||
const iosApps = rows.filter(r => r.e?.app?.platforms.ios).length, androidApps = rows.filter(r => r.e?.app?.platforms.android).length;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.portfolio')}</h1><p>{t('portfolio.sub', { count: d.projects.length, stores })}</p></div>
|
||||
<div className="actions">
|
||||
<button className="btn" onClick={() => d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}><Icon name="shield" />{t('action.checkConfigs')}</button>
|
||||
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy} title={t('action.readStoresHint')}>
|
||||
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
|
||||
{!known && d.ready && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
|
||||
<div className="kpis">
|
||||
<Kpi icon="layers" label={t('kpi.apps')} value={d.projects.length} sub={t('kpi.appsSub', { ios: iosApps, android: androidApps })} />
|
||||
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : t('kpi.notRead')} />
|
||||
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? t('kpi.notRead') : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
|
||||
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? t('kpi.notRead') : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
|
||||
<Kpi icon="alert" label={t('kpi.attention')} value={att.length} sub={blockers ? t('kpi.blockers', { count: blockers }) : t('kpi.noBlockers')} tone={blockers ? 'bad' : att.length ? 'warn' : undefined} />
|
||||
</div>
|
||||
|
||||
<Card title={t('portfolio.apps')} icon="portfolio" sub={known ? t('portfolio.appsSub', { when: ago(d.live?.at) }) : t('portfolio.appsSubOffline')} pad={false}
|
||||
actions={<div className="legend-row"><span><span className="pips"><b className="on" /></span>{t('legend.done')}</span><span>{t('legend.stages')}</span></div>}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr>
|
||||
<th>{t('col.app')}</th><th>App Store</th><th>Google Play</th><th>{t('col.next')}</th><th>{t('col.crash')}</th><th>{t('col.catalog')}</th><th className="right">{t('col.config')}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows.map(({ p, e, l }) => {
|
||||
const st = checkState(e);
|
||||
const cat = l?.catalog ?? e?.catalog;
|
||||
return (
|
||||
<tr key={p.id} className="clickable" onClick={() => open(p)}>
|
||||
<td>
|
||||
<div className="cell-app">
|
||||
<AppIcon name={e?.app?.name ?? p.name} src={d.icons[p.id]} />
|
||||
<div className="t">
|
||||
<button className="link n" onClick={ev => { ev.stopPropagation(); open(p); }}>{e?.app?.name ?? p.name}</button>
|
||||
<div className="s mono-id">{e?.app?.platforms.ios?.bundleId ?? e?.app?.platforms.android?.packageName ?? ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{e ? <StoreCell e={l} store="ios" project={e} /> : <Skeleton lines={2} />}</td>
|
||||
<td>{e ? <StoreCell e={l} store="android" project={e} /> : <Skeleton lines={2} />}</td>
|
||||
<td><NextCell e={l} /></td>
|
||||
<td><HealthCell c={health[p.id]} /></td>
|
||||
<td>
|
||||
{cat ? (
|
||||
<div className="mini">
|
||||
<span className="num small strong">{t('portfolio.products', { count: cat.products })} · {t('portfolio.subs', { count: cat.subscriptions })}</span>
|
||||
{cat.checked === 'live' ? (cat.ok ? <Chip tone="ok">{t('portfolio.inParity')}</Chip> : <Chip tone="warn">{t('portfolio.differs', { count: cat.missingIos.length + cat.missingAndroid.length + cat.onlyIos.length + cat.onlyAndroid.length })}</Chip>)
|
||||
: <span className="small muted">{t('portfolio.configOnly')}</span>}
|
||||
</div>
|
||||
) : <span className="muted small">–</span>}
|
||||
</td>
|
||||
<td className="right"><Chip tone={st.tone}>{st.text}</Chip></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid g-main-side">
|
||||
<Card title={t('portfolio.attention')} icon="alert" sub={t('portfolio.attentionSub')} pad={false}><AttentionList items={att} /></Card>
|
||||
<Card title={t('portfolio.workspace')} icon="settings">
|
||||
<dl className="kv">
|
||||
<dt>{t('nav.accounts')}</dt><dd>{d.accounts.length ? t('portfolio.accounts', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length }) : <button className="link" onClick={() => nav.go({ screen: 'accounts' })}>{t('action.addAccount')}</button>}</dd>
|
||||
<dt>{t('portfolio.storesRead')}</dt><dd>{d.live ? ago(d.live.at) : t('time.never')}</dd>
|
||||
<dt>{t('portfolio.tools')}</dt><dd>{d.sidecar?.tools ? t('portfolio.toolsFound', { found: d.sidecar.tools.tools.filter(x => x.found).length, total: d.sidecar.tools.tools.length }) : '–'}</dd>
|
||||
<dt>CLI</dt><dd>{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? '–'}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { api, errorText } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { useNav } from '../nav';
|
||||
import { usePrefs } from '../prefs-ctx';
|
||||
import { useData } from '../store';
|
||||
import { Icon } from '../ui/icons';
|
||||
import { Card, Kbd, MOD, Notice, Seg } from '../ui/kit';
|
||||
|
||||
export function Settings({ onTheme: _onTheme }: { onTheme: () => void }) {
|
||||
const d = useData();
|
||||
const nav = useNav();
|
||||
const { prefs, update } = usePrefs();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const tools = d.sidecar?.tools;
|
||||
const clear = () => api.clearCache().then(() => d.toast({ kind: 'ok', title: t('settings.cacheCleared') }), e => setError(errorText(e)));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph"><div><h1>{t('nav.settings')}</h1><p>{t('settings.lead')}</p></div></div>
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
<div className="grid g2" style={{ alignItems: 'start' }}>
|
||||
<Card title={t('settings.appearance')} icon="sun">
|
||||
<div className="form">
|
||||
<div className="field" role="group" aria-label={t('settings.theme')}><span className="legend">{t('settings.theme')}</span>
|
||||
<Seg value={prefs.theme} onChange={theme => update({ ...prefs, theme })} label={t('settings.theme')} options={[['system', t('settings.system'), 'settings'], ['light', t('settings.light'), 'sun'], ['dark', t('settings.dark'), 'moon']]} /></div>
|
||||
<div className="field" role="group" aria-label={t('settings.motion')}><span className="legend">{t('settings.motion')}</span>
|
||||
<Seg value={prefs.motion} onChange={motion => update({ ...prefs, motion })} label={t('settings.motion')} options={[['system', t('settings.followSystem')], ['reduce', t('settings.reduce')]]} /></div>
|
||||
<div className="field" role="group" aria-label={t('settings.language')}><span className="legend">{t('settings.language')}</span>
|
||||
<Seg value={prefs.lang} onChange={lang => update({ ...prefs, lang })} label={t('settings.language')} options={[['system', t('settings.system')], ['en', 'English'], ['tr', 'Türkçe']]} /></div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('settings.shortcuts')} icon="keyboard">
|
||||
<dl className="shortcuts">
|
||||
<dt>{t('settings.scPalette')}</dt><dd><Kbd keys={[MOD, 'K']} /></dd>
|
||||
<dt>{t('nav.portfolio')}</dt><dd><Kbd keys={[MOD, '1']} /></dd>
|
||||
<dt>{t('settings.scWorkspace')}</dt><dd><Kbd keys={[MOD, '2']} /><span className="muted">–</span><Kbd keys={[MOD, '5']} /></dd>
|
||||
<dt>{t('settings.scTabs')}</dt><dd><Kbd keys={[MOD, '⇧', '[']} /><Kbd keys={[MOD, '⇧', ']']} /></dd>
|
||||
<dt>{t('settings.scTabArrows')}</dt><dd><Kbd keys={['←', '→']} /></dd>
|
||||
<dt>{t('settings.scClose')}</dt><dd><Kbd keys={['Esc']} /></dd>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title={t('settings.data')} icon="database">
|
||||
<div className="stack">
|
||||
<p className="small muted">{t('settings.dataText')}</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={clear}><Icon name="trash" />{t('settings.clearCache')}</button>
|
||||
<button className="btn btn-sm" onClick={nav.openWelcome}><Icon name="sparkle" />{t('action.welcome')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('settings.about')} icon="info">
|
||||
<dl className="kv">
|
||||
<dt>{t('settings.app')}</dt><dd>{d.info ? `storebound desktop ${d.info.version}${d.info.debug ? ` (${t('settings.debug')})` : ''}` : '–'}</dd>
|
||||
<dt>CLI</dt><dd>{d.sidecar ? `storebound ${d.sidecar.version} · ${d.sidecar.protocol} · ${t('settings.methods', { count: d.sidecar.methods.length })}` : d.sidecarError ?? t('foot.cliStarting')}</dd>
|
||||
<dt>{t('settings.tools')}</dt><dd>{tools ? t('portfolio.toolsFound', { found: tools.tools.filter(x => x.found).length, total: tools.tools.length }) : '–'}</dd>
|
||||
<dt>{t('settings.telemetry')}</dt><dd>{t('settings.telemetryNone')}</dd>
|
||||
</dl>
|
||||
<div className="actions" style={{ marginTop: 12 }}>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal('https://storebound.dev/').catch(e => setError(errorText(e)))}><Icon name="external" />storebound.dev</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
// 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 type { Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { ago, num } from '../../lib/format';
|
||||
import { type CatalogDiff, type CellState, parityMatrix } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon, type IconName } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, Kpi, Notice, Skeleton } from '../../ui/kit';
|
||||
|
||||
const CELL: Record<CellState, [IconName, Key]> = { ok: ['check', 'cat.ok'], warn: ['alert', 'cat.warn'], error: ['x', 'cat.error'], na: ['dot', 'cat.na'], unknown: ['info', 'cat.unknown'] };
|
||||
|
||||
function Cell({ c }: { c: { state: CellState; notes: string[] } }) {
|
||||
const [icon, key] = CELL[c.state];
|
||||
return (
|
||||
<div>
|
||||
<span className="mx-cell"><span className={`mx-icon mx-${c.state}`}><Icon name={icon} /></span>{t(key)}</span>
|
||||
{c.notes.length > 0 && <ul className="note-list">{c.notes.slice(0, 3).map((n, i) => <li key={i}>{n}</li>)}</ul>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalogTab({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const e = d.statusOf(project);
|
||||
const diff = useRead<CatalogDiff>(project.id, 'catalog.diff', { network: true, title: t('tab.catalog') });
|
||||
const s = e?.summary ?? null;
|
||||
if (!e) return <Card><Skeleton lines={5} block /></Card>;
|
||||
const rows = parityMatrix(s, diff.data);
|
||||
const read = !!diff.data;
|
||||
const inParity = rows.filter(r => r.ios.state !== 'error' && r.ios.state !== 'warn' && r.android.state !== 'error' && r.android.state !== 'warn' && r.kind !== 'extra').length;
|
||||
const gaps = diff.data?.parity ?? [];
|
||||
|
||||
if (!rows.length) {
|
||||
return <Card pad={false}><Empty illo="catalog" title={t('cat.emptyTitle')}>{t('cat.emptyText')}</Empty></Card>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="tag" label={t('cat.products')} value={s?.products ?? 0} sub={t('cat.productsSub')} />
|
||||
<Kpi icon="refresh" label={t('cat.subscriptions')} value={s?.subscriptions ?? 0} sub={t('cat.subsSub')} />
|
||||
<Kpi icon="check" label={t('cat.parity')} value={read ? `${inParity}/${rows.filter(r => r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} />
|
||||
<Kpi icon="globe" label={t('cat.gaps')} value={read ? gaps.length : '–'} sub={read ? t('cat.gapsSub') : t('kpi.notRead')} />
|
||||
</div>
|
||||
{diff.error && <Notice tone="warn">{t('common.readFailedKeep', { error: diff.error })}</Notice>}
|
||||
<Card title={t('cat.matrix')} icon="layers" sub={read ? (diff.fromCache ? t('common.cachedFrom', { when: ago(diff.at) }) : t('common.readWhen', { when: ago(diff.at) })) : t('cat.configOnly')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={diff.refresh} disabled={diff.loading}><Icon name="refresh" className={diff.loading ? 'spin' : undefined} />{read ? t('action.refresh') : t('cat.compare')}</button>}>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.item')}</th><th>{t('col.type')}</th><th>{t('col.config')}</th><th>App Store</th><th>Google Play</th><th className="right">{t('col.priceGap')}</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td><span className="mono-id strong">{r.id}</span></td>
|
||||
<td>{r.kind === 'extra' ? <Chip tone="warn">{t('cat.extra')}</Chip> : <Chip tone="outline">{r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}</Chip>}</td>
|
||||
<td className="small">{r.config ? [s?.catalog.ios.includes(r.id) ? 'iOS' : null, s?.catalog.android.includes(r.id) ? 'Android' : null].filter(Boolean).join(' + ') || t('cat.na') : <span className="muted">{t('cat.notInConfig')}</span>}</td>
|
||||
<td><Cell c={r.ios} /></td>
|
||||
<td><Cell c={r.android} /></td>
|
||||
<td className="right">{r.maxDiff !== null ? <Chip tone={r.maxDiff > 25 ? 'bad' : 'warn'}>{`${num(r.maxDiff, 1)} %`}</Chip> : <span className="muted small">{read ? t('cat.noGap') : '–'}</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
{gaps.length > 0 && (
|
||||
<Card title={t('cat.gapsTitle')} icon="globe" sub={t('cat.gapsTitleSub')} pad={false}>
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.item')}</th><th>{t('col.region')}</th><th className="right">App Store</th><th className="right">Google Play</th><th className="right">{t('col.diff')}</th></tr></thead>
|
||||
<tbody>
|
||||
{gaps.slice(0, 12).map((g, i) => (
|
||||
<tr key={i}>
|
||||
<td className="mono-id">{g.id}</td><td>{g.region}</td>
|
||||
<td className="right num">{num(g.apple, 2)} {g.currency}</td><td className="right num">{num(g.play, 2)} {g.currency}</td>
|
||||
<td className="right"><Chip tone={g.diffPct > 25 ? 'bad' : 'warn'}>{`${num(g.diffPct, 1)} %`}</Chip></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
// Checks: the offline config check (every time) and the read-only store readiness report (`validate`, on demand)
|
||||
// with its ordered blockers and fix hints.
|
||||
import type { Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ago } from '../../lib/format';
|
||||
import type { Finding, ValidateResult } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, Notice, Skeleton } from '../../ui/kit';
|
||||
|
||||
const LEVEL: Record<Finding['level'], ['bad' | 'warn' | 'ok', 'alertCircle' | 'alert' | 'check']> = { blocker: ['bad', 'alertCircle'], warn: ['warn', 'alert'], ok: ['ok', 'check'] };
|
||||
|
||||
export function ChecksTab({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const e = d.statusOf(project);
|
||||
const v = useRead<ValidateResult>(project.id, 'validate', { network: true, title: t('checks.validate') });
|
||||
const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])];
|
||||
const warnings = e?.check?.warnings ?? [];
|
||||
const findings = [...(v.data?.findings ?? [])].sort((a, b) => ['blocker', 'warn', 'ok'].indexOf(a.level) - ['blocker', 'warn', 'ok'].indexOf(b.level));
|
||||
return (
|
||||
<div className="grid g2" style={{ alignItems: 'start' }}>
|
||||
<Card title={t('checks.offline')} icon="shield" sub={t('checks.offlineSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}><Icon name="refresh" className={d.portfolioBusy === 'offline' ? 'spin' : undefined} />{t('checks.again')}</button>}>
|
||||
{!e ? <div className="card-body"><Skeleton lines={4} /></div> : !problems.length && !warnings.length ? (
|
||||
<Empty compact illo="shield" title={t('checks.clean')}>{t('checks.cleanText')}</Empty>
|
||||
) : (
|
||||
<div className="list">
|
||||
{problems.map((p, i) => <div className="li" key={`p${i}`}><span className="li-icon tone-bg-bad"><Icon name="alertCircle" /></span><div className="li-main"><span className="li-title">{p}</span><span className="li-sub">{t('checks.problem')}</span></div></div>)}
|
||||
{warnings.map((w, i) => <div className="li" key={`w${i}`}><span className="li-icon tone-bg-warn"><Icon name="alert" /></span><div className="li-main"><span className="li-title">{w}</span><span className="li-sub">{t('checks.warning')}</span></div></div>)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={t('checks.validate')} icon="layers" sub={v.data ? (v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })) : t('checks.validateSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={v.refresh} disabled={v.loading}><Icon name="refresh" />{v.data ? t('action.refresh') : t('checks.run')}</button>}>
|
||||
{v.error && <div className="card-body"><Notice tone="warn">{v.error}</Notice></div>}
|
||||
{!v.data ? (
|
||||
v.loading && !v.error ? <div className="card-body"><Skeleton lines={4} /></div> : <Empty compact illo="cloud" title={t('checks.validateEmpty')}>{t('checks.validateEmptyText')}</Empty>
|
||||
) : !findings.length ? <Empty compact illo="shield" title={t('checks.ready')}>{t('checks.readyText')}</Empty> : (
|
||||
<div className="list">
|
||||
{findings.map((f, i) => {
|
||||
const [tone, icon] = LEVEL[f.level];
|
||||
return (
|
||||
<div className="li" key={i}>
|
||||
<span className={`li-icon tone-bg-${tone}`}><Icon name={icon} /></span>
|
||||
<div className="li-main">
|
||||
<span className="li-title">{f.msg}</span>
|
||||
{f.fix && <span className="fix"><Icon name="wand" /><code className="selectable">{f.fix}</code></span>}
|
||||
</div>
|
||||
<Chip tone="outline">{f.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
// Health: Google Play vitals (user-perceived crash and ANR rate per day against the bad behavior thresholds, worst
|
||||
// devices, top issues) and App Store diagnostics (power and performance metrics, signatures, TestFlight crashes).
|
||||
import type { Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ago, date, num, pct } from '../../lib/format';
|
||||
import { type AppleHealth, healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type PlayHealth, type Series } from '../../lib/model';
|
||||
import { useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, Notice, Skeleton, Sparkline } from '../../ui/kit';
|
||||
|
||||
function Metric({ title, s }: { title: string; s: Series }) {
|
||||
return (
|
||||
<Card title={title} icon="activity" sub={t('health.window', { from: date(s.dates[0]), to: date(s.dates.at(-1)) })}
|
||||
actions={<Chip tone={s.tone === 'neutral' ? 'neutral' : s.tone}>{s.tone === 'bad' ? t('health.over') : s.tone === 'warn' ? t('health.near') : s.tone === 'ok' ? t('health.under') : t('common.unknown')}</Chip>}>
|
||||
<div className="metric">
|
||||
<div className="metric-top">
|
||||
<div>
|
||||
<div className={`metric-val ${s.tone}`}>{pct(s.current)}</div>
|
||||
<div className="small muted">{t('health.current28')}</div>
|
||||
</div>
|
||||
<div className="metric-legend">
|
||||
<span className="lg"><span className={`sw ${s.tone}`} />{t('health.daily')}</span>
|
||||
<span className="lg"><span className="sw thr" />{t('health.threshold', { value: pct(s.threshold) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={s.values} threshold={s.threshold} tone={s.tone} label={t('health.chartAria', { metric: title, value: pct(s.current) })} />
|
||||
<div className="axis"><span>{date(s.dates[0])}</span><span>{date(s.dates[Math.floor(s.dates.length / 2)])}</span><span>{date(s.dates.at(-1))}</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Play({ h }: { h: PlayHealth }) {
|
||||
const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr);
|
||||
const devices = (h.devices ?? []).slice(0, 6);
|
||||
const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0)));
|
||||
return (
|
||||
<>
|
||||
<div className="grid g2">
|
||||
<Metric title={t('health.crash')} s={crash} />
|
||||
<Metric title={t('health.anr')} s={anr} />
|
||||
</div>
|
||||
<div className="grid g2">
|
||||
<Card title={t('health.devices')} icon="phone" sub={t('health.devicesSub', { value: pct(PLAY_THRESHOLDS.perDevice, 0) })}>
|
||||
{devices.length ? (
|
||||
<div className="hbars">
|
||||
{devices.map(x => {
|
||||
const v = Math.max(x.crash ?? 0, x.anr ?? 0);
|
||||
return (
|
||||
<div className="hbar" key={x.model}>
|
||||
<span className="nm">{x.model}</span>
|
||||
<Bar value={v / worst} tone={v >= PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} />
|
||||
<span className="num right small">{pct(v)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <p className="muted">{t('health.noDevices')}</p>}
|
||||
</Card>
|
||||
<Card title={t('health.issues')} icon="alert" sub={t('health.issuesSub')} pad={false}>
|
||||
{h.issues.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.issue')}</th><th className="right">{t('col.reports')}</th><th className="right">{t('col.users')}</th></tr></thead>
|
||||
<tbody>
|
||||
{h.issues.slice(0, 6).map((i, n) => (
|
||||
<tr key={n}>
|
||||
<td><div className="row" style={{ gap: 8 }}><Chip tone={i.type === 'ANR' ? 'warn' : 'bad'}>{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}</Chip><span className="strong" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }}>{i.cause || '?'}</span></div>
|
||||
<div className="mono-id muted" style={{ marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 360 }}>{i.location}</div></td>
|
||||
<td className="right num">{num(i.reports)}</td><td className="right num">{num(i.users)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="shield" title={t('health.noIssues')} />}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Apple({ h }: { h: AppleHealth }) {
|
||||
const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count));
|
||||
return (
|
||||
<div className="grid g2">
|
||||
<Card title={t('health.appleMetrics')} icon="activity" sub={t('health.appleMetricsSub')} pad={false}>
|
||||
{h.metrics.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.metric')}</th><th className="right">{t('col.value')}</th><th>{t('col.version')}</th><th>{t('col.goal')}</th></tr></thead>
|
||||
<tbody>
|
||||
{h.metrics.slice(0, 8).map((m, i) => (
|
||||
<tr key={i}><td><div className="strong">{m.metric}</div><div className="small muted">{m.category}</div></td><td className="right num strong">{num(m.value, m.value < 10 ? 2 : 0)} <span className="muted small">{m.unit}</span></td>
|
||||
<td className="num muted">{m.version}</td><td>{m.goal ? <Chip tone={m.goal === 'poor' ? 'bad' : m.goal === 'fair' ? 'warn' : 'ok'}>{m.goal}</Chip> : '–'}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="pulse" title={t('health.noMetrics')} />}
|
||||
</Card>
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('health.signatures')} icon="layers" sub={t('health.signaturesSub')} pad={false}>
|
||||
{h.signatures.length ? (
|
||||
<div className="list">
|
||||
{h.signatures.slice(0, 5).map((s, i) => (
|
||||
<div className="li" key={i}>
|
||||
<span className="li-icon tone-bg-neutral"><Icon name="alertCircle" /></span>
|
||||
<div className="li-main"><span className="li-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.signature || s.type}</span><span className="li-sub">{s.type} · {t('health.build', { build: s.build })}</span></div>
|
||||
<span className="num small strong">{num(s.weight * 100, 1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <Empty compact illo="shield" title={t('health.noSignatures')} />}
|
||||
</Card>
|
||||
<Card title={t('health.betaCrashes')} icon="plane">
|
||||
{h.betaCrashes.length ? (
|
||||
<div className="hbars">{h.betaCrashes.slice(0, 5).map(b => (
|
||||
<div className="hbar" key={b.build}><span className="nm">{t('health.build', { build: b.build })}</span><Bar value={b.count / maxCrash} tone="warn" label={b.build} /><span className="num right small">{num(b.count)}</span></div>
|
||||
))}</div>
|
||||
) : <p className="muted">{t('health.noBetaCrashes')}</p>}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthTab({ project }: { project: Project }) {
|
||||
const r = useRead<HealthSnapshot>(project.id, 'health', { network: true, title: t('tab.health') });
|
||||
const refresh = <button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" className={r.loading && !r.data ? 'spin' : undefined} />{r.data ? t('action.refresh') : t('health.read')}</button>;
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="pulse" title={t('health.emptyTitle')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('health.read')}</button>}>{t('health.emptyText')}</Empty>
|
||||
{r.error && <div className="card-foot"><Icon name="alertCircle" />{r.error}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const h = r.data;
|
||||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
|
||||
{refresh}
|
||||
</div>
|
||||
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
|
||||
<h2 className="label">Google Play</h2>
|
||||
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <Notice tone="warn">{t('health.unreadable', { store: 'Google Play', error: h.play.error })}</Notice> : <Play h={h.play} />}
|
||||
<h2 className="label">App Store</h2>
|
||||
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <Notice tone="warn">{t('health.unreadable', { store: 'App Store', error: h.apple.error })}</Notice> : <Apple h={h.apple} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
// Listings: per language and store, which listing fields are written and within the store limits (from the listing
|
||||
// files in the app folder), and how many screenshots are rendered per language.
|
||||
import type { ListingField, ListingLocale, ListingsResult, Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { ago, num } from '../../lib/format';
|
||||
import { useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, Kpi, Notice, Skeleton } from '../../ui/kit';
|
||||
|
||||
const FIELD_KEY: Record<string, Key> = {
|
||||
title: 'lst.title', short: 'lst.short', full: 'lst.full', whatsNew: 'lst.whatsNew', name: 'lst.name', subtitle: 'lst.subtitle',
|
||||
promotionalText: 'lst.promo', keywords: 'lst.keywords', description: 'lst.description',
|
||||
};
|
||||
|
||||
function FieldCell({ f }: { f: ListingField | undefined }) {
|
||||
if (!f) return <span className="muted small">–</span>;
|
||||
const ratio = f.limit ? Math.min(1, f.length / f.limit) : 0;
|
||||
const tone = f.state === 'over' || f.state === 'missing' ? 'bad' : ratio > 0.97 ? 'warn' : '';
|
||||
const label = f.state === 'missing' ? t('lst.missing') : f.state === 'empty-optional' ? t('lst.optional') : f.state === 'over' ? t('lst.over', { n: f.length - f.limit }) : `${num(f.length)} / ${num(f.limit)}`;
|
||||
return (
|
||||
<div className={`lg-cell ${f.state}`} title={`${f.length} / ${f.limit}`}>
|
||||
<div className={`bar thin ${f.state === 'empty-optional' ? '' : tone}`}><span style={{ width: `${f.state === 'missing' ? 100 : ratio * 100}%`, opacity: f.state === 'missing' ? 0.25 : 1 }} /></div>
|
||||
<span className="c"><span>{label}</span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ring({ value }: { value: number }) {
|
||||
const tone = value >= 1 ? 'var(--ok-solid)' : value >= 0.6 ? 'var(--warn-solid)' : 'var(--bad-solid)';
|
||||
return (
|
||||
<span className={`ring${value >= 1 ? ' full' : ''}`} style={{ ['--p' as string]: Math.round(value * 100), ['--ring' as string]: tone }} role="img" aria-label={t('lst.completeAria', { pct: Math.round(value * 100) })}>
|
||||
<span>{value >= 1 ? <Icon name="check" /> : `${Math.round(value * 100)}%`}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) {
|
||||
const fields = locales.find(l => l[store])?.[store]?.fields.map(f => f.field) ?? [];
|
||||
const done = (l: ListingLocale) => {
|
||||
const fs = l[store]?.fields.filter(f => f.required) ?? [];
|
||||
return fs.length ? fs.filter(f => f.state === 'ok').length / fs.length : 0;
|
||||
};
|
||||
return (
|
||||
<Card title={store === 'play' ? 'Google Play' : 'App Store'} icon="text" sub={store === 'play' ? t('lst.playSub') : t('lst.appleSub')} pad={false}>
|
||||
<div className="lg-grid" style={{ ['--cols' as string]: fields.length }}>
|
||||
<div className="lg-row head"><span>{t('col.language')}</span>{fields.map(f => <span key={f}>{t(FIELD_KEY[f] ?? 'lst.title')}</span>)}<span style={{ textAlign: 'right' }}>{t('lst.complete')}</span></div>
|
||||
{locales.map(l => (
|
||||
<div className="lg-row" key={l.lang}>
|
||||
<div className="lg-lang"><span className="mono-id strong">{l.lang}</span><span className="small muted">{l[store]?.code ?? ''}</span>{l.rtl && <Chip tone="outline">RTL</Chip>}</div>
|
||||
{fields.map(f => <FieldCell key={f} f={l[store]?.fields.find(x => x.field === f)} />)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}><Ring value={done(l)} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListingsTab({ project }: { project: Project }) {
|
||||
const r = useRead<ListingsResult>(project.id, 'project.listings', { network: false });
|
||||
if (!r.data) {
|
||||
if (r.error) return <Notice tone="bad">{r.error}</Notice>;
|
||||
return <Card><Skeleton lines={6} block /></Card>;
|
||||
}
|
||||
const L = r.data;
|
||||
const missing = L.locales.reduce((n, l) => n + [...(l.play?.fields ?? []), ...(l.apple?.fields ?? [])].filter(f => f.state === 'missing' || f.state === 'over').length, 0);
|
||||
const avg = L.locales.length ? L.locales.reduce((n, l) => n + l.complete, 0) / L.locales.length : 0;
|
||||
const shots = L.locales.reduce((n, l) => n + (l.screenshots ?? 0), 0);
|
||||
const noFiles = !L.files.play && !L.files.apple;
|
||||
return (
|
||||
<>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="globe" label={t('lst.languages')} value={L.locales.length} sub={L.locales.map(l => l.lang).join(' · ')} />
|
||||
<Kpi icon="check" label={t('lst.completeKpi')} value={noFiles ? '–' : `${Math.round(avg * 100)}%`} sub={t('lst.completeSub')} tone={!noFiles && avg < 1 ? 'warn' : undefined} />
|
||||
<Kpi icon="alert" label={t('lst.toFix')} value={noFiles ? '–' : missing} sub={t('lst.toFixSub')} tone={missing ? 'bad' : undefined} />
|
||||
<Kpi icon="image" label={t('lst.screenshots')} value={L.locales.some(l => l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} />
|
||||
</div>
|
||||
{L.problems.map((p, i) => <Notice key={i} tone="warn">{p}</Notice>)}
|
||||
{noFiles ? (
|
||||
<Card pad={false}>
|
||||
<Empty illo="text" title={t('lst.noFilesTitle')}>{t('lst.noFilesText')}</Empty>
|
||||
<div className="card-foot"><Icon name="terminal" /><code className="selectable">storebound init</code><span>{t('lst.noFilesHint')}</span></div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{L.files.play && <StoreGrid store="play" locales={L.locales} />}
|
||||
{L.files.apple && <StoreGrid store="apple" locales={L.locales} />}
|
||||
</>
|
||||
)}
|
||||
<p className="small muted">{t('lst.foot', { when: ago(r.at) })}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live ->
|
||||
// rollout %), then every version read from the stores. Submit and release stay in the consoles.
|
||||
import { api, type Project, type ReleaseTrain } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
import { ago, date, share } from '../../lib/format';
|
||||
import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, Notice, PhaseChip, storeName } from '../../ui/kit';
|
||||
import { ascUrl, PLAY_CONSOLE } from '../AppView';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
||||
function stageCaption(l: Lane, i: number): string {
|
||||
if (i !== l.at || !l.release) return '';
|
||||
const r = l.release;
|
||||
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing'));
|
||||
if (i === 2) return t(`phase.${r.phase}` as Key);
|
||||
return '';
|
||||
}
|
||||
|
||||
function LaneRow({ lane, version }: { lane: Lane; version: string }) {
|
||||
const r = lane.release;
|
||||
return (
|
||||
<div className="tl-lane">
|
||||
<div className="tl-who">
|
||||
<StoreGlyph store={lane.store} />
|
||||
<div>
|
||||
<div className="w1">{storeName(lane.store)}</div>
|
||||
<div className="w2">{r ? `${version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{lane.at < 0 ? <div className="tl-none">{t('tl.noVersion', { version, store: storeName(lane.store) })}</div> : STAGES.map((s, i) => {
|
||||
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
|
||||
const last = s === 'rollout';
|
||||
return (
|
||||
<div key={s} className={`tl-step ${cls}${i === 0 ? ' first' : ''}`} title={i === lane.at ? r?.state : undefined}>
|
||||
<span className="tl-node">{i < lane.at && <Icon name="check" />}</span>
|
||||
{last && i === lane.at && lane.share !== null ? (
|
||||
<div className="tl-roll"><Bar value={lane.share} tone={lane.tone === 'bad' ? 'bad' : lane.share >= 1 ? 'ok' : 'accent'} label={t('tl.rolloutAria')} />
|
||||
<span className="pct">{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}</span></div>
|
||||
) : <span className="tl-cap">{stageCaption(lane, i)}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Train({ title, train, tf, tone }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok' }) {
|
||||
const lanes = trainLanes(train, tf);
|
||||
return (
|
||||
<Card pad={false} title={<span className="train-head"><span className="ver">{train.version}</span><Chip tone={tone}>{title}</Chip></span>}
|
||||
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
|
||||
<div className="tl" style={{ paddingTop: 12 }}>
|
||||
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
|
||||
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} />)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleasesTab({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const l = d.liveOf(project);
|
||||
const tf = useRead<TestFlightStatus>(project.id, 'apple.testflight_status', { network: true, title: 'TestFlight' });
|
||||
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
|
||||
|
||||
if (!l) {
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="cloud" title={t('releases.notReadTitle')} actions={<>
|
||||
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
|
||||
{!project.playAccount && !project.appleAccount && <span className="small muted">{t('releases.noAccounts')}</span>}
|
||||
</>}>{t('releases.notReadText')}</Empty>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const history = versionHistory(l.release.all ?? []);
|
||||
const errs = Object.entries(l.storeErrors);
|
||||
return (
|
||||
<>
|
||||
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: msg })}</Notice>)}
|
||||
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" />}
|
||||
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" />}
|
||||
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
|
||||
|
||||
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>
|
||||
{history.length ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
|
||||
<tbody>
|
||||
{history.map(h => (
|
||||
<tr key={h.version}>
|
||||
<td className="strong num">{h.version}</td>
|
||||
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
|
||||
<td className="num muted">{h.ios?.build ?? '–'}</td>
|
||||
<td>{h.android ? <PhaseChip r={h.android} /> : <span className="muted small">–</span>}</td>
|
||||
<td className="num muted">{h.android?.build ?? '–'}</td>
|
||||
<td className="muted">{h.android?.track ?? '–'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <Empty compact illo="unknown" title={t('releases.noVersions')} />}
|
||||
</Card>
|
||||
|
||||
<Notice tone="info" icon="shield" action={<div className="actions">
|
||||
{l.app?.platforms.ios && <button className="btn btn-sm" onClick={() => open(ascUrl(l.app?.platforms.ios?.ascAppId))}><Icon name="external" />App Store Connect</button>}
|
||||
{l.app?.platforms.android && <button className="btn btn-sm" onClick={() => open(PLAY_CONSOLE)}><Icon name="external" />Play Console</button>}
|
||||
</div>}>{t('releases.consoleNote')}</Notice>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
// Reviews: the inbox of both stores with the star distribution and filters, and per review its local reply draft
|
||||
// (.storebound/replies) with the send checks. Approving a draft is a local, confirmed write; sending stays in the
|
||||
// terminal (`storebound reviews send`): replies are public.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api, type DraftsResult, errorText, newJob, type Project, type ReplyDraft } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ago, date, num } from '../../lib/format';
|
||||
import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { Bar, Card, Chip, Empty, Notice, Seg, Skeleton, Stars } from '../../ui/kit';
|
||||
|
||||
const base = (f: string) => f.split(/[\\/]/).pop() ?? f;
|
||||
|
||||
function Detail({ r, draft, onApprove, busy }: { r: Review; draft: ReplyDraft | undefined; onApprove: (d: ReplyDraft, undo: boolean) => void; busy: boolean }) {
|
||||
const errors = draft?.issues.filter(i => i.level === 'error') ?? [];
|
||||
return (
|
||||
<Card className="rv-detail" title={<span className="row" style={{ gap: 10 }}><Stars n={r.rating} />{r.title || t('reviews.noTitle')}</span>}
|
||||
sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store={r.store === 'apple' ? 'ios' : 'android'} className="ic13" />{r.store === 'apple' ? 'App Store' : 'Google Play'}{r.where ? ` · ${r.where}` : ''} · {date(r.date)}{r.appVersion ? ` · ${t('reviews.version', { v: r.appVersion })}` : ''}</span>}>
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<p className="quote selectable">{r.body || t('reviews.noText')}</p>
|
||||
{r.reply && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<span className="label">{t('reviews.currentReply')}{r.reply.date ? ` · ${date(r.reply.date)}` : ''}</span>
|
||||
<div className="reply-box selectable">{r.reply.text}</div>
|
||||
</div>
|
||||
)}
|
||||
<hr className="divider" />
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<div className="row between">
|
||||
<span className="label">{t('reviews.draft')}</span>
|
||||
{draft && (draft.sent ? <Chip tone="ok">{t('reviews.sentOn', { date: date(draft.sent) })}</Chip> : draft.approved ? <Chip tone="ok" icon="check">{t('reviews.approved')}</Chip> : <Chip tone="neutral">{t('reviews.notApproved')}</Chip>)}
|
||||
</div>
|
||||
{!draft ? <p className="muted small">{t('reviews.noDraft')}</p> : (
|
||||
<>
|
||||
{draft.reply ? <div className="reply-box selectable">{draft.reply}</div> : <p className="muted small">{t('reviews.emptyDraft', { file: base(draft.file) })}</p>}
|
||||
<div className="row small muted" style={{ gap: 10 }}><span className="num">{t('reviews.chars', { n: num([...draft.reply].length), limit: r.store === 'play' ? 350 : 5970 })}</span><span className="mono-id">{base(draft.file)}</span></div>
|
||||
{draft.issues.length > 0 && (
|
||||
<ul className="issues">
|
||||
{draft.issues.map((i, n) => <li key={n} className={i.level === 'error' ? 'e' : 'w'}><Icon name={i.level === 'error' ? 'alertCircle' : 'alert'} />{i.msg}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{!draft.sent && (
|
||||
<div className="actions">
|
||||
{draft.approved
|
||||
? <button className="btn" onClick={() => onApprove(draft, true)} disabled={busy}>{t('reviews.unapprove')}</button>
|
||||
: <button className="btn btn-primary" onClick={() => onApprove(draft, false)} disabled={busy || !draft.reply || errors.length > 0} title={errors.length ? t('reviews.fixFirst') : t('reviews.approveHint')}><Icon name="check" />{t('reviews.approve')}</button>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="small muted">{t('reviews.sendNote')} <code className="selectable">storebound reviews send</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewsTab({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const list = useRead<Review[]>(project.id, 'reviews.list', { network: true, args: { days: 30 }, title: t('tab.reviews') });
|
||||
const drafts = useRead<DraftsResult>(project.id, 'reviews.drafts', { network: false });
|
||||
const [f, setF] = useState<ReviewFilter>({ store: 'all', stars: [], unanswered: false, query: '' });
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const all = list.data ?? [];
|
||||
const stats = useMemo(() => starStats(f.store === 'all' ? all : all.filter(r => r.store === f.store)), [all, f.store]);
|
||||
const shown = useMemo(() => filterReviews(all, f), [all, f]);
|
||||
const byFile = useMemo(() => new Map((drafts.data?.drafts ?? []).map(x => [base(x.file), x] as const)), [drafts.data]);
|
||||
const cur = shown.find(r => `${r.store}:${r.id}` === sel) ?? shown.find(r => !r.reply && byFile.has(draftName(r))) ?? shown[0];
|
||||
|
||||
const write = async (method: string, args: Record<string, unknown>, title: string) => {
|
||||
setBusy(true);
|
||||
const job = newJob(method);
|
||||
try {
|
||||
const o = await api.writeWithConfirm(method, project.id, args, job);
|
||||
if (o.confirmed && o.result?.ok) { d.toast({ kind: 'ok', title, detail: undefined }); await drafts.refresh(); }
|
||||
else if (o.stage === 'dry-run') d.toast({ kind: 'bad', title: t('write.dryFailed'), detail: o.dryRun.output.split('\n').filter(Boolean).at(-1) });
|
||||
else if (o.confirmed) d.toast({ kind: 'bad', title: t('write.failed'), detail: o.result?.output.split('\n').filter(Boolean).at(-1) });
|
||||
} catch (e) { d.toast({ kind: 'bad', title: t('write.failed'), detail: errorText(e) }); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (!list.data) {
|
||||
if (list.loading && !list.error) return <Card><Skeleton lines={5} block /></Card>;
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="reviews" title={t('reviews.emptyTitle')} actions={<button className="btn btn-primary" onClick={list.refresh}><Icon name="refresh" />{t('reviews.read')}</button>}>{t('reviews.emptyText')}</Empty>
|
||||
{list.error && <div className="card-foot"><Icon name="alertCircle" />{list.error}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleStar = (n: number) => setF(x => ({ ...x, stars: x.stars.includes(n) ? x.stars.filter(s => s !== n) : [...x.stars, n] }));
|
||||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{list.fromCache ? t('common.cachedFrom', { when: ago(list.at) }) : t('common.readWhen', { when: ago(list.at) })}</span>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={() => write('reviews.draft', { days: 30 }, t('reviews.draftsMade'))} disabled={busy} title={t('reviews.makeDraftsHint')}><Icon name="text" />{t('reviews.makeDrafts')}</button>
|
||||
<button className="btn btn-sm" onClick={list.refresh} disabled={list.loading}><Icon name="refresh" />{t('action.refresh')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{list.error && <Notice tone="warn">{t('common.readFailedKeep', { error: list.error })}</Notice>}
|
||||
<div className="rv">
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Card title={t('reviews.ratings')} icon="star">
|
||||
<div className="stack" style={{ gap: 14 }}>
|
||||
<div className="big-rating"><b>{stats.average !== null ? num(stats.average, 1) : '–'}</b><Stars n={Math.round(stats.average ?? 0)} /></div>
|
||||
<span className="small muted">{t('reviews.count', { count: stats.count })} · {t('reviews.unansweredN', { count: stats.unanswered })}</span>
|
||||
<div className="dist">
|
||||
{[5, 4, 3, 2, 1].map(n => (
|
||||
<button key={n} className="dist-row" onClick={() => toggleStar(n)} aria-pressed={f.stars.includes(n)} style={{ all: 'unset', display: 'grid', gridTemplateColumns: '26px 1fr 30px', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12, opacity: f.stars.length && !f.stars.includes(n) ? 0.45 : 1 }}>
|
||||
<span className="num">{n}★</span><Bar value={stats.count ? stats.byStar[n - 1]! / stats.count : 0} tone="warn" label={`${n}`} /><span className="num right">{stats.byStar[n - 1]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('reviews.filter')} icon="search">
|
||||
<div className="form">
|
||||
<Seg value={f.store} onChange={store => setF(x => ({ ...x, store }))} label={t('reviews.store')} options={[['all', t('reviews.all')], ['apple', 'App Store'], ['play', 'Google Play']]} />
|
||||
<label className="check"><input type="checkbox" checked={f.unanswered} onChange={e => setF(x => ({ ...x, unanswered: e.target.checked }))} />{t('reviews.onlyUnanswered')}</label>
|
||||
<div className="search-input"><Icon name="search" /><input className="input" value={f.query} onChange={e => setF(x => ({ ...x, query: e.target.value }))} placeholder={t('reviews.search')} aria-label={t('reviews.search')} /></div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card title={t('reviews.inbox')} icon="message" sub={t('reviews.shown', { count: shown.length })} pad={false}>
|
||||
{shown.length ? (
|
||||
<div className="inbox" role="listbox" aria-label={t('reviews.inbox')}>
|
||||
{shown.map(r => {
|
||||
const dr = byFile.get(draftName(r));
|
||||
const k = `${r.store}:${r.id}`;
|
||||
return (
|
||||
<button key={k} role="option" aria-selected={cur === r} className="inbox-item" onClick={() => setSel(k)}>
|
||||
<span className="t1"><Stars n={r.rating} /><StoreGlyph store={r.store === 'apple' ? 'ios' : 'android'} className="ic13" /><span>{r.where ?? ''}</span><span className="grow" />
|
||||
{r.reply ? <Chip tone="ok" dot={false}>{t('reviews.answered')}</Chip> : dr?.approved ? <Chip tone="accent" dot={false}>{t('reviews.approved')}</Chip> : dr ? <Chip tone="neutral" dot={false}>{t('reviews.drafted')}</Chip> : null}
|
||||
<span>{date(r.date)}</span></span>
|
||||
{r.title && <span className="t2">{r.title}</span>}
|
||||
<span className="t3">{r.body}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <Empty compact illo="reviews" title={t('reviews.noMatch')} />}
|
||||
</Card>
|
||||
{cur ? <Detail r={cur} draft={byFile.get(draftName(cur))} busy={busy} onApprove={(dr, undo) => write('reviews.approve', { file: base(dr.file), ...(undo ? { undo: true } : {}) }, undo ? t('reviews.unapproved') : t('reviews.approvedToast'))} />
|
||||
: <Card className="rv-detail"><Empty compact illo="reviews" title={t('reviews.pick')} /></Card>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
// TestFlight: beta groups (internal / external, testers, builds, public link) and the recent builds with their
|
||||
// processing and beta review states.
|
||||
import type { Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ago, date, num } from '../../lib/format';
|
||||
import type { TestFlightStatus, Tone } from '../../lib/model';
|
||||
import { useData, useRead } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, Notice, Skeleton } from '../../ui/kit';
|
||||
|
||||
/** Beta state word -> tone and a short label. */
|
||||
export function betaState(s: string | null): { tone: Tone; text: string } {
|
||||
if (!s) return { tone: 'neutral', text: '–' };
|
||||
if (/IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING|VALID/.test(s)) return { tone: 'ok', text: s.replace(/_/g, ' ').toLowerCase() };
|
||||
if (/WAITING|IN_BETA_REVIEW|PROCESSING|READY_FOR_BETA_SUBMISSION/.test(s)) return { tone: 'warn', text: s.replace(/_/g, ' ').toLowerCase() };
|
||||
if (/EXPIRED/.test(s)) return { tone: 'neutral', text: s.replace(/_/g, ' ').toLowerCase() };
|
||||
if (/REJECTED|FAILED|INVALID|MISSING_EXPORT_COMPLIANCE/.test(s)) return { tone: 'bad', text: s.replace(/_/g, ' ').toLowerCase() };
|
||||
return { tone: 'neutral', text: s.replace(/_/g, ' ').toLowerCase() };
|
||||
}
|
||||
|
||||
function State({ s }: { s: string | null }) {
|
||||
const b = betaState(s);
|
||||
return s ? <Chip tone={b.tone}>{b.text}</Chip> : <span className="muted small">–</span>;
|
||||
}
|
||||
|
||||
export function TestFlightTab({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const e = d.statusOf(project);
|
||||
const r = useRead<TestFlightStatus>(project.id, 'apple.testflight_status', { network: true, title: 'TestFlight' });
|
||||
if (e && !e.app?.platforms.ios) return <Card pad={false}><Empty illo="plane" title={t('tf.noIos')}>{t('tf.noIosText')}</Empty></Card>;
|
||||
if (!r.data) {
|
||||
if (r.loading && !r.error) return <Card><Skeleton lines={4} block /></Card>;
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<Empty illo="plane" title={t('tf.emptyTitle')} actions={<button className="btn btn-primary" onClick={r.refresh}><Icon name="refresh" />{t('tf.read')}</button>}>{t('tf.emptyText')}</Empty>
|
||||
{r.error && <div className="card-foot"><Icon name="alertCircle" />{r.error}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const s = r.data;
|
||||
return (
|
||||
<>
|
||||
<div className="row between">
|
||||
<span className="stamp"><Icon name="clock" />{r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })}</span>
|
||||
<button className="btn btn-sm" onClick={r.refresh} disabled={r.loading}><Icon name="refresh" />{t('action.refresh')}</button>
|
||||
</div>
|
||||
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
|
||||
{s.missingGroups.length > 0 && <Notice tone="warn">{t('tf.missingGroups', { groups: s.missingGroups.join(', ') })}</Notice>}
|
||||
<div className="grid-cards">
|
||||
{s.groups.map(g => (
|
||||
<div key={g.id} className="tf-group">
|
||||
<div className="row between"><span className="strong">{g.name}</span><Chip tone={g.internal ? 'info' : 'accent'}>{g.internal ? t('tf.internal') : t('tf.external')}</Chip></div>
|
||||
<div className="stat">
|
||||
<div><b>{num(g.testers)}</b><span className="small muted">{t('tf.testers')}</span></div>
|
||||
<div><b>{g.builds === 'all' ? t('tf.all') : num(g.builds)}</b><span className="small muted">{t('tf.builds')}</span></div>
|
||||
</div>
|
||||
<div className="row small muted" style={{ gap: 6 }}><Icon name={g.publicLink ? 'globe' : 'users'} className="ic13" />{g.publicLink ? t('tf.publicOn') : t('tf.publicOff')}</div>
|
||||
</div>
|
||||
))}
|
||||
{!s.groups.length && <Card><Empty compact illo="plane" title={t('tf.noGroups')} /></Card>}
|
||||
</div>
|
||||
<Card title={t('tf.buildsTitle')} icon="cube" sub={t('tf.buildsSub')} pad={false}>
|
||||
{s.builds.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.version')}</th><th>{t('col.build')}</th><th>{t('tf.processing')}</th><th>{t('tf.internalTesting')}</th><th>{t('tf.externalTesting')}</th><th className="right">{t('tf.uploaded')}</th></tr></thead>
|
||||
<tbody>
|
||||
{s.builds.map((b, i) => (
|
||||
<tr key={i}>
|
||||
<td className="strong num">{b.version ?? '–'}</td><td className="num">{b.build ?? '–'}</td>
|
||||
<td><State s={b.processing} /></td><td><State s={b.internal} /></td><td><State s={b.external} /></td>
|
||||
<td className="right muted">{date(b.uploaded)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <Empty compact illo="plane" title={t('tf.noBuilds')} />}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
// App data shared by every screen: app folders, accounts, the sidecar, the portfolio (offline check + the last live
|
||||
// read from the cache), app icons, and background jobs shown as toasts with progress and cancel.
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type Account, api, type AppInfo, type AppStatus, type CacheEntry, errorText, newJob, onDownload, onProgress, type PortfolioResult, type Project, reads,
|
||||
type SidecarStatus, type ToolResult,
|
||||
} from './api';
|
||||
import { t } from './i18n';
|
||||
|
||||
export type ToastKind = 'job' | 'ok' | 'bad' | 'info';
|
||||
export interface Toast {
|
||||
id: string;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
detail?: string;
|
||||
/** null: running without a known total. */
|
||||
progress?: { done: number; total: number } | null;
|
||||
/** Cancellable job id. */
|
||||
job?: string;
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
ready: boolean;
|
||||
info: AppInfo | null;
|
||||
projects: Project[];
|
||||
accounts: Account[];
|
||||
sidecar: SidecarStatus | null;
|
||||
sidecarError: string | null;
|
||||
/** Latest portfolio read (offline check, or live when just read). */
|
||||
portfolio: PortfolioResult | null;
|
||||
/** Last live read of the stores (cached across runs). */
|
||||
live: CacheEntry<PortfolioResult> | null;
|
||||
portfolioBusy: 'offline' | 'live' | null;
|
||||
portfolioError: string | null;
|
||||
icons: Record<string, string | null>;
|
||||
refreshPortfolio: (live?: boolean) => Promise<void>;
|
||||
reload: () => Promise<void>;
|
||||
statusOf: (p: Project) => AppStatus | undefined;
|
||||
liveOf: (p: Project) => AppStatus | undefined;
|
||||
toasts: Toast[];
|
||||
toast: (t: Omit<Toast, 'id'> & { id?: string }) => string;
|
||||
updateToast: (id: string, patch: Partial<Toast>) => void;
|
||||
dismiss: (id: string) => void;
|
||||
cancelJob: (job: string) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<Data | null>(null);
|
||||
export const useData = (): Data => {
|
||||
const d = useContext(Ctx);
|
||||
if (!d) throw new Error('useData outside DataProvider');
|
||||
return d;
|
||||
};
|
||||
|
||||
const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(-1)?.replace(/^\S+ \[[^\]]+\] /, '') ?? '';
|
||||
|
||||
export function DataProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [info, setInfo] = useState<AppInfo | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [sidecar, setSidecar] = useState<SidecarStatus | null>(null);
|
||||
const [sidecarError, setSidecarError] = useState<string | null>(null);
|
||||
const [portfolio, setPortfolio] = useState<PortfolioResult | null>(null);
|
||||
const [live, setLive] = useState<CacheEntry<PortfolioResult> | null>(null);
|
||||
const [portfolioBusy, setBusy] = useState<Data['portfolioBusy']>(null);
|
||||
const [portfolioError, setPortfolioError] = useState<string | null>(null);
|
||||
const [icons, setIcons] = useState<Record<string, string | null>>({});
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts(ts => ts.filter(x => x.id !== id));
|
||||
const tm = timers.current.get(id);
|
||||
if (tm) { clearTimeout(tm); timers.current.delete(id); }
|
||||
}, []);
|
||||
const autoClose = useCallback((id: string, kind: ToastKind) => {
|
||||
if (kind === 'job') return;
|
||||
const old = timers.current.get(id);
|
||||
if (old) clearTimeout(old);
|
||||
timers.current.set(id, setTimeout(() => dismiss(id), kind === 'bad' ? 9000 : 4500));
|
||||
}, [dismiss]);
|
||||
const toast = useCallback((x: Omit<Toast, 'id'> & { id?: string }) => {
|
||||
const id = x.id ?? newJob('toast');
|
||||
setToasts(ts => [...ts.filter(o => o.id !== id), { ...x, id }].slice(-4));
|
||||
autoClose(id, x.kind);
|
||||
return id;
|
||||
}, [autoClose]);
|
||||
const updateToast = useCallback((id: string, patch: Partial<Toast>) => {
|
||||
setToasts(ts => ts.map(x => (x.id === id ? { ...x, ...patch } : x)));
|
||||
if (patch.kind) autoClose(id, patch.kind);
|
||||
}, [autoClose]);
|
||||
const cancelJob = useCallback((job: string) => { api.cancel(job).catch(() => {}); }, []);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [p, a] = await Promise.all([api.projects(), api.accounts()]);
|
||||
setProjects(p);
|
||||
setAccounts(a);
|
||||
}, []);
|
||||
|
||||
const refreshPortfolio = useCallback(async (liveRead = false) => {
|
||||
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
|
||||
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
|
||||
const tid = liveRead ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const r = await api.portfolio(liveRead, job);
|
||||
setPortfolio(r);
|
||||
if (liveRead) {
|
||||
setLive({ value: r, at: r.at });
|
||||
const failed = r.apps.filter(a => a.storeErrors.ios || a.storeErrors.android).length;
|
||||
if (tid) updateToast(tid, failed ? { kind: 'info', title: t('toast.readDonePartial'), detail: t('toast.readDonePartialDetail', { count: failed }), progress: undefined, job: undefined }
|
||||
: { kind: 'ok', title: t('toast.readDone'), detail: t('toast.readDoneDetail', { count: r.apps.length }), progress: undefined, job: undefined });
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = errorText(e);
|
||||
setPortfolioError(msg);
|
||||
if (tid) updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailed'), detail: msg, progress: undefined, job: undefined });
|
||||
} finally { setBusy(null); }
|
||||
}, [toast, updateToast]);
|
||||
|
||||
// start: app info, folders, accounts, sidecar, cached live portfolio
|
||||
useEffect(() => {
|
||||
api.appInfo().then(setInfo, () => {});
|
||||
reload().catch(() => {}).finally(() => setReady(true));
|
||||
api.sidecarStatus().then(s => { setSidecar(s); setSidecarError(null); }, e => setSidecarError(errorText(e)));
|
||||
api.cached<PortfolioResult>('portfolio.status', '*', { live: true }).then(e => { if (e) setLive(e); }, () => {});
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
|
||||
|
||||
// icons: cached first, then read (offline, cheap)
|
||||
useEffect(() => {
|
||||
for (const p of projects) {
|
||||
if (p.id in icons) continue;
|
||||
setIcons(m => ({ ...m, [p.id]: null }));
|
||||
api.cached<ToolResult & { data?: { dataUrl: string | null } }>('project.icon', p.id).then(c => {
|
||||
if (c?.value.data?.dataUrl) setIcons(m => ({ ...m, [p.id]: c.value.data!.dataUrl }));
|
||||
}, () => {}).finally(() => {
|
||||
reads.icon(p.id).then(r => setIcons(m => ({ ...m, [p.id]: r.data?.dataUrl ?? m[p.id] ?? null })), () => {});
|
||||
});
|
||||
}
|
||||
}, [projects]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// progress of jobs -> their toasts
|
||||
useEffect(() => {
|
||||
const un = onProgress(p => {
|
||||
if (!p.job) return;
|
||||
setToasts(ts => ts.map(x => {
|
||||
if (x.job !== p.job) return x;
|
||||
if (p.total) return { ...x, progress: { done: p.n ?? 0, total: p.total } };
|
||||
if (p.line) return { ...x, detail: lastLine(p.line) || x.detail };
|
||||
return x;
|
||||
}));
|
||||
});
|
||||
const unD = onDownload(e => {
|
||||
setToasts(ts => ts.map(x => (x.job === e.job && e.stage !== 'done' && e.stage !== 'error' && e.stage !== 'cancelled'
|
||||
? { ...x, detail: t(`dl.${e.stage}`), progress: e.total ? { done: e.received, total: e.total } : null } : x)));
|
||||
});
|
||||
return () => { un.then(f => f()); unD.then(f => f()); };
|
||||
}, []);
|
||||
|
||||
const statusOf = useCallback((p: Project) => portfolio?.apps.find(a => a.project === p.path || a.app?.id === p.path), [portfolio]);
|
||||
const liveOf = useCallback((p: Project) => {
|
||||
const cur = portfolio?.apps.find(a => (a.project === p.path || a.app?.id === p.path) && a.release.source === 'live');
|
||||
return cur ?? live?.value.apps.find(a => a.project === p.path || a.app?.id === p.path);
|
||||
}, [portfolio, live]);
|
||||
|
||||
const value = useMemo<Data>(() => ({
|
||||
ready, info, projects, accounts, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, icons,
|
||||
refreshPortfolio, reload, statusOf, liveOf, toasts, toast, updateToast, dismiss, cancelJob,
|
||||
}), [ready, info, projects, accounts, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError, icons, refreshPortfolio, reload, statusOf, liveOf, toasts, toast, updateToast, dismiss, cancelJob]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export interface ReadState<T> {
|
||||
data: T | null;
|
||||
/** When `data` was read (from the store or the cache). */
|
||||
at: string | null;
|
||||
fromCache: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** The command line it ran (shown with errors). */
|
||||
command: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One read method of one app. The cached value shows at once with its time; offline reads (`network: false`) then
|
||||
* refresh by themselves, store reads only on demand (they use the account's API quota). A failed read keeps the last
|
||||
* good value and says so: never an empty "all fine".
|
||||
*/
|
||||
export function useRead<T>(projectId: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string }): ReadState<T> {
|
||||
const d = useData();
|
||||
const argsKey = JSON.stringify(o.args ?? {});
|
||||
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
const seq = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const my = ++seq.current;
|
||||
const job = newJob(method);
|
||||
set(x => ({ ...x, loading: true, error: null }));
|
||||
const tid = o.network ? d.toast({ id: job, kind: 'job', title: o.title ?? t('toast.reading'), detail: t('toast.readingDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const r = await api.call<ToolResult>(method, projectId, { args: JSON.parse(argsKey) as Record<string, unknown> }, job);
|
||||
if (my !== seq.current) return;
|
||||
if (r.ok && r.data !== undefined) {
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command });
|
||||
if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined });
|
||||
} else {
|
||||
const msg = lastLine(r.output) || t('common.exit', { code: r.exitCode });
|
||||
set(x => ({ ...x, loading: false, error: msg, command: r.command }));
|
||||
if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: msg, progress: undefined, job: undefined });
|
||||
}
|
||||
} catch (e) {
|
||||
if (my !== seq.current) return;
|
||||
const msg = errorText(e);
|
||||
set(x => ({ ...x, loading: false, error: msg }));
|
||||
if (tid) d.updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailedWhat', { what: o.title ?? method }), detail: msg, progress: undefined, job: undefined });
|
||||
}
|
||||
}, [projectId, method, argsKey, o.network, o.title]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
set({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
api.cached<ToolResult>(method, projectId, JSON.parse(argsKey) as Record<string, unknown>).then(c => {
|
||||
if (!alive) return;
|
||||
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: !o.network, error: null, command: c.value.command });
|
||||
else set(x => ({ ...x, loading: !o.network }));
|
||||
if (!o.network) refresh();
|
||||
}, () => { if (alive) { set(x => ({ ...x, loading: !o.network })); if (!o.network) refresh(); } });
|
||||
return () => { alive = false; };
|
||||
}, [projectId, method, argsKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { ...s, refresh };
|
||||
}
|
||||
|
|
@ -1,634 +0,0 @@
|
|||
/* storebound desktop design system.
|
||||
Tokens follow storebound.dev (slate neutrals, indigo accent). Text tokens keep 7:1 contrast on every surface they
|
||||
are used on, in both themes (tests/theme.test.ts checks it). Scales: space 4-40, type 11-28, radius 6/10/14.
|
||||
Motion: 120-180 ms, none with reduced motion (system setting or Settings > Motion). */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--bg-sunken: #eef1f5;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f9fafb;
|
||||
--hover: #f1f4f8;
|
||||
--ink: #0b1220;
|
||||
--ink-2: #2b3648;
|
||||
--muted: #455066;
|
||||
--faint: #8a95a8;
|
||||
--line: #e4e7ec;
|
||||
--line-2: #d2d7e0;
|
||||
--accent: #3f35c2;
|
||||
--accent-solid: #5b55e8;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: #eef0ff;
|
||||
--accent-line: #c7cbfa;
|
||||
--focus: #5b55e8;
|
||||
|
||||
--ok-bg: #e3f7ea; --ok-ink: #11502b; --ok-solid: #1f9d55;
|
||||
--warn-bg: #fff3d6; --warn-ink: #6b3a06; --warn-solid: #d68a0c;
|
||||
--bad-bg: #ffe6ea; --bad-ink: #7f1230; --bad-solid: #e0355b;
|
||||
--info-bg: #e2f2fd; --info-ink: #0b4467; --info-solid: #1689d0;
|
||||
--neutral-bg: #eef1f5; --neutral-ink: #2b3648; --neutral-solid: #9aa4b5;
|
||||
|
||||
--illo-1: #eef0ff;
|
||||
--illo-2: #dfe3ff;
|
||||
--illo-line: #5b55e8;
|
||||
--illo-soft: #c9d0dc;
|
||||
|
||||
--code-bg: #0e1526;
|
||||
--code-ink: #dbe3f0;
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(16, 24, 40, .05);
|
||||
--shadow-2: 0 1px 2px rgba(16, 24, 40, .06), 0 8px 24px -12px rgba(16, 24, 40, .16);
|
||||
--shadow-3: 0 2px 6px rgba(16, 24, 40, .08), 0 24px 64px -16px rgba(16, 24, 40, .28);
|
||||
--scrim: rgba(15, 20, 32, .38);
|
||||
|
||||
--s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 20px; --s6: 24px; --s8: 32px; --s10: 40px;
|
||||
--r-sm: 6px; --r: 10px; --r-lg: 14px;
|
||||
--t-micro: 11px; --t-meta: 12px; --t-body: 13px; --t-em: 14px; --t-section: 15px; --t-title: 20px; --t-kpi: 26px;
|
||||
--font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", "Inter", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--speed: .16s;
|
||||
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0a0f1a; --bg-sunken: #080c16; --surface: #111827; --surface-2: #0e1522; --hover: #172133;
|
||||
--ink: #f3f6fb; --ink-2: #d3dbe7; --muted: #a9b5c7; --faint: #6b788d; --line: #1e2838; --line-2: #2b374b;
|
||||
--accent: #b4bcff; --accent-solid: #8189f8; --accent-ink: #0a0f1a; --accent-soft: #1b1f45; --accent-line: #3a3f86; --focus: #9aa2ff;
|
||||
--ok-bg: #0c2c1b; --ok-ink: #9ff0bf; --ok-solid: #34c172;
|
||||
--warn-bg: #33230a; --warn-ink: #ffd88a; --warn-solid: #eba22f;
|
||||
--bad-bg: #3a0f1c; --bad-ink: #ffb3c2; --bad-solid: #f2557a;
|
||||
--info-bg: #0b2a40; --info-ink: #a6dcff; --info-solid: #3aa7ec;
|
||||
--neutral-bg: #1a2333; --neutral-ink: #d3dbe7; --neutral-solid: #6b788d;
|
||||
--illo-1: #181d3d; --illo-2: #232a5a; --illo-line: #8f97ff; --illo-soft: #334058;
|
||||
--code-bg: #060a13; --code-ink: #dbe3f0;
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, .4);
|
||||
--shadow-2: 0 1px 2px rgba(0, 0, 0, .45), 0 12px 32px -16px rgba(0, 0, 0, .7);
|
||||
--shadow-3: 0 2px 8px rgba(0, 0, 0, .5), 0 30px 80px -20px rgba(0, 0, 0, .8);
|
||||
--scrim: rgba(2, 4, 10, .6);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0a0f1a; --bg-sunken: #080c16; --surface: #111827; --surface-2: #0e1522; --hover: #172133;
|
||||
--ink: #f3f6fb; --ink-2: #d3dbe7; --muted: #a9b5c7; --faint: #6b788d; --line: #1e2838; --line-2: #2b374b;
|
||||
--accent: #b4bcff; --accent-solid: #8189f8; --accent-ink: #0a0f1a; --accent-soft: #1b1f45; --accent-line: #3a3f86; --focus: #9aa2ff;
|
||||
--ok-bg: #0c2c1b; --ok-ink: #9ff0bf; --ok-solid: #34c172;
|
||||
--warn-bg: #33230a; --warn-ink: #ffd88a; --warn-solid: #eba22f;
|
||||
--bad-bg: #3a0f1c; --bad-ink: #ffb3c2; --bad-solid: #f2557a;
|
||||
--info-bg: #0b2a40; --info-ink: #a6dcff; --info-solid: #3aa7ec;
|
||||
--neutral-bg: #1a2333; --neutral-ink: #d3dbe7; --neutral-solid: #6b788d;
|
||||
--illo-1: #181d3d; --illo-2: #232a5a; --illo-line: #8f97ff; --illo-soft: #334058;
|
||||
--code-bg: #060a13; --code-ink: #dbe3f0;
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, .4);
|
||||
--shadow-2: 0 1px 2px rgba(0, 0, 0, .45), 0 12px 32px -16px rgba(0, 0, 0, .7);
|
||||
--shadow-3: 0 2px 8px rgba(0, 0, 0, .5), 0 30px 80px -20px rgba(0, 0, 0, .8);
|
||||
--scrim: rgba(2, 4, 10, .6);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { :root { --speed: 0s; } }
|
||||
:root[data-motion="reduce"] { --speed: 0s; }
|
||||
|
||||
/* ---------- base ---------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--ink);
|
||||
font: var(--t-body)/1.5 var(--font); font-feature-settings: "cv11", "ss01";
|
||||
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
||||
user-select: none; -webkit-user-select: none; cursor: default; overflow: hidden;
|
||||
}
|
||||
input, textarea, .selectable, pre, code, .mono-id { user-select: text; -webkit-user-select: text; }
|
||||
:focus { outline: none; }
|
||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: var(--r-sm); }
|
||||
h1, h2, h3, h4 { margin: 0; letter-spacing: -.012em; }
|
||||
p { margin: 0; }
|
||||
code, pre, .mono { font-family: var(--mono); font-size: 12px; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
button { font: inherit; color: inherit; }
|
||||
svg { flex: none; }
|
||||
.sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.skip { position: absolute; left: -9999px; top: 8px; z-index: 100; background: var(--ink); color: var(--bg); padding: 8px 12px; border-radius: var(--r-sm); }
|
||||
.skip:focus { left: 8px; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
/* ---------- shell ---------- */
|
||||
.shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); height: 100%; }
|
||||
.side { background: var(--bg-sunken); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; }
|
||||
.side-top { padding: 14px 12px 8px; display: grid; gap: 10px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 2px 6px; font-weight: 650; font-size: var(--t-em); letter-spacing: -.01em; }
|
||||
.brand-mark { width: 24px; height: 24px; }
|
||||
.brand small { margin-left: auto; font: 600 10px/1 var(--mono); color: var(--muted); border: 1px solid var(--line-2); padding: 3px 5px; border-radius: 5px; letter-spacing: .04em; }
|
||||
.search-btn {
|
||||
all: unset; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 8px 0 10px; border-radius: 8px; cursor: pointer;
|
||||
background: var(--surface); border: 1px solid var(--line); color: var(--muted); box-shadow: var(--shadow-1); transition: border-color var(--speed) var(--ease);
|
||||
}
|
||||
.search-btn:hover { border-color: var(--line-2); }
|
||||
.search-btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.search-btn svg { width: 15px; height: 15px; }
|
||||
.search-btn .lbl { flex: 1; text-align: left; white-space: nowrap; }
|
||||
.kbd { display: inline-flex; align-items: center; gap: 2px; font: 600 10.5px/1 var(--font); color: var(--muted); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 5px; padding: 3px 5px; background: var(--surface); }
|
||||
.side-scroll { flex: 1; overflow: auto; padding: 4px 8px 12px; min-height: 0; }
|
||||
.side-label { display: flex; align-items: center; justify-content: space-between; margin: 14px 10px 4px; font-size: var(--t-micro); font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
||||
.side-label button { all: unset; cursor: pointer; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 5px; color: var(--muted); }
|
||||
.side-label button:hover { background: var(--hover); color: var(--ink); }
|
||||
.side-label button:focus-visible { outline: 2px solid var(--focus); }
|
||||
.side-label svg { width: 14px; height: 14px; }
|
||||
.nav {
|
||||
all: unset; box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px; border-radius: 7px;
|
||||
color: var(--ink-2); font-weight: 520; cursor: pointer; transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease);
|
||||
}
|
||||
.nav + .nav { margin-top: 1px; }
|
||||
.nav svg { width: 16px; height: 16px; color: var(--muted); }
|
||||
.nav:hover { background: var(--hover); color: var(--ink); }
|
||||
.nav[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1), inset 0 0 0 1px var(--line); font-weight: 600; }
|
||||
.nav[aria-current="page"] svg { color: var(--accent-solid); }
|
||||
.nav:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
.nav .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav .count { font-size: var(--t-micro); color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.nav-app { height: 34px; }
|
||||
.nav-app .app-icon { width: 20px; height: 20px; border-radius: 5px; font-size: 8.5px; }
|
||||
.nav .pip { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.side-empty { color: var(--muted); font-size: var(--t-meta); padding: 6px 10px; }
|
||||
.side-foot { border-top: 1px solid var(--line); padding: 10px 14px; display: grid; gap: 6px; font-size: var(--t-meta); color: var(--muted); }
|
||||
.side-foot .row { display: flex; align-items: center; gap: 8px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--neutral-solid); flex: none; }
|
||||
.dot.ok { background: var(--ok-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok-solid) 22%, transparent); }
|
||||
.dot.bad { background: var(--bad-solid); }
|
||||
.dot.warn { background: var(--warn-solid); }
|
||||
|
||||
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.topbar { height: 52px; flex: none; display: flex; align-items: center; gap: 12px; padding: 0 24px 0 28px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 85%, var(--surface)); }
|
||||
.crumbs { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: var(--t-body); color: var(--muted); }
|
||||
.crumbs button { all: unset; cursor: pointer; color: var(--muted); border-radius: 5px; padding: 2px 4px; }
|
||||
.crumbs button:hover { color: var(--ink); }
|
||||
.crumbs button:focus-visible { outline: 2px solid var(--focus); }
|
||||
.crumbs .here { color: var(--ink); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.crumbs svg { width: 14px; height: 14px; color: var(--faint); }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.stamp { display: inline-flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); white-space: nowrap; }
|
||||
.stamp svg { width: 13px; height: 13px; }
|
||||
.dev-pill { font: 700 10px/1 var(--mono); letter-spacing: .06em; color: var(--warn-ink); background: var(--warn-bg); border: 1px solid color-mix(in srgb, var(--warn-solid) 45%, transparent); padding: 5px 7px; border-radius: 5px; }
|
||||
.content { flex: 1; overflow: auto; min-height: 0; scroll-behavior: smooth; }
|
||||
.page { padding: 24px 28px 48px; max-width: 1360px; margin: 0 auto; display: grid; gap: 20px; }
|
||||
@media (prefers-reduced-motion: reduce) { .content { scroll-behavior: auto; } }
|
||||
|
||||
/* ---------- page header ---------- */
|
||||
.ph { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
|
||||
.ph h1 { font-size: var(--t-title); font-weight: 650; line-height: 1.2; }
|
||||
.ph p { color: var(--muted); margin-top: 4px; max-width: 72ch; }
|
||||
.ph .actions { flex: none; }
|
||||
.actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.app-head { display: flex; gap: 16px; align-items: center; }
|
||||
.app-head .app-icon { width: 60px; height: 60px; border-radius: 14px; font-size: 20px; box-shadow: var(--shadow-2); }
|
||||
.app-head h1 { font-size: 22px; font-weight: 680; }
|
||||
.app-head .ids { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* ---------- surfaces ---------- */
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow-1); min-width: 0; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--line); min-height: 52px; }
|
||||
.card-head h2 { font-size: var(--t-em); font-weight: 650; display: flex; align-items: center; gap: 8px; }
|
||||
.card-head h2 > svg { width: 16px; height: 16px; color: var(--muted); }
|
||||
.card-head .sub { color: var(--muted); font-size: var(--t-meta); font-weight: 450; }
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card-foot { padding: 10px 18px; border-top: 1px solid var(--line); color: var(--muted); font-size: var(--t-meta); display: flex; align-items: center; gap: 8px; background: var(--surface-2); border-radius: 0 0 var(--r-lg) var(--r-lg); }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.g2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.g-main-side { grid-template-columns: minmax(0, 1fr) 340px; align-items: start; }
|
||||
@media (max-width: 1180px) { .g-main-side { grid-template-columns: 1fr; } .g3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.stack { display: grid; gap: 12px; }
|
||||
.row { display: flex; align-items: center; gap: 8px; }
|
||||
.between { justify-content: space-between; }
|
||||
.muted { color: var(--muted); }
|
||||
.ink2 { color: var(--ink-2); }
|
||||
.small { font-size: var(--t-meta); }
|
||||
.micro { font-size: var(--t-micro); }
|
||||
.strong { font-weight: 620; }
|
||||
.label { font-size: var(--t-micro); font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
||||
.divider { height: 1px; background: var(--line); border: 0; margin: 0; }
|
||||
.mono-id { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
|
||||
|
||||
/* ---------- KPI ---------- */
|
||||
.kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
@media (max-width: 1180px) { .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
.kpi { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 14px 16px; display: grid; gap: 6px; box-shadow: var(--shadow-1); position: relative; overflow: hidden; }
|
||||
.kpi .k-label { display: flex; align-items: center; gap: 6px; font-size: var(--t-meta); color: var(--muted); font-weight: 550; }
|
||||
.kpi .k-label svg { width: 14px; height: 14px; }
|
||||
.kpi .k-value { font-size: var(--t-kpi); font-weight: 680; letter-spacing: -.02em; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.kpi .k-sub { font-size: var(--t-meta); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.kpi.tone-bad .k-value { color: var(--bad-ink); }
|
||||
.kpi.tone-warn .k-value { color: var(--warn-ink); }
|
||||
.kpi::after { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: transparent; }
|
||||
.kpi.tone-bad::after { background: var(--bad-solid); }
|
||||
.kpi.tone-warn::after { background: var(--warn-solid); }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: 8px;
|
||||
font-weight: 600; font-size: var(--t-body); cursor: pointer; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); white-space: nowrap;
|
||||
box-shadow: var(--shadow-1); transition: background-color var(--speed) var(--ease), border-color var(--speed) var(--ease), box-shadow var(--speed) var(--ease);
|
||||
}
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn:hover { background: var(--hover); }
|
||||
.btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.btn[disabled], .btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--accent); border-color: transparent; color: var(--accent-ink); box-shadow: 0 1px 2px rgba(67, 56, 202, .3), inset 0 1px 0 rgba(255, 255, 255, .12); }
|
||||
.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, var(--ink)); }
|
||||
.btn-ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--ink-2); }
|
||||
.btn-ghost:hover { background: var(--hover); color: var(--ink); }
|
||||
.btn-danger { color: var(--bad-ink); }
|
||||
.btn-sm { height: 26px; padding: 0 9px; font-size: var(--t-meta); border-radius: 7px; gap: 5px; }
|
||||
.btn-sm svg { width: 13px; height: 13px; }
|
||||
.btn-icon { width: 32px; padding: 0; }
|
||||
.btn-sm.btn-icon { width: 26px; }
|
||||
.spin { animation: spin .9s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
:root[data-motion="reduce"] .spin { animation: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||||
|
||||
.seg { display: inline-flex; padding: 2px; gap: 2px; background: var(--bg-sunken); border: 1px solid var(--line); border-radius: 9px; }
|
||||
.seg button { all: unset; height: 26px; padding: 0 10px; border-radius: 7px; cursor: pointer; font-weight: 580; font-size: var(--t-meta); color: var(--muted); display: inline-flex; align-items: center; gap: 6px; }
|
||||
.seg button:hover { color: var(--ink); }
|
||||
.seg button[aria-pressed="true"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1), inset 0 0 0 1px var(--line); }
|
||||
.seg button:focus-visible { outline: 2px solid var(--focus); }
|
||||
.seg svg { width: 13px; height: 13px; }
|
||||
|
||||
/* ---------- chips, badges ---------- */
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border-radius: 999px; font-size: var(--t-micro); font-weight: 650; white-space: nowrap; letter-spacing: .01em; }
|
||||
.chip i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.chip svg { width: 12px; height: 12px; }
|
||||
.chip.ok { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.chip.warn { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.chip.bad { background: var(--bad-bg); color: var(--bad-ink); }
|
||||
.chip.info { background: var(--info-bg); color: var(--info-ink); }
|
||||
.chip.accent { background: var(--accent-soft); color: var(--accent); }
|
||||
.chip.neutral { background: var(--neutral-bg); color: var(--neutral-ink); }
|
||||
.chip.outline { background: transparent; color: var(--ink-2); box-shadow: inset 0 0 0 1px var(--line-2); }
|
||||
.chip.ok i { background: var(--ok-solid); } .chip.warn i { background: var(--warn-solid); } .chip.bad i { background: var(--bad-solid); }
|
||||
.chip.info i { background: var(--info-solid); } .chip.accent i { background: var(--accent-solid); } .chip.neutral i { background: var(--neutral-solid); }
|
||||
.store-badge { display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px 0 8px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); font-size: var(--t-meta); font-weight: 600; color: var(--ink-2); white-space: nowrap; }
|
||||
.store-badge svg { width: 14px; height: 14px; }
|
||||
.store-badge .sb-sep { width: 1px; height: 12px; background: var(--line-2); }
|
||||
.store-badge.off { color: var(--muted); border-style: dashed; background: transparent; }
|
||||
.glyph-ios { color: var(--ink); }
|
||||
.glyph-play { color: var(--ink); }
|
||||
.tag-count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; font-size: 10.5px; font-weight: 700; background: var(--neutral-bg); color: var(--neutral-ink); font-variant-numeric: tabular-nums; }
|
||||
.tag-count.bad { background: var(--bad-bg); color: var(--bad-ink); }
|
||||
.tag-count.warn { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
|
||||
/* ---------- app icon ---------- */
|
||||
.app-icon { width: 36px; height: 36px; border-radius: 9px; display: grid; place-items: center; overflow: hidden; flex: none; color: #fff; font-weight: 750; font-size: 13px; letter-spacing: .02em; position: relative; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .06); }
|
||||
.app-icon img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.app-icon.mono::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, .22), transparent 55%); }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
||||
.table th { text-align: left; font-size: var(--t-micro); font-weight: 650; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); padding: 9px 12px; background: var(--surface-2); border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
.table th:first-child { border-top-left-radius: var(--r-lg); padding-left: 18px; }
|
||||
.table th:last-child { border-top-right-radius: var(--r-lg); padding-right: 18px; }
|
||||
.table td { padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
.table td:first-child { padding-left: 18px; }
|
||||
.table td:last-child { padding-right: 18px; }
|
||||
.table tr:last-child td { border-bottom: 0; }
|
||||
.table tbody tr { transition: background-color var(--speed) var(--ease); }
|
||||
.table tbody tr.clickable { cursor: pointer; }
|
||||
.table tbody tr.clickable:hover { background: var(--hover); }
|
||||
.table .right { text-align: right; }
|
||||
.table .center { text-align: center; }
|
||||
.table .nowrap { white-space: nowrap; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.cell-app { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.cell-app .t { min-width: 0; }
|
||||
.cell-app .n { font-weight: 640; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cell-app .s { color: var(--muted); font-size: var(--t-meta); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
|
||||
.link { all: unset; cursor: pointer; color: var(--accent); font-weight: 600; border-radius: 4px; }
|
||||
.link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
/* ---------- mini lanes (portfolio) ---------- */
|
||||
.mini { display: grid; gap: 6px; justify-items: start; }
|
||||
.mini-row { display: grid; grid-template-columns: 16px 64px 74px 1fr; align-items: center; gap: 8px; font-size: var(--t-meta); }
|
||||
.mini-row svg { width: 14px; height: 14px; color: var(--ink-2); }
|
||||
.mini-row .v { font-weight: 640; font-variant-numeric: tabular-nums; }
|
||||
.pips { display: flex; align-items: center; gap: 3px; }
|
||||
.pips b { width: 14px; height: 4px; border-radius: 2px; background: var(--line-2); }
|
||||
.pips b.on { background: var(--accent-solid); }
|
||||
.pips b.ok { background: var(--ok-solid); } .pips b.warn { background: var(--warn-solid); } .pips b.bad { background: var(--bad-solid); } .pips b.info { background: var(--info-solid); }
|
||||
.pips .pct { margin-left: 6px; font-size: var(--t-micro); color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---------- release timeline ---------- */
|
||||
.tl { display: grid; gap: 0; }
|
||||
.tl-head, .tl-lane { display: grid; grid-template-columns: 200px repeat(5, minmax(0, 1fr)); align-items: center; }
|
||||
.tl-head { padding: 0 18px 8px; }
|
||||
.tl-head span { font-size: var(--t-micro); font-weight: 650; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); text-align: center; }
|
||||
.tl-head span:first-child { text-align: left; }
|
||||
.tl-lane { padding: 14px 18px; border-top: 1px solid var(--line); }
|
||||
.tl-who { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.tl-who svg { width: 18px; height: 18px; }
|
||||
.tl-who .w1 { font-weight: 640; }
|
||||
.tl-who .w2 { font-size: var(--t-meta); color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.tl-step { position: relative; display: grid; justify-items: center; gap: 6px; min-height: 52px; align-content: center; }
|
||||
.tl-step::before { content: ""; position: absolute; top: 50%; left: 0; right: 0; height: 2px; margin-top: -9px; background: var(--line-2); }
|
||||
.tl-step.first::before { left: 50%; }
|
||||
.tl-step:last-child::before { right: 50%; }
|
||||
.tl-step.done::before, .tl-step.cur::before { background: var(--accent-solid); }
|
||||
.tl-step.cur::before { right: 50%; }
|
||||
.tl-step.cur.first::before { display: none; }
|
||||
.tl-node { position: relative; z-index: 1; width: 18px; height: 18px; margin-top: -18px; border-radius: 50%; background: var(--surface); border: 2px solid var(--line-2); display: grid; place-items: center; }
|
||||
.tl-node svg { width: 10px; height: 10px; color: #fff; }
|
||||
.tl-step.done .tl-node { background: var(--accent-solid); border-color: var(--accent-solid); }
|
||||
.tl-step.cur .tl-node { border-width: 5px; background: var(--surface); }
|
||||
.tl-step.cur.ok .tl-node { border-color: var(--ok-solid); } .tl-step.cur.warn .tl-node { border-color: var(--warn-solid); }
|
||||
.tl-step.cur.bad .tl-node { border-color: var(--bad-solid); } .tl-step.cur.info .tl-node { border-color: var(--info-solid); }
|
||||
.tl-step.cur.accent .tl-node { border-color: var(--accent-solid); } .tl-step.cur.neutral .tl-node { border-color: var(--neutral-solid); }
|
||||
.tl-cap { position: absolute; top: calc(50% + 4px); font-size: var(--t-micro); color: var(--muted); white-space: nowrap; font-weight: 550; }
|
||||
.tl-step.cur .tl-cap { color: var(--ink); font-weight: 650; }
|
||||
.tl-roll { width: 76%; display: grid; gap: 4px; position: absolute; top: calc(50% + 2px); }
|
||||
.tl-roll .bar { height: 6px; }
|
||||
.tl-roll .pct { font-size: var(--t-micro); text-align: center; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||
.tl-none { grid-column: 2 / -1; color: var(--muted); font-size: var(--t-meta); text-align: center; border: 1px dashed var(--line-2); border-radius: 8px; padding: 10px; }
|
||||
.train-head { display: flex; align-items: center; gap: 10px; }
|
||||
.train-head .ver { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
|
||||
|
||||
.bar { height: 8px; border-radius: 99px; background: var(--neutral-bg); overflow: hidden; box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.bar > span { display: block; height: 100%; border-radius: inherit; background: var(--accent-solid); transition: width var(--speed) var(--ease); }
|
||||
.bar.ok > span { background: var(--ok-solid); } .bar.warn > span { background: var(--warn-solid); } .bar.bad > span { background: var(--bad-solid); } .bar.info > span { background: var(--info-solid); }
|
||||
.bar.thin { height: 4px; }
|
||||
|
||||
/* ---------- charts ---------- */
|
||||
.spark { width: 100%; height: 72px; display: block; overflow: visible; }
|
||||
.spark .area { opacity: 1; }
|
||||
.spark .stop-a { stop-color: var(--spark, var(--accent-solid)); stop-opacity: .28; }
|
||||
.spark .stop-b { stop-color: var(--spark, var(--accent-solid)); stop-opacity: 0; }
|
||||
.spark .line { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.spark .thr { stroke: var(--bad-solid); stroke-width: 1.25; stroke-dasharray: 4 4; vector-effect: non-scaling-stroke; }
|
||||
.spark.ok { --spark: var(--ok-solid); } .spark.ok .line { stroke: var(--ok-solid); }
|
||||
.spark.warn { --spark: var(--warn-solid); } .spark.warn .line { stroke: var(--warn-solid); }
|
||||
.spark.bad { --spark: var(--bad-solid); } .spark.bad .line { stroke: var(--bad-solid); }
|
||||
.spark.neutral .line, .spark.accent .line, .spark.info .line { stroke: var(--accent-solid); }
|
||||
.metric { display: grid; gap: 10px; }
|
||||
.metric-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.metric-val { font-size: 28px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
|
||||
.metric-val.bad { color: var(--bad-ink); } .metric-val.warn { color: var(--warn-ink); }
|
||||
.metric-legend { display: flex; gap: 14px; font-size: var(--t-micro); color: var(--muted); }
|
||||
.metric-legend .lg { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metric-legend .sw { width: 14px; height: 2px; background: var(--accent-solid); }
|
||||
.metric-legend .sw.ok { background: var(--ok-solid); } .metric-legend .sw.warn { background: var(--warn-solid); } .metric-legend .sw.bad { background: var(--bad-solid); }
|
||||
.metric-legend .sw.thr { background: repeating-linear-gradient(90deg, var(--bad-solid) 0 4px, transparent 4px 7px); }
|
||||
.axis { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.hbars { display: grid; gap: 8px; }
|
||||
.hbar { display: grid; grid-template-columns: minmax(0, 1fr) 120px 64px; gap: 12px; align-items: center; font-size: var(--t-meta); }
|
||||
.hbar .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ---------- catalog matrix ---------- */
|
||||
.mx-cell { display: inline-flex; align-items: center; gap: 6px; font-size: var(--t-meta); font-weight: 600; }
|
||||
.mx-icon { width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center; }
|
||||
.mx-icon svg { width: 12px; height: 12px; }
|
||||
.mx-ok { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.mx-warn { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.mx-error { background: var(--bad-bg); color: var(--bad-ink); }
|
||||
.mx-na { background: transparent; color: var(--faint); box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.mx-unknown { background: var(--neutral-bg); color: var(--neutral-ink); }
|
||||
.note-list { margin: 4px 0 0; padding: 0; list-style: none; display: grid; gap: 2px; color: var(--muted); font-size: var(--t-micro); }
|
||||
|
||||
/* ---------- listings grid ---------- */
|
||||
.lg-grid { display: grid; gap: 0; }
|
||||
.lg-row { display: grid; grid-template-columns: 150px repeat(var(--cols), minmax(0, 1fr)) 120px; align-items: center; gap: 10px; padding: 10px 18px; border-top: 1px solid var(--line); }
|
||||
.lg-row.head { border-top: 0; background: var(--surface-2); padding-top: 8px; padding-bottom: 8px; }
|
||||
.lg-row.head span { font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 650; }
|
||||
.lg-lang { display: flex; align-items: center; gap: 8px; font-weight: 640; }
|
||||
.lg-cell { display: grid; gap: 4px; }
|
||||
.lg-cell .bar { height: 5px; }
|
||||
.lg-cell .c { font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; display: flex; justify-content: space-between; }
|
||||
.lg-cell.missing .c { color: var(--bad-ink); font-weight: 650; }
|
||||
.lg-cell.over .c { color: var(--bad-ink); font-weight: 650; }
|
||||
.lg-cell.empty-optional .c { color: var(--muted); }
|
||||
.ring { --p: 0; width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||
background: conic-gradient(var(--ring, var(--accent-solid)) calc(var(--p) * 1%), var(--neutral-bg) 0); position: relative; }
|
||||
.ring::before { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: var(--surface); }
|
||||
.ring span { position: relative; display: grid; place-items: center; }
|
||||
.ring.full span { color: var(--ok-ink); }
|
||||
.ring svg { width: 15px; height: 15px; }
|
||||
|
||||
/* ---------- reviews ---------- */
|
||||
.rv { display: grid; grid-template-columns: 260px minmax(0, 1fr) minmax(0, 1.1fr); gap: 16px; align-items: start; }
|
||||
@media (max-width: 1240px) { .rv { grid-template-columns: 240px minmax(0, 1fr); } .rv-detail { grid-column: 1 / -1; } }
|
||||
.stars { display: inline-flex; gap: 1px; color: var(--warn-solid); }
|
||||
.stars svg { width: 12px; height: 12px; }
|
||||
.stars .off { color: var(--line-2); }
|
||||
.dist { display: grid; gap: 6px; }
|
||||
.dist-row { display: grid; grid-template-columns: 26px 1fr 30px; align-items: center; gap: 8px; font-size: var(--t-meta); font-variant-numeric: tabular-nums; }
|
||||
.dist-row .bar > span { background: var(--warn-solid); }
|
||||
.dist-row button { all: unset; cursor: pointer; display: contents; }
|
||||
.big-rating { display: flex; align-items: baseline; gap: 8px; }
|
||||
.big-rating b { font-size: 34px; font-weight: 720; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
|
||||
.inbox { display: grid; }
|
||||
.inbox-item { all: unset; box-sizing: border-box; display: grid; gap: 4px; padding: 12px 16px; border-top: 1px solid var(--line); cursor: pointer; position: relative; }
|
||||
.inbox-item:first-child { border-top: 0; }
|
||||
.inbox-item:hover { background: var(--hover); }
|
||||
.inbox-item[aria-selected="true"] { background: var(--accent-soft); }
|
||||
.inbox-item[aria-selected="true"]::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent-solid); }
|
||||
.inbox-item:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
.inbox-item .t1 { display: flex; align-items: center; gap: 8px; font-size: var(--t-meta); color: var(--muted); }
|
||||
.inbox-item .t1 .grow { flex: 1; }
|
||||
.inbox-item .t2 { font-weight: 620; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.inbox-item .t3 { color: var(--ink-2); font-size: var(--t-meta); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.quote { border-left: 3px solid var(--line-2); padding: 2px 0 2px 12px; color: var(--ink-2); white-space: pre-wrap; }
|
||||
.reply-box { border: 1px solid var(--line); border-radius: var(--r); padding: 12px 14px; background: var(--surface-2); white-space: pre-wrap; }
|
||||
.issues { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.issues li { display: flex; gap: 8px; align-items: flex-start; font-size: var(--t-meta); }
|
||||
.issues svg { width: 14px; height: 14px; margin-top: 1px; }
|
||||
.issues .e { color: var(--bad-ink); } .issues .w { color: var(--warn-ink); }
|
||||
|
||||
/* ---------- lists ---------- */
|
||||
.list { display: grid; }
|
||||
.li { display: flex; align-items: flex-start; gap: 12px; padding: 12px 18px; border-top: 1px solid var(--line); }
|
||||
.li:first-child { border-top: 0; }
|
||||
.li .li-icon { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; flex: none; }
|
||||
.li .li-icon svg { width: 15px; height: 15px; }
|
||||
.li .li-main { flex: 1; min-width: 0; display: grid; gap: 2px; }
|
||||
.li .li-title { font-weight: 600; }
|
||||
.li .li-sub { color: var(--muted); font-size: var(--t-meta); }
|
||||
.li.clickable { cursor: pointer; }
|
||||
.li.clickable:hover { background: var(--hover); }
|
||||
button.li { all: unset; box-sizing: border-box; display: flex; align-items: flex-start; gap: 12px; padding: 12px 18px; border-top: 1px solid var(--line); cursor: pointer; width: 100%; }
|
||||
button.li:first-child { border-top: 0; }
|
||||
button.li:hover { background: var(--hover); }
|
||||
button.li:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
.tone-bg-ok { background: var(--ok-bg); color: var(--ok-ink); } .tone-bg-warn { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.tone-bg-bad { background: var(--bad-bg); color: var(--bad-ink); } .tone-bg-info { background: var(--info-bg); color: var(--info-ink); }
|
||||
.tone-bg-neutral { background: var(--neutral-bg); color: var(--neutral-ink); } .tone-bg-accent { background: var(--accent-soft); color: var(--accent); }
|
||||
.kv { display: grid; grid-template-columns: minmax(96px, max-content) minmax(0, 1fr); gap: 8px 16px; font-size: var(--t-body); }
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; min-width: 0; }
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-top: -4px; }
|
||||
.tab { all: unset; display: inline-flex; align-items: center; gap: 7px; height: 38px; padding: 0 12px; font-weight: 600; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color var(--speed) var(--ease), border-color var(--speed) var(--ease); }
|
||||
.tab svg { width: 15px; height: 15px; }
|
||||
.tab:hover { color: var(--ink); }
|
||||
.tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent-solid); }
|
||||
.tab[aria-selected="true"] svg { color: var(--accent-solid); }
|
||||
.tab:focus-visible { outline: 2px solid var(--focus); outline-offset: -4px; border-radius: 8px; }
|
||||
|
||||
/* ---------- empty states ---------- */
|
||||
.empty { display: grid; justify-items: center; text-align: center; gap: 8px; padding: 40px 24px 44px; }
|
||||
.empty svg.illo { width: 168px; height: 112px; margin-bottom: 6px; }
|
||||
.empty h3 { font-size: var(--t-section); font-weight: 650; }
|
||||
.empty p { color: var(--muted); max-width: 52ch; }
|
||||
.empty .actions { margin-top: 10px; justify-content: center; }
|
||||
.empty.compact { padding: 24px 16px; }
|
||||
.empty.compact svg.illo { width: 120px; height: 80px; }
|
||||
.illo .f1 { fill: var(--illo-1); } .illo .f2 { fill: var(--illo-2); } .illo .s1 { stroke: var(--illo-line); fill: none; } .illo .s2 { stroke: var(--illo-soft); fill: none; }
|
||||
.illo .fl { fill: var(--illo-line); } .illo .fs { fill: var(--surface); } .illo .fsoft { fill: var(--illo-soft); }
|
||||
|
||||
/* ---------- skeletons ---------- */
|
||||
.sk { display: block; border-radius: 6px; background: linear-gradient(90deg, var(--neutral-bg) 0%, color-mix(in srgb, var(--neutral-bg) 55%, var(--surface)) 50%, var(--neutral-bg) 100%); background-size: 200% 100%; animation: sk 1.3s ease-in-out infinite; }
|
||||
@keyframes sk { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
||||
:root[data-motion="reduce"] .sk { animation: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .sk { animation: none; } }
|
||||
.sk-line { height: 10px; }
|
||||
.sk-block { height: 72px; border-radius: var(--r); }
|
||||
|
||||
/* ---------- notices ---------- */
|
||||
.notice { display: flex; gap: 10px; align-items: flex-start; border-radius: var(--r); padding: 10px 14px; font-size: var(--t-body); border: 1px solid transparent; }
|
||||
.notice svg { width: 16px; height: 16px; margin-top: 1px; flex: none; }
|
||||
.notice.bad { background: var(--bad-bg); color: var(--bad-ink); border-color: color-mix(in srgb, var(--bad-solid) 25%, transparent); }
|
||||
.notice.warn { background: var(--warn-bg); color: var(--warn-ink); border-color: color-mix(in srgb, var(--warn-solid) 25%, transparent); }
|
||||
.notice.ok { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.notice.info { background: var(--accent-soft); color: var(--ink); border-color: var(--accent-line); }
|
||||
.notice .grow { flex: 1; }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
.form { display: grid; gap: 14px; align-content: start; }
|
||||
.field { display: grid; gap: 6px; }
|
||||
.field label, .legend { font-weight: 600; font-size: var(--t-body); }
|
||||
.field .hint { color: var(--muted); font-size: var(--t-meta); }
|
||||
.input, select.input {
|
||||
font: inherit; color: var(--ink); background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; height: 34px; padding: 0 10px; width: 100%;
|
||||
box-shadow: var(--shadow-1); transition: border-color var(--speed) var(--ease), box-shadow var(--speed) var(--ease);
|
||||
}
|
||||
.input::placeholder { color: var(--faint); }
|
||||
.input:focus-visible { outline: none; border-color: var(--accent-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-solid) 25%, transparent); }
|
||||
select.input { padding-right: 8px; }
|
||||
.search-input { position: relative; }
|
||||
.search-input svg { position: absolute; left: 10px; top: 50%; width: 14px; height: 14px; margin-top: -7px; color: var(--muted); }
|
||||
.search-input .input { padding-left: 30px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: var(--t-meta); font-weight: 560; }
|
||||
.check input { width: 15px; height: 15px; accent-color: var(--accent-solid); margin: 0; }
|
||||
|
||||
/* ---------- log ---------- */
|
||||
.log { background: var(--code-bg); color: var(--code-ink); border-radius: var(--r); padding: 12px 14px; max-height: 260px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; font-size: 11.5px; line-height: 1.55; }
|
||||
.cmd { display: flex; align-items: center; gap: 8px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 8px; padding: 6px 6px 6px 10px; font-family: var(--mono); font-size: 11.5px; }
|
||||
.cmd code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ---------- overlays: palette, onboarding ---------- */
|
||||
.scrim { position: fixed; inset: 0; background: var(--scrim); z-index: 50; display: grid; place-items: start center; padding-top: 12vh; animation: fade var(--speed) var(--ease); }
|
||||
@keyframes fade { from { opacity: 0; } }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px) scale(.99); } }
|
||||
.palette { width: min(640px, calc(100vw - 48px)); background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow-3); overflow: hidden; animation: rise var(--speed) var(--ease); }
|
||||
.palette-in { display: flex; align-items: center; gap: 10px; padding: 0 16px; border-bottom: 1px solid var(--line); }
|
||||
.palette-in svg { width: 17px; height: 17px; color: var(--muted); }
|
||||
.palette-in input { all: unset; flex: 1; height: 52px; font-size: var(--t-section); color: var(--ink); }
|
||||
.palette-in input::placeholder { color: var(--faint); }
|
||||
.palette-list { max-height: 380px; overflow: auto; padding: 6px; }
|
||||
.palette-group { padding: 10px 10px 4px; font-size: var(--t-micro); font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
||||
.palette-item { display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 10px; border-radius: 8px; cursor: pointer; color: var(--ink-2); }
|
||||
.palette-item svg { width: 16px; height: 16px; color: var(--muted); }
|
||||
.palette-item .app-icon { width: 20px; height: 20px; border-radius: 5px; font-size: 8.5px; }
|
||||
.palette-item .grow { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.palette-item .hint { font-size: var(--t-micro); color: var(--muted); }
|
||||
.palette-item[aria-selected="true"] { background: var(--accent-soft); color: var(--ink); }
|
||||
.palette-item[aria-selected="true"] svg { color: var(--accent-solid); }
|
||||
.palette-foot { display: flex; gap: 14px; padding: 8px 14px; border-top: 1px solid var(--line); background: var(--surface-2); font-size: var(--t-micro); color: var(--muted); }
|
||||
.palette-foot span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.palette-empty { padding: 28px; text-align: center; color: var(--muted); }
|
||||
|
||||
.wizard-scrim { position: fixed; inset: 0; z-index: 40; background: var(--scrim); display: grid; place-items: center; padding: 32px; animation: fade var(--speed) var(--ease); }
|
||||
.wizard { width: min(980px, 100%); height: min(640px, 100%); display: grid; grid-template-columns: 280px minmax(0, 1fr); background: var(--surface); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow-3); overflow: hidden; animation: rise var(--speed) var(--ease); }
|
||||
.wz-side { background: var(--bg-sunken); border-right: 1px solid var(--line); padding: 24px 20px; display: flex; flex-direction: column; gap: 22px; }
|
||||
.wz-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
|
||||
.wz-step { all: unset; box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 12px; padding: 9px 10px; border-radius: 9px; color: var(--muted); cursor: pointer; font-weight: 560; }
|
||||
.wz-step:hover { background: var(--hover); color: var(--ink); }
|
||||
.wz-step:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
.wz-step[aria-current="step"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1), inset 0 0 0 1px var(--line); font-weight: 640; }
|
||||
.wz-num { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-size: var(--t-micro); font-weight: 700; border: 1.5px solid var(--line-2); color: var(--muted); background: var(--surface); flex: none; }
|
||||
.wz-num svg { width: 12px; height: 12px; }
|
||||
.wz-step[aria-current="step"] .wz-num { border-color: var(--accent-solid); color: var(--accent); }
|
||||
.wz-step.done .wz-num { background: var(--accent-solid); border-color: var(--accent-solid); color: #fff; }
|
||||
.wz-side .foot { margin-top: auto; font-size: var(--t-meta); color: var(--muted); display: grid; gap: 6px; }
|
||||
.wz-main { display: flex; flex-direction: column; min-height: 0; }
|
||||
.wz-body { flex: 1; overflow: auto; padding: 32px 36px 20px; display: grid; gap: 18px; align-content: start; }
|
||||
.wz-body h2 { font-size: 22px; font-weight: 680; letter-spacing: -.015em; }
|
||||
.wz-body .lead { color: var(--muted); font-size: var(--t-em); max-width: 60ch; line-height: 1.55; }
|
||||
.wz-foot { display: flex; align-items: center; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--line); background: var(--surface-2); }
|
||||
.wz-foot .grow { flex: 1; }
|
||||
.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.choice { all: unset; box-sizing: border-box; display: grid; gap: 6px; align-content: start; padding: 16px; border: 1px solid var(--line); border-radius: var(--r-lg); cursor: pointer; background: var(--surface); transition: border-color var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
|
||||
.choice:hover { border-color: var(--accent-line); box-shadow: var(--shadow-2); }
|
||||
.choice:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.choice[aria-pressed="true"] { border-color: var(--accent-solid); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-solid) 18%, transparent); }
|
||||
.choice .ch-icon { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); margin-bottom: 4px; }
|
||||
.choice .ch-icon svg { width: 18px; height: 18px; }
|
||||
.choice b { font-weight: 650; }
|
||||
.choice span { color: var(--muted); font-size: var(--t-meta); line-height: 1.45; }
|
||||
.checklist { display: grid; gap: 8px; }
|
||||
.ck { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); }
|
||||
.ck .ck-ic { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; flex: none; }
|
||||
.ck .ck-ic svg { width: 12px; height: 12px; }
|
||||
.ck .grow { flex: 1; min-width: 0; }
|
||||
.ck .t { font-weight: 600; }
|
||||
.ck .s { color: var(--muted); font-size: var(--t-meta); }
|
||||
|
||||
/* ---------- toasts ---------- */
|
||||
.toasts { position: fixed; right: 20px; bottom: 20px; z-index: 60; display: grid; gap: 10px; width: 360px; }
|
||||
.toast { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow-3); padding: 12px 14px; display: grid; gap: 8px; animation: rise var(--speed) var(--ease); }
|
||||
.toast-top { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.toast-top .ti { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; flex: none; }
|
||||
.toast-top .ti svg { width: 13px; height: 13px; }
|
||||
.toast-top .tt { flex: 1; min-width: 0; display: grid; gap: 2px; }
|
||||
.toast-top .tt b { font-weight: 640; }
|
||||
.toast-top .tt span { color: var(--muted); font-size: var(--t-meta); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.toast .bar { height: 5px; }
|
||||
.toast .bar.indet > span { width: 35% !important; animation: indet 1.2s ease-in-out infinite; }
|
||||
@keyframes indet { from { transform: translateX(-100%); } to { transform: translateX(290%); } }
|
||||
:root[data-motion="reduce"] .toast .bar.indet > span { animation: none; width: 100% !important; opacity: .5; }
|
||||
@media (prefers-reduced-motion: reduce) { .toast .bar.indet > span { animation: none; width: 100% !important; opacity: .5; } }
|
||||
.toast-actions { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
|
||||
/* ---------- misc ---------- */
|
||||
.grid-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.tf-group { padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--surface); display: grid; gap: 10px; box-shadow: var(--shadow-1); }
|
||||
.tf-group .stat { display: flex; gap: 18px; }
|
||||
.tf-group .stat div { display: grid; gap: 2px; }
|
||||
.tf-group .stat b { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.tool-row .ti { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--neutral-bg); color: var(--ink-2); flex: none; }
|
||||
.tool-row .ti svg { width: 17px; height: 17px; }
|
||||
.prog { display: grid; gap: 4px; min-width: 180px; }
|
||||
.prog .bar { height: 6px; }
|
||||
.prog .c { font-size: var(--t-micro); color: var(--muted); display: flex; justify-content: space-between; font-variant-numeric: tabular-nums; }
|
||||
.shortcuts { display: grid; grid-template-columns: 1fr auto; gap: 8px 24px; font-size: var(--t-body); }
|
||||
.shortcuts dd { margin: 0; display: flex; gap: 4px; justify-content: flex-end; }
|
||||
.legend-row { display: flex; gap: 16px; flex-wrap: wrap; font-size: var(--t-micro); color: var(--muted); }
|
||||
.legend-row span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.ic13 { width: 13px; height: 13px; }
|
||||
.ic14 { width: 14px; height: 14px; }
|
||||
.side-foot svg { width: 13px; height: 13px; }
|
||||
.fix { display: flex; align-items: flex-start; gap: 8px; margin-top: 6px; padding: 6px 10px; border-radius: 8px; background: var(--surface-2); border: 1px solid var(--line); color: var(--ink-2); }
|
||||
.fix svg { width: 13px; height: 13px; margin-top: 2px; color: var(--accent-solid); flex: none; }
|
||||
.fix code { font-size: 11.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.field > .seg { justify-self: start; }
|
||||
.wz-body svg.illo { width: 168px; height: 112px; margin-bottom: -6px; }
|
||||
h2[tabindex="-1"]:focus, h2[tabindex="-1"]:focus-visible { outline: none; }
|
||||
.acc-pick { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; }
|
||||
.acc-pick svg { width: 15px; height: 15px; color: var(--muted); }
|
||||
.li .app-icon { width: 40px; height: 40px; border-radius: 10px; }
|
||||
.app-head .store-badge { height: 30px; padding-right: 4px; }
|
||||
.app-head .store-badge .chip { height: 22px; }
|
||||
.mini-health { display: flex; align-items: center; gap: 8px; }
|
||||
.mini-health .spark { width: 76px; height: 26px; }
|
||||
.tbad { color: var(--bad-ink); } .twarn { color: var(--warn-ink); }
|
||||
.table .mini, .table .mini > * { white-space: nowrap; }
|
||||
.table .mini .row { flex-wrap: nowrap; }
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
// Command palette (Cmd/Ctrl+K): jump to an app, a tab, a screen, or run an action. Arrow keys, Enter, Esc.
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { t } from '../i18n';
|
||||
import { fuzzy } from '../lib/model';
|
||||
import { Icon, type IconName } from './icons';
|
||||
import { AppIcon, Kbd, MOD } from './kit';
|
||||
|
||||
export interface Command { id: string; group: string; title: string; hint?: string; icon?: IconName; app?: { name: string; icon?: string | null }; keywords?: string; run: () => void }
|
||||
|
||||
export function Palette({ commands, onClose }: { commands: Command[]; onClose: () => void }) {
|
||||
const [q, setQ] = useState('');
|
||||
const [sel, setSel] = useState(0);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
const before = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
before.current = document.activeElement;
|
||||
input.current?.focus();
|
||||
return () => { (before.current as HTMLElement | null)?.focus?.(); };
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!q.trim()) return commands;
|
||||
return commands.map(c => ({ c, s: Math.max(fuzzy(q, c.title), fuzzy(q, `${c.group} ${c.title} ${c.keywords ?? ''}`) - 1) }))
|
||||
.filter(x => x.s >= 0).sort((a, b) => b.s - a.s).map(x => x.c);
|
||||
}, [q, commands]);
|
||||
useEffect(() => { setSel(0); }, [q]);
|
||||
useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [sel]);
|
||||
|
||||
const run = (c: Command | undefined) => { if (!c) return; onClose(); c.run(); };
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(items.length - 1, s + 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); run(items[sel]); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
else if (e.key === 'Tab') e.preventDefault();
|
||||
};
|
||||
|
||||
const rows: ReactNode[] = [];
|
||||
let group = '';
|
||||
items.forEach((c, i) => {
|
||||
if (!q.trim() && c.group !== group) { group = c.group; rows.push(<div key={`g-${group}`} className="palette-group" role="presentation">{group}</div>); }
|
||||
rows.push(
|
||||
<div key={c.id} id={`pi-${i}`} role="option" aria-selected={i === sel} className="palette-item" onMouseMove={() => setSel(i)} onClick={() => run(c)}>
|
||||
{c.app ? <AppIcon name={c.app.name} src={c.app.icon} /> : <Icon name={c.icon ?? 'arrowRight'} />}
|
||||
<span className="grow">{c.title}</span>
|
||||
{c.hint && <span className="hint">{c.hint}</span>}
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="palette" role="dialog" aria-modal="true" aria-label={t('palette.title')} onKeyDown={onKey}>
|
||||
<div className="palette-in">
|
||||
<Icon name="search" />
|
||||
<input ref={input} value={q} onChange={e => setQ(e.target.value)} placeholder={t('palette.placeholder')} role="combobox" aria-expanded="true"
|
||||
aria-controls="palette-list" aria-activedescendant={items.length ? `pi-${sel}` : undefined} aria-autocomplete="list" spellCheck={false} />
|
||||
<Kbd keys={['Esc']} />
|
||||
</div>
|
||||
<div className="palette-list" id="palette-list" role="listbox" ref={list} aria-label={t('palette.results')}>
|
||||
{items.length ? rows : <div className="palette-empty">{t('palette.none', { q })}</div>}
|
||||
</div>
|
||||
<div className="palette-foot">
|
||||
<span><Kbd keys={['↑', '↓']} />{t('palette.move')}</span>
|
||||
<span><Kbd keys={['↵']} />{t('palette.open')}</span>
|
||||
<span><Kbd keys={[MOD, 'K']} />{t('palette.toggle')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { t } from '../i18n';
|
||||
import { bytes, num } from '../lib/format';
|
||||
import { useData } from '../store';
|
||||
import { Icon, type IconName } from './icons';
|
||||
|
||||
const ICON: Record<string, [IconName, string]> = { job: ['refresh', 'tone-bg-accent'], ok: ['check', 'tone-bg-ok'], bad: ['alertCircle', 'tone-bg-bad'], info: ['info', 'tone-bg-info'] };
|
||||
|
||||
/** Background jobs and results, bottom right. Jobs show progress and a Cancel button; results close by themselves. */
|
||||
export function Toasts() {
|
||||
const d = useData();
|
||||
return (
|
||||
<div className="toasts" aria-live="polite" aria-relevant="additions text">
|
||||
{d.toasts.map(x => {
|
||||
const [icon, cls] = ICON[x.kind]!;
|
||||
const p = x.progress;
|
||||
const big = p && p.total > 10_000;
|
||||
return (
|
||||
<div key={x.id} className="toast" role={x.kind === 'bad' ? 'alert' : 'status'}>
|
||||
<div className="toast-top">
|
||||
<span className={`ti ${cls}`}><Icon name={icon} className={x.kind === 'job' ? 'spin' : undefined} /></span>
|
||||
<div className="tt"><b>{x.title}</b>{x.detail && <span>{x.detail}</span>}</div>
|
||||
<button className="btn btn-ghost btn-sm btn-icon" onClick={() => d.dismiss(x.id)} aria-label={t('common.close')}><Icon name="x" /></button>
|
||||
</div>
|
||||
{x.kind === 'job' && (
|
||||
<>
|
||||
{p ? (
|
||||
<div className="prog">
|
||||
<div className="bar"><span style={{ width: `${Math.min(100, (p.done / Math.max(1, p.total)) * 100)}%` }} /></div>
|
||||
<div className="c"><span>{big ? `${bytes(p.done)} / ${bytes(p.total)}` : `${num(p.done)} / ${num(p.total)}`}</span><span>{Math.round((p.done / Math.max(1, p.total)) * 100)}%</span></div>
|
||||
</div>
|
||||
) : <div className="bar indet"><span /></div>}
|
||||
{x.job && <div className="toast-actions"><button className="btn btn-sm" onClick={() => d.cancelJob(x.job!)}>{t('common.cancel')}</button></div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// One stroke icon set (24 x 24 grid, 1.75 stroke, round caps), drawn for this app. Decorative: aria-hidden.
|
||||
const P = {
|
||||
portfolio: 'M4 4h6.5v6.5H4zM13.5 4H20v6.5h-6.5zM4 13.5h6.5V20H4zM13.5 13.5H20V20h-6.5z',
|
||||
folder: 'M3.5 7A1.5 1.5 0 0 1 5 5.5h4l2 2h8A1.5 1.5 0 0 1 20.5 9v8.5A1.5 1.5 0 0 1 19 19H5a1.5 1.5 0 0 1-1.5-1.5z',
|
||||
folderPlus: 'M3.5 7A1.5 1.5 0 0 1 5 5.5h4l2 2h8A1.5 1.5 0 0 1 20.5 9v8.5A1.5 1.5 0 0 1 19 19H5a1.5 1.5 0 0 1-1.5-1.5zM12 10.5v5M9.5 13h5',
|
||||
key: 'M8 20a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM11.2 12.3 20 3.5M16.5 7l2.5 2.5M14.2 9.3l2 2',
|
||||
package: 'M12 3 20 7.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9',
|
||||
settings: 'M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4zM19.1 13.6l1.4 1.1-1.8 3.1-1.7-.6a7 7 0 0 1-1.9 1.1l-.3 1.8h-3.6l-.3-1.8a7 7 0 0 1-1.9-1.1l-1.7.6-1.8-3.1 1.4-1.1a7 7 0 0 1 0-2.2L3.5 9.3l1.8-3.1 1.7.6A7 7 0 0 1 8.9 5.7l.3-1.8h3.6l.3 1.8a7 7 0 0 1 1.9 1.1l1.7-.6 1.8 3.1-1.4 1.1a7 7 0 0 1 0 2.2z',
|
||||
search: 'M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM15.3 15.3 20 20',
|
||||
refresh: 'M20 11a8 8 0 0 0-14.3-4.9L4 8M4 4v4h4M4 13a8 8 0 0 0 14.3 4.9L20 16M20 20v-4h-4',
|
||||
chevronRight: 'M9.5 6l6 6-6 6',
|
||||
chevronDown: 'M6 9.5l6 6 6-6',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
x: 'M6 6l12 12M18 6 6 18',
|
||||
check: 'M5 12.5l4.5 4.5L19 7.5',
|
||||
alert: 'M12 4 21 19.5H3zM12 10v4.5M12 17.2v.1',
|
||||
alertCircle: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7.5v5.5M12 16.3v.1',
|
||||
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5.5M12 7.7v.1',
|
||||
external: 'M14 4h6v6M20 4l-8.5 8.5M18 14v4.5A1.5 1.5 0 0 1 16.5 20h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10',
|
||||
download: 'M12 4v11M7.5 10.5 12 15l4.5-4.5M4.5 19.5h15',
|
||||
trash: 'M4.5 7h15M9.5 7V4.5h5V7M6.5 7l1 12.5h9l1-12.5M10.5 11v5M13.5 11v5',
|
||||
star: 'M12 3.8l2.5 5.1 5.6.8-4 4 1 5.5-5.1-2.7-5 2.7 1-5.5-4.1-4 5.7-.8z',
|
||||
message: 'M5 5.5h14A1.5 1.5 0 0 1 20.5 7v9a1.5 1.5 0 0 1-1.5 1.5h-7l-4.5 3v-3H5A1.5 1.5 0 0 1 3.5 16V7A1.5 1.5 0 0 1 5 5.5z',
|
||||
activity: 'M3.5 12h4l2.5-6.5 4 13 2.5-6.5h4',
|
||||
tag: 'M3.5 12.3V4.5a1 1 0 0 1 1-1h7.8l8.2 8.2a1.4 1.4 0 0 1 0 2L13.7 20.5a1.4 1.4 0 0 1-2 0zM8 8.2v.1',
|
||||
text: 'M6 3.5h8.5L19 8v12.5H6zM14 3.5V8.5h5M9 12.5h7M9 16h5',
|
||||
plane: 'M21 3.5 10.5 14M21 3.5l-6.5 17-4-6.5-6.5-4z',
|
||||
rocket: 'M14 4.5c3-1 5.5-1 5.5-1s0 2.5-1 5.5L12 15.5 8.5 12zM8.5 12l-3 .5-2 2 4 1M12 15.5l-.5 3-2 2-1-4M15.5 8.5v.1',
|
||||
shield: 'M12 3.5 19 6v5.5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6zM8.8 12l2.2 2.2 4.4-4.4',
|
||||
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3.5 2',
|
||||
command: 'M9 6.5A2.5 2.5 0 1 0 6.5 9H9zm0 0v11m0-11h6m-6 11a2.5 2.5 0 1 1-2.5-2.5H9zm6-11A2.5 2.5 0 1 1 17.5 9H15zm0 0v11m0 0a2.5 2.5 0 1 0 2.5-2.5H15zM9 9h6M9 15h6',
|
||||
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3.5 12h17M12 3c2.5 2.5 3.5 5.5 3.5 9s-1 6.5-3.5 9c-2.5-2.5-3.5-5.5-3.5-9S9.5 5.5 12 3z',
|
||||
moon: 'M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10z',
|
||||
sun: 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4',
|
||||
terminal: 'M4.5 5h15A1.5 1.5 0 0 1 21 6.5v11a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5v-11A1.5 1.5 0 0 1 4.5 5zM7 9.5l3 2.5-3 2.5M12.5 15h4',
|
||||
copy: 'M9 9h10.5v10.5H9zM15 9V4.5H4.5V15H9',
|
||||
image: 'M5 4.5h14A1.5 1.5 0 0 1 20.5 6v12a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18V6A1.5 1.5 0 0 1 5 4.5zM3.5 16l5-5 4 4 2.5-2.5 5.5 5.5M15.5 9.5v.1',
|
||||
users: 'M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM2.5 20a6.5 6.5 0 0 1 13 0M16 4.3a3.5 3.5 0 0 1 0 6.4M18 14a6.5 6.5 0 0 1 3.5 6',
|
||||
layers: 'M12 3.5 21 8l-9 4.5L3 8zM3 12.5l9 4.5 9-4.5M3 16.5l9 4.5 9-4.5',
|
||||
sparkle: 'M12 3.5c.6 4 2.4 5.9 6.5 6.5-4.1.6-5.9 2.5-6.5 6.5-.6-4-2.4-5.9-6.5-6.5 4.1-.6 5.9-2.5 6.5-6.5zM18.5 15.5c.3 1.6 1 2.3 2.5 2.5-1.5.2-2.2.9-2.5 2.5-.3-1.6-1-2.3-2.5-2.5 1.5-.2 2.2-.9 2.5-2.5z',
|
||||
pause: 'M8.5 5.5v13M15.5 5.5v13',
|
||||
cube: 'M12 3.5 20 8v8l-8 4.5L4 16V8zM12 12.5V20.5M12 12.5 20 8M12 12.5 4 8',
|
||||
arrowRight: 'M5 12h14M13 6l6 6-6 6',
|
||||
arrowLeft: 'M19 12H5M11 6l-6 6 6 6',
|
||||
dot: 'M12 12.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1z',
|
||||
phone: 'M7.5 3h9A1.5 1.5 0 0 1 18 4.5v15a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19.5v-15A1.5 1.5 0 0 1 7.5 3zM10.5 18h3',
|
||||
keyboard: 'M3.5 6.5h17A1 1 0 0 1 21.5 7.5v9a1 1 0 0 1-1 1h-17a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1zM6.5 10h.1M10 10h.1M13.5 10h.1M17 10h.1M7.5 14h9',
|
||||
database: 'M12 8c4.4 0 8-1.3 8-3s-3.6-3-8-3-8 1.3-8 3 3.6 3 8 3zM4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3',
|
||||
wand: 'M14.5 6.5l3 3M4 20 17.5 6.5l-3-3L1 17zM19 2.5v3M20.5 4h-3M21 10v2M22 11h-2',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof P;
|
||||
|
||||
export function Icon({ name, className, title }: { name: IconName; className?: string; title?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden={title ? undefined : true} role={title ? 'img' : undefined}>
|
||||
{title && <title>{title}</title>}
|
||||
<path d={P[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic store glyphs (not the stores' logos): a rounded "A" tile for the App Store, a play triangle for Google Play. */
|
||||
export function StoreGlyph({ store, className }: { store: 'ios' | 'android'; className?: string }) {
|
||||
return store === 'ios' ? (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="5" />
|
||||
<path d="M9 16.5 12.6 7.5M15 16.5l-2.2-5.4M8 13.5h8" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinejoin="round">
|
||||
<path d="M6 3.8v16.4a.8.8 0 0 0 1.2.7l13.6-8.2a.8.8 0 0 0 0-1.4L7.2 3.1A.8.8 0 0 0 6 3.8z" />
|
||||
<path d="M6.3 3.4 14.5 12l-8.2 8.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The storebound mark (an arrow out of a tray, like the site's favicon). */
|
||||
export function Mark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" className={className} aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="sb-mark" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#6d67f0" />
|
||||
<stop offset="1" stopColor="#4338ca" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#sb-mark)" />
|
||||
<path d="M16 7.5v11M11 12.5l5-5 5 5M8.5 18v5.5h15V18" fill="none" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
// Empty state illustrations: inline SVG in the theme's tokens (no external assets, both themes). Decorative.
|
||||
import type { ReactNode } from 'react';
|
||||
const Frame = ({ children }: { children: ReactNode }) => (
|
||||
<svg className="illo" viewBox="0 0 168 112" aria-hidden="true" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="84" cy="100" rx="58" ry="6" className="f1" />
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type IlloName = 'apps' | 'cloud' | 'reviews' | 'shield' | 'key' | 'plane' | 'pulse' | 'catalog' | 'text' | 'tools' | 'unknown';
|
||||
|
||||
export function Illo({ name }: { name: IlloName }) {
|
||||
switch (name) {
|
||||
case 'apps': return (
|
||||
<Frame>
|
||||
<rect x="40" y="22" width="40" height="70" rx="9" className="f2" />
|
||||
<rect x="64" y="14" width="44" height="78" rx="10" className="fs s1" />
|
||||
<rect x="72" y="26" width="28" height="28" rx="7" className="f1" />
|
||||
<path d="M80 72h12M80 79h8" className="s2" />
|
||||
<circle cx="118" cy="28" r="13" className="fl" />
|
||||
<path d="M118 22v12M112 28h12" stroke="#fff" />
|
||||
</Frame>
|
||||
);
|
||||
case 'cloud': return (
|
||||
<Frame>
|
||||
<path d="M54 66a16 16 0 0 1 3-31.7A22 22 0 0 1 99 30a17 17 0 0 1 14 36z" className="f2" />
|
||||
<path d="M60 62a14 14 0 0 1 2.6-27.7A19 19 0 0 1 98.5 32 15 15 0 0 1 110 62" className="s1" />
|
||||
<path d="M84 50v32M75 73l9 9 9-9" className="s1" />
|
||||
<path d="M60 92h48" className="s2" strokeDasharray="3 5" />
|
||||
</Frame>
|
||||
);
|
||||
case 'reviews': return (
|
||||
<Frame>
|
||||
<path d="M38 24h70a8 8 0 0 1 8 8v30a8 8 0 0 1-8 8H66l-14 12V70H38a8 8 0 0 1-8-8V32a8 8 0 0 1 8-8z" className="f2" />
|
||||
<path d="M60 36h66a8 8 0 0 1 8 8v28a8 8 0 0 1-8 8h-12v12l-14-12H60a8 8 0 0 1-8-8V44a8 8 0 0 1 8-8z" className="fs s1" />
|
||||
{[72, 86, 100, 114].map((x, i) => <path key={x} d={`M${x} 52l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7z`} className={i < 3 ? 'fl' : 'f2'} strokeWidth="0" />)}
|
||||
</Frame>
|
||||
);
|
||||
case 'shield': return (
|
||||
<Frame>
|
||||
<path d="M84 12 116 24v26c0 22-14 36-32 44-18-8-32-22-32-44V24z" className="f2" />
|
||||
<path d="M84 20 110 30v20c0 18-11 30-26 37-15-7-26-19-26-37V30z" className="fs s1" />
|
||||
<path d="M71 52l9 9 18-18" className="s1" strokeWidth="3" />
|
||||
</Frame>
|
||||
);
|
||||
case 'key': return (
|
||||
<Frame>
|
||||
<rect x="34" y="30" width="100" height="58" rx="10" className="f2" />
|
||||
<rect x="42" y="22" width="84" height="58" rx="10" className="fs s1" />
|
||||
<circle cx="70" cy="51" r="11" className="s1" />
|
||||
<path d="M81 51h30M103 51v8M111 51v6" className="s1" />
|
||||
<path d="M54 70h20" className="s2" />
|
||||
</Frame>
|
||||
);
|
||||
case 'plane': return (
|
||||
<Frame>
|
||||
<path d="M30 80c20-6 44-6 64-22" className="s2" strokeDasharray="3 6" />
|
||||
<path d="M136 18 58 50l28 10 10 28z" className="f2" />
|
||||
<path d="M136 18 58 50l28 10 10 28zM136 18 86 60" className="s1" />
|
||||
</Frame>
|
||||
);
|
||||
case 'pulse': return (
|
||||
<Frame>
|
||||
<rect x="26" y="20" width="116" height="68" rx="12" className="f2" />
|
||||
<rect x="34" y="14" width="100" height="68" rx="11" className="fs s1" />
|
||||
<path d="M44 56h16l7-18 12 34 8-22 6 6h21" className="s1" strokeWidth="2.5" />
|
||||
<path d="M44 40h80" className="s2" strokeDasharray="4 5" />
|
||||
</Frame>
|
||||
);
|
||||
case 'catalog': return (
|
||||
<Frame>
|
||||
<rect x="36" y="26" width="44" height="54" rx="9" className="f2" />
|
||||
<rect x="88" y="26" width="44" height="54" rx="9" className="f2" />
|
||||
<rect x="44" y="18" width="80" height="60" rx="10" className="fs s1" />
|
||||
<path d="M56 36h14M56 48h14M56 60h14" className="s2" />
|
||||
<path d="M86 34l4 4 7-7M86 46l4 4 7-7M86 58l4 4 7-7" className="s1" />
|
||||
<path d="M106 34l8 0M106 46l8 0M106 58l8 0" className="s2" />
|
||||
</Frame>
|
||||
);
|
||||
case 'text': return (
|
||||
<Frame>
|
||||
<rect x="46" y="18" width="70" height="78" rx="9" className="f2" />
|
||||
<rect x="54" y="10" width="70" height="78" rx="9" className="fs s1" />
|
||||
<path d="M66 28h46M66 40h40M66 52h46M66 64h28" className="s2" />
|
||||
<circle cx="122" cy="76" r="12" className="fl" />
|
||||
<path d="M117 76l4 4 7-8" stroke="#fff" />
|
||||
</Frame>
|
||||
);
|
||||
case 'tools': return (
|
||||
<Frame>
|
||||
<path d="M84 14 118 32v38L84 88 50 70V32z" className="f2" />
|
||||
<path d="M84 22 112 37v31L84 83 56 68V37zM56 37l28 15 28-15M84 52v31" className="s1" />
|
||||
</Frame>
|
||||
);
|
||||
default: return (
|
||||
<Frame>
|
||||
<circle cx="84" cy="52" r="34" className="f2" />
|
||||
<circle cx="84" cy="52" r="26" className="s1" strokeDasharray="5 6" />
|
||||
<path d="M76 45a8 8 0 1 1 11 7.5c-2 .8-3 2-3 4v2M84 66v.1" className="s1" strokeWidth="3" />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
// Shared UI pieces of the design system (styles.css): chips, store badges, app icons, KPI tiles, bars, sparklines,
|
||||
// stars, notices, skeletons, empty states, segmented controls, cards.
|
||||
import { type ReactNode, useId } from 'react';
|
||||
import type { ReleasePhase, StoreRelease } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { share } from '../lib/format';
|
||||
import { hueOf, monogram, type Tone } from '../lib/model';
|
||||
import { Icon, type IconName, StoreGlyph } from './icons';
|
||||
import { Illo, type IlloName } from './illustrations';
|
||||
|
||||
export function Chip({ tone = 'neutral', children, dot = true, title, icon }: { tone?: Tone | 'outline'; children: ReactNode; dot?: boolean; title?: string; icon?: IconName }) {
|
||||
return <span className={`chip ${tone}`} title={title}>{icon ? <Icon name={icon} /> : dot && tone !== 'outline' && <i />}{children}</span>;
|
||||
}
|
||||
|
||||
const PHASE_TONE: Record<ReleasePhase, Tone> = { live: 'ok', rollout: 'accent', halted: 'bad', approved: 'ok', review: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' };
|
||||
|
||||
/** Chip of a store release: "Live", "Rollout 20%", "In review" ... (iOS phased release: "Phased 50%", "Paused"). */
|
||||
export function phaseLabel(r: StoreRelease | null | undefined): { text: string; tone: Tone } {
|
||||
if (!r) return { text: t('phase.none'), tone: 'neutral' };
|
||||
const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : '';
|
||||
if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' };
|
||||
if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' };
|
||||
if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' };
|
||||
return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] };
|
||||
}
|
||||
|
||||
export function PhaseChip({ r }: { r: StoreRelease | null | undefined }) {
|
||||
const p = phaseLabel(r);
|
||||
return <Chip tone={p.tone} title={r?.state}>{p.text}</Chip>;
|
||||
}
|
||||
|
||||
export const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play');
|
||||
|
||||
/** "App Store · com.example.app" badge, dashed when the app is not on that store. */
|
||||
export function StoreBadge({ store, id, off, children }: { store: 'ios' | 'android'; id?: string | null; off?: boolean; children?: ReactNode }) {
|
||||
return (
|
||||
<span className={`store-badge${off ? ' off' : ''}`} title={off ? t('app.notSetUp') : id ?? undefined}>
|
||||
<StoreGlyph store={store} className={store === 'ios' ? 'glyph-ios' : 'glyph-play'} />
|
||||
{storeName(store)}
|
||||
{id && <><span className="sb-sep" /><span className="mono-id">{id}</span></>}
|
||||
{off && <span className="muted">· {t('app.notSetUp')}</span>}
|
||||
{children && <><span className="sb-sep" />{children}</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The app's own icon when the project has one, else a monogram on a gradient picked from the name. */
|
||||
export function AppIcon({ name, src, className = '' }: { name: string; src?: string | null; className?: string }) {
|
||||
if (src) return <span className={`app-icon ${className}`}><img src={src} alt="" draggable={false} /></span>;
|
||||
const h = hueOf(name);
|
||||
return (
|
||||
<span className={`app-icon mono ${className}`} style={{ background: `linear-gradient(135deg, hsl(${h} 72% 58%), hsl(${(h + 40) % 360} 70% 44%))` }} aria-hidden="true">
|
||||
{monogram(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Kpi({ label, value, sub, icon, tone }: { label: string; value: ReactNode; sub?: ReactNode; icon?: IconName; tone?: Tone }) {
|
||||
return (
|
||||
<div className={`kpi${tone ? ` tone-${tone}` : ''}`}>
|
||||
<div className="k-label">{icon && <Icon name={icon} />}{label}</div>
|
||||
<div className="k-value">{value}</div>
|
||||
{sub !== undefined && <div className="k-sub">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bar({ value, tone, thin, label }: { value: number; tone?: Tone; thin?: boolean; label?: string }) {
|
||||
const v = Math.max(0, Math.min(1, value));
|
||||
return (
|
||||
<div className={`bar${tone && tone !== 'accent' ? ` ${tone}` : ''}${thin ? ' thin' : ''}`} role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(v * 100)} aria-label={label}>
|
||||
<span style={{ width: `${v * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Line + area sparkline with a dashed threshold line. Values are fractions; the y range always shows the threshold. */
|
||||
export function Sparkline({ values, threshold, tone = 'neutral', label }: { values: number[]; threshold?: number; tone?: Tone; label: string }) {
|
||||
const W = 300, H = 72, pad = 4;
|
||||
const gid = useId().replace(/:/g, '');
|
||||
if (!values.length) return <div className="spark" />;
|
||||
const max = Math.max(...values, (threshold ?? 0) * 1.25) * 1.08 || 1;
|
||||
const x = (i: number) => (values.length === 1 ? W / 2 : (i / (values.length - 1)) * W);
|
||||
const y = (v: number) => H - pad - (v / max) * (H - pad * 2);
|
||||
const line = values.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' ');
|
||||
const area = `${line} L${W} ${H} L0 ${H} Z`;
|
||||
return (
|
||||
<svg className={`spark ${tone}`} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label={label}>
|
||||
<defs><linearGradient id={`sg${gid}`} x1="0" y1="0" x2="0" y2="1"><stop offset="0" className="stop-a" /><stop offset="1" className="stop-b" /></linearGradient></defs>
|
||||
<path className="area" d={area} fill={`url(#sg${gid})`} />
|
||||
{threshold !== undefined && <line className="thr" x1="0" x2={W} y1={y(threshold)} y2={y(threshold)} />}
|
||||
<path className="line" d={line} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stars({ n, label }: { n: number; label?: string }) {
|
||||
return (
|
||||
<span className="stars" role="img" aria-label={label ?? t('reviews.starsAria', { count: n })}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<svg key={i} viewBox="0 0 24 24" className={i <= n ? '' : 'off'} aria-hidden="true"><path fill="currentColor" d="M12 3.8l2.5 5.1 5.6.8-4 4 1 5.5-5.1-2.7-5 2.7 1-5.5-4.1-4 5.7-.8z" /></svg>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Notice({ tone, children, icon, action }: { tone: 'bad' | 'warn' | 'ok' | 'info'; children: ReactNode; icon?: IconName; action?: ReactNode }) {
|
||||
const ic: IconName = icon ?? (tone === 'bad' ? 'alertCircle' : tone === 'warn' ? 'alert' : tone === 'ok' ? 'check' : 'info');
|
||||
return <div className={`notice ${tone}`} role={tone === 'bad' ? 'alert' : 'status'}><Icon name={ic} /><div className="grow">{children}</div>{action}</div>;
|
||||
}
|
||||
|
||||
export function Skeleton({ lines = 3, block }: { lines?: number; block?: boolean }) {
|
||||
return (
|
||||
<div className="stack" aria-busy="true" aria-label={t('common.loading')}>
|
||||
{block && <span className="sk sk-block" />}
|
||||
{Array.from({ length: lines }, (_, i) => <span key={i} className="sk sk-line" style={{ width: `${[92, 76, 84, 58, 70][i % 5]}%` }} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ illo, title, children, actions, compact }: { illo: IlloName; title: string; children?: ReactNode; actions?: ReactNode; compact?: boolean }) {
|
||||
return (
|
||||
<div className={`empty${compact ? ' compact' : ''}`}>
|
||||
<Illo name={illo} />
|
||||
<h3>{title}</h3>
|
||||
{children && <p>{children}</p>}
|
||||
{actions && <div className="actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Seg<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string, IconName?][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="seg" role="group" aria-label={label}>
|
||||
{options.map(([v, text, icon]) => <button key={v} type="button" aria-pressed={value === v} onClick={() => onChange(v)}>{icon && <Icon name={icon} />}{text}</button>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ title, icon, sub, actions, children, foot, className = '', pad = true }: { title?: ReactNode; icon?: IconName; sub?: ReactNode; actions?: ReactNode; children: ReactNode; foot?: ReactNode; className?: string; pad?: boolean }) {
|
||||
return (
|
||||
<section className={`card ${className}`}>
|
||||
{title !== undefined && (
|
||||
<div className="card-head">
|
||||
<div className="stack" style={{ gap: 2 }}><h2>{icon && <Icon name={icon} />}{title}</h2>{sub && <div className="sub">{sub}</div>}</div>
|
||||
{actions && <div className="actions">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
{pad ? <div className="card-body">{children}</div> : children}
|
||||
{foot && <div className="card-foot">{foot}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Kbd({ keys }: { keys: string[] }) {
|
||||
return <>{keys.map(k => <span key={k} className="kbd">{k}</span>)}</>;
|
||||
}
|
||||
|
||||
/** Platform modifier label (⌘ on macOS, Ctrl elsewhere). */
|
||||
export const MOD = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';
|
||||
1
apps/desktop/src/vite-env.d.ts
vendored
1
apps/desktop/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import type { AppStatus, ProjectSummary, StoreRelease } from '../src/api';
|
||||
import { setLang, t } from '../src/i18n';
|
||||
import {
|
||||
attention, type CatalogDiff, filterReviews, fuzzy, healthSeries, hueOf, laneOf, monogram, parityMatrix, PLAY_THRESHOLDS, type Review, starStats,
|
||||
storeNow, toneFor, trainLanes, versionHistory,
|
||||
} from '../src/lib/model';
|
||||
import { checkState } from '../src/screens/Portfolio';
|
||||
import { betaState } from '../src/screens/tabs/TestFlight';
|
||||
import { phaseLabel } from '../src/ui/kit';
|
||||
|
||||
const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial<StoreRelease> = {}): StoreRelease =>
|
||||
({ store, version, build: null, state: phase, phase, fraction: null, track: null, ...o });
|
||||
|
||||
describe('release timeline', () => {
|
||||
test('lanes: stage and tone per phase, unknown store = -1', () => {
|
||||
expect(laneOf('ios', null)).toMatchObject({ at: -1 });
|
||||
expect(laneOf('ios', rel('ios', '1', 'draft'))).toMatchObject({ at: 0, tone: 'neutral' });
|
||||
expect(laneOf('ios', rel('ios', '1', 'draft'), true)).toMatchObject({ at: 1, tone: 'info' });
|
||||
expect(laneOf('android', rel('android', '1', 'testing'))).toMatchObject({ at: 1 });
|
||||
expect(laneOf('ios', rel('ios', '1', 'review'))).toMatchObject({ at: 2, tone: 'warn' });
|
||||
expect(laneOf('ios', rel('ios', '1', 'rejected'))).toMatchObject({ at: 2, tone: 'bad' });
|
||||
expect(laneOf('ios', rel('ios', '1', 'live'))).toMatchObject({ at: 4, share: 1, tone: 'ok' });
|
||||
expect(laneOf('android', rel('android', '1', 'rollout', { fraction: 0.2 }))).toMatchObject({ at: 4, share: 0.2, tone: 'accent' });
|
||||
expect(laneOf('android', rel('android', '1', 'halted', { fraction: 0.05 }))).toMatchObject({ at: 4, share: 0.05, tone: 'bad' });
|
||||
});
|
||||
test('TestFlight counts as testing for an iOS draft when a build of that version is in beta', () => {
|
||||
const tf = { groups: [], missingGroups: [], builds: [{ version: '1.5.0', build: '31', processing: 'VALID', internal: 'IN_BETA_TESTING', external: null, uploaded: null }] };
|
||||
const lanes = trainLanes({ version: '1.5.0', ios: rel('ios', '1.5.0', 'draft'), android: null }, tf);
|
||||
expect(lanes.map(l => l.at)).toEqual([1, -1]);
|
||||
expect(trainLanes({ version: '1.6.0', ios: rel('ios', '1.6.0', 'draft'), android: null }, tf)[0]!.at).toBe(0);
|
||||
});
|
||||
test('per store: what reaches users now, and what is next', () => {
|
||||
const all = [rel('ios', '2.4.0', 'review'), rel('ios', '2.3.0', 'rollout', { fraction: 0.2 }), rel('ios', '2.2.1', 'other'), rel('android', '2.2.1', 'live'), rel('android', '2.4.0', 'testing')];
|
||||
expect(storeNow(all, 'ios')).toMatchObject({ now: { version: '2.3.0' }, next: { version: '2.4.0' } });
|
||||
expect(storeNow(all, 'android')).toMatchObject({ now: { version: '2.2.1' }, next: { version: '2.4.0' } });
|
||||
expect(storeNow([], 'ios')).toEqual({ now: null, next: null });
|
||||
expect(versionHistory(all).map(h => [h.version, h.ios?.phase ?? null, h.android?.phase ?? null])).toEqual([['2.4.0', 'review', 'testing'], ['2.3.0', 'rollout', null], ['2.2.1', 'other', 'live']]);
|
||||
});
|
||||
test('phase labels: rollout share, iOS phased and paused words', () => {
|
||||
setLang('en');
|
||||
expect(phaseLabel(rel('android', '1', 'rollout', { fraction: 0.2 }))).toEqual({ text: 'Rollout 20%', tone: 'accent' });
|
||||
expect(phaseLabel(rel('ios', '1', 'rollout', { fraction: 0.5 }))).toEqual({ text: 'Phased 50%', tone: 'accent' });
|
||||
expect(phaseLabel(rel('ios', '1', 'halted', { fraction: 0.02 }))).toEqual({ text: 'Paused 2%', tone: 'bad' });
|
||||
expect(phaseLabel(rel('android', '1', 'testing', { track: 'internal' }))).toEqual({ text: 'Testing · internal', tone: 'info' });
|
||||
expect(phaseLabel(null)).toEqual({ text: 'Not on this store', tone: 'neutral' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('health', () => {
|
||||
const rows = (vals: number[], w28?: number) => vals.map((v, i) => ({ date: `2026-10-0${i + 1}`, dims: {}, values: { userPerceivedCrashRate: v, ...(i === vals.length - 1 && w28 !== undefined ? { userPerceivedCrashRate28dUserWeighted: w28 } : {}) } }));
|
||||
test('series sorted by date, current = the 28-day value, tone against the threshold', () => {
|
||||
const s = healthSeries([...rows([0.005, 0.006, 0.007], 0.0095)].reverse(), 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash);
|
||||
expect(s.values).toEqual([0.005, 0.006, 0.007]);
|
||||
expect(s.current).toBe(0.0095);
|
||||
expect(s.tone).toBe('warn');
|
||||
expect(healthSeries(rows([0.012]), 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash).tone).toBe('bad');
|
||||
expect(healthSeries([], 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash)).toMatchObject({ current: null, tone: 'neutral' });
|
||||
expect(toneFor(0.001, 0.0109)).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviews', () => {
|
||||
const r = (id: string, store: 'apple' | 'play', rating: number, reply = false, date = '2026-10-01'): Review => ({ store, id, rating, body: `body ${id}`, date, ...(reply ? { reply: { text: 'x' } } : {}) });
|
||||
const rs = [r('a', 'apple', 5, true, '2026-10-03'), r('b', 'play', 1, false, '2026-10-05'), r('c', 'play', 4), r('d', 'apple', 1)];
|
||||
test('star distribution and average', () => {
|
||||
expect(starStats(rs)).toEqual({ count: 4, average: 2.75, byStar: [2, 0, 0, 1, 1], unanswered: 3 });
|
||||
expect(starStats([]).average).toBeNull();
|
||||
});
|
||||
test('filters: store, stars, unanswered, text; newest first', () => {
|
||||
expect(filterReviews(rs, { store: 'all', stars: [], unanswered: false, query: '' }).map(x => x.id)).toEqual(['b', 'a', 'c', 'd']);
|
||||
expect(filterReviews(rs, { store: 'play', stars: [], unanswered: false, query: '' }).map(x => x.id)).toEqual(['b', 'c']);
|
||||
expect(filterReviews(rs, { store: 'all', stars: [1], unanswered: true, query: '' }).map(x => x.id)).toEqual(['b', 'd']);
|
||||
expect(filterReviews(rs, { store: 'all', stars: [], unanswered: false, query: 'BODY C' }).map(x => x.id)).toEqual(['c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog parity matrix', () => {
|
||||
const s = { productIds: ['coins', 'no_ads'], subscriptionIds: ['pro'], catalog: { ios: ['coins', 'no_ads', 'pro'], android: ['coins', 'pro'] } } as unknown as ProjectSummary;
|
||||
test('not compared yet: unknown where sold, n/a where not ("unknown, never false")', () => {
|
||||
const m = parityMatrix(s, null);
|
||||
expect(m.map(x => [x.id, x.kind, x.ios.state, x.android.state])).toEqual([['coins', 'product', 'unknown', 'unknown'], ['no_ads', 'product', 'unknown', 'na'], ['pro', 'subscription', 'unknown', 'unknown']]);
|
||||
});
|
||||
test('compared: worst level per cell, store-only items, the largest price gap', () => {
|
||||
const diff: CatalogDiff = { errors: 1, rows: [
|
||||
{ id: 'coins', store: 'apple', level: 'ok', code: 'ok', msg: '' }, { id: 'coins', store: 'play', level: 'warn', code: 'locale', msg: 'no listing for de-DE' },
|
||||
{ id: 'pro', store: 'play', level: 'error', code: 'missing', msg: 'not on Google Play' }, { id: 'pro', store: 'apple', level: 'ok', code: 'ok', msg: '' },
|
||||
{ id: 'old', store: 'apple', level: 'warn', code: 'extra', msg: 'on the store only' },
|
||||
], parity: [{ id: 'coins', region: 'TR', currency: 'TRY', apple: 10, play: 7, diffPct: 30 }, { id: 'coins', region: 'BR', currency: 'BRL', apple: 5, play: 4.5, diffPct: 10 }] };
|
||||
const m = parityMatrix(s, diff);
|
||||
const by = Object.fromEntries(m.map(x => [x.id, x]));
|
||||
expect(by.coins).toMatchObject({ ios: { state: 'ok' }, android: { state: 'warn', notes: ['no listing for de-DE'] }, maxDiff: 30 });
|
||||
expect(by.no_ads).toMatchObject({ ios: { state: 'unknown' }, android: { state: 'na' } });
|
||||
expect(by.pro).toMatchObject({ android: { state: 'error' } });
|
||||
expect(by.old).toMatchObject({ kind: 'extra', config: false, ios: { state: 'warn' }, android: { state: 'na' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('portfolio', () => {
|
||||
const base: AppStatus = { project: '/g', ok: true, app: null, summary: null, check: { ok: true, problems: [], warnings: [] }, release: { source: 'none', current: null, next: null }, catalog: null, storeErrors: {}, errors: [] };
|
||||
test('config check chip', () => {
|
||||
setLang('en');
|
||||
expect(checkState(undefined)).toEqual({ text: 'Not checked', tone: 'neutral' });
|
||||
expect(checkState({ ...base, check: null })).toEqual({ text: 'Config not loaded', tone: 'bad' });
|
||||
expect(checkState({ ...base, check: { ok: false, problems: ['a', 'b'], warnings: [] } })).toEqual({ text: '2 problems', tone: 'bad' });
|
||||
expect(checkState({ ...base, check: { ok: true, problems: [], warnings: ['w'] } })).toEqual({ text: '1 warning', tone: 'warn' });
|
||||
expect(checkState(base)).toEqual({ text: 'Config OK', tone: 'ok' });
|
||||
});
|
||||
test('needs attention: problems, unreadable stores, rejected / halted releases, catalog drift, health near the threshold', () => {
|
||||
const live: AppStatus = { ...base, release: { source: 'live', current: null, next: null, all: [rel('ios', '2.0', 'rejected'), rel('android', '1.9', 'halted')] }, storeErrors: { ios: 'HTTP 401' },
|
||||
catalog: { checked: 'live', products: 2, subscriptions: 0, onlyIos: ['x'], onlyAndroid: [], missingIos: [], missingAndroid: ['y'], ok: false } };
|
||||
const a = attention('p', 'Demo', { ...base, check: { ok: false, problems: ['bad config'], warnings: ['w1'] } }, live,
|
||||
{ date: '', play: { window: { start: '', end: '', days: 28 }, crash: [{ date: '2026-10-01', dims: {}, values: { userPerceivedCrashRate: 0.02 } }], anr: [], issues: [] } });
|
||||
expect(a.map(x => [x.tone, x.tab])).toEqual([['bad', 'checks'], ['warn', 'checks'], ['warn', 'releases'], ['bad', 'releases'], ['bad', 'releases'], ['warn', 'catalog'], ['bad', 'health']]);
|
||||
expect(a[5]!.text).toBe('2 catalog items differ between the stores');
|
||||
expect(attention('p', 'Demo', undefined, undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('small helpers', () => {
|
||||
test('palette matching prefers word starts and runs', () => {
|
||||
expect(fuzzy('rel', 'Releases')).toBeGreaterThan(fuzzy('rel', 'Prism Puddle · Catalog listing'));
|
||||
expect(fuzzy('xyz', 'Releases')).toBe(-1);
|
||||
expect(fuzzy('', 'x')).toBe(0);
|
||||
});
|
||||
test('monograms and stable hues', () => {
|
||||
expect(monogram('Prism Puddle')).toBe('PP');
|
||||
expect(monogram('tidy')).toBe('TI');
|
||||
expect(hueOf('Prism Puddle')).toBe(hueOf('Prism Puddle'));
|
||||
});
|
||||
test('TestFlight states: expired is history, not an error', () => {
|
||||
expect(betaState('IN_BETA_TESTING').tone).toBe('ok');
|
||||
expect(betaState('WAITING_FOR_BETA_REVIEW').tone).toBe('warn');
|
||||
expect(betaState('BETA_REJECTED').tone).toBe('bad');
|
||||
expect(betaState('EXPIRED').tone).toBe('neutral');
|
||||
expect(betaState(null).text).toBe('–');
|
||||
});
|
||||
test('plural forms', () => {
|
||||
setLang('en');
|
||||
expect(t('reviews.count', { count: 1 })).toBe('1 review');
|
||||
expect(t('reviews.count', { count: 3 })).toBe('3 reviews');
|
||||
setLang('tr');
|
||||
expect(t('reviews.count', { count: 3 })).toBe('3 yorum');
|
||||
setLang('en');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseStart } from '../src/nav';
|
||||
import { shortPath, tildeText } from '../src/paths';
|
||||
import { applyPrefs, DEFAULT_PREFS, parsePrefs, resolveLang } from '../src/prefs';
|
||||
|
||||
describe('prefs', () => {
|
||||
test('parse is tolerant and only keeps known values', () => {
|
||||
expect(parsePrefs(null)).toEqual(DEFAULT_PREFS);
|
||||
expect(parsePrefs('{broken')).toEqual(DEFAULT_PREFS);
|
||||
expect(parsePrefs('{"theme":"dark","motion":"reduce","lang":"tr","onboarded":true}')).toEqual({ theme: 'dark', motion: 'reduce', lang: 'tr', onboarded: true });
|
||||
expect(parsePrefs('{"theme":"neon","motion":"fast","lang":"xx","onboarded":"yes"}')).toEqual(DEFAULT_PREFS);
|
||||
});
|
||||
test('apply sets and clears data attributes and the document language', () => {
|
||||
const root = { dataset: {} as DOMStringMap, lang: '' };
|
||||
applyPrefs({ theme: 'light', motion: 'reduce', lang: 'tr', onboarded: true }, root);
|
||||
expect({ ...root.dataset }).toEqual({ theme: 'light', motion: 'reduce' });
|
||||
expect(root.lang).toBe('tr');
|
||||
applyPrefs(DEFAULT_PREFS, root, 'en-GB');
|
||||
expect({ ...root.dataset }).toEqual({});
|
||||
expect(root.lang).toBe('en');
|
||||
});
|
||||
test('system language: Turkish when the system is, else English', () => {
|
||||
expect(resolveLang('system', 'tr-TR')).toBe('tr');
|
||||
expect(resolveLang('system', 'de-DE')).toBe('en');
|
||||
expect(resolveLang('en', 'tr-TR')).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paths', () => {
|
||||
test('home shown as ~', () => {
|
||||
expect(shortPath('/home/you/games/a', '/home/you')).toBe('~/games/a');
|
||||
expect(shortPath('/home/you', '/home/you/')).toBe('~');
|
||||
expect(shortPath('/home/you-2/a', '/home/you')).toBe('/home/you-2/a');
|
||||
expect(shortPath('C:\\Users\\you\\a', 'C:\\Users\\you')).toBe('~\\a');
|
||||
expect(shortPath('/x', null)).toBe('/x');
|
||||
expect(tildeText('in /home/you/a and /home/you/b', '/home/you')).toBe('in ~/a and ~/b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routes', () => {
|
||||
test('deep links and start switches', () => {
|
||||
expect(parseStart('accounts', [])).toEqual({ screen: 'accounts' });
|
||||
expect(parseStart('portfolio', [])).toEqual({ screen: 'portfolio' });
|
||||
expect(parseStart('app:health', ['a', 'b'])).toEqual({ screen: 'app', appId: 'a', tab: 'health' });
|
||||
expect(parseStart('app:reviews:2', ['a', 'b'])).toEqual({ screen: 'app', appId: 'b', tab: 'reviews' });
|
||||
expect(parseStart('app:nope', ['a'])).toEqual({ screen: 'app', appId: 'a', tab: 'releases' });
|
||||
expect(parseStart('app:health', [])).toBeNull();
|
||||
expect(parseStart('write', ['a'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
// Theme doctor: every text token keeps 7:1 contrast (WCAG AAA) on every surface it is used on, in light and dark;
|
||||
// the dark theme is the same whether it comes from the system or from Settings.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const css = readFileSync(join(import.meta.dir, '..', 'src', 'styles.css'), 'utf8');
|
||||
|
||||
function block(selector: string): Record<string, string> {
|
||||
const at = css.indexOf(selector);
|
||||
if (at < 0) throw new Error(`no ${selector} block`);
|
||||
const open = css.indexOf('{', at), close = css.indexOf('}', open);
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of css.slice(open + 1, close).matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})\b/g)) out[m[1]!] = m[2]!.toLowerCase();
|
||||
return out;
|
||||
}
|
||||
|
||||
const lum = (hex: string): number => {
|
||||
const c = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255).map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!;
|
||||
};
|
||||
export const contrast = (a: string, b: string): number => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x! + 0.05) / (y! + 0.05); };
|
||||
|
||||
/** [text token, background token] pairs the stylesheet uses for text. */
|
||||
const PAIRS: [string, string][] = [
|
||||
...['ink', 'ink-2', 'muted'].flatMap(f => ['bg', 'bg-sunken', 'surface', 'surface-2', 'hover'].map(b => [f, b] as [string, string])),
|
||||
['ink', 'accent-soft'], ['accent', 'surface'], ['accent', 'bg'], ['accent', 'accent-soft'], ['accent-ink', 'accent'],
|
||||
['ok-ink', 'ok-bg'], ['warn-ink', 'warn-bg'], ['bad-ink', 'bad-bg'], ['info-ink', 'info-bg'], ['neutral-ink', 'neutral-bg'],
|
||||
['code-ink', 'code-bg'],
|
||||
];
|
||||
|
||||
describe('theme doctor', () => {
|
||||
const light = block(':root {');
|
||||
const dark = block(':root[data-theme="dark"] {');
|
||||
for (const [name, theme] of [['light', light], ['dark', dark]] as const) {
|
||||
test(`${name}: text tokens reach 7:1`, () => {
|
||||
const low: string[] = [];
|
||||
for (const [f, b] of PAIRS) {
|
||||
const fg = theme[f] ?? light[f], bg = theme[b] ?? light[b];
|
||||
expect(fg, `--${f}`).toBeDefined();
|
||||
expect(bg, `--${b}`).toBeDefined();
|
||||
const c = contrast(fg!, bg!);
|
||||
if (c < 7) low.push(`--${f} on --${b}: ${c.toFixed(2)}:1`);
|
||||
}
|
||||
expect(low).toEqual([]);
|
||||
});
|
||||
}
|
||||
test('system dark and chosen dark are the same tokens', () => {
|
||||
const at = css.indexOf(':root:not([data-theme="light"]) {');
|
||||
const open = css.indexOf('{', at), close = css.indexOf('}', open);
|
||||
const sys: Record<string, string> = {};
|
||||
for (const m of css.slice(open + 1, close).matchAll(/--([\w-]+):\s*([^;]+);/g)) sys[m[1]!] = m[2]!.trim();
|
||||
const chosen: Record<string, string> = {};
|
||||
const a2 = css.indexOf(':root[data-theme="dark"] {'), o2 = css.indexOf('{', a2), c2 = css.indexOf('}', o2);
|
||||
for (const m of css.slice(o2 + 1, c2).matchAll(/--([\w-]+):\s*([^;]+);/g)) chosen[m[1]!] = m[2]!.trim();
|
||||
expect(Object.keys(sys).length).toBeGreaterThan(30);
|
||||
expect(sys).toEqual(chosen);
|
||||
});
|
||||
test('reduced motion turns transitions and animations off', () => {
|
||||
expect(css).toMatch(/prefers-reduced-motion: reduce\) \{ :root \{ --speed: 0s; \} \}/);
|
||||
expect(css).toMatch(/:root\[data-motion="reduce"\] \{ --speed: 0s; \}/);
|
||||
for (const anim of ['.spin', '.sk', '.toast .bar.indet > span']) expect(css).toContain(`:root[data-motion="reduce"] ${anim}`);
|
||||
// no transition longer than 200 ms
|
||||
for (const m of css.matchAll(/transition:[^;]*?(\d*\.?\d+)s/g)) expect(Number(m[1])).toBeLessThanOrEqual(0.2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
// What the webview code may do: only the app commands (no plugin JS API, no network of its own, no eval), no secret
|
||||
// ever passes through it, the catalogs are complete, and the release bundle carries no dev fixture mode.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { checkCatalogs } from '../../../tools/i18n-check';
|
||||
import { en } from '../src/i18n/en';
|
||||
import { tr } from '../src/i18n/tr';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const src = (f: string) => readFileSync(join(ROOT, f), 'utf8');
|
||||
function files(dir: string): string[] {
|
||||
return readdirSync(join(ROOT, dir)).flatMap(n => {
|
||||
const rel = join(dir, n);
|
||||
return statSync(join(ROOT, rel)).isDirectory() ? files(rel) : /\.(ts|tsx)$/.test(n) ? [rel] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('webview rules', () => {
|
||||
test('only app commands: no plugin JS APIs, no direct network, no eval', () => {
|
||||
const all = files('src');
|
||||
expect(all.length).toBeGreaterThan(20);
|
||||
for (const f of all) {
|
||||
const s = src(f);
|
||||
expect(s, f).not.toMatch(/@tauri-apps\/plugin-/);
|
||||
expect(s, f).not.toMatch(/\bfetch\(|XMLHttpRequest|WebSocket|eval\(|new Function|dangerouslySetInnerHTML|innerHTML\s*=/);
|
||||
// only api.ts talks to the host
|
||||
if (!f.endsWith('api.ts')) expect(s, f).not.toMatch(/@tauri-apps\/api/);
|
||||
}
|
||||
expect(JSON.parse(src('package.json')).dependencies).toEqual({ '@tauri-apps/api': expect.any(String), react: expect.any(String), 'react-dom': expect.any(String) });
|
||||
});
|
||||
test('the confirm token and credentials never appear in the webview code', () => {
|
||||
for (const f of files('src')) expect(src(f), f).not.toMatch(/confirmToken|credentials|PLAY_SERVICE_ACCOUNT|ASC_P8/);
|
||||
});
|
||||
test('fixtures load only behind import.meta.env.DEV', () => {
|
||||
for (const f of files('src')) {
|
||||
const s = src(f);
|
||||
if (f.includes(`${'dev'}/`)) continue;
|
||||
for (const m of s.matchAll(/import\(['"]\.\/dev\/fixtures['"]\)/g)) {
|
||||
const before = s.slice(Math.max(0, m.index! - 120), m.index!);
|
||||
expect(before, f).toContain('import.meta.env.DEV');
|
||||
}
|
||||
expect(s, f).not.toMatch(/^import .*dev\/fixtures/m);
|
||||
}
|
||||
});
|
||||
test('built bundle (when present) has no fixture mode and no dev switches', () => {
|
||||
const dist = join(ROOT, 'dist', 'assets');
|
||||
if (!existsSync(dist)) return;
|
||||
for (const n of readdirSync(dist)) {
|
||||
const s = readFileSync(join(dist, n), 'utf8');
|
||||
expect(s, n).not.toContain('storebound-dev-fixtures');
|
||||
expect(s, n).not.toContain('mockInvoke');
|
||||
expect(s, n).not.toContain('VITE_START');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n', () => {
|
||||
test('en and tr: same keys, no empty strings, matching placeholders, house style', () => {
|
||||
expect(checkCatalogs(en, { tr })).toEqual([]);
|
||||
});
|
||||
test('every t() key used in the code exists', () => {
|
||||
const keys = new Set(Object.keys(en));
|
||||
const missing: string[] = [];
|
||||
for (const f of files('src')) {
|
||||
for (const m of src(f).matchAll(/\bt\('([a-zA-Z0-9_.-]+)'/g)) if (!keys.has(m[1]!)) missing.push(`${f}: ${m[1]}`);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Tauri loads the dev server from build.devUrl (http://127.0.0.1:1420) and the built files from ../dist.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: { host: '127.0.0.1', port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } },
|
||||
build: {
|
||||
target: ['safari15', 'chrome110'],
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
// no inline scripts or styles: the CSP is script-src 'self', style-src 'self'
|
||||
assetsInlineLimit: 0,
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
latestPerDate, mergeSeries, monthsBetween, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot,
|
||||
} from './lib/analytics';
|
||||
import { STORAGE_READ_SCOPE } from './lib/google-auth';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
import { done, log, warn } from './lib/log';
|
||||
import { pct, textTable } from './lib/table';
|
||||
import { googleClient } from './play/google';
|
||||
|
||||
|
|
@ -145,7 +145,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const events = parseGitLog(g.stdout.toString()).reverse();
|
||||
log(TAG, `${snaps.length} snapshot(s), ${series.apple.length} App Store day(s), ${series.play.length} Play day(s), ${events.length} store change(s) since ${first}`);
|
||||
const fx = compareAroundEvents(series, events, a.num('days', 7));
|
||||
if (a.has('json')) { printJson({ snapshots: snaps.length, since: first, series, effects: fx }); return; }
|
||||
console.log(a.has('md') ? effectMarkdown(fx) : effectTable(fx));
|
||||
log(TAG, 'reading: a change needs several days on both sides and enough traffic; seasonality, featuring and ads move these numbers too');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
// Then attach exactly that build: storebound apple listing --version x.y.z --build 42 (never "the latest").
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
import { printJson } from '../lib/log';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { ascClient } from './asc';
|
||||
|
||||
|
|
@ -15,7 +14,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const appId = await c.app();
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version`);
|
||||
const next = nextBuildNumber(builds.map(b => String(b.attributes.version)));
|
||||
if (ctx.args.has('json')) { printJson({ next, builds: builds.length }); return; }
|
||||
console.error(`[apple] ${builds.length} builds on App Store Connect; next build number:`);
|
||||
console.log(next);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// App Store Product Page Optimization through the App Store Connect API, wired to the `shots` variants.
|
||||
// storebound apple ppo status [--json]
|
||||
// storebound apple ppo status
|
||||
// storebound apple ppo create --variant b[,c] [--name "..."] [--traffic 30] [--dry]
|
||||
// storebound apple ppo upload --variant b [--experiment "..."] [--only en-US,tr] [--src store/renders] [--dry]
|
||||
// storebound apple ppo start | stop [--experiment "..."] [--dry] [--yes]
|
||||
|
|
@ -13,7 +13,7 @@ import { existsSync } from 'node:fs';
|
|||
import { join } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { byAppleLocale, confirm, need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { done, log, warn } from '../lib/log';
|
||||
import {
|
||||
checkCreate, createExperimentReq, defaultName, type Experiment, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq,
|
||||
} from '../lib/ppo';
|
||||
|
|
@ -44,16 +44,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const list = await experiments(c, appId);
|
||||
const locales = config.languages.map(l => l.apple).filter((x): x is string => !!x);
|
||||
|
||||
if (sub === 'status' && a.has('json')) {
|
||||
const tests = [];
|
||||
for (const x of list) {
|
||||
const ts = [];
|
||||
if (!['COMPLETED', 'STOPPED'].includes(x.state)) for (const t of await treatments(c, x.id)) ts.push({ id: t.id, name: t.attributes.name, locales: (await treatmentLocs(c, t.id)).length });
|
||||
tests.push({ ...x, treatments: ts });
|
||||
}
|
||||
printJson({ tests });
|
||||
return;
|
||||
}
|
||||
if (sub === 'status') {
|
||||
if (!list.length) { done(TAG, 'no Product Page Optimization tests (storebound apple ppo create --variant b)'); return; }
|
||||
console.log(textTable(['test', 'state', 'traffic', 'start', 'end'], list.map(x => [x.name, x.state, x.trafficProportion !== undefined ? `${x.trafficProportion}%` : '', (x.startDate ?? '').slice(0, 10), (x.endDate ?? '').slice(0, 10)])));
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue