From d74d99bcc9378771d34d747e9d6c0142b9294060 Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 11 Oct 2026 01:11:56 +0800 Subject: [PATCH 1/8] serve --stdio: desktop JSON-RPC profile on the MCP core, confirm tokens, --json results, sidecar builds - cli/serve.ts: session.hello with a per-session secret, typed methods per tool, project.inspect, portfolio.status over many project folders, progress notifications, cancellation - cli/lib/confirm-token.ts: single-use HMAC tokens bound to method + canonical params + expiry; writes are dry runs without one; no submit / release / publish method exists - cli/lib/runner.ts: shared child runner for mcp and serve, works from a compiled binary - --json on check, testflight / ppo status, next-build, next-version-code, analytics compare, prices import; inspect command; confirm() always answers no under serve - tools/build-sidecars.ts: bun build --compile for five targets, ad-hoc re-sign on macOS, path leak check - tests/serve.test.ts: methods, token binding / expiry / replay, shared vector, stdio and compiled smoke --- .gitignore | 7 + CHANGELOG.md | 13 ++ README.md | 14 ++ cli/analytics.ts | 3 +- cli/apple/next-build.ts | 2 + cli/apple/ppo.ts | 14 +- cli/apple/status.ts | 4 +- cli/apple/testflight.ts | 19 ++- cli/check.ts | 18 +- cli/health.ts | 4 +- cli/index.ts | 2 + cli/inspect.ts | 28 ++++ cli/lib/config.ts | 4 +- cli/lib/confirm-token.ts | 95 +++++++++++ cli/lib/ctx.ts | 7 +- cli/lib/desktop-protocol.ts | 99 +++++++++++ cli/lib/log.ts | 12 ++ cli/lib/runner.ts | 79 +++++++++ cli/mcp.ts | 17 +- cli/play/next-version-code.ts | 8 +- cli/play/status.ts | 3 +- cli/prices.ts | 6 +- cli/reviews.ts | 4 +- cli/serve.ts | 306 ++++++++++++++++++++++++++++++++++ cli/shots/check.ts | 4 +- cli/validate.ts | 4 +- tests/serve.test.ts | 243 +++++++++++++++++++++++++++ tools/build-sidecars.ts | 71 ++++++++ 28 files changed, 1048 insertions(+), 42 deletions(-) create mode 100644 cli/inspect.ts create mode 100644 cli/lib/confirm-token.ts create mode 100644 cli/lib/desktop-protocol.ts create mode 100644 cli/lib/runner.ts create mode 100644 cli/serve.ts create mode 100644 tests/serve.test.ts create mode 100644 tools/build-sidecars.ts diff --git a/.gitignore b/.gitignore index e5c7dc0..ed7d4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,10 @@ 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/ +.scratch/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f206e63..528072f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # 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` (many projects; `live` adds App Store and Play status). 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. +- `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. diff --git a/README.md b/README.md index 3e5b354..6022cea 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ 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 ` | 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 | @@ -376,6 +378,18 @@ 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`). + ## Safety model - **Credentials** live in env vars or the gitignored `.storebound/credentials.json`, never in the config or the repo. diff --git a/cli/analytics.ts b/cli/analytics.ts index 2df81f7..9c581dc 100644 --- a/cli/analytics.ts +++ b/cli/analytics.ts @@ -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, warn } from './lib/log'; +import { done, log, printJson, warn } from './lib/log'; import { pct, textTable } from './lib/table'; import { googleClient } from './play/google'; @@ -145,6 +145,7 @@ export async function run(ctx: Ctx): Promise { 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; diff --git a/cli/apple/next-build.ts b/cli/apple/next-build.ts index 0ee770c..5c4a560 100644 --- a/cli/apple/next-build.ts +++ b/cli/apple/next-build.ts @@ -5,6 +5,7 @@ // 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'; @@ -14,6 +15,7 @@ export async function run(ctx: Ctx): Promise { 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); } diff --git a/cli/apple/ppo.ts b/cli/apple/ppo.ts index de07de9..af8d2c7 100644 --- a/cli/apple/ppo.ts +++ b/cli/apple/ppo.ts @@ -1,5 +1,5 @@ // App Store Product Page Optimization through the App Store Connect API, wired to the `shots` variants. -// storebound apple ppo status +// storebound apple ppo status [--json] // 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, warn } from '../lib/log'; +import { done, log, printJson, warn } from '../lib/log'; import { checkCreate, createExperimentReq, defaultName, type Experiment, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq, } from '../lib/ppo'; @@ -44,6 +44,16 @@ export async function run(ctx: Ctx): Promise { 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)]))); diff --git a/cli/apple/status.ts b/cli/apple/status.ts index 9cd5c24..31ef125 100644 --- a/cli/apple/status.ts +++ b/cli/apple/status.ts @@ -7,7 +7,7 @@ // exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review) import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; -import { log } from '../lib/log'; +import { log, printJson } from '../lib/log'; import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release'; import { type Asc, ascClient, type J } from './asc'; @@ -54,7 +54,7 @@ export async function run(ctx: Ctx): Promise { const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`)); const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`); const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`); - if (ctx.args.has('json')) { console.log(JSON.stringify({ app, versions, builds: builds.data, iaps, subs }, null, 2)); return; } + if (ctx.args.has('json')) { printJson({ app, versions, builds: builds.data, iaps, subs }); return; } console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`); for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`); diff --git a/cli/apple/testflight.ts b/cli/apple/testflight.ts index 626b55f..89db3ba 100644 --- a/cli/apple/testflight.ts +++ b/cli/apple/testflight.ts @@ -1,5 +1,5 @@ // TestFlight through the App Store Connect API. -// storebound apple testflight status groups, testers per group, recent builds + beta states +// storebound apple testflight status [--json] groups, testers per group, recent builds + beta states // storebound apple testflight groups [--dry] create the groups of apple.testflight.groups that are missing // storebound apple testflight testers [--csv f] [--group a,b] [--remove] [--invite] [--dry] [--yes] // storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text "..."] [--dry] @@ -14,7 +14,7 @@ import { existsSync, readFileSync } from 'node:fs'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { reviewContact } from '../lib/credentials'; -import { done, log, warn } from '../lib/log'; +import { done, log, printJson, warn } from '../lib/log'; import { textTable } from '../lib/table'; import { type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, type Group, groupFromApi, inviteReq, maskEmail, @@ -52,21 +52,30 @@ export async function run(ctx: Ctx): Promise { if (sub === 'status') { const gs = await groups(c, appId); const rows: (string | number)[][] = []; + const jsonGroups: Record[] = []; for (const g of gs) { const testers = await c.all(`/v1/betaGroups/${g.id}/betaTesters?limit=200`); const builds = await c.all(`/v1/betaGroups/${g.id}/builds?limit=200`); + jsonGroups.push({ id: g.id, name: g.name, internal: g.internal, testers: testers.length, builds: g.internal && g.hasAccessToAllBuilds ? 'all' : builds.length, publicLink: !!g.publicLinkEnabled }); rows.push([g.name, g.internal ? 'internal' : 'external', testers.length, g.internal && g.hasAccessToAllBuilds ? 'all' : builds.length, g.publicLinkEnabled ? 'public link on' : '']); } - console.log(textTable(['group', 'kind', 'testers', 'builds', ''], rows)); const q = new URLSearchParams({ 'filter[app]': appId, include: 'preReleaseVersion,buildBetaDetail', sort: '-uploadedDate', limit: '8' }); const resp = await c.asc('GET', `/v1/builds?${q}`); const details = new Map((resp.included ?? []).filter((x: J) => x.type === 'buildBetaDetails').map((x: J) => [x.id, x.attributes] as const)); + const missingGroups = (tf?.groups ?? []).filter(w => !gs.some(g => g.name === w.name)).map(w => w.name); + if (a.has('json')) { + printJson({ groups: jsonGroups, builds: buildsWithVersion(resp).map(b => { + const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J; + return { version: b.marketing ?? null, build: b.attributes.version ?? null, processing: b.attributes.processingState ?? null, internal: d?.internalBuildState ?? null, external: d?.externalBuildState ?? null, uploaded: b.attributes.uploadedDate ?? null }; + }), missingGroups }); + return; + } + console.log(textTable(['group', 'kind', 'testers', 'builds', ''], rows)); console.log('\n' + textTable(['version', 'build', 'processing', 'internal', 'external', 'uploaded'], buildsWithVersion(resp).map(b => { const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J; return [b.marketing ?? '', b.attributes.version ?? '', b.attributes.processingState ?? '', d?.internalBuildState ?? '', d?.externalBuildState ?? '', (b.attributes.uploadedDate ?? '').slice(0, 10)]; }))); - const missing = (tf?.groups ?? []).filter(w => !gs.some(g => g.name === w.name)).map(w => w.name); - if (missing.length) warn(TAG, `configured but not on App Store Connect: ${missing.join(', ')} (storebound apple testflight groups)`); + if (missingGroups.length) warn(TAG, `configured but not on App Store Connect: ${missingGroups.join(', ')} (storebound apple testflight groups)`); return; } diff --git a/cli/check.ts b/cli/check.ts index 8c4309f..e69099f 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -3,6 +3,7 @@ // subscriptions + terms URL) and the files the config points at. No network; credentials are only checked for presence. // storebound check everything above // storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors +// --json structured result ({ ok, items, warnings, problems } or { findings, errors }), exit 1 on problems import { existsSync, readFileSync } from 'node:fs'; import { type AsoFinding, auditAso } from './lib/aso'; import { playCodes } from './lib/config'; @@ -10,7 +11,7 @@ import { APPLE_IAP_LIMITS, len, parseAppStoreListing, parsePlayListing } from '. import type { Ctx } from './lib/ctx'; import { dataSafetyRows } from './lib/data-safety'; import { reviewDemoAccount } from './lib/credentials'; -import { log, warn } from './lib/log'; +import { log, printJson, warn } from './lib/log'; import { profileIssues } from './lib/profile'; import { allSubscriptions } from './lib/subscriptions'; import { type CopyFile, validateCopy } from './shots/lib'; @@ -20,6 +21,7 @@ function aso(ctx: Ctx): void { const apple = config.apple ? parseAppStoreListing(readFileSync(resolve(config.apple.listing ?? 'store/appstore.md'), 'utf8')) : undefined; const play = config.play ? parsePlayListing(readFileSync(resolve(config.play.listing ?? 'store/listing.md'), 'utf8'), config.app.name) : undefined; const f: AsoFinding[] = auditAso({ apple, play, appName: config.app.name, storefront: ctx.args.get('storefront') ?? 'US' }); + if (ctx.args.has('json')) { const errors = f.filter(x => x.level === 'error').length; printJson({ findings: f, errors }); if (errors) process.exitCode = 1; return; } const tag = { error: 'ERROR', warn: 'warn ', info: 'info ' } as const; for (const x of f) console.log(`${tag[x.level]} [${x.where}] ${x.msg}`); const errors = f.filter(x => x.level === 'error').length; @@ -32,24 +34,29 @@ export async function run(ctx: Ctx): Promise { const { config, resolve, path } = ctx.loaded; log('check', `config ok: ${path}`); const problems: string[] = []; - const tryDo = (what: string, fn: () => string): void => { try { log('check', `${what}: ${fn()}`); } catch (e) { problems.push(`${what}: ${(e as Error).message}`); } }; + const items: { what: string; detail: string }[] = []; + const warnings: string[] = []; + const note = (msg: string): void => { warnings.push(msg); warn('check', msg); }; + const tryDo = (what: string, fn: () => string): void => { + try { const detail = fn(); items.push({ what, detail }); log('check', `${what}: ${detail}`); } catch (e) { problems.push(`${what}: ${(e as Error).message}`); } + }; if (config.play) tryDo('play listing', () => { const f = resolve(config.play!.listing ?? 'store/listing.md'); const copy = parsePlayListing(readFileSync(f, 'utf8'), config.app.name); const want = config.languages.flatMap(playCodes); const missing = want.filter(c => !copy.some(x => x.lang === c)); - if (missing.length) warn('check', `play listing has no section for ${missing.join(', ')}`); + if (missing.length) note(`play listing has no section for ${missing.join(', ')}`); const def = copy.find(x => x.lang === (config.play!.defaultLanguage ?? 'en-US')); const same = copy.filter(x => def && x !== def && x.whatsNew && x.whatsNew === def.whatsNew && x.lang.slice(0, 2) !== def.lang.slice(0, 2)); - if (same.length) warn('check', `what's new of ${same.map(x => x.lang).join(', ')} is the same text as ${def!.lang}: translate it`); + if (same.length) note(`what's new of ${same.map(x => x.lang).join(', ')} is the same text as ${def!.lang}: translate it`); return `${copy.length} languages`; }); if (config.apple) tryDo('appstore listing', () => { const f = resolve(config.apple!.listing ?? 'store/appstore.md'); const copy = parseAppStoreListing(readFileSync(f, 'utf8')); const missing = config.languages.filter(l => l.apple && !copy[l.apple]).map(l => l.apple); - if (missing.length) warn('check', `appstore listing has no section for ${missing.join(', ')}`); + if (missing.length) note(`appstore listing has no section for ${missing.join(', ')}`); return `${Object.keys(copy).length} locales`; }); if (config.dataSafety) tryDo('data safety', () => `${dataSafetyRows(config.dataSafety!).length - 1} CSV rows`); @@ -74,6 +81,7 @@ export async function run(ctx: Ctx): Promise { if (existsSync(resolve('.gitignore')) && !/^\/?\.storebound\/?$/m.test(readFileSync(resolve('.gitignore'), 'utf8'))) problems.push('.gitignore does not ignore .storebound/ (credentials live there)'); + if (ctx.args.has('json')) { printJson({ config: path, ok: !problems.length, items, warnings, problems }); if (problems.length) process.exitCode = 1; return; } if (problems.length) { for (const p of problems) console.error(` - ${p}`); throw new Error(`${problems.length} problems`); } log('check', 'all good'); } diff --git a/cli/health.ts b/cli/health.ts index fe56980..a0b80d7 100644 --- a/cli/health.ts +++ b/cli/health.ts @@ -16,7 +16,7 @@ import { type AppleHealth, ANR_METRICS, betaCrashCounts, CRASH_METRICS, deviceBreakdown, healthFindings, healthMarkdown, type HealthSnapshot, healthTable, issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, ymd, ymdString, } from './lib/health'; -import { done, log, warn } from './lib/log'; +import { done, log, printJson, warn } from './lib/log'; import { formatFindings } from './lib/validate-rules'; import { googleClient, REPORTING_API } from './play/google'; @@ -84,7 +84,7 @@ export async function run(ctx: Ctx): Promise { const days = a.num('days', 7); if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90'); const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days }); - if (a.has('json')) { console.log(JSON.stringify(h, null, 2)); return; } + if (a.has('json')) { printJson(h); return; } console.log(healthTable(h)); console.log('\n' + formatFindings(healthFindings(h))); if (weekly) { diff --git a/cli/index.ts b/cli/index.ts index 52d73f3..58e8553 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -12,9 +12,11 @@ const PLAIN: Record Promise, string]> = { 'init': [() => import('./init'), 'copy the templates (config, listing, copy, declarations, privacy policy) into this project; --profile app|game|subscription-app (default app)'], 'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'], 'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'], + 'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'], 'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios )'], }; const COMMANDS: Record Promise, string]> = { + 'inspect': [() => import('./inspect'), 'offline summary of the config (name, store ids, languages, products, subscriptions); --json'], 'check': [() => import('./check'), 'validate config, copy files, data safety answers, products, subscriptions and the app profile (offline); --aso keyword audit'], 'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'], 'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'], diff --git a/cli/inspect.ts b/cli/inspect.ts new file mode 100644 index 0000000..dd93d14 --- /dev/null +++ b/cli/inspect.ts @@ -0,0 +1,28 @@ +// Offline summary of the project config: app name, store ids, languages, products, shots. No network, no credentials. +// storebound inspect [--json] +import type { Ctx } from './lib/ctx'; +import type { ProjectSummary } from './lib/desktop-protocol'; +import { log, printJson } from './lib/log'; + +export function summarize(ctx: Ctx): ProjectSummary { + const { config, root, path } = ctx.loaded; + return { + dir: root, + config: path, + name: config.app.name, + languages: config.languages.map(l => l.id), + play: config.play ? { packageName: config.play.packageName } : null, + apple: config.apple ? { bundleId: config.apple.bundleId, appId: config.apple.appId ?? null } : null, + products: config.products?.length ?? 0, + shots: !!config.shots, + testflightGroups: config.apple?.testflight?.groups?.length ?? 0, + }; +} + +export async function run(ctx: Ctx): Promise { + const s = summarize(ctx); + if (ctx.args.has('json')) { printJson(s); return; } + log('inspect', `${s.name} · ${s.languages.length} languages · ${s.products} products`); + log('inspect', `play ${s.play?.packageName ?? '-'} · apple ${s.apple?.bundleId ?? '-'}${s.apple?.appId ? ` (${s.apple.appId})` : ''}`); + log('inspect', `config ${s.config}`); +} diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 93e5d97..b4ef28b 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -617,14 +617,14 @@ export function validateConfig(c: unknown): string[] { export interface Loaded { config: ShipConfig; root: string; path: string; resolve(p: string): string } -const NAMES = ['storebound.config.ts', 'storebound.config.js', 'storebound.config.json']; +export const CONFIG_NAMES = ['storebound.config.ts', 'storebound.config.js', 'storebound.config.json']; /** Finds storebound.config.* from `start` upwards (or uses `explicit`), imports and validates it. */ export async function loadConfig(explicit?: string, start = process.cwd()): Promise { let path = explicit ? resolve(explicit) : ''; if (!path) { for (let d = resolve(start); ; d = dirname(d)) { - const hit = NAMES.map(n => join(d, n)).find(existsSync); + const hit = CONFIG_NAMES.map(n => join(d, n)).find(existsSync); if (hit) { path = hit; break; } if (dirname(d) === d) break; } diff --git a/cli/lib/confirm-token.ts b/cli/lib/confirm-token.ts new file mode 100644 index 0000000..e3344f9 --- /dev/null +++ b/cli/lib/confirm-token.ts @@ -0,0 +1,95 @@ +// Confirm tokens for `storebound serve --stdio` (the desktop app's sidecar protocol). +// +// A write method only runs for real (dryRun: false) with a token that the desktop host mints after the owner said yes +// in a native dialog showing the dry run. The token is an HMAC over the method, the canonical params and an expiry, +// keyed with a per-session secret the host generates and hands over in `session.hello` on stdin. Only the process that +// owns the sidecar's stdin (the Rust host) knows the secret; the webview never sees it, and child commands never get it +// (runner.ts strips STOREBOUND_SESSION* from their env, and the secret never is in the env to begin with). +// +// token = "sbt1." + expiryMs + "." + nonce + "." + base64url(HMAC-SHA256(secret, payload)) +// payload = "storebound-confirm-v1\n" + method + "\n" + canonical(params) + "\n" + expiryMs + "\n" + nonce +// +// Tokens are single use (nonce kept until expiry), live at most MAX_TTL_MS, and are bound to the exact method and +// params: another project, other arguments or another method do not verify. The Rust side (apps/desktop/src-tauri/ +// src/confirm.rs) implements the same scheme; both test the shared vector in tests/serve.test.ts and tests/confirm.rs. +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +export const TOKEN_PREFIX = 'sbt1'; +export const DOMAIN = 'storebound-confirm-v1'; +/** A token may not be valid for longer than this (the host mints 60 s ones right after the dialog). */ +export const MAX_TTL_MS = 120_000; +export const MIN_SECRET_BYTES = 32; + +/** + * Canonical JSON: object keys sorted (by UTF-16 code units, like JS sort), no whitespace, strings escaped by + * JSON.stringify. Numbers must be safe integers so every language prints them the same way; undefined object + * members are dropped, like JSON.stringify does. + */ +export function canonical(v: unknown): string { + if (v === null) return 'null'; + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (typeof v === 'number') { + if (!Number.isSafeInteger(v)) throw new Error('canonical: only safe integers are allowed as numbers'); + return String(v); + } + if (typeof v === 'string') return JSON.stringify(v); + if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`; + if (typeof v === 'object') { + const o = v as Record; + const keys = Object.keys(o).filter(k => o[k] !== undefined).sort(); + return `{${keys.map(k => `${JSON.stringify(k)}:${canonical(o[k])}`).join(',')}}`; + } + throw new Error(`canonical: unsupported ${typeof v}`); +} + +const b64url = (b: Buffer): string => b.toString('base64url'); + +export function payload(method: string, params: unknown, expiry: number, nonce: string): string { + return `${DOMAIN}\n${method}\n${canonical(params)}\n${expiry}\n${nonce}`; +} + +export function mac(secret: Buffer, method: string, params: unknown, expiry: number, nonce: string): string { + return b64url(createHmac('sha256', secret).update(payload(method, params, expiry, nonce), 'utf8').digest()); +} + +/** Mints a token (the host does this in Rust; here for tests and tools). */ +export function mint(secret: Buffer, method: string, params: unknown, o: { now?: number; ttlMs?: number; nonce?: string } = {}): string { + const expiry = (o.now ?? Date.now()) + (o.ttlMs ?? 60_000); + const nonce = o.nonce ?? b64url(randomBytes(16)); + return `${TOKEN_PREFIX}.${expiry}.${nonce}.${mac(secret, method, params, expiry, nonce)}`; +} + +export function parseSecret(hex: unknown): Buffer { + if (typeof hex !== 'string' || !/^[0-9a-f]+$/i.test(hex) || hex.length % 2) throw new Error('secret: hex string expected'); + const b = Buffer.from(hex, 'hex'); + if (b.length < MIN_SECRET_BYTES) throw new Error(`secret: at least ${MIN_SECRET_BYTES} bytes`); + return b; +} + +export type VerifyError = 'malformed' | 'expired' | 'too-long' | 'replayed' | 'mismatch'; + +/** Verifies tokens of one session and remembers used nonces until they expire (replay protection). */ +export class TokenGuard { + private used = new Map(); + constructor(private readonly secret: Buffer, private readonly clock: () => number = Date.now) {} + + verify(token: unknown, method: string, params: unknown): { ok: true } | { ok: false; error: VerifyError } { + const now = this.clock(); + for (const [n, exp] of this.used) if (exp <= now) this.used.delete(n); + if (typeof token !== 'string') return { ok: false, error: 'malformed' }; + const parts = token.split('.'); + if (parts.length !== 4 || parts[0] !== TOKEN_PREFIX) return { ok: false, error: 'malformed' }; + const [, exp, nonce, sig] = parts as [string, string, string, string]; + if (!/^\d{1,16}$/.test(exp) || !/^[A-Za-z0-9_-]{16,64}$/.test(nonce) || !/^[A-Za-z0-9_-]{43}$/.test(sig)) return { ok: false, error: 'malformed' }; + const expiry = Number(exp); + let want: string; + try { want = mac(this.secret, method, params, expiry, nonce); } catch { return { ok: false, error: 'malformed' }; } + const a = Buffer.from(sig), b = Buffer.from(want); + if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, error: 'mismatch' }; + if (expiry <= now) return { ok: false, error: 'expired' }; + if (expiry - now > MAX_TTL_MS) return { ok: false, error: 'too-long' }; + if (this.used.has(nonce)) return { ok: false, error: 'replayed' }; + this.used.set(nonce, expiry); + return { ok: true }; + } +} diff --git a/cli/lib/ctx.ts b/cli/lib/ctx.ts index 79f97f5..d85a96b 100644 --- a/cli/lib/ctx.ts +++ b/cli/lib/ctx.ts @@ -21,8 +21,13 @@ export function byAppleLocale(langs: Language[]): Map { return new Map(langs.filter(l => l.apple).map(l => [l.apple!, l] as const)); } -/** Asks on the terminal unless --yes. Store actions that are public or irreversible go through this. */ +/** + * Asks on the terminal unless --yes. Store actions that are public or irreversible go through this. + * Under `storebound serve` (the desktop sidecar) nothing can answer and nothing may skip the question, not even --yes: + * those actions stay terminal-only, so the answer is always no. + */ export async function confirm(ctx: Ctx, question: string): Promise { + if (process.env.STOREBOUND_SERVE === '1') { console.error(`confirm-required: ${question}\n terminal only: run it in a terminal`); return false; } if (ctx.args.has('yes')) return true; if (!process.stdin.isTTY) { console.log(`${question}\n not a terminal: re-run with --yes to confirm`); return false; } process.stdout.write(`${question} [y/N] `); diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts new file mode 100644 index 0000000..bb2e2d0 --- /dev/null +++ b/cli/lib/desktop-protocol.ts @@ -0,0 +1,99 @@ +// Types of the `storebound serve --stdio` protocol (desktop profile). Type-only, no runtime code: the desktop app +// (apps/desktop) imports this file so both sides agree on shapes. Newline-delimited JSON-RPC 2.0 on stdio. +// +// Session: the first request must be `session.hello` with the per-session secret (hex, >= 32 bytes) the host +// generated. Every other method answers -32002 before that. One hello per process. +// Progress: notifications `progress` { requestId, line?, stream?, n?, total?, project? } while a request runs. +// Cancel: notification `$/cancelRequest` { id } (or MCP-style `notifications/cancelled` { requestId }); the request +// then answers -32800. +// Writes: every write method is a dry run unless `dryRun: false` comes with a `confirmToken` minted by the host for +// exactly (method, { project, args }) (cli/lib/confirm-token.ts). No method submits, releases or publishes anything. + +export const DESKTOP_PROTOCOL = 'storebound-desktop/1'; + +export const ERR = { + parse: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internal: -32603, + notInitialized: -32002, + confirmRequired: -32010, + confirmInvalid: -32011, + cancelled: -32800, +} as const; + +export interface JsonSchemaLite { type: string; description?: string; enum?: (string | number)[]; default?: unknown; minimum?: number; maximum?: number; pattern?: string } + +export interface MethodDescriptor { + method: string; + title: string; + description: string; + kind: 'read' | 'write' | 'session' | 'system' | 'portfolio'; + /** Talks to a store API or the internet. */ + network: boolean; + /** Replaces existing data on a store (screenshots, whole forms). */ + destructive: boolean; + /** Result carries structured `data` (the command's --json output). */ + json: boolean; + /** Schema of `params.args` (tool methods), or of `params` (other methods). */ + args: { type: 'object'; properties: Record; required?: string[]; additionalProperties: false }; +} + +export interface HelloParams { secret: string; client?: { name: string; version: string } } +export interface HelloResult { protocol: typeof DESKTOP_PROTOCOL; server: { name: 'storebound'; version: string; compiled: boolean }; methods: MethodDescriptor[] } + +export interface Credentials { PLAY_SERVICE_ACCOUNT?: string; ASC_KEY_ID?: string; ASC_ISSUER_ID?: string; ASC_P8?: string } + +export interface ToolParams { + /** Absolute path of a folder that contains storebound.config.ts. */ + project: string; + args?: Record; + /** Write methods: false runs for real and needs confirmToken. Default true. */ + dryRun?: boolean; + confirmToken?: string; + /** Per-call credentials (kept in memory, passed to the command's env only). Not part of the token. */ + credentials?: Credentials; +} + +export interface ToolResult { + method: string; + ok: boolean; + exitCode: number; + dryRun: boolean; + /** The CLI line it ran, for logs ("storebound play listing --dry"). */ + command: string; + /** Text output (log lines), trimmed to the last 60 000 characters. */ + output: string; + /** Structured result of a --json capable command. */ + data?: unknown; +} + +export interface ProgressParams { requestId: string | number; line?: string; stream?: 'stdout' | 'stderr'; n?: number; total?: number; project?: string } + +export interface ProjectSummary { + dir: string; + config: string; + name: string; + languages: string[]; + play: { packageName: string } | null; + apple: { bundleId: string; appId: string | null } | null; + products: number; + shots: boolean; + testflightGroups: number; +} + +export interface PortfolioParams { projects: string[]; live?: boolean; credentials?: Credentials } + +export interface PortfolioEntry { + project: string; + ok: boolean; + summary: ProjectSummary | null; + check: { ok: boolean; problems: string[]; warnings: string[] } | null; + /** live only: `apple status --json` / `play status --json` data, or the error text. */ + apple?: { data?: unknown; error?: string }; + play?: { data?: unknown; error?: string }; + errors: string[]; +} + +export interface PortfolioResult { projects: PortfolioEntry[]; at: string } diff --git a/cli/lib/log.ts b/cli/lib/log.ts index 9d6c090..ebe9e0e 100644 --- a/cli/lib/log.ts +++ b/cli/lib/log.ts @@ -10,3 +10,15 @@ export const done = (tag: string, msg: string): void => console.log(`${stamp()} /** "3/14 de-DE" style counters. */ export const step = (i: number, n: number, what: string): string => `${String(i).padStart(String(n).length)}/${n} ${what}`; + +/** Prefix of the one machine-readable result line that `storebound serve --stdio` reads from a child command. */ +export const RESULT_MARK = '\u001estorebound-result '; + +/** + * Structured result of a `--json` run. In a terminal (or a pipe) it is pretty JSON on stdout as before; under + * `storebound serve` (STOREBOUND_MACHINE=1) it is one marked line, so log lines printed before it never break parsing. + */ +export function printJson(data: unknown): void { + if (process.env.STOREBOUND_MACHINE === '1') process.stdout.write(RESULT_MARK + JSON.stringify(data) + '\n'); + else console.log(JSON.stringify(data, null, 2)); +} diff --git a/cli/lib/runner.ts b/cli/lib/runner.ts new file mode 100644 index 0000000..1af8f75 --- /dev/null +++ b/cli/lib/runner.ts @@ -0,0 +1,79 @@ +// Runs one storebound command as a child process: the shared core of `storebound mcp` and `storebound serve --stdio`. +// Works from source (`bun cli/index.ts ...`) and from a `bun build --compile` binary (the desktop sidecar), where the +// binary re-executes itself. Output is streamed line by line (progress) and the `--json` result line is picked out. +import { join } from 'node:path'; +import { RESULT_MARK } from './log'; + +/** True inside a `bun build --compile` executable (the entry module lives in Bun's embedded file system). */ +export const compiled = (): boolean => /^(\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/.test(Bun.main); + +/** Command prefix that starts this storebound: the compiled binary itself, or bun + cli/index.ts. */ +export function selfCommand(): string[] { + return compiled() ? [process.execPath] : [process.execPath, join(import.meta.dir, '..', 'index.ts')]; +} + +export interface RunOptions { + cwd: string; + /** Extra environment for this call only (credentials from the desktop keychain, never written anywhere). */ + env?: Record; + /** Every output line as it arrives (stdout and stderr), without the machine result line. */ + onLine?: (line: string, stream: 'stdout' | 'stderr') => void; + /** Ask for the marked `--json` result line (STOREBOUND_MACHINE=1). */ + machine?: boolean; +} + +export interface RunResult { code: number; out: string; data?: unknown; cancelled: boolean } + +export interface Running { result: Promise; cancel(): void } + +/** Env names a caller may pass per call; anything else in `env` is ignored. */ +export const CALL_ENV = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8'] as const; + +export function pickCallEnv(env: Record | undefined): Record { + const out: Record = {}; + for (const k of CALL_ENV) { const v = env?.[k]; if (typeof v === 'string' && v) out[k] = v; } + return out; +} + +/** Environment of a child: the parent's minus anything secret to the session, plus the per-call values. */ +export function childEnv(extra: Record = {}, machine = false): Record { + const base: Record = {}; + for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !k.startsWith('STOREBOUND_SESSION')) base[k] = v; + return { ...base, ...extra, NO_COLOR: '1', ...(machine ? { STOREBOUND_MACHINE: '1', STOREBOUND_SERVE: '1' } : {}) }; +} + +async function pump(stream: ReadableStream, kind: 'stdout' | 'stderr', each: (line: string, kind: 'stdout' | 'stderr') => void): Promise { + const dec = new TextDecoder(); + let buf = ''; + const reader = stream.getReader(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let nl: number; + while ((nl = buf.indexOf('\n')) >= 0) { each(buf.slice(0, nl).replace(/\r$/, ''), kind); buf = buf.slice(nl + 1); } + } + buf += dec.decode(); + if (buf) each(buf, kind); +} + +export function runCommand(argv: string[], o: RunOptions): Running { + const p = Bun.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) }); + let cancelled = false; + const lines: string[] = []; + let data: unknown; + const each = (line: string, kind: 'stdout' | 'stderr'): void => { + if (kind === 'stdout' && line.startsWith(RESULT_MARK)) { + try { data = JSON.parse(line.slice(RESULT_MARK.length)); } catch { lines.push(line); } + return; + } + lines.push(line); + o.onLine?.(line, kind); + }; + const result = (async (): Promise => { + await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]); + const code = await p.exited; + return { code, out: lines.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled }; + })(); + return { result, cancel: () => { cancelled = true; p.kill(); } }; +} diff --git a/cli/mcp.ts b/cli/mcp.ts index 571042f..04a552d 100644 --- a/cli/mcp.ts +++ b/cli/mcp.ts @@ -20,8 +20,8 @@ // Every tool runs the CLI as a child process in the project directory (STOREBOUND_PROJECT or the server's cwd) and // returns its output. stdout of this process carries protocol messages only; logs go to stderr. // Tool list and safety rules: mcp-tools.ts (write tools default to dry_run, nothing public or irreversible exposed). -import { join } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; +import { runCommand, type Running } from './lib/runner'; import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools'; /** Current (modern, stateless) revision. */ @@ -54,7 +54,6 @@ type Id = string | number; interface Msg { jsonrpc: '2.0'; id?: Id; method?: string; params?: Record } const MAX_OUTPUT = 60_000; -const CLI = join(import.meta.dir, 'index.ts'); const serverInfo = () => ({ name: 'storebound', title: 'storebound', version: pkg.version }); /** Era of one request: modern when its _meta names a modern (or unknown) protocol version, else legacy. */ @@ -117,17 +116,13 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ } } -const running = new Map>(); -function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.kill(); } +const running = new Map(); +function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.cancel(); } async function runCli(argv: string[], id: Id): Promise<{ code: number; out: string }> { - const cwd = process.env.STOREBOUND_PROJECT || process.cwd(); - const p = Bun.spawn([process.execPath, CLI, ...argv], { cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } }); - running.set(id, p); - try { - const [out, errText, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]); - return { code, out: (out + (errText ? `\n${errText}` : '')).trim() }; - } finally { running.delete(id); } + const r = runCommand(argv, { cwd: process.env.STOREBOUND_PROJECT || process.cwd() }); + running.set(id, r); + try { const { code, out } = await r.result; return { code, out }; } finally { running.delete(id); } } export async function serve(): Promise { diff --git a/cli/play/next-version-code.ts b/cli/play/next-version-code.ts index d7a5b40..2d8bcb8 100644 --- a/cli/play/next-version-code.ts +++ b/cli/play/next-version-code.ts @@ -4,6 +4,7 @@ // ./gradlew bundleRelease -PversionCode=$(storebound play next-version-code) import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; +import { printJson } from '../lib/log'; import { nextVersionCode } from '../lib/release'; import { playClient } from './client'; @@ -16,8 +17,11 @@ export async function run(ctx: Ctx): Promise { const apks = (await play.call<{ apks?: { versionCode: number }[] }>('GET', `${play.API}/edits/${e.id}/apks`).catch(() => ({ apks: [] }))).apks ?? []; const tracks = (await play.call<{ tracks?: { releases?: { versionCodes?: string[] }[] }[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? []; const codes = [...bundles.map(b => b.versionCode), ...apks.map(a => a.versionCode), ...tracks.flatMap(t => (t.releases ?? []).flatMap(r => r.versionCodes ?? []))]; - console.error(`[play] ${codes.length} version codes seen; next versionCode:`); - console.log(nextVersionCode(codes)); + if (ctx.args.has('json')) printJson({ next: nextVersionCode(codes), seen: codes.length }); + else { + console.error(`[play] ${codes.length} version codes seen; next versionCode:`); + console.log(nextVersionCode(codes)); + } } finally { await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {}); } diff --git a/cli/play/status.ts b/cli/play/status.ts index 5525b25..73668e3 100644 --- a/cli/play/status.ts +++ b/cli/play/status.ts @@ -2,6 +2,7 @@ // storebound play status [--json] import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; +import { printJson } from '../lib/log'; import { playClient } from './client'; import { listOneTimeProducts } from './products'; @@ -16,7 +17,7 @@ export async function run(ctx: Ctx): Promise { const listings = (await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${e.id}/listings`)).listings ?? []; const details = await play.call>('GET', `${play.API}/edits/${e.id}/details`); const products = await listOneTimeProducts(play); - if (ctx.args.has('json')) { console.log(JSON.stringify({ details, tracks, listings, products }, null, 2)); return; } + if (ctx.args.has('json')) { printJson({ details, tracks, listings, products }); return; } console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`); console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`); for (const t of tracks) { diff --git a/cli/prices.ts b/cli/prices.ts index 555af5f..220e8a5 100644 --- a/cli/prices.ts +++ b/cli/prices.ts @@ -13,7 +13,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { ascClient, type Asc, type J } from './apple/asc'; import type { Ctx } from './lib/ctx'; import { confirm, need } from './lib/ctx'; -import { done, log, step, warn } from './lib/log'; +import { done, log, printJson, step, warn } from './lib/log'; import { toMoney } from './lib/money'; import { appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions, @@ -96,10 +96,12 @@ export async function run(ctx: Ctx): Promise { const wantedAll = parsePriceCsv(readFileSync(csvPath, 'utf8'), { products: config.products!.map(p => p.id) }) .filter(r => products.some(p => p.id === r.product) && ((r.store === 'apple' && useApple) || (r.store === 'play' && usePlay))); const diff = diffPrices(current, wantedAll); - console.log(diffTable(diff, { all: a.has('all') })); const changed = new Set(diff.filter(d => d.change !== 'same').map(d => `${d.product}|${d.store}`)); const reconvert = a.has('reconvert'); if (reconvert && usePlay) for (const p of products) if (playProducts.has(p.id)) changed.add(`${p.id}|play`); + // --json is a read-only diff: it never writes, whatever the other flags say. + if (a.has('json')) { printJson({ csv: csvPath, diff: a.has('all') ? diff : diff.filter(d => d.change !== 'same'), changed: [...changed] }); return; } + console.log(diffTable(diff, { all: a.has('all') })); if (!changed.size) { done(TAG, 'prices up to date'); return; } log(TAG, `${changed.size} product / store pair(s) to update`); if (dry) { done(TAG, 'dry run: nothing written'); return; } diff --git a/cli/reviews.ts b/cli/reviews.ts index d9448e8..0df436a 100644 --- a/cli/reviews.ts +++ b/cli/reviews.ts @@ -13,7 +13,7 @@ import { join } from 'node:path'; import { ascClient, type J } from './apple/asc'; import type { Ctx } from './lib/ctx'; import { confirm } from './lib/ctx'; -import { done, log, warn } from './lib/log'; +import { done, log, printJson, warn } from './lib/log'; import { checkReply, type Draft, draftMarkdown, draftName, filterReviews, markSent, parseAppleReviews, parseDraft, parsePlayReviews, replyRequest, type Review, sendPlan, @@ -92,7 +92,7 @@ export async function run(ctx: Ctx): Promise { if (sub === 'list') { const rs = await collect(ctx, { since, unanswered: a.has('unanswered'), ratings }); - if (a.has('json')) { console.log(JSON.stringify(rs.map(({ author: _a, ...r }) => r), null, 2)); return; } + if (a.has('json')) { printJson(rs.map(({ author: _a, ...r }) => r)); return; } console.log(textTable(['store', 'date', 'stars', 'where', 'replied', 'review'], rs.map(r => [r.store, r.date.slice(0, 10), r.rating, r.where ?? '', r.reply ? 'yes' : '', snippet(r)]))); done(TAG, `${rs.length} review(s) in the last ${days} days, ${rs.filter(r => !r.reply).length} without a reply`); return; diff --git a/cli/serve.ts b/cli/serve.ts new file mode 100644 index 0000000..0698569 --- /dev/null +++ b/cli/serve.ts @@ -0,0 +1,306 @@ +// `storebound serve --stdio`: the machine interface the desktop app (apps/desktop) drives as a sidecar. +// Newline-delimited JSON-RPC 2.0 on stdin / stdout, logs on stderr. Protocol types: lib/desktop-protocol.ts. +// +// It reuses the MCP core: the same tool list (mcp-tools.ts: schemas, validation, argv building, the forbidden-flag +// rules) and the same child-process runner (lib/runner.ts). On top of that, the desktop profile adds: +// - a session handshake (`session.hello`) carrying the per-session secret of the host, +// - typed methods (one per tool, e.g. `play.listing`, plus project.inspect, portfolio.status, system.*), +// - structured results (`data`, the command's --json output), progress notifications and cancellation, +// - writes that are dry runs unless the host sends a confirm token (lib/confirm-token.ts) it minted after the owner +// confirmed a native dialog showing that dry run. +// Nothing here submits for review, releases or publishes: those methods do not exist (tests/serve.test.ts). +import { existsSync, statSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import pkg from '../package.json' with { type: 'json' }; +import { parseSecret, TokenGuard } from './lib/confirm-token'; +import { CONFIG_NAMES } from './lib/config'; +import { + DESKTOP_PROTOCOL, ERR, type MethodDescriptor, type PortfolioEntry, type PortfolioResult, type ProjectSummary, type ToolResult, +} from './lib/desktop-protocol'; +import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner'; +import { inputSchema, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools'; + +type Id = string | number; +interface Msg { jsonrpc?: string; id?: Id | null; method?: string; params?: unknown } +type Json = Record; +export type Send = (msg: Json) => void; +export type Runner = (argv: string[], o: RunOptions) => Running; + +const MAX_OUTPUT = 60_000; + +/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes). */ +export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_release']); +/** Words no desktop method name may contain. */ +export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i; + +/** Read tools whose command prints a structured result with --json. */ +export const JSON_TOOLS = new Set(['check', 'check_aso', 'validate', 'health', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status', + 'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status']); + +/** `apple_testflight_status` -> `apple.testflight_status`: first underscore becomes the namespace dot. */ +export const methodName = (tool: string): string => tool.replace('_', '.'); + +export const DESKTOP_TOOLS: ToolDef[] = TOOLS.filter(t => !DESKTOP_EXCLUDED.has(t.name)); +const byMethod = new Map(DESKTOP_TOOLS.map(t => [methodName(t.name), t] as const)); + +function argsSchema(t: ToolDef): MethodDescriptor['args'] { + const s = inputSchema(t) as MethodDescriptor['args']; + const { dry_run: _d, ...properties } = s.properties; + return { ...s, properties }; +} + +const obj = (properties: MethodDescriptor['args']['properties'], required?: string[]): MethodDescriptor['args'] => + ({ type: 'object', properties, ...(required ? { required } : {}), additionalProperties: false }); + +const SYSTEM: MethodDescriptor[] = [ + { method: 'session.hello', title: 'Start the session', description: 'Hand over the per-session secret (hex, >= 32 bytes). Must be the first request.', kind: 'session', network: false, destructive: false, json: true, + args: obj({ secret: { type: 'string' }, client: { type: 'object' } }, ['secret']) }, + { method: 'system.version', title: 'Version', description: 'storebound version, platform and whether this is the compiled sidecar.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, + { method: 'system.methods', title: 'Methods', description: 'Every method of this profile with its argument schema.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, + { method: 'system.tools', title: 'Local tools', description: 'Which optional local tools are installed (git, ffmpeg, Google Chrome, Playwright); screenshot and video features need them.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, + { method: 'project.inspect', title: 'Inspect a project', description: 'Offline summary of a folder\'s storebound.config.ts (name, store ids, languages, products).', kind: 'read', network: false, destructive: false, json: true, + args: obj({ project: { type: 'string', description: 'Absolute folder path' } }, ['project']) }, + { method: 'portfolio.status', title: 'Portfolio status', description: 'Summary + offline check of many projects; with live, also App Store and Google Play status (read-only).', kind: 'portfolio', network: true, destructive: false, json: true, + args: obj({ projects: { type: 'array' }, live: { type: 'boolean', default: false } }, ['projects']) }, +]; + +export function describeMethods(): MethodDescriptor[] { + return [...SYSTEM, ...DESKTOP_TOOLS.map(t => ({ + method: methodName(t.name), title: t.title, description: t.description, kind: t.write ? 'write' as const : 'read' as const, + network: t.network, destructive: !!t.destructive, json: !t.write && JSON_TOOLS.has(t.name), args: argsSchema(t), + }))]; +} + +class RpcError extends Error { + constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); } +} + +/** Absolute folder with a storebound config in it, else an error. */ +export function checkProject(p: unknown): string { + if (typeof p !== 'string' || !p) throw new RpcError(ERR.invalidParams, 'project: absolute folder path required'); + if (!isAbsolute(p)) throw new RpcError(ERR.invalidParams, 'project: must be an absolute path'); + const dir = resolve(p); + let isDir = false; + try { isDir = statSync(dir).isDirectory(); } catch { /* missing */ } + if (!isDir) throw new RpcError(ERR.invalidParams, `project: not a folder: ${dir}`); + if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new RpcError(ERR.invalidParams, `project: no storebound.config.ts in ${dir}`); + return dir; +} + +const cut = (s: string): string => (s.length > MAX_OUTPUT ? `[... first ${s.length - MAX_OUTPUT} characters cut ...]\n` + s.slice(-MAX_OUTPUT) : s); + +function credentialsOf(v: unknown): Record { + if (v === undefined) return {}; + if (typeof v !== 'object' || v === null || Array.isArray(v)) throw new RpcError(ERR.invalidParams, 'credentials: object expected'); + return pickCallEnv(v as Record); +} + +/** One sidecar session: state is the secret (after hello), the token guard and the running requests. */ +export class Session { + private guard: TokenGuard | null = null; + private running = new Map>(); + private cancelled = new Set(); + + constructor(private readonly send: Send, private readonly run: Runner = runCommand, private readonly clock: () => number = Date.now) {} + + get ready(): boolean { return this.guard !== null; } + + /** Stops every running child (the host went away). */ + shutdown(): void { for (const set of this.running.values()) for (const r of set) r.cancel(); } + + /** Handles one incoming message; resolves with the response (or null for notifications). */ + async handle(m: Msg): Promise { + if (typeof m !== 'object' || m === null || Array.isArray(m)) return { jsonrpc: '2.0', id: null, error: { code: ERR.invalidRequest, message: 'Invalid request' } }; + if (m.id === undefined || m.id === null) { this.notification(m); return null; } + const id = m.id; + try { + const result = await this.dispatch(String(m.method ?? ''), (m.params ?? {}) as Json, id); + return { jsonrpc: '2.0', id, result }; + } catch (e) { + if (this.cancelled.has(id)) return { jsonrpc: '2.0', id, error: { code: ERR.cancelled, message: 'Request cancelled' } }; + const err = e instanceof RpcError ? e : new RpcError(ERR.internal, (e as Error).message); + return { jsonrpc: '2.0', id, error: { code: err.code, message: err.message, ...(err.data === undefined ? {} : { data: err.data }) } }; + } finally { + this.running.delete(id); + this.cancelled.delete(id); + } + } + + private notification(m: Msg): void { + const p = (m.params ?? {}) as Json; + if (m.method === '$/cancelRequest' || m.method === 'notifications/cancelled') { + const id = (p.id ?? p.requestId) as Id | undefined; + if (id === undefined) return; + this.cancelled.add(id); + for (const r of this.running.get(id) ?? []) r.cancel(); + } + } + + private track(id: Id, r: Running): Running { + if (this.cancelled.has(id)) r.cancel(); + let set = this.running.get(id); + if (!set) this.running.set(id, (set = new Set())); + set.add(r); + return r; + } + + private async dispatch(method: string, params: Json, id: Id): Promise { + if (typeof params !== 'object' || params === null || Array.isArray(params)) throw new RpcError(ERR.invalidParams, 'params must be an object'); + if (method === 'session.hello') { + if (this.guard) throw new RpcError(ERR.invalidRequest, 'session already started'); + let secret: Buffer; + try { secret = parseSecret(params.secret); } catch (e) { throw new RpcError(ERR.invalidParams, (e as Error).message); } + this.guard = new TokenGuard(secret, this.clock); + return { protocol: DESKTOP_PROTOCOL, server: { name: 'storebound', version: pkg.version, compiled: compiled() }, methods: describeMethods() }; + } + if (!this.guard) throw new RpcError(ERR.notInitialized, 'session.hello first'); + switch (method) { + case 'system.version': return { version: pkg.version, compiled: compiled(), platform: process.platform, arch: process.arch, bun: Bun.version }; + case 'system.methods': return { methods: describeMethods() }; + case 'system.tools': return localTools(); + case 'project.inspect': return this.inspect(checkProject(params.project), {}, id); + case 'portfolio.status': return this.portfolio(params, id); + } + const tool = byMethod.get(method); + if (!tool) throw new RpcError(ERR.methodNotFound, `Method not found: ${method}`); + return this.tool(method, tool, params, id); + } + + private async tool(method: string, t: ToolDef, params: Json, id: Id): Promise { + for (const k of Object.keys(params)) if (!['project', 'args', 'dryRun', 'confirmToken', 'credentials'].includes(k)) throw new RpcError(ERR.invalidParams, `unknown param: ${k}`); + const project = checkProject(params.project); + const args = (params.args ?? {}) as Json; + if (typeof args !== 'object' || args === null || Array.isArray(args)) throw new RpcError(ERR.invalidParams, 'args must be an object'); + if ('dry_run' in args) throw new RpcError(ERR.invalidParams, 'args.dry_run: use params.dryRun'); + const problems = validateInput(t, args); + if (problems.length) throw new RpcError(ERR.invalidParams, `Invalid arguments: ${problems.join('; ')}`, { problems }); + const env = credentialsOf(params.credentials); + let dryRun = true; + if (params.dryRun === false) { + if (!t.write) dryRun = false; + else { + if (params.confirmToken === undefined) throw new RpcError(ERR.confirmRequired, `${method} writes: dryRun false needs a confirmToken from the desktop host`); + const v = this.guard!.verify(params.confirmToken, method, { project, args }); + if (!v.ok) throw new RpcError(ERR.confirmInvalid, `confirm token rejected: ${v.error}`, { reason: v.error }); + dryRun = false; + } + } else if (params.dryRun !== undefined && params.dryRun !== true) throw new RpcError(ERR.invalidParams, 'dryRun must be a boolean'); + if (!t.write) dryRun = false; + const built = toArgv(t, { ...args, ...(t.write ? { dry_run: dryRun } : {}) }); + const json = !t.write && JSON_TOOLS.has(t.name); + const argv = json ? [...built.argv, '--json'] : built.argv; + const r = this.track(id, this.run(argv, { cwd: project, env, machine: true, onLine: (line, stream) => this.progress(id, { line, stream }) })); + const res = await r.result; + if (res.cancelled || this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); + return { + method, ok: res.code === 0, exitCode: res.code, dryRun: t.write ? dryRun : false, command: `storebound ${argv.join(' ')}`, output: cut(res.out), + ...(res.data === undefined ? {} : { data: res.data }), + }; + } + + private progress(id: Id, p: Json): void { + this.send({ jsonrpc: '2.0', method: 'progress', params: { requestId: id, ...p } }); + } + + private async inspect(project: string, env: Record, id: Id): Promise { + const r = await this.track(id, this.run(['inspect', '--json'], { cwd: project, env, machine: true })).result; + if (r.cancelled) throw new RpcError(ERR.cancelled, 'Request cancelled'); + if (r.code !== 0 || !r.data) throw new RpcError(ERR.invalidParams, `project: ${lastLine(r.out) || 'config could not be loaded'}`); + return r.data as ProjectSummary; + } + + private async portfolio(params: Json, id: Id): Promise { + const list = params.projects; + if (!Array.isArray(list) || !list.length || list.length > 200) throw new RpcError(ERR.invalidParams, 'projects: 1-200 absolute folder paths'); + const live = params.live === true; + const env = credentialsOf(params.credentials); + const out: PortfolioEntry[] = new Array(list.length); + let done = 0; + const one = async (i: number): Promise => { + const raw = list[i]; + const e: PortfolioEntry = { project: String(raw), ok: false, summary: null, check: null, errors: [] }; + out[i] = e; + try { + const dir = checkProject(raw); + e.project = dir; + const summary = e.summary = await this.inspect(dir, env, id); + const c = await this.track(id, this.run(['check', '--json'], { cwd: dir, env, machine: true })).result; + const cd = c.data as { ok?: boolean; problems?: string[]; warnings?: string[] } | undefined; + e.check = cd ? { ok: !!cd.ok, problems: cd.problems ?? [], warnings: cd.warnings ?? [] } : { ok: false, problems: [lastLine(c.out) || `check exit ${c.code}`], warnings: [] }; + if (live) { + const store = async (argv: string[]): Promise<{ data?: unknown; error?: string }> => { + const r = await this.track(id, this.run(argv, { cwd: dir, env, machine: true })).result; + return r.code === 0 && r.data !== undefined ? { data: r.data } : { error: lastLine(r.out) || `exit ${r.code}` }; + }; + const [apple, play] = await Promise.all([summary.apple ? store(['apple', 'status', '--json']) : undefined, summary.play ? store(['play', 'status', '--json']) : undefined]); + if (apple) e.apple = apple; + if (play) e.play = play; + } + e.ok = e.check.ok && !e.apple?.error && !e.play?.error; + } catch (err) { + if (this.cancelled.has(id)) throw err; + e.errors.push((err as Error).message); + } + this.progress(id, { n: ++done, total: list.length, project: e.project }); + }; + // A few projects at a time: each one is a few child processes (and API calls when live). + let next = 0; + await Promise.all(Array.from({ length: Math.min(3, list.length) }, async () => { while (next < list.length) await one(next++); })); + if (this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); + return { projects: out, at: new Date(this.clock()).toISOString() }; + } +} + +const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(-1)?.replace(/^error:\s*/, '') ?? ''; + +export function localTools(): Json { + const which = (n: string): string | null => Bun.which(n); + const chrome = process.platform === 'darwin' ? existsSync('/Applications/Google Chrome.app') + : process.platform === 'win32' ? [process.env['PROGRAMFILES'], process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].some(d => d && existsSync(join(d, 'Google/Chrome/Application/chrome.exe'))) + : !!(which('google-chrome') || which('google-chrome-stable') || which('chromium')); + const ffmpeg = which('ffmpeg'); + return { + git: !!which('git'), ffmpeg: !!ffmpeg, chrome, + notes: [ + ...(ffmpeg ? [] : ['ffmpeg not found: icons, screenshot renders and app preview videos need it (brew install ffmpeg / apt install ffmpeg / winget install ffmpeg)']), + ...(chrome ? [] : ['Google Chrome not found: screenshot renders and web captures need Chrome or Playwright\'s Chromium in the project']), + ], + }; +} + +export async function serveStdio(): Promise { + const send: Send = o => { process.stdout.write(JSON.stringify(o) + '\n'); }; + const session = new Session(send); + console.error(`[storebound serve] ${pkg.version} on stdio (${DESKTOP_PROTOCOL}), waiting for session.hello`); + let buf = ''; + const decoder = new TextDecoder(); + const reader = Bun.stdin.stream().getReader(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line) continue; + let msg: Msg; + try { msg = JSON.parse(line) as Msg; } catch { send({ jsonrpc: '2.0', id: null, error: { code: ERR.parse, message: 'Parse error' } }); continue; } + if (Array.isArray(msg)) { send({ jsonrpc: '2.0', id: null, error: { code: ERR.invalidRequest, message: 'Batches are not supported' } }); continue; } + // The handshake must finish before anything else is read, so a request right behind it sees the session. + if (!session.ready && msg.method === 'session.hello') { const r = await session.handle(msg); if (r) send(r); continue; } + session.handle(msg).then(r => { if (r) send(r); }, e => send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: ERR.internal, message: String((e as Error).message) } })); + } + } + // stdin closed: the host is gone; stop what still runs. + session.shutdown(); + process.exit(0); +} + +export async function run(args: { has(n: string): boolean }): Promise { + if (!args.has('stdio')) { + console.error('usage: storebound serve --stdio (JSON-RPC for the storebound desktop app; for AI tools use `storebound mcp`)'); + process.exit(2); + } + await serveStdio(); +} diff --git a/cli/shots/check.ts b/cli/shots/check.ts index bd2539e..00a64dc 100644 --- a/cli/shots/check.ts +++ b/cli/shots/check.ts @@ -11,7 +11,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { ShipConfig } from '../lib/config'; import type { Ctx } from '../lib/ctx'; -import { log } from '../lib/log'; +import { log, printJson } from '../lib/log'; import { checkFrames, type FrameIssue } from './devices'; import { defaultSizes, frameDims, SIZES } from './lib'; import type { LocalIssue, RendersCheck } from '../lib/validate-rules'; @@ -64,7 +64,7 @@ export async function run(ctx: Ctx): Promise { const cap = config.shots?.capture; const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs) : []; if (ctx.args.has('json')) { - console.log(JSON.stringify({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues }, null, 2)); + printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues }); } else { for (const { where, issue } of issues) console.log(`${issue.level === 'error' ? 'ERROR' : 'warn '} ${where}: ${issue.msg}`); for (const f of frameIssues) console.log(`ERROR frames/${f.file}: ${f.msg}`); diff --git a/cli/validate.ts b/cli/validate.ts index 8179922..0fb06c6 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -19,7 +19,7 @@ import type { Ctx } from './lib/ctx'; import { checkUrl } from './lib/http'; import { collectHealth } from './health'; import { healthFindings } from './lib/health'; -import { log, warn } from './lib/log'; +import { log, printJson, warn } from './lib/log'; import { type AppleSnapshot, appleRules, type Finding, formatFindings, PLAY_REQUIRED_IMAGES, type PlaySnapshot, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules'; import { rendersCheck } from './shots/check'; import { playClient } from './play/client'; @@ -177,7 +177,7 @@ export async function run(ctx: Ctx): Promise { findings.push(...sortFindings(healthFindings(h, { unreadable: 'ok' }))); } const blockers = findings.filter(f => f.level === 'blocker').length; - if (a.has('json')) console.log(JSON.stringify({ blockers, findings }, null, 2)); + if (a.has('json')) printJson({ blockers, findings }); else { console.log(formatFindings(findings)); log('validate', blockers ? `${blockers} blocker(s)` : 'no blockers (UI-only steps are not visible to the API: App Privacy, Play App content)'); diff --git a/tests/serve.test.ts b/tests/serve.test.ts new file mode 100644 index 0000000..63930ac --- /dev/null +++ b/tests/serve.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { canonical, mac, MAX_TTL_MS, mint, parseSecret, TokenGuard } from '../cli/lib/confirm-token'; +import { ERR } from '../cli/lib/desktop-protocol'; +import { childEnv, pickCallEnv, type RunOptions, type Running } from '../cli/lib/runner'; +import { FORBIDDEN_ARGS, TOOLS } from '../cli/mcp-tools'; +import { describeMethods, DESKTOP_TOOLS, FORBIDDEN_METHOD_WORDS, JSON_TOOLS, methodName, Session } from '../cli/serve'; +import { buildOne, hostTarget, leaks } from '../tools/build-sidecars'; + +type J = Record; +const ROOT = join(import.meta.dir, '..'); +const DEMO = join(ROOT, 'examples/demo-game'); +const SECRET_HEX = Array.from({ length: 32 }, (_, i) => i.toString(16).padStart(2, '0')).join(''); +const SECRET = Buffer.from(SECRET_HEX, 'hex'); + +interface Call { argv: string[]; o: RunOptions } +function fakeRunner(reply: (argv: string[]) => { code?: number; out?: string; data?: unknown; lines?: string[]; wait?: Promise } = () => ({})) { + const calls: Call[] = []; + const run = (argv: string[], o: RunOptions): Running => { + calls.push({ argv, o }); + const r = reply(argv); + let cancelled = false; + let release: () => void = () => {}; + const gate = new Promise(res => { release = res; }); + const result = (async () => { + for (const l of r.lines ?? []) o.onLine?.(l, 'stdout'); + if (r.wait) await Promise.race([r.wait, gate]); + return { code: cancelled ? 143 : r.code ?? 0, out: r.out ?? '', ...(r.data === undefined ? {} : { data: r.data }), cancelled }; + })(); + return { result, cancel: () => { cancelled = true; release(); } }; + }; + return { calls, run }; +} + +async function session(reply?: Parameters[0], clock: () => number = Date.now) { + const sent: J[] = []; + const f = fakeRunner(reply); + const s = new Session(m => sent.push(m), f.run, clock); + let id = 0; + const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))!; + const hello = await call('session.hello', { secret: SECRET_HEX, client: { name: 'test', version: '1' } }); + return { s, sent, calls: f.calls, call, hello, nextId: () => id + 1 }; +} +const errOf = (r: J) => r.error as { code: number; message: string; data?: J }; +const resOf = (r: J) => r.result as J; + +describe('confirm tokens', () => { + test('canonical JSON: sorted keys, no whitespace, safe integers only', () => { + expect(canonical({ b: 1, a: [true, null, 'x'], c: { z: '', y: undefined } })).toBe('{"a":[true,null,"x"],"b":1,"c":{"z":""}}'); + expect(() => canonical({ f: 0.5 })).toThrow(); + expect(() => canonical({ f: 2 ** 60 })).toThrow(); + }); + test('shared vector (the Rust host tests the same one in src-tauri/tests/confirm.rs)', () => { + const p = { project: '/tmp/game', args: { only: 'en-US,tr', b: [1, true, null, 'ü"\n'] } }; + expect(canonical(p)).toBe('{"args":{"b":[1,true,null,"ü\\"\\n"],"only":"en-US,tr"},"project":"/tmp/game"}'); + expect(mac(SECRET, 'play.listing', p, 1700000060000, 'AAAAAAAAAAAAAAAAAAAAAA')).toBe('JhSxUL2ej4cm4yrmlcA23rTCPq3YMKJthbB7n6aixxw'); + }); + test('binding, expiry, max lifetime, replay', () => { + let now = 1_000_000; + const g = new TokenGuard(SECRET, () => now); + const p = { project: '/p', args: { only: 'en-US' } }; + const t = mint(SECRET, 'play.listing', p, { now }); + expect(g.verify(t, 'play.listing', { project: '/p', args: { only: 'tr' } })).toEqual({ ok: false, error: 'mismatch' }); + expect(g.verify(t, 'play.listing', { project: '/q', args: { only: 'en-US' } })).toEqual({ ok: false, error: 'mismatch' }); + expect(g.verify(t, 'play.images', p)).toEqual({ ok: false, error: 'mismatch' }); + expect(g.verify(mint(Buffer.alloc(32, 7), 'play.listing', p, { now }), 'play.listing', p)).toEqual({ ok: false, error: 'mismatch' }); + expect(g.verify(t, 'play.listing', p)).toEqual({ ok: true }); + expect(g.verify(t, 'play.listing', p)).toEqual({ ok: false, error: 'replayed' }); + const old = mint(SECRET, 'play.listing', p, { now, ttlMs: 1000 }); + now += 1000; + expect(g.verify(old, 'play.listing', p)).toEqual({ ok: false, error: 'expired' }); + expect(g.verify(mint(SECRET, 'play.listing', p, { now, ttlMs: MAX_TTL_MS + 1 }), 'play.listing', p)).toEqual({ ok: false, error: 'too-long' }); + for (const bad of [undefined, '', 'sbt1', 'sbt2.1.AAAAAAAAAAAAAAAAAAAAAA.x', `sbt1.${now + 5000}.short.${'A'.repeat(43)}`, t + 'x']) + expect(g.verify(bad, 'play.listing', p).ok).toBe(false); + }); + test('secrets: hex, at least 32 bytes', () => { + expect(parseSecret(SECRET_HEX).length).toBe(32); + expect(() => parseSecret('abcd')).toThrow(); + expect(() => parseSecret('zz'.repeat(32))).toThrow(); + expect(() => parseSecret(42)).toThrow(); + }); +}); + +describe('serve --stdio: methods', () => { + test('nothing submits, releases or publishes', () => { + const names = describeMethods().map(m => m.method); + for (const n of names) expect(n).not.toMatch(FORBIDDEN_METHOD_WORDS); + for (const n of ['apple.submit', 'apple.release', 'play.release', 'play.release_testing', 'apple.phased_release', 'play.rollout', 'reviews.send', 'apple.ppo_start', 'prices.import']) + expect(names).not.toContain(n); + // every desktop tool still goes through the MCP rules: no forbidden flag can come out of any of them + for (const t of DESKTOP_TOOLS) expect(t.command.some(c => FORBIDDEN_ARGS.includes(c))).toBe(false); + expect(new Set(names).size).toBe(names.length); + }); + test('typed descriptors for every tool; json flags only on read tools', () => { + const ms = describeMethods(); + expect(ms.filter(m => m.kind === 'read' || m.kind === 'write').length).toBe(DESKTOP_TOOLS.length + 1); // + project.inspect + for (const m of ms) { expect(m.args.type).toBe('object'); expect(m.args.additionalProperties).toBe(false); expect(m.args.properties.dry_run).toBeUndefined(); } + for (const t of JSON_TOOLS) expect(TOOLS.find(x => x.name === t)?.write).toBe(false); + expect(ms.map(m => m.method)).toEqual(expect.arrayContaining(['portfolio.status', 'project.inspect', 'check', 'validate', 'apple.status', 'play.status', 'reviews.list', 'apple.testflight_status'])); + expect(methodName('apple_testflight_status')).toBe('apple.testflight_status'); + }); +}); + +describe('serve --stdio: session', () => { + test('hello first, once, with a real secret', async () => { + const s = new Session(() => {}, fakeRunner().run); + expect(errOf((await s.handle({ jsonrpc: '2.0', id: 1, method: 'system.version' }))!).code).toBe(ERR.notInitialized); + expect(errOf((await s.handle({ jsonrpc: '2.0', id: 2, method: 'session.hello', params: { secret: 'ab' } }))!).code).toBe(ERR.invalidParams); + const ok = resOf((await s.handle({ jsonrpc: '2.0', id: 3, method: 'session.hello', params: { secret: SECRET_HEX } }))!); + expect(ok.protocol).toBe('storebound-desktop/1'); + expect((ok.methods as unknown[]).length).toBe(describeMethods().length); + expect(errOf((await s.handle({ jsonrpc: '2.0', id: 4, method: 'session.hello', params: { secret: SECRET_HEX } }))!).code).toBe(ERR.invalidRequest); + expect(resOf((await s.handle({ jsonrpc: '2.0', id: 5, method: 'system.version' }))!).version).toBeString(); + expect(errOf((await s.handle({ jsonrpc: '2.0', id: 6, method: 'apple.submit', params: { project: DEMO } }))!).code).toBe(ERR.methodNotFound); + }); + test('read method: --json appended, data and progress passed through, credentials only as per-call env', async () => { + const { call, calls, sent } = await session(() => ({ data: { blockers: 0, findings: [] }, out: 'log line', lines: ['12:00:00 [validate] apple'] })); + const r = resOf(await call('validate', { project: DEMO, args: { store: 'apple' }, credentials: { ASC_KEY_ID: 'K', HOME: '/evil', ASC_P8: '' } })); + expect(calls[0]!.argv).toEqual(['validate', '--apple', '--json']); + expect(calls[0]!.o).toMatchObject({ cwd: DEMO, machine: true, env: { ASC_KEY_ID: 'K' } }); + expect(Object.keys(calls[0]!.o.env!)).toEqual(['ASC_KEY_ID']); + expect(r).toMatchObject({ ok: true, exitCode: 0, dryRun: false, data: { blockers: 0 }, command: 'storebound validate --apple --json' }); + expect(sent).toContainEqual({ jsonrpc: '2.0', method: 'progress', params: { requestId: 2, line: '12:00:00 [validate] apple', stream: 'stdout' } }); + }); + test('bad params: relative or config-less project, unknown args, dry_run inside args', async () => { + const { call } = await session(); + expect(errOf(await call('check', { project: 'examples/demo-game' })).code).toBe(ERR.invalidParams); + expect(errOf(await call('check', { project: tmpdir() })).message).toContain('no storebound.config.ts'); + expect(errOf(await call('check', { project: DEMO, args: { nope: 1 } })).code).toBe(ERR.invalidParams); + expect(errOf(await call('play.listing', { project: DEMO, args: { dry_run: false } })).code).toBe(ERR.invalidParams); + expect(errOf(await call('check', { project: DEMO, extra: true })).code).toBe(ERR.invalidParams); + expect(errOf(await call('play.listing', { project: DEMO, args: { only: '--yes' } })).code).toBe(ERR.invalidParams); + }); + test('writes: dry run by default; real run only with a matching, fresh, unused host token', async () => { + let now = 5_000_000; + const { call, calls } = await session(undefined, () => now); + const p = { project: DEMO, args: { only: 'en-US' } }; + expect(resOf(await call('play.listing', p))).toMatchObject({ dryRun: true }); + expect(calls[0]!.argv).toEqual(['play', 'listing', '--only', 'en-US', '--dry']); + expect(errOf(await call('play.listing', { ...p, dryRun: false })).code).toBe(ERR.confirmRequired); + const token = mint(SECRET, 'play.listing', p, { now }); + expect(errOf(await call('play.listing', { ...p, args: { only: 'tr-TR' }, dryRun: false, confirmToken: token })).code).toBe(ERR.confirmInvalid); + expect(errOf(await call('play.images', { ...p, dryRun: false, confirmToken: token })).code).toBe(ERR.confirmInvalid); + const real = resOf(await call('play.listing', { ...p, dryRun: false, confirmToken: token, credentials: { PLAY_SERVICE_ACCOUNT: '{}' } })); + expect(real).toMatchObject({ dryRun: false }); + expect(calls.at(-1)!.argv).toEqual(['play', 'listing', '--only', 'en-US']); + const replay = errOf(await call('play.listing', { ...p, dryRun: false, confirmToken: token })); + expect(replay).toMatchObject({ code: ERR.confirmInvalid, data: { reason: 'replayed' } }); + const soon = mint(SECRET, 'play.listing', p, { now, ttlMs: 10 }); + now += 11; + expect(errOf(await call('play.listing', { ...p, dryRun: false, confirmToken: soon })).data).toEqual({ reason: 'expired' }); + // a read method never needs a token, and dryRun false on it is just a normal run + expect(resOf(await call('check', { project: DEMO, dryRun: false })).dryRun).toBe(false); + }); + test('cancellation answers -32800 and kills the child', async () => { + const { s, call, nextId } = await session(() => ({ wait: new Promise(() => {}) })); + const id = nextId(); + const pending = call('validate', { project: DEMO }); + await Bun.sleep(5); + await s.handle({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } }); + expect(errOf(await pending).code).toBe(ERR.cancelled); + }); + test('portfolio.status: many projects, per-project errors, progress n/total', async () => { + const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: { name: 'Demo', apple: { bundleId: 'a.b' }, play: null } } + : argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } } + : argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' } : {}); + const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { projects: J[] }; + expect(r.projects[0]).toMatchObject({ project: DEMO, summary: { name: 'Demo' }, check: { ok: true, warnings: ['w'] }, apple: { error: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false }); + expect(r.projects[0]!.play).toBeUndefined(); + expect(r.projects[1]).toMatchObject({ ok: false, summary: null }); + expect((r.projects[1]!.errors as string[])[0]).toContain('not a folder'); + const prog = sent.filter(m => m.method === 'progress').map(m => (m.params as J).n); + expect(prog.sort()).toEqual([1, 2]); + expect(errOf(await call('portfolio.status', { projects: [] })).code).toBe(ERR.invalidParams); + }); + test('children never inherit session secrets; only credential names pass', () => { + process.env.STOREBOUND_SESSION_SECRET = 'x'; + try { expect(childEnv({}, true).STOREBOUND_SESSION_SECRET).toBeUndefined(); } finally { delete process.env.STOREBOUND_SESSION_SECRET; } + expect(childEnv({}, true)).toMatchObject({ STOREBOUND_MACHINE: '1', STOREBOUND_SERVE: '1', NO_COLOR: '1' }); + expect(pickCallEnv({ PATH: '/x', ASC_ISSUER_ID: 'i', ASC_P8: 3 })).toEqual({ ASC_ISSUER_ID: 'i' }); + }); +}); + +/** Talks to a real `storebound serve --stdio` process (source or compiled). */ +async function stdioRoundTrip(cmd: string[]): Promise { + const p = Bun.spawn([...cmd, 'serve', '--stdio'], { cwd: ROOT, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }); + const lines = [ + { jsonrpc: '2.0', id: 1, method: 'system.version' }, + { jsonrpc: '2.0', id: 2, method: 'session.hello', params: { secret: SECRET_HEX } }, + { jsonrpc: '2.0', id: 3, method: 'project.inspect', params: { project: DEMO } }, + { jsonrpc: '2.0', id: 4, method: 'shots.check', params: { project: DEMO } }, + { jsonrpc: '2.0', id: 5, method: 'play.listing', params: { project: DEMO, dryRun: false, confirmToken: 'sbt1.1.AAAAAAAAAAAAAAAAAAAAAA.' + 'A'.repeat(43) } }, + ]; + // hello must be answered before the next request is read: send the version probe, then the rest + p.stdin.write(lines.map(l => JSON.stringify(l)).join('\n') + '\n'); + await p.stdin.flush(); + const out: J[] = []; + const reader = p.stdout.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const deadline = Date.now() + 25_000; + while (out.filter(m => m.id !== undefined).length < lines.length && Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let nl: number; + while ((nl = buf.indexOf('\n')) >= 0) { out.push(JSON.parse(buf.slice(0, nl)) as J); buf = buf.slice(nl + 1); } + } + p.stdin.end(); + await p.exited; + return out; +} + +function checkRoundTrip(out: J[]): void { + const byId = (id: number) => out.find(m => m.id === id)!; + expect(errOf(byId(1)).code).toBe(ERR.notInitialized); + expect(resOf(byId(2)).protocol).toBe('storebound-desktop/1'); + expect(resOf(byId(3))).toMatchObject({ name: 'Prism Puddle', play: { packageName: 'com.example.prismpuddle' } }); + const shots = resOf(byId(4)); + expect(shots.command).toBe('storebound shots check --json'); + expect((shots.data as J).issues).toBeArray(); + expect(errOf(byId(5)).code).toBe(ERR.confirmInvalid); + expect(out.some(m => m.method === 'progress')).toBe(true); +} + +describe('serve --stdio over a real pipe', () => { + test('from source', async () => { checkRoundTrip(await stdioRoundTrip([process.execPath, join(ROOT, 'cli/index.ts')])); }, 30_000); + + test('compiled sidecar for this machine: version + handshake, no private paths inside', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-sidecar-')); + try { + const bin = await buildOne(hostTarget(), dir); + expect(existsSync(bin)).toBe(true); + const v = Bun.spawnSync([bin, 'version'], { cwd: ROOT, stdout: 'pipe' }); + expect(v.exitCode).toBe(0); + expect(v.stdout.toString().trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(leaks(bin)).toEqual([]); + checkRoundTrip(await stdioRoundTrip([bin])); + } finally { rmSync(dir, { recursive: true, force: true }); } + }, 60_000); +}); diff --git a/tools/build-sidecars.ts b/tools/build-sidecars.ts new file mode 100644 index 0000000..5539f19 --- /dev/null +++ b/tools/build-sidecars.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun +// Compiles the storebound CLI into single-file executables for the desktop app's sidecar (Tauri `externalBin`). +// bun tools/build-sidecars.ts every target +// bun tools/build-sidecars.ts --host only this machine's target (what `tauri dev` needs) +// bun tools/build-sidecars.ts --target bun-linux-x64[,bun-windows-x64] +// Output: apps/desktop/src-tauri/binaries/storebound-[.exe] (Tauri's sidecar naming; gitignored). +// macOS outputs are re-signed ad hoc: the signature `bun build --compile` leaves is invalid once the payload is +// appended, and the kernel kills such a binary on start (exit 137). Release builds re-sign with Developer ID (D5). +// Every output is scanned for the build machine's home and checkout paths: nothing private may ship. +import { mkdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const TARGETS: Record = { + 'bun-darwin-arm64': 'aarch64-apple-darwin', + 'bun-darwin-x64': 'x86_64-apple-darwin', + 'bun-linux-x64': 'x86_64-unknown-linux-gnu', + 'bun-linux-arm64': 'aarch64-unknown-linux-gnu', + 'bun-windows-x64': 'x86_64-pc-windows-msvc', +}; + +export const ROOT = join(import.meta.dir, '..'); +export const OUT_DIR = join(ROOT, 'apps/desktop/src-tauri/binaries'); + +export function hostTarget(): string { + const os = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux'; + const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; + return `bun-${os}-${arch}`; +} + +export const outName = (target: string): string => `storebound-${TARGETS[target]}${target.includes('windows') ? '.exe' : ''}`; + +/** Paths of this machine that must not appear in a shipped binary. */ +export function privateStrings(): string[] { + return [...new Set([homedir(), ROOT].filter(s => s.length > 4))]; +} + +export function leaks(file: string, needles = privateStrings()): string[] { + const buf = readFileSync(file); + return needles.filter(n => buf.includes(Buffer.from(n))); +} + +export async function buildOne(target: string, outDir = OUT_DIR): Promise { + if (!TARGETS[target]) throw new Error(`unknown target ${target} (${Object.keys(TARGETS).join(', ')})`); + mkdirSync(outDir, { recursive: true }); + const out = join(outDir, outName(target)); + rmSync(out, { force: true }); + const t = Date.now(); + const p = Bun.spawnSync(['bun', 'build', '--compile', `--target=${target}`, '--minify-syntax', '--minify-whitespace', 'cli/index.ts', '--outfile', out], + { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }); + if (p.exitCode !== 0) throw new Error(`${target}: bun build failed\n${p.stderr.toString()}${p.stdout.toString()}`); + if (target.startsWith('bun-darwin') && process.platform === 'darwin') { + const s = Bun.spawnSync(['codesign', '--force', '--sign', '-', out], { stdout: 'pipe', stderr: 'pipe' }); + if (s.exitCode !== 0) throw new Error(`${target}: ad-hoc codesign failed: ${s.stderr.toString()}`); + } + const hits = leaks(out); + if (hits.length) throw new Error(`${target}: ${out} contains private paths (${hits.length}); refusing to keep it`); + console.log(`sidecar ${target} -> ${out} (${(statSync(out).size / 1e6).toFixed(1)} MB, ${((Date.now() - t) / 1000).toFixed(1)}s)`); + return out; +} + +if (import.meta.main) { + const argv = process.argv.slice(2); + const i = argv.indexOf('--target'); + const targets = argv.includes('--host') ? [hostTarget()] : i >= 0 ? (argv[i + 1] ?? '').split(',').filter(Boolean) : Object.keys(TARGETS); + let failed = 0; + for (const t of targets) { + try { await buildOne(t); } catch (e) { failed++; console.error(`sidecar ${t}: ${(e as Error).message}`); } + } + if (failed) process.exit(1); +} From ab13d95898eeef778283fb30ef3812b61053718f Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 11 Oct 2026 01:56:46 +0800 Subject: [PATCH 2/8] serve: app-centric portfolio (apps across iOS / Android, release trains, catalog parity) and local tool detection - portfolio.status returns apps: identity with both platforms, offline config check, release trains (current = highest version live on a store, next = highest above it; per-store phase, build, rollout fraction) and catalog parity (config vs both stores) when live - inspect --json adds product ids and the subscription count - system.tools: ffmpeg, ffprobe, Chrome / Chrome for Testing, Xcode tools, simctl, adb, git with versions and source; STOREBOUND_TOOL_PATH (front of PATH for every command) and STOREBOUND_CHROME (Playwright executablePath) let the desktop app hand over its own copies --- cli/inspect.ts | 3 ++ cli/lib/desktop-protocol.ts | 60 ++++++++++++++++++++-- cli/lib/portfolio.ts | 100 ++++++++++++++++++++++++++++++++++++ cli/lib/runner.ts | 3 ++ cli/lib/tools.ts | 90 ++++++++++++++++++++++++++++++++ cli/serve.ts | 41 +++++++-------- cli/shots/browser.ts | 3 ++ tests/portfolio.test.ts | 68 ++++++++++++++++++++++++ tests/serve.test.ts | 20 +++++--- tests/tools.test.ts | 40 +++++++++++++++ 10 files changed, 393 insertions(+), 35 deletions(-) create mode 100644 cli/lib/portfolio.ts create mode 100644 cli/lib/tools.ts create mode 100644 tests/portfolio.test.ts create mode 100644 tests/tools.test.ts diff --git a/cli/inspect.ts b/cli/inspect.ts index dd93d14..56ae93d 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -14,6 +14,9 @@ export function summarize(ctx: Ctx): ProjectSummary { play: config.play ? { packageName: config.play.packageName } : null, apple: config.apple ? { bundleId: config.apple.bundleId, appId: config.apple.appId ?? null } : null, products: config.products?.length ?? 0, + productIds: (config.products ?? []).map(p => p.id), + // subscriptions arrive in the config schema separately; counted when present + subscriptions: ((config as { subscriptions?: unknown[] }).subscriptions ?? []).length, shots: !!config.shots, testflightGroups: config.apple?.testflight?.groups?.length ?? 0, }; diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index bb2e2d0..abddce3 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -78,22 +78,72 @@ export interface ProjectSummary { languages: string[]; play: { packageName: string } | null; apple: { bundleId: string; appId: string | null } | null; + /** One-time in-app products in the config (shared by both stores). */ products: number; + productIds: string[]; + /** Subscriptions in the config (0 until the config defines them). */ + subscriptions: number; shots: boolean; testflightGroups: number; } +// ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ---- + +/** One product across platforms. id = its project folder (one storebound.config.ts per app). */ +export interface AppIdentity { + id: string; + name: string; + platforms: { ios: { bundleId: string; ascAppId: string | null } | null; android: { packageName: string } | null }; +} + +/** Where a version is on one store. */ +export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'approved' | 'review' | 'rejected' | 'testing' | 'draft' | 'other'; + +export interface StoreRelease { + store: 'ios' | 'android'; + version: string | null; + /** iOS build number (CFBundleVersion) / highest Android versionCode. */ + build: string | null; + /** The store's own state word (READY_FOR_SALE, inProgress ...). */ + state: string; + phase: ReleasePhase; + /** Android staged rollout user fraction (0-1); iOS phased release percentage comes with D2. */ + fraction: number | null; + /** Android track (production, beta, internal ...). */ + track: string | null; +} + +/** A release train: one version (e.g. 2.3.0) across both stores. */ +export interface ReleaseTrain { version: string; ios: StoreRelease | null; android: StoreRelease | null } + +/** In-app catalog parity between the config and both stores. */ +export interface CatalogParity { + checked: 'config' | 'live'; + products: number; + subscriptions: number; + /** Live: ids on one store only, and config ids missing on a store the app is on. */ + onlyIos: string[]; + onlyAndroid: string[]; + missingIos: string[]; + missingAndroid: string[]; + ok: boolean; +} + export interface PortfolioParams { projects: string[]; live?: boolean; credentials?: Credentials } -export interface PortfolioEntry { +export interface AppStatus { + /** Project folder as given. */ project: string; ok: boolean; + app: AppIdentity | null; summary: ProjectSummary | null; check: { ok: boolean; problems: string[]; warnings: string[] } | null; - /** live only: `apple status --json` / `play status --json` data, or the error text. */ - apple?: { data?: unknown; error?: string }; - play?: { data?: unknown; error?: string }; + /** Release trains; `source: none` offline (no store data read). */ + release: { source: 'none' | 'live'; current: ReleaseTrain | null; next: ReleaseTrain | null }; + catalog: CatalogParity | null; + /** live only: per-platform read errors ("unknown, never false"). */ + storeErrors: { ios?: string; android?: string }; errors: string[]; } -export interface PortfolioResult { projects: PortfolioEntry[]; at: string } +export interface PortfolioResult { apps: AppStatus[]; at: string } diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts new file mode 100644 index 0000000..4153ac7 --- /dev/null +++ b/cli/lib/portfolio.ts @@ -0,0 +1,100 @@ +// Pure pieces of `portfolio.status` (serve.ts): an app's identity across platforms, its release trains (one version +// across the App Store and Google Play) and the in-app catalog parity between the config and both stores. +// Inputs are the `apple status --json` / `play status --json` results, read defensively: "unknown, never false". +import type { AppIdentity, CatalogParity, ProjectSummary, ReleasePhase, ReleaseTrain, StoreRelease } from './desktop-protocol'; + +type J = Record; // eslint-disable-line @typescript-eslint/no-explicit-any + +export function appIdentity(s: ProjectSummary): AppIdentity { + return { + id: s.dir, + name: s.name, + platforms: { + ios: s.apple ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null, + android: s.play ? { packageName: s.play.packageName } : null, + }, + }; +} + +/** Numeric compare of dotted versions ("2.10.0" > "2.9.1"); non-numeric parts compare as 0. */ +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; +} + +/** App Store version states (appStoreState / appVersionState) -> phase. */ +export function applePhase(state: string): ReleasePhase { + if (/^(READY_FOR_SALE|READY_FOR_DISTRIBUTION)$/.test(state)) return 'live'; + if (/^(PENDING_DEVELOPER_RELEASE|PENDING_APPLE_RELEASE|ACCEPTED)$/.test(state)) return 'approved'; + if (/^(WAITING_FOR_REVIEW|IN_REVIEW|WAITING_FOR_EXPORT_COMPLIANCE|PROCESSING_FOR_DISTRIBUTION|PROCESSING_FOR_APP_STORE)$/.test(state)) return 'review'; + if (/REJECTED$/.test(state)) return 'rejected'; + if (/^(PREPARE_FOR_SUBMISSION|DEVELOPER_ACTION_NEEDED)$/.test(state)) return 'draft'; + return 'other'; +} + +export function appleReleases(data: unknown): StoreRelease[] { + const versions = ((data as J)?.versions ?? []) as J[]; + const builds = new Map((((data as J)?.builds ?? []) as J[]).map(b => [b.id, b.attributes?.version] as const)); + return versions.filter(v => typeof v?.attributes?.versionString === 'string' && (v.attributes.platform ?? 'IOS') === 'IOS').map(v => { + const state = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? 'UNKNOWN'); + const buildId = v.relationships?.build?.data?.id; + return { store: 'ios', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase: applePhase(state), fraction: null, track: null }; + }); +} + +/** Leading dotted version of a Play release name ("2.3.0 (45)" -> "2.3.0"). */ +export const versionOfName = (name: unknown): string | null => (typeof name === 'string' ? name.match(/\d+(?:\.\d+){0,3}/)?.[0] ?? null : null); + +export function playReleases(data: unknown): StoreRelease[] { + const out: StoreRelease[] = []; + for (const t of ((data as J)?.tracks ?? []) as J[]) { + for (const r of (t?.releases ?? []) as J[]) { + const codes = ((r.versionCodes ?? []) as unknown[]).map(Number).filter(Number.isFinite); + const production = t.track === 'production'; + const status = String(r.status ?? 'unknown'); + const phase: ReleasePhase = !production ? (status === 'draft' ? 'draft' : 'testing') + : status === 'completed' ? 'live' : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other'; + out.push({ store: 'android', version: versionOfName(r.name), build: codes.length ? String(Math.max(...codes)) : null, state: status, phase, fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) }); + } + } + return out; +} + +const PHASE_RANK: Record = { live: 0, rollout: 1, halted: 2, approved: 3, review: 4, rejected: 5, testing: 6, draft: 7, other: 8 }; +const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase])[0] ?? null; + +/** + * Release trains: one version across both stores. current = the highest version that is live (fully) on a store; + * next = the highest version above it on either store (review, testing, rollout, draft ...). A store without that + * version has null in the train. + */ +export function releaseTrains(ios: StoreRelease[], android: StoreRelease[]): { current: ReleaseTrain | null; next: ReleaseTrain | null } { + const all = [...ios, ...android].filter((r): r is StoreRelease & { version: string } => !!r.version); + const train = (v: string): ReleaseTrain => ({ version: v, ios: pick(all.filter(r => r.store === 'ios' && r.version === v)), android: pick(all.filter(r => r.store === 'android' && r.version === v)) }); + const live = all.filter(r => r.phase === 'live').map(r => r.version).sort(cmpVersion).at(-1) ?? null; + const above = all.filter(r => !live || cmpVersion(r.version, live) > 0).map(r => r.version).sort(cmpVersion).at(-1) ?? null; + return { current: live ? train(live) : null, next: above ? train(above) : null }; +} + +/** Product ids on App Store Connect (`apple status --json` iaps) and Google Play (`play status --json` products). */ +export const appleProductIds = (data: unknown): string[] => (((data as J)?.iaps ?? []) as J[]).map(p => p?.attributes?.productId).filter((x): x is string => typeof x === 'string'); +export const playProductIds = (data: unknown): string[] => (((data as J)?.products ?? []) as J[]).map(p => p?.productId ?? p?.sku).filter((x): x is string => typeof x === 'string'); + +/** + * Catalog parity. Without store data (offline) only the config side is known. With it: ids only on one store, + * and config ids missing from a store the app is on. + */ +export function catalogParity(s: ProjectSummary, apple?: string[], play?: string[]): CatalogParity { + const ids = s.productIds; + const live = apple !== undefined || play !== undefined; + const onlyIos = apple && play ? apple.filter(x => !play.includes(x)) : []; + const onlyAndroid = apple && play ? play.filter(x => !apple.includes(x)) : []; + const missingIos = s.apple && apple ? ids.filter(x => !apple.includes(x)) : []; + const missingAndroid = s.play && play ? ids.filter(x => !play.includes(x)) : []; + return { + checked: live ? 'live' : 'config', products: s.products, subscriptions: s.subscriptions, + onlyIos, onlyAndroid, missingIos, missingAndroid, + ok: !onlyIos.length && !onlyAndroid.length && !missingIos.length && !missingAndroid.length, + }; +} diff --git a/cli/lib/runner.ts b/cli/lib/runner.ts index 1af8f75..98f1ab0 100644 --- a/cli/lib/runner.ts +++ b/cli/lib/runner.ts @@ -3,6 +3,7 @@ // binary re-executes itself. Output is streamed line by line (progress) and the `--json` result line is picked out. import { join } from 'node:path'; import { RESULT_MARK } from './log'; +import { toolPath } from './tools'; /** True inside a `bun build --compile` executable (the entry module lives in Bun's embedded file system). */ export const compiled = (): boolean => /^(\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/.test(Bun.main); @@ -39,6 +40,8 @@ export function pickCallEnv(env: Record | undefined): Record = {}, machine = false): Record { const base: Record = {}; for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !k.startsWith('STOREBOUND_SESSION')) base[k] = v; + // app-managed tools (desktop data folder) come first on PATH + if (base.STOREBOUND_TOOL_PATH) base.PATH = toolPath(base); return { ...base, ...extra, NO_COLOR: '1', ...(machine ? { STOREBOUND_MACHINE: '1', STOREBOUND_SERVE: '1' } : {}) }; } diff --git a/cli/lib/tools.ts b/cli/lib/tools.ts new file mode 100644 index 0000000..a3960cb --- /dev/null +++ b/cli/lib/tools.ts @@ -0,0 +1,90 @@ +// Optional local tools storebound uses (screenshots, previews, icons, simulator and emulator captures), found on this +// machine with their versions. The desktop app can manage its own copies of ffmpeg and Chrome for Testing in its data +// folder and points storebound at them: +// STOREBOUND_TOOL_PATH folders put in front of PATH for every command (app-managed ffmpeg / ffprobe) +// STOREBOUND_CHROME Chrome / Chrome for Testing executable for renders and web captures +// Nothing is installed system-wide. Sources and licenses: docs/desktop.md. +import { existsSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; + +export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git'; + +export interface ToolStatus { + id: ToolId; + name: string; + found: boolean; + path: string | null; + version: string | null; + /** 'app' = the desktop app's own copy (STOREBOUND_TOOL_PATH / STOREBOUND_CHROME), 'system' = installed by the user. */ + source: 'app' | 'system' | null; + /** What needs it. */ + usedFor: string; + /** How to get it: the desktop app downloads ffmpeg and Chrome for Testing; Xcode and the Android SDK are link-out. */ + install: { kind: 'download' | 'link'; url: string; note: string }; +} + +/** PATH with the app-managed tool folders first. */ +export function toolPath(env: Record = process.env): string { + const extra = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean); + return [...extra, env.PATH ?? ''].filter(Boolean).join(delimiter); +} + +/** First line of ` ` (version banners), or null. */ +function firstLine(cmd: string[], env: Record): string | null { + try { + const r = Bun.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe', env, timeout: 5_000 }); + if (r.exitCode !== 0) return null; + return (r.stdout.toString() || r.stderr.toString()).trim().split('\n')[0]?.trim() || null; + } catch { return null; } +} + +/** "ffmpeg version 7.1.1 Copyright ..." -> "7.1.1"; "Google Chrome for Testing 131.0.6778.85" -> "131.0.6778.85". */ +export function parseVersion(line: string | null): string | null { + if (!line) return null; + return line.match(/version\s+([^\s,]+)/i)?.[1] ?? line.match(/(\d+(?:\.\d+)+)/)?.[1] ?? null; +} + +export function systemChrome(platform = process.platform, env: Record = process.env): string | null { + const c = platform === 'darwin' ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'] + : platform === 'win32' ? [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter((d): d is string => !!d).map(d => join(d, 'Google/Chrome/Application/chrome.exe')) + : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser']; + return c.find(p => existsSync(p)) ?? null; +} + +const FFMPEG_NOTE = 'Static build into the app data folder (GPL; never bundled with the app). Checksum-verified.'; + +export function detectTools(env: Record = process.env): ToolStatus[] { + const PATH = toolPath(env); + const spawnEnv: Record = { ...Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)), PATH }; + const appDirs = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean); + const which = (n: string): string | null => Bun.which(n, { PATH }); + const src = (p: string | null): ToolStatus['source'] => (!p ? null : appDirs.some(d => p.startsWith(d)) ? 'app' : 'system'); + const bin = (id: ToolId, name: string, usedFor: string, install: ToolStatus['install'], versionArgs: string[]): ToolStatus => { + const path = which(id); + const version = path ? parseVersion(firstLine([path, ...versionArgs], spawnEnv)) : null; + return { id, name, found: !!path, path, version, source: src(path), usedFor, install }; + }; + const out: ToolStatus[] = [ + bin('ffmpeg', 'ffmpeg', 'icons, screenshot renders, app preview videos, frame crops', { kind: 'download', url: 'https://ffmpeg.org/download.html', note: FFMPEG_NOTE }, ['-version']), + bin('ffprobe', 'ffprobe', 'checks of app preview videos and clips', { kind: 'download', url: 'https://ffmpeg.org/download.html', note: 'Comes with the ffmpeg download.' }, ['-version']), + ]; + const appChrome = env.STOREBOUND_CHROME && existsSync(env.STOREBOUND_CHROME) ? env.STOREBOUND_CHROME : null; + const chrome = appChrome ?? systemChrome(process.platform, env); + out.push({ + id: 'chrome', name: appChrome ? 'Chrome for Testing' : 'Google Chrome', found: !!chrome, path: chrome, + version: chrome ? parseVersion(firstLine([chrome, '--version'], spawnEnv)) : null, source: appChrome ? 'app' : chrome ? 'system' : null, + usedFor: 'screenshot renders, captions, web captures and recordings', + install: { kind: 'download', url: 'https://googlechromelabs.github.io/chrome-for-testing/', note: 'Chrome for Testing from Google\'s official endpoints into the app data folder.' }, + }); + if (process.platform === 'darwin') { + const dev = firstLine(['xcode-select', '-p'], spawnEnv); + out.push({ id: 'xcode', name: 'Xcode command line tools', found: !!dev, path: dev, version: parseVersion(firstLine(['xcodebuild', '-version'], spawnEnv)), source: dev ? 'system' : null, + usedFor: 'iOS builds, the iOS Simulator (simctl)', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: 'Install Xcode from the App Store or developer.apple.com.' } }); + const simctl = firstLine(['xcrun', '--find', 'simctl'], spawnEnv); + out.push({ id: 'simctl', name: 'simctl (iOS Simulator)', found: !!simctl, path: simctl, version: null, source: simctl ? 'system' : null, + usedFor: 'iOS Simulator screenshots and recordings', install: { kind: 'link', url: 'https://developer.apple.com/xcode/', note: 'Part of Xcode.' } }); + } + out.push(bin('adb', 'adb (Android platform tools)', 'Android emulator / device screenshots and recordings', { kind: 'link', url: 'https://developer.android.com/tools/releases/platform-tools', note: 'Install Android Studio or the SDK platform tools.' }, ['version'])); + out.push(bin('git', 'git', 'release notes from history, analytics compare', { kind: 'link', url: 'https://git-scm.com/downloads', note: 'Install git from your OS package manager.' }, ['--version'])); + return out; +} diff --git a/cli/serve.ts b/cli/serve.ts index 0698569..8db2777 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -15,8 +15,10 @@ import pkg from '../package.json' with { type: 'json' }; import { parseSecret, TokenGuard } from './lib/confirm-token'; import { CONFIG_NAMES } from './lib/config'; import { - DESKTOP_PROTOCOL, ERR, type MethodDescriptor, type PortfolioEntry, type PortfolioResult, type ProjectSummary, type ToolResult, + DESKTOP_PROTOCOL, ERR, type AppStatus, type MethodDescriptor, type PortfolioResult, type ProjectSummary, type ToolResult, } from './lib/desktop-protocol'; +import { appIdentity, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './lib/portfolio'; +import { detectTools } from './lib/tools'; import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner'; import { inputSchema, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools'; @@ -57,10 +59,10 @@ const SYSTEM: MethodDescriptor[] = [ args: obj({ secret: { type: 'string' }, client: { type: 'object' } }, ['secret']) }, { method: 'system.version', title: 'Version', description: 'storebound version, platform and whether this is the compiled sidecar.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, { method: 'system.methods', title: 'Methods', description: 'Every method of this profile with its argument schema.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, - { method: 'system.tools', title: 'Local tools', description: 'Which optional local tools are installed (git, ffmpeg, Google Chrome, Playwright); screenshot and video features need them.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, + { method: 'system.tools', title: 'Local tools', description: 'Optional local tools with versions and where they come from (ffmpeg, ffprobe, Chrome, Xcode / simctl, adb, git); screenshot, video and capture features need them.', kind: 'system', network: false, destructive: false, json: true, args: obj({}) }, { method: 'project.inspect', title: 'Inspect a project', description: 'Offline summary of a folder\'s storebound.config.ts (name, store ids, languages, products).', kind: 'read', network: false, destructive: false, json: true, args: obj({ project: { type: 'string', description: 'Absolute folder path' } }, ['project']) }, - { method: 'portfolio.status', title: 'Portfolio status', description: 'Summary + offline check of many projects; with live, also App Store and Google Play status (read-only).', kind: 'portfolio', network: true, destructive: false, json: true, + { method: 'portfolio.status', title: 'Portfolio status', description: 'Apps (one per project folder) across iOS and Android: identity, offline config check, catalog parity; with live, release trains (current / next version on both stores) and live catalog parity from App Store and Google Play status (read-only).', kind: 'portfolio', network: true, destructive: false, json: true, args: obj({ projects: { type: 'array' }, live: { type: 'boolean', default: false } }, ['projects']) }, ]; @@ -180,7 +182,8 @@ export class Session { if (!t.write) dryRun = false; else { if (params.confirmToken === undefined) throw new RpcError(ERR.confirmRequired, `${method} writes: dryRun false needs a confirmToken from the desktop host`); - const v = this.guard!.verify(params.confirmToken, method, { project, args }); + // Bound to the project string exactly as sent (the host mints over what it sends) and the args object. + const v = this.guard!.verify(params.confirmToken, method, { project: params.project, args }); if (!v.ok) throw new RpcError(ERR.confirmInvalid, `confirm token rejected: ${v.error}`, { reason: v.error }); dryRun = false; } @@ -214,16 +217,18 @@ export class Session { if (!Array.isArray(list) || !list.length || list.length > 200) throw new RpcError(ERR.invalidParams, 'projects: 1-200 absolute folder paths'); const live = params.live === true; const env = credentialsOf(params.credentials); - const out: PortfolioEntry[] = new Array(list.length); + const out: AppStatus[] = new Array(list.length); let done = 0; const one = async (i: number): Promise => { const raw = list[i]; - const e: PortfolioEntry = { project: String(raw), ok: false, summary: null, check: null, errors: [] }; + const e: AppStatus = { project: String(raw), ok: false, app: null, summary: null, check: null, release: { source: 'none', current: null, next: null }, catalog: null, storeErrors: {}, errors: [] }; out[i] = e; try { const dir = checkProject(raw); e.project = dir; const summary = e.summary = await this.inspect(dir, env, id); + e.app = appIdentity(summary); + e.catalog = catalogParity(summary); const c = await this.track(id, this.run(['check', '--json'], { cwd: dir, env, machine: true })).result; const cd = c.data as { ok?: boolean; problems?: string[]; warnings?: string[] } | undefined; e.check = cd ? { ok: !!cd.ok, problems: cd.problems ?? [], warnings: cd.warnings ?? [] } : { ok: false, problems: [lastLine(c.out) || `check exit ${c.code}`], warnings: [] }; @@ -233,10 +238,12 @@ export class Session { return r.code === 0 && r.data !== undefined ? { data: r.data } : { error: lastLine(r.out) || `exit ${r.code}` }; }; const [apple, play] = await Promise.all([summary.apple ? store(['apple', 'status', '--json']) : undefined, summary.play ? store(['play', 'status', '--json']) : undefined]); - if (apple) e.apple = apple; - if (play) e.play = play; + if (apple?.error) e.storeErrors.ios = apple.error; + if (play?.error) e.storeErrors.android = play.error; + e.release = { source: 'live', ...releaseTrains(apple?.data ? appleReleases(apple.data) : [], play?.data ? playReleases(play.data) : []) }; + e.catalog = catalogParity(summary, apple?.data ? appleProductIds(apple.data) : undefined, play?.data ? playProductIds(play.data) : undefined); } - e.ok = e.check.ok && !e.apple?.error && !e.play?.error; + e.ok = e.check.ok && !e.storeErrors.ios && !e.storeErrors.android && (e.catalog?.ok ?? true); } catch (err) { if (this.cancelled.has(id)) throw err; e.errors.push((err as Error).message); @@ -247,25 +254,15 @@ export class Session { let next = 0; await Promise.all(Array.from({ length: Math.min(3, list.length) }, async () => { while (next < list.length) await one(next++); })); if (this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); - return { projects: out, at: new Date(this.clock()).toISOString() }; + return { apps: out, at: new Date(this.clock()).toISOString() }; } } const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(-1)?.replace(/^error:\s*/, '') ?? ''; +/** Optional local tools with versions; app-managed copies (STOREBOUND_TOOL_PATH / STOREBOUND_CHROME) included. */ export function localTools(): Json { - const which = (n: string): string | null => Bun.which(n); - const chrome = process.platform === 'darwin' ? existsSync('/Applications/Google Chrome.app') - : process.platform === 'win32' ? [process.env['PROGRAMFILES'], process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].some(d => d && existsSync(join(d, 'Google/Chrome/Application/chrome.exe'))) - : !!(which('google-chrome') || which('google-chrome-stable') || which('chromium')); - const ffmpeg = which('ffmpeg'); - return { - git: !!which('git'), ffmpeg: !!ffmpeg, chrome, - notes: [ - ...(ffmpeg ? [] : ['ffmpeg not found: icons, screenshot renders and app preview videos need it (brew install ffmpeg / apt install ffmpeg / winget install ffmpeg)']), - ...(chrome ? [] : ['Google Chrome not found: screenshot renders and web captures need Chrome or Playwright\'s Chromium in the project']), - ], - }; + return { tools: detectTools() }; } export async function serveStdio(): Promise { diff --git a/cli/shots/browser.ts b/cli/shots/browser.ts index d44ba36..cd31dcb 100644 --- a/cli/shots/browser.ts +++ b/cli/shots/browser.ts @@ -17,6 +17,9 @@ async function chromium(root: string): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function launch(root: string, opts: Record): Promise { const c = await chromium(root); + // the desktop app's own Chrome for Testing (or any Chrome the user points at) + const exe = process.env.STOREBOUND_CHROME; + if (exe) { try { return await c.launch({ ...opts, executablePath: exe }); } catch { /* fall back below */ } } try { return await c.launch(opts); } catch (e) { try { return await c.launch({ ...opts, channel: 'chrome' }); } catch { throw e; } } diff --git a/tests/portfolio.test.ts b/tests/portfolio.test.ts new file mode 100644 index 0000000..cbd0837 --- /dev/null +++ b/tests/portfolio.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import type { ProjectSummary } from '../cli/lib/desktop-protocol'; +import { appIdentity, applePhase, appleProductIds, appleReleases, catalogParity, cmpVersion, playProductIds, playReleases, releaseTrains, versionOfName } from '../cli/lib/portfolio'; + +const summary: ProjectSummary = { + dir: '/games/demo', config: '/games/demo/storebound.config.ts', name: 'Demo', languages: ['en'], + play: { packageName: 'com.example.demo' }, apple: { bundleId: 'com.example.demo', appId: null }, + products: 2, productIds: ['coins', 'no_ads'], subscriptions: 0, shots: false, testflightGroups: 0, +}; + +const apple = { + versions: [ + { attributes: { versionString: '2.3.0', appVersionState: 'WAITING_FOR_REVIEW', platform: 'IOS' }, relationships: { build: { data: { id: 'b2' } } } }, + { attributes: { versionString: '2.2.1', appStoreState: 'READY_FOR_SALE', platform: 'IOS' }, relationships: { build: { data: { id: 'b1' } } } }, + { attributes: { versionString: '9.9.9', appStoreState: 'READY_FOR_SALE', platform: 'TV_OS' } }, + ], + builds: [{ id: 'b1', attributes: { version: '41' } }, { id: 'b2', attributes: { version: '45' } }], + iaps: [{ attributes: { productId: 'coins' } }, { attributes: { productId: 'ios_only' } }], +}; +const play = { + tracks: [ + { track: 'production', releases: [{ name: '2.2.1 (220)', versionCodes: ['220'], status: 'completed' }, { name: '2.3.0', versionCodes: ['230', '231'], status: 'inProgress', userFraction: 0.2 }] }, + { track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] }, + ], + products: [{ productId: 'coins' }, { productId: 'no_ads' }, { sku: 'android_only' }], +}; + +describe('portfolio (apps, release trains, catalog parity)', () => { + test('identity: one app, both platforms', () => { + expect(appIdentity(summary)).toEqual({ id: '/games/demo', name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo', ascAppId: null }, android: { packageName: 'com.example.demo' } } }); + expect(appIdentity({ ...summary, apple: null }).platforms.ios).toBeNull(); + }); + test('versions and phases', () => { + expect(cmpVersion('2.10.0', '2.9.1')).toBeGreaterThan(0); + expect(cmpVersion('2.3', '2.3.0')).toBe(0); + expect(versionOfName('2.3.0 (45)')).toBe('2.3.0'); + expect(versionOfName(undefined)).toBeNull(); + expect(applePhase('READY_FOR_SALE')).toBe('live'); + expect(applePhase('PENDING_DEVELOPER_RELEASE')).toBe('approved'); + expect(applePhase('METADATA_REJECTED')).toBe('rejected'); + expect(applePhase('PREPARE_FOR_SUBMISSION')).toBe('draft'); + expect(applePhase('SOMETHING_NEW')).toBe('other'); + }); + test('store releases from status data; garbage gives nothing', () => { + const ios = appleReleases(apple); + expect(ios.map(r => [r.version, r.build, r.phase])).toEqual([['2.3.0', '45', 'review'], ['2.2.1', '41', 'live']]); + const android = playReleases(play); + expect(android.map(r => [r.version, r.build, r.phase, r.fraction, r.track])).toEqual([['2.2.1', '220', 'live', null, 'production'], ['2.3.0', '231', 'rollout', 0.2, 'production'], ['2.4.0', '240', 'testing', null, 'internal']]); + expect(appleReleases(null)).toEqual([]); + expect(playReleases({ tracks: 'x' })).toEqual([]); + }); + test('trains: current = highest live version, next = highest above it, per store', () => { + const t = releaseTrains(appleReleases(apple), playReleases(play)); + expect(t.current).toMatchObject({ version: '2.2.1', ios: { phase: 'live', build: '41' }, android: { phase: 'live', build: '220' } }); + expect(t.next).toMatchObject({ version: '2.4.0', ios: null, android: { phase: 'testing' } }); + expect(releaseTrains([], [])).toEqual({ current: null, next: null }); + const onlyNext = releaseTrains(appleReleases({ versions: [apple.versions[0]] }), []); + expect(onlyNext.current).toBeNull(); + expect(onlyNext.next?.version).toBe('2.3.0'); + }); + test('catalog parity: config only offline, both stores live', () => { + expect(catalogParity(summary)).toEqual({ checked: 'config', products: 2, subscriptions: 0, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true }); + const live = catalogParity(summary, appleProductIds(apple), playProductIds(play)); + expect(live).toMatchObject({ checked: 'live', onlyIos: ['ios_only'], onlyAndroid: ['no_ads', 'android_only'], missingIos: ['no_ads'], missingAndroid: [], ok: false }); + // one store unreadable: no cross-store claims + expect(catalogParity(summary, undefined, playProductIds(play))).toMatchObject({ onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [] }); + }); +}); diff --git a/tests/serve.test.ts b/tests/serve.test.ts index 63930ac..f0cea41 100644 --- a/tests/serve.test.ts +++ b/tests/serve.test.ts @@ -162,15 +162,19 @@ describe('serve --stdio: session', () => { await s.handle({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } }); expect(errOf(await pending).code).toBe(ERR.cancelled); }); - test('portfolio.status: many projects, per-project errors, progress n/total', async () => { - const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: { name: 'Demo', apple: { bundleId: 'a.b' }, play: null } } + test('portfolio.status: apps across platforms, release trains, catalog parity, per-app errors, progress n/total', async () => { + const summary = { dir: DEMO, name: 'Demo', apple: { bundleId: 'com.example.demo', appId: null }, play: { packageName: 'com.example.demo' }, products: 2, productIds: ['coins', 'no_ads'], subscriptions: 0 }; + const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary } : argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } } - : argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' } : {}); - const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { projects: J[] }; - expect(r.projects[0]).toMatchObject({ project: DEMO, summary: { name: 'Demo' }, check: { ok: true, warnings: ['w'] }, apple: { error: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false }); - expect(r.projects[0]!.play).toBeUndefined(); - expect(r.projects[1]).toMatchObject({ ok: false, summary: null }); - expect((r.projects[1]!.errors as string[])[0]).toContain('not a folder'); + : argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' } + : argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }] } } : {}); + const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { apps: J[] }; + expect(r.apps[0]).toMatchObject({ project: DEMO, app: { id: DEMO, name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo' }, android: { packageName: 'com.example.demo' } } }, + check: { ok: true, warnings: ['w'] }, storeErrors: { ios: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false }); + expect(r.apps[0]!.release).toMatchObject({ source: 'live', current: { version: '1.2.0', ios: null, android: { phase: 'live', build: '12' } }, next: { version: '1.3.0', android: { phase: 'testing', track: 'beta' } } }); + expect(r.apps[0]!.catalog).toMatchObject({ checked: 'live', missingAndroid: ['no_ads'], missingIos: [], ok: false }); + expect(r.apps[1]).toMatchObject({ ok: false, summary: null, app: null }); + expect((r.apps[1]!.errors as string[])[0]).toContain('not a folder'); const prog = sent.filter(m => m.method === 'progress').map(m => (m.params as J).n); expect(prog.sort()).toEqual([1, 2]); expect(errOf(await call('portfolio.status', { projects: [] })).code).toBe(ERR.invalidParams); diff --git a/tests/tools.test.ts b/tests/tools.test.ts new file mode 100644 index 0000000..7ea341f --- /dev/null +++ b/tests/tools.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { childEnv } from '../cli/lib/runner'; +import { detectTools, parseVersion, toolPath } from '../cli/lib/tools'; + +describe('local tools (desktop Dependencies screen)', () => { + test('version banners', () => { + expect(parseVersion('ffmpeg version 7.1.1 Copyright (c) 2000-2025')).toBe('7.1.1'); + expect(parseVersion('ffmpeg version N-118000-g1234abcd-20250101 Copyright')).toBe('N-118000-g1234abcd-20250101'); + expect(parseVersion('Google Chrome for Testing 131.0.6778.85')).toBe('131.0.6778.85'); + expect(parseVersion('Android Debug Bridge version 1.0.41')).toBe('1.0.41'); + expect(parseVersion('git version 2.50.1')).toBe('2.50.1'); + expect(parseVersion(null)).toBeNull(); + }); + test('app-managed folders go first on PATH, also for every child command', () => { + expect(toolPath({ PATH: '/usr/bin', STOREBOUND_TOOL_PATH: '/app/tools/bin' })).toBe(['/app/tools/bin', '/usr/bin'].join(delimiter)); + expect(toolPath({ PATH: '/usr/bin' })).toBe('/usr/bin'); + const before = process.env.STOREBOUND_TOOL_PATH; + process.env.STOREBOUND_TOOL_PATH = '/app/tools/bin'; + try { expect(childEnv().PATH!.startsWith('/app/tools/bin' + delimiter)).toBe(true); } finally { + if (before === undefined) delete process.env.STOREBOUND_TOOL_PATH; else process.env.STOREBOUND_TOOL_PATH = before; + } + }); + test('an app-managed ffmpeg is found first and marked as the app\'s', () => { + if (process.platform === 'win32') return; + const dir = mkdtempSync(join(tmpdir(), 'sb-tools-')); + try { + const f = join(dir, 'ffmpeg'); + writeFileSync(f, '#!/bin/sh\necho "ffmpeg version 7.1.1-test Copyright"\n'); + chmodSync(f, 0o755); + const tools = detectTools({ PATH: '/usr/bin:/bin', STOREBOUND_TOOL_PATH: dir }); + const ff = tools.find(t => t.id === 'ffmpeg')!; + expect(ff).toMatchObject({ found: true, path: f, version: '7.1.1-test', source: 'app', install: { kind: 'download' } }); + expect(tools.map(t => t.id)).toEqual(expect.arrayContaining(['ffmpeg', 'ffprobe', 'chrome', 'adb', 'git'])); + for (const t of tools) if (t.id === 'xcode' || t.id === 'simctl' || t.id === 'adb') expect(t.install.kind).toBe('link'); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); From 61a1e832a060e23cf94123180909f7f7e96a6f7a Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 11 Oct 2026 01:56:47 +0800 Subject: [PATCH 3/8] Desktop app shell (apps/desktop): Tauri 2 + React 19 around the storebound sidecar - Rust host: spawns storebound serve --stdio with fixed args, hands over a per-session secret, mints confirm tokens only after a native dialog showing the summary and the dry run - every app command re-checks window label and page origin; one capability (main window, app commands + event listen only, no plugin API in the webview); strict CSP; tests/security.rs pins it - store keys picked in native dialogs, kept in the OS keychain, passed per call in memory only - plugins: shell (Rust only), single-instance, deep-link storebound://, window-state, opener, dialog, notification, updater (placeholder endpoint and key until signing), process - app-centric UI: Portfolio, App (Releases, Catalog, Listings, Health, Reviews, TestFlight), App folders, Accounts, Dependencies, Settings; light / dark with the storebound.dev tokens, reduced motion, home folder shown as ~ - macOS menu: Cmd+Q quits, Edit menu, Window menu keeps the main window; Linux DMABUF fix - Bun workspace (npm files unchanged), ci runs desktop typecheck, build, cargo test and clippy; scrub covers home paths; docs/desktop.md (protocol, safety, dependency sources and licenses) --- apps/desktop/index.html | 13 + apps/desktop/package.json | 31 + apps/desktop/src-tauri/Cargo.lock | 6173 +++++++++++++++++ apps/desktop/src-tauri/Cargo.toml | 53 + apps/desktop/src-tauri/build.rs | 11 + apps/desktop/src-tauri/capabilities/main.json | 25 + apps/desktop/src-tauri/icons/128x128.png | Bin 0 -> 5402 bytes apps/desktop/src-tauri/icons/128x128@2x.png | Bin 0 -> 10589 bytes apps/desktop/src-tauri/icons/32x32.png | Bin 0 -> 1431 bytes apps/desktop/src-tauri/icons/icon.icns | Bin 0 -> 111203 bytes apps/desktop/src-tauri/icons/icon.ico | Bin 0 -> 21243 bytes apps/desktop/src-tauri/icons/icon.png | Bin 0 -> 21646 bytes apps/desktop/src-tauri/icons/icon.svg | 1 + .../autogenerated/account_add_apple.toml | 11 + .../autogenerated/account_add_play.toml | 11 + .../autogenerated/account_remove.toml | 11 + .../autogenerated/accounts_list.toml | 11 + .../permissions/autogenerated/app_info.toml | 11 + .../autogenerated/open_external.toml | 11 + .../autogenerated/portfolio_status.toml | 11 + .../autogenerated/project_add.toml | 11 + .../autogenerated/project_remove.toml | 11 + .../autogenerated/project_set_accounts.toml | 11 + .../autogenerated/projects_list.toml | 11 + .../autogenerated/sidecar_call.toml | 11 + .../autogenerated/sidecar_status.toml | 11 + .../autogenerated/write_with_confirm.toml | 11 + apps/desktop/src-tauri/src/accounts.rs | 134 + apps/desktop/src-tauri/src/commands.rs | 375 + apps/desktop/src-tauri/src/commands_list.rs | 18 + apps/desktop/src-tauri/src/confirm.rs | 100 + apps/desktop/src-tauri/src/lib.rs | 172 + apps/desktop/src-tauri/src/main.rs | 6 + apps/desktop/src-tauri/src/policy.rs | 142 + apps/desktop/src-tauri/src/sidecar.rs | 295 + apps/desktop/src-tauri/src/store.rs | 74 + apps/desktop/src-tauri/tauri.conf.json | 85 + apps/desktop/src-tauri/tests/accounts.rs | 98 + apps/desktop/src-tauri/tests/confirm.rs | 43 + apps/desktop/src-tauri/tests/security.rs | 201 + apps/desktop/src/App.tsx | 156 + apps/desktop/src/api.ts | 53 + apps/desktop/src/main.tsx | 11 + apps/desktop/src/paths.ts | 13 + apps/desktop/src/prefs.ts | 31 + apps/desktop/src/screens/Accounts.tsx | 88 + apps/desktop/src/screens/AppFolders.tsx | 116 + apps/desktop/src/screens/AppView.tsx | 92 + apps/desktop/src/screens/Dependencies.tsx | 69 + apps/desktop/src/screens/Portfolio.tsx | 79 + apps/desktop/src/screens/Settings.tsx | 51 + apps/desktop/src/styles.css | 175 + apps/desktop/src/vite-env.d.ts | 1 + apps/desktop/tests/prefs.test.ts | 66 + apps/desktop/tsconfig.json | 21 + apps/desktop/vite.config.ts | 18 + docs/desktop.md | 85 + package.json | 3 + tools/ci.ts | 21 +- tools/scrub.ts | 9 +- 60 files changed, 9355 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/index.html create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/src-tauri/Cargo.lock create mode 100644 apps/desktop/src-tauri/Cargo.toml create mode 100644 apps/desktop/src-tauri/build.rs create mode 100644 apps/desktop/src-tauri/capabilities/main.json create mode 100644 apps/desktop/src-tauri/icons/128x128.png create mode 100644 apps/desktop/src-tauri/icons/128x128@2x.png create mode 100644 apps/desktop/src-tauri/icons/32x32.png create mode 100644 apps/desktop/src-tauri/icons/icon.icns create mode 100644 apps/desktop/src-tauri/icons/icon.ico create mode 100644 apps/desktop/src-tauri/icons/icon.png create mode 100644 apps/desktop/src-tauri/icons/icon.svg create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/account_add_apple.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/account_add_play.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/account_remove.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/accounts_list.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/app_info.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/open_external.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/portfolio_status.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/project_add.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/project_remove.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/project_set_accounts.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/projects_list.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/sidecar_call.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/sidecar_status.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/write_with_confirm.toml create mode 100644 apps/desktop/src-tauri/src/accounts.rs create mode 100644 apps/desktop/src-tauri/src/commands.rs create mode 100644 apps/desktop/src-tauri/src/commands_list.rs create mode 100644 apps/desktop/src-tauri/src/confirm.rs create mode 100644 apps/desktop/src-tauri/src/lib.rs create mode 100644 apps/desktop/src-tauri/src/main.rs create mode 100644 apps/desktop/src-tauri/src/policy.rs create mode 100644 apps/desktop/src-tauri/src/sidecar.rs create mode 100644 apps/desktop/src-tauri/src/store.rs create mode 100644 apps/desktop/src-tauri/tauri.conf.json create mode 100644 apps/desktop/src-tauri/tests/accounts.rs create mode 100644 apps/desktop/src-tauri/tests/confirm.rs create mode 100644 apps/desktop/src-tauri/tests/security.rs create mode 100644 apps/desktop/src/App.tsx create mode 100644 apps/desktop/src/api.ts create mode 100644 apps/desktop/src/main.tsx create mode 100644 apps/desktop/src/paths.ts create mode 100644 apps/desktop/src/prefs.ts create mode 100644 apps/desktop/src/screens/Accounts.tsx create mode 100644 apps/desktop/src/screens/AppFolders.tsx create mode 100644 apps/desktop/src/screens/AppView.tsx create mode 100644 apps/desktop/src/screens/Dependencies.tsx create mode 100644 apps/desktop/src/screens/Portfolio.tsx create mode 100644 apps/desktop/src/screens/Settings.tsx create mode 100644 apps/desktop/src/styles.css create mode 100644 apps/desktop/src/vite-env.d.ts create mode 100644 apps/desktop/tests/prefs.test.ts create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/desktop/vite.config.ts create mode 100644 docs/desktop.md diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..573ef21 --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,13 @@ + + + + + + + storebound + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..3255438 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,31 @@ +{ + "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" + } +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..262144c --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.lock @@ -0,0 +1,6173 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.7", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.7", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "notify-rust" +version = "4.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4587364a9a0074333429b3df75a30a205340c56a536ca3eb6ca0e59b87bbf8af" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.19.0", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "storebound-desktop" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "getrandom 0.3.4", + "hmac", + "keyring", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d061d042ccd2df9c9f1508c1e63d835278c0a4c8801eb41764080e51f2eaaf34" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac7a92a46ab5c88f44532ca50906d6e448a948d4ddf8c5376ab125e3260f736" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "tracing", + "url", + "windows-registry", + "windows-result", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca325d6e66e9170f2e1b9631722e4e544bcbc51781575d266abdaba697bd51" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213fae2d85d4b76b06de5915d1862db9fa74541171f9d2c15da6ac9bd6e788c6" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf3cce3ea492b3a8a7f431a4e5dd5d31dc41b2a4a243faa660cbe8095a577bd" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.5", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "time", + "url", + "zbus 5.19.0", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus 5.19.0", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ae3d8549ae564c0397377373e262f340908eb23a5b9fbe03f2a00007dc0038" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521" +dependencies = [ + "encoding_rs", + "libc", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "tokio", + "windows-sys 0.60.2", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus 5.19.0", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", + "zip", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3" +dependencies = [ + "bitflags 2.13.2", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.7", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.7", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.7", + "winnow 1.0.4", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..cad1df1 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,53 @@ +[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"] } + +[dev-dependencies] +tempfile = "3" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = true diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..a76dc9d --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,11 @@ +// App command manifest: every #[tauri::command] gets an `allow-` 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"); +} diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json new file mode 100644 index 0000000..f78cc80 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -0,0 +1,25 @@ +{ + "$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, 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-write-with-confirm", + "allow-projects-list", + "allow-project-add", + "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" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..92ac64511fdec45a35f2325f3d6d9da7c1148296 GIT binary patch literal 5402 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 Ef-i_<;s5{u literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ab8b36b2085205de64f0ee7a5d5ba586fb2e3fc2 GIT binary patch literal 10589 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 ME2=A0$(x7#4+7V66aWAK literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..7f2ebdc49df7f59199c2113a02500751d7f819ad GIT binary patch literal 1431 zcmV;I1!($-P)7%Q6n-=7pW3lww<$q{6FqVO0aCeu3J1dlDMV`0NJ(2!i5r(5dVz!G16+GT2vq`s zFl~|w5miNMazGW8kWh=nfkQ6=Ar(YPY6t(Mws&WE-;8&?Ysbb7tTOUs=KXy0-uLDw zMq_(=&9CMg$wMHO$Yz%8ErLOC{1#^#4Z$lqeQSluA+! z1z??9fpc$H7m4%|Lpi1eIYJ1{-&jC=S)s{e4_iD^{^U~rO23Z?0Dt2Ad6lZ}R@V=u z4i}VCq*RAz^}zzb=B12tlk{+}vYF52XD?oqdtneF8fK}_zO}QFP9G`kH6OHX4E0c+ zNNLrT(jy=UI8v$fkwmlkVDqz2r(W}Y1thd2VdrOe7L$p?g=S;B%^^&1i}B*+evN(0 zMnjo~{xCV%fGyaBZTNsMpO^-~^zUEEve=crXz2=!Q z%nj3nP1uHyuup?xZB2OHP#*K#78E!>VjprbVs5)tk4~P<(9%-IjF-!Gx_Y%vxtt_q zL+_gr2EexABkVKQ%ZCz5{pncZDKL33=R8)ac(kyPHO_rU7KopHg#Xg_5tu2YkvhBXfWI7=CXA9Og;$fM<4Fcwe?z{ zhd##qbU+q5(1ne#P2HE9fe84y!msBkavV)>vN4|rUwmGnFUu9mWO|WAA7hyFQv)67 z!baHU$Ox#uIz~P_9YrgbtF*oC(xs1g=yLf1O=blf?ioTKW0->sWT69H>gGO(u8u|^ zLPE`kl<1;Gp>U<7l0@HJuhPbiT{ftBwbJYZT1N@yxr%amhra%*Lf5ZV$#pfQQ_^fs zCh9&1JUs${?xB>Ny?V`}oya%_RP2x-l2rFrk6$lzS=BLnbzXJW))tt9 zYKxsbZNb{B7>IS|+~YJif0QdggyYh8B@@9V)8qfMS$h~k#u5}EmeTkL`7{9T-+#<% zyP`}9=MBw_Qr!&$2Bwb2O*`lZTd>J=wLU^V2QHOp!kbwHu4Fjb~bN(_d?`S8X{o{F7$4vHJgoXuh(`b3m4wGM~S56 z!vXx8TUEOyWh|CvYHV-T-1cnP*T{!S;iVrI8R(L|!|^pr^C2O|dzkcOg~ut};>DIX zZyg`~FbTn6@)aXbGcF;4V^)p(AnV=>r0jp*VdW(?@@*J?oK8>wrNLDC4blgYmBT!-F_YcIISGKkA1?K<&002ovPDHLkV1jnXu9g4* literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..7708163853de62cd0ed01d7c147a9e511237de1b GIT binary patch literal 111203 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�(#cXI&THtYIHREj+f}~o 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 T{pauf$9(>CzW<5mKk@!QQ&^v- literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..27bd503c98194b184a4ea99fafa8910d61711801 GIT binary patch literal 21243 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 Yl-u260Xr<964Ab<%p-g9zdgTy1ElzMkN^Mx literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b0979b29598ce78863f6f87eccb90d09169af277 GIT binary patch literal 21646 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 diff --git a/apps/desktop/src-tauri/permissions/autogenerated/account_add_apple.toml b/apps/desktop/src-tauri/permissions/autogenerated/account_add_apple.toml new file mode 100644 index 0000000..898ea53 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/account_add_apple.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/account_add_play.toml b/apps/desktop/src-tauri/permissions/autogenerated/account_add_play.toml new file mode 100644 index 0000000..fc4044e --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/account_add_play.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/account_remove.toml b/apps/desktop/src-tauri/permissions/autogenerated/account_remove.toml new file mode 100644 index 0000000..1bd8a73 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/account_remove.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/accounts_list.toml b/apps/desktop/src-tauri/permissions/autogenerated/accounts_list.toml new file mode 100644 index 0000000..54a88f0 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/accounts_list.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/app_info.toml b/apps/desktop/src-tauri/permissions/autogenerated/app_info.toml new file mode 100644 index 0000000..6ef3c4a --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/app_info.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/open_external.toml b/apps/desktop/src-tauri/permissions/autogenerated/open_external.toml new file mode 100644 index 0000000..c9f4753 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/open_external.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/portfolio_status.toml b/apps/desktop/src-tauri/permissions/autogenerated/portfolio_status.toml new file mode 100644 index 0000000..16aff36 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/portfolio_status.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/project_add.toml b/apps/desktop/src-tauri/permissions/autogenerated/project_add.toml new file mode 100644 index 0000000..af9c7d4 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/project_add.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/project_remove.toml b/apps/desktop/src-tauri/permissions/autogenerated/project_remove.toml new file mode 100644 index 0000000..758b2ba --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/project_remove.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/project_set_accounts.toml b/apps/desktop/src-tauri/permissions/autogenerated/project_set_accounts.toml new file mode 100644 index 0000000..e17a6c7 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/project_set_accounts.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/projects_list.toml b/apps/desktop/src-tauri/permissions/autogenerated/projects_list.toml new file mode 100644 index 0000000..deaa39c --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/projects_list.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/sidecar_call.toml b/apps/desktop/src-tauri/permissions/autogenerated/sidecar_call.toml new file mode 100644 index 0000000..556e1a3 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/sidecar_call.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/sidecar_status.toml b/apps/desktop/src-tauri/permissions/autogenerated/sidecar_status.toml new file mode 100644 index 0000000..9c06486 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/sidecar_status.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/write_with_confirm.toml b/apps/desktop/src-tauri/permissions/autogenerated/write_with_confirm.toml new file mode 100644 index 0000000..0a642da --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/write_with_confirm.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/src/accounts.rs b/apps/desktop/src-tauri/src/accounts.rs new file mode 100644 index 0000000..06f6b10 --- /dev/null +++ b/apps/desktop/src-tauri/src/accounts.rs @@ -0,0 +1,134 @@ +//! 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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + 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 { + 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}")), + } + } +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs new file mode 100644 index 0000000..73d9fcf --- /dev/null +++ b/apps/desktop/src-tauri/src/commands.rs @@ -0,0 +1,375 @@ +//! 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::Mutex; + +use serde::Serialize; +use serde_json::{json, Map, Value}; +use tauri::{AppHandle, 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::policy::{self, Kind}; +use crate::sidecar::{Fail, Sidecar}; +use crate::store::{self, Project, State as Saved}; + +pub struct AppState { + pub sidecar: Sidecar, + pub data_dir: PathBuf, + pub saved: Mutex, + pub secrets: Box, +} + +type Res = Result; + +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> { + 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) +} + +/// Project folder + credentials of its mapped accounts, as sidecar params. +fn project_params(s: &AppState, project_id: &str) -> Res<(Project, Map)> { + 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, +} + +#[tauri::command] +pub fn app_info(window: WebviewWindow, app: AppHandle) -> Res { + 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 { + 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). +#[tauri::command] +pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, method: String, project_id: Option, params: Option) -> Res { + guard(&window)?; + 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)?; + 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)); + } + } + r.call(&method, p).await +} + +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::>().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 +} + +/// 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) -> Res { + guard(&window)?; + let r = state.sidecar.get(&app).await?; + if r.kind(&method) != Some(Kind::Write) { + return Err(Fail::host(format!("{method} is not a write method"))); + } + 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(&method, dry_params).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()); + let text = tilde(&text, home.as_deref()); + let w = window.clone(); + let title = format!("Apply: {}", d.title); + let yes = 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()))?; + if !yes { + 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(&method, real).await?; + Ok(json!({ "confirmed": true, "stage": "done", "dryRun": dry, "result": result })) +} + +#[tauri::command] +pub fn projects_list(window: WebviewWindow, state: State<'_, AppState>) -> Res> { + 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()) +} + +/// 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> { + guard(&window)?; + let w = window.clone(); + let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title("Add a game folder (with storebound.config.ts)").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()))?; + let dir = std::fs::canonicalize(&dir).unwrap_or(dir); + if !has_config(&dir) { + return Err(Fail::host(format!("no storebound.config.ts in {}", dir.display()))); + } + 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(Some(project)) +} + +#[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); + persist(&state, &st) +} + +#[tauri::command] +pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, id: String, play: Option, apple: Option) -> Res { + guard(&window)?; + let mut st = saved(&state)?; + let kind_of = |acc: &Option| 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> { + guard(&window)?; + Ok(saved(&state)?.accounts.clone()) +} + +fn add_account(state: &AppState, label: &str, secret: Secret) -> Res { + 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> { + 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> { + 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> { + 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) +} + +/// `portfolio.status` over every project. Offline unless `live`; live runs each project with its own accounts. +#[tauri::command] +pub async fn portfolio_status(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, live: Option) -> Res { + guard(&window)?; + 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("portfolio.status", json!({ "projects": paths })).await; + } + let mut all = Vec::new(); + for p in &projects { + let (_, creds) = project_params(&state, &p.id)?; + let res = r.call("portfolio.status", json!({ "projects": [p.path], "live": true, "credentials": creds })).await?; + all.extend(res["apps"].as_array().cloned().unwrap_or_default()); + } + Ok(json!({ "apps": all, "at": store::now_iso() })) +} + +#[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())) +} diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs new file mode 100644 index 0000000..2f21eac --- /dev/null +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -0,0 +1,18 @@ +/// Every app command the webview can call. build.rs turns each into an `allow-` 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", + "write_with_confirm", + "projects_list", + "project_add", + "project_remove", + "project_set_accounts", + "accounts_list", + "account_add_play", + "account_add_apple", + "account_remove", + "portfolio_status", + "open_external", +]; diff --git a/apps/desktop/src-tauri/src/confirm.rs b/apps/desktop/src-tauri/src/confirm.rs new file mode 100644 index 0000000..fca5cc8 --- /dev/null +++ b/apps/desktop/src-tauri/src/confirm.rs @@ -0,0 +1,100 @@ +//! 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 { + 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 { + 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 { + let mut m = Hmac::::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 { + 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 { + 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() +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..1700aa5 --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,172 @@ +//! 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 commands; +pub mod confirm; +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(app: &AppHandle) { + 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(app: &AppHandle) -> tauri::Result> { + 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(app: &AppHandle, 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); + } +} + +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()?; + app.manage(commands::AppState { + sidecar: sidecar::Sidecar::default(), + saved: Mutex::new(store::load(&data_dir)), + data_dir, + secrets: Box::new(accounts::Keychain), + }); + #[cfg(target_os = "macos")] + app.set_menu(menu(app.handle())?)?; + #[cfg(not(target_os = "macos"))] + let _ = menu::; + 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::write_with_confirm, + commands::projects_list, + commands::project_add, + 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, + ]) + .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::() { + s.sidecar.shutdown_now(); + } + } + _ => {} + }); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..cdf4943 --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// No console window on Windows release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + storebound_desktop::run(); +} diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs new file mode 100644 index 0000000..7ed2c71 --- /dev/null +++ b/apps/desktop/src-tauri/src/policy.rs @@ -0,0 +1,142 @@ +//! 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 { + 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, params: &Value) -> Result { + 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://` 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) +} diff --git a/apps/desktop/src-tauri/src/sidecar.rs b/apps/desktop/src-tauri/src/sidecar.rs new file mode 100644 index 0000000..98e574c --- /dev/null +++ b/apps/desktop/src-tauri/src/sidecar.rs @@ -0,0 +1,295 @@ +//! 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. +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) -> Fail { + Fail { code, message: message.into(), data: Value::Null } + } + /// The host refused (window, origin, params, state). + pub fn host(message: impl Into) -> Fail { + Fail::new(-1, message) + } +} + +impl From 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>>>>; + +pub struct Running { + child: Mutex>, + secret: [u8; 32], + pending: Pending, + next: AtomicU64, + alive: Arc, + pub version: String, + pub protocol: String, + pub methods: Vec, +} + +impl Running { + pub fn kind(&self, method: &str) -> Option { + 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 { + 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>, 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 { + let id = self.next.fetch_add(1, Ordering::SeqCst); + let rx = self.send(id, method, ¶ms)?; + rx.await.map_err(|_| Fail::host("the storebound sidecar stopped"))? + } + + 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>>, +} + +/// Progress of a running request, forwarded to the main window as `sidecar-progress`. +#[derive(Clone, Serialize)] +struct Progress { + params: Value, +} + +impl Sidecar { + /// The running sidecar, started (and greeted) if needed. + pub async fn get(&self, app: &AppHandle) -> Result, 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) + } + + 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/` 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(app: &AppHandle) -> Result { + 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 alive = Arc::new(AtomicBool::new(true)); + + let (p, a, handle) = (pending.clone(), alive.clone(), app.clone()); + tauri::async_runtime::spawn(async move { + let mut out: Vec = Vec::new(); + let mut err: Vec = 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 = out.drain(..=nl).collect(); + route(&handle, &p, &line); + } + } + CommandEvent::Stderr(bytes) => { + err.extend_from_slice(&bytes); + while let Some(nl) = err.iter().position(|b| *b == b'\n') { + let line: Vec = 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, + 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(app: &AppHandle, pending: &Pending, line: &[u8]) { + let Ok(msg) = serde_json::from_slice::(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 _ = app.emit_to(policy::MAIN, "sidecar-progress", Progress { params: msg["params"].clone() }); + } +} diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs new file mode 100644 index 0000000..be386b0 --- /dev/null +++ b/apps/desktop/src-tauri/src/store.rs @@ -0,0 +1,74 @@ +//! 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, + #[serde(default)] + pub apple_account: Option, + pub added: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct State { + #[serde(default)] + pub projects: Vec, + #[serde(default)] + pub accounts: Vec, +} + +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 { + 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) +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..aaf7859 --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,85 @@ +{ + "$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" + } + } +} diff --git a/apps/desktop/src-tauri/tests/accounts.rs b/apps/desktop/src-tauri/tests/accounts.rs new file mode 100644 index 0000000..1e8d1e4 --- /dev/null +++ b/apps/desktop/src-tauri/tests/accounts.rs @@ -0,0 +1,98 @@ +//! 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!["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!["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())); +} diff --git a/apps/desktop/src-tauri/tests/confirm.rs b/apps/desktop/src-tauri/tests/confirm.rs new file mode 100644 index 0000000..c26c06b --- /dev/null +++ b/apps/desktop/src-tauri/tests/confirm.rs @@ -0,0 +1,43 @@ +//! 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 { + (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 = 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"); +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs new file mode 100644 index 0000000..2ca125b --- /dev/null +++ b/apps/desktop/src-tauri/tests/security.rs @@ -0,0 +1,201 @@ +//! 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 = 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 = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().expect("plain permission ids only").to_string()).collect(); + let mut want: BTreeSet = 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 = 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::>()); + // 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()); + 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); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx new file mode 100644 index 0000000..7ebfccb --- /dev/null +++ b/apps/desktop/src/App.tsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useState } from 'react'; +import { type Account, api, type AppStatus, errorText, onDeepLink, onProgress, type PortfolioResult, type Project, type SidecarStatus } from './api'; +import { Accounts } from './screens/Accounts'; +import { AppFolders } from './screens/AppFolders'; +import { AppView, type AppTab } from './screens/AppView'; +import { Dependencies } from './screens/Dependencies'; +import { Portfolio } from './screens/Portfolio'; +import { Settings } from './screens/Settings'; + +/** + * App-centric navigation: Portfolio (every app) -> one App (Releases, Catalog, Listings, Health, Reviews, TestFlight), + * plus the workspace screens (app folders, store accounts, settings). + */ +export type Route = + | { screen: 'portfolio' } + | { screen: 'app'; appId: string; tab: AppTab } + | { screen: 'apps' } + | { screen: 'accounts' } + | { screen: 'dependencies' } + | { screen: 'settings' }; + +const WORKSPACE = [['apps', 'App folders'], ['accounts', 'Accounts'], ['dependencies', 'Dependencies'], ['settings', 'Settings']] as const; +const DEEP = ['portfolio', 'apps', 'accounts', 'dependencies', 'settings']; + +const ICON = { + portfolio: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z', + app: 'M7 3h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM11 18h2', + apps: 'M3 6.5A1.5 1.5 0 0 1 4.5 5H9l2 2h8.5A1.5 1.5 0 0 1 21 8.5v9a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z', + accounts: 'M15 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM3 21a8 8 0 0 1 16 0M17 11h4m-2-2v4', + dependencies: 'M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4a2 2 0 0 0 1-1.7zM3.3 7 12 12l8.7-5M12 22V12', + settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z', +} as const; + +export function Icon({ d }: { d: string }) { + return ; +} + +function Mark() { + return ( + + ); +} + +/** Shared app data: app folders, accounts, sidecar state and the last portfolio read. */ +export interface Data { + projects: Project[]; + accounts: Account[]; + sidecar: SidecarStatus | null; + sidecarError: string | null; + portfolio: PortfolioResult | null; + portfolioBusy: string | null; + portfolioError: string | null; + refreshPortfolio: (live?: boolean) => Promise; + reload: () => Promise; + /** Home folder, for showing paths as ~/... */ + home: string | null; + /** The portfolio entry of an app folder (by project id). */ + statusOf: (p: Project) => AppStatus | undefined; +} + +/** Dev builds only (import.meta.env.DEV is false in release builds, so this is dropped): VITE_START=accounts, + * VITE_START=app:catalog ... opens that screen first, for screenshots and manual checks. */ +function startRoute(projects: Project[]): Route | null { + const v = import.meta.env.DEV ? String(import.meta.env.VITE_START ?? '') : ''; + const [screen, tab] = v.split(':'); + if (screen === 'app' && projects[0]) return { screen: 'app', appId: projects[0].id, tab: (tab as AppTab) || 'releases' }; + return DEEP.includes(screen ?? '') ? { screen: screen as 'portfolio' } : null; +} + +export function App() { + const [route, setRoute] = useState({ screen: 'portfolio' }); + const [projects, setProjects] = useState([]); + const [accounts, setAccounts] = useState([]); + const [sidecar, setSidecar] = useState(null); + const [sidecarError, setSidecarError] = useState(null); + const [portfolio, setPortfolio] = useState(null); + const [portfolioBusy, setPortfolioBusy] = useState(null); + const [portfolioError, setPortfolioError] = useState(null); + const [home, setHome] = useState(null); + + const reload = useCallback(async () => { + const [p, a] = await Promise.all([api.projects(), api.accounts()]); + setProjects(prev => { if (!prev.length) { const r = startRoute(p); if (r) setRoute(r); } return p; }); + setAccounts(a); + }, []); + + const refreshPortfolio = useCallback(async (live = false) => { + setPortfolioBusy(live ? 'Reading the stores' : 'Checking'); setPortfolioError(null); + try { setPortfolio(await api.portfolio(live)); } catch (e) { setPortfolioError(errorText(e)); } finally { setPortfolioBusy(null); } + }, []); + + useEffect(() => { + reload().catch(() => {}); + api.appInfo().then(i => setHome(i.home), () => {}); + api.sidecarStatus().then(s => { setSidecar(s); setSidecarError(null); }, e => setSidecarError(errorText(e))); + const un = onDeepLink(s => { if (DEEP.includes(s)) setRoute({ screen: s as 'portfolio' }); }); + const unP = onProgress(p => { if (p.total) setPortfolioBusy(b => (b ? `${b.replace(/ \d+\/\d+$/, '')} ${p.n}/${p.total}` : b)); }); + return () => { un.then(f => f()); unP.then(f => f()); }; + }, [reload]); + useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]); + + // Keyboard: Cmd/Ctrl+1 portfolio, 2-4 workspace screens. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return; + const order = ['portfolio', 'apps', 'accounts', 'dependencies', 'settings'] as const; + const s = order[Number(e.key) - 1]; + if (s) { e.preventDefault(); setRoute({ screen: s }); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const statusOf = (p: Project) => portfolio?.apps.find(a => a.project === p.path || a.app?.id === p.path); + const data: Data = { projects, accounts, sidecar, sidecarError, portfolio, portfolioBusy, portfolioError, refreshPortfolio, reload, statusOf, home }; + const current = (r: Route) => (JSON.stringify(r) === JSON.stringify(route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) ? 'page' : undefined); + const openApp = (p: Project) => setRoute({ screen: 'app', appId: p.id, tab: route.screen === 'app' ? route.tab : 'releases' }); + const app = route.screen === 'app' ? projects.find(p => p.id === route.appId) : undefined; + + return ( +
+ +
+ {route.screen === 'portfolio' && setRoute({ screen: s })} />} + {route.screen === 'app' && app && setRoute({ screen: 'app', appId: app.id, tab })} />} + {route.screen === 'apps' && } + {route.screen === 'accounts' && } + {route.screen === 'dependencies' && } + {route.screen === 'settings' && } +
+
+ ); +} diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts new file mode 100644 index 0000000..d5cb015 --- /dev/null +++ b/apps/desktop/src/api.ts @@ -0,0 +1,53 @@ +// The webview's only way out: the app commands of src-tauri/src/commands.rs. No plugin API is used from here. +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol'; + +export type { + AppIdentity, AppStatus, CatalogParity, MethodDescriptor, PortfolioResult, ProgressParams, ReleaseTrain, 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[]; 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 } + +/** 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'; +} + +export const api = { + appInfo: () => invoke('app_info'), + sidecarStatus: () => invoke('sidecar_status'), + /** Reads, or dry runs of writes (the host forces dryRun for writes coming from here). */ + call: (method: string, projectId?: string, params?: { args?: Record }) => + invoke('sidecar_call', { method, projectId: projectId ?? null, params: params ?? null }), + /** 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 = {}) => + invoke('write_with_confirm', { method, projectId, args }), + projects: () => invoke('projects_list'), + addProject: () => invoke('project_add'), + removeProject: (id: string) => invoke('project_remove', { id }), + setProjectAccounts: (id: string, play: string | null, apple: string | null) => invoke('project_set_accounts', { id, play, apple }), + accounts: () => invoke('accounts_list'), + addPlayAccount: (label: string) => invoke('account_add_play', { label }), + addAppleAccount: (label: string, keyId: string, issuerId: string) => invoke('account_add_apple', { label, keyId, issuerId }), + removeAccount: (id: string) => invoke('account_remove', { id }), + portfolio: (live = false) => invoke('portfolio_status', { live }), + openExternal: (url: string) => invoke('open_external', { url }), +}; + +export const onProgress = (cb: (p: ProgressParams) => void): Promise => + listen<{ params: ProgressParams }>('sidecar-progress', e => cb(e.payload.params)); + +export const onDeepLink = (cb: (screen: string) => void): Promise => listen('deep-link', e => cb(e.payload)); diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..6ba611d --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import { applyPrefs, loadPrefs } from './prefs'; +import './styles.css'; + +const prefs = loadPrefs(); +// Dev builds only: VITE_THEME=light|dark forces the theme (screenshots of both themes without clicking). +if (import.meta.env.DEV && (import.meta.env.VITE_THEME === 'light' || import.meta.env.VITE_THEME === 'dark')) prefs.theme = import.meta.env.VITE_THEME; +applyPrefs(prefs); +createRoot(document.getElementById('root')!).render(); diff --git a/apps/desktop/src/paths.ts b/apps/desktop/src/paths.ts new file mode 100644 index 0000000..809be78 --- /dev/null +++ b/apps/desktop/src/paths.ts @@ -0,0 +1,13 @@ +/** 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; +} diff --git a/apps/desktop/src/prefs.ts b/apps/desktop/src/prefs.ts new file mode 100644 index 0000000..06d65ea --- /dev/null +++ b/apps/desktop/src/prefs.ts @@ -0,0 +1,31 @@ +// Per-device UI preferences (theme, motion). Only conveniences live in localStorage; nothing about projects or accounts. +export type Theme = 'system' | 'light' | 'dark'; +export type Motion = 'system' | 'reduce'; +export interface Prefs { theme: Theme; motion: Motion } + +export const DEFAULT_PREFS: Prefs = { theme: 'system', motion: 'system' }; +const KEY = 'storebound.prefs.v1'; + +export function parsePrefs(raw: string | null): Prefs { + try { + const v = JSON.parse(raw ?? '{}') as Partial; + return { + theme: v.theme === 'light' || v.theme === 'dark' ? v.theme : 'system', + motion: v.motion === 'reduce' ? 'reduce' : 'system', + }; + } 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 : data-theme (light / dark, absent = follow the system) and data-motion. */ +export function applyPrefs(p: Prefs, root: { dataset: DOMStringMap } = document.documentElement): 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; +} diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx new file mode 100644 index 0000000..933e16a --- /dev/null +++ b/apps/desktop/src/screens/Accounts.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { type Account, api, errorText } from '../api'; +import type { Data } from '../App'; + +const KIND: Record = { play: 'Google Play', apple: 'App Store Connect' }; + +export function Accounts({ data }: { data: Data }) { + const [error, setError] = useState(null); + const [saved, setSaved] = useState(null); + const [busy, setBusy] = useState(null); + const [playLabel, setPlayLabel] = useState(''); + const [appleLabel, setAppleLabel] = useState(''); + const [keyId, setKeyId] = useState(''); + const [issuerId, setIssuerId] = useState(''); + + const act = async (key: string, fn: () => Promise) => { + setBusy(key); setError(null); setSaved(null); + try { + const r = await fn(); + if (r) setSaved(`Saved "${r.label}" in the system keychain.`); + await data.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) => data.projects.filter(p => p.playAccount === a.id || p.appleAccount === a.id).map(p => p.name); + + return ( + <> +
+
+

Accounts

+

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 only handed to storebound in memory for a call.

+
+
+
+ {error &&
{error}
} + {saved &&
{saved}
} +
+ {!data.accounts.length ? ( +

No accounts

Add a Google Play service account or an App Store Connect API key below.

+ ) : ( + + + + {data.accounts.map(a => ( + + + + + + + + ))} + +
NameStoreUsed byAddedActions
{a.label}{KIND[a.kind]}{used(a).join(', ') || no project}{a.created.slice(0, 10)}
+ )} +
+
+
{ e.preventDefault(); addPlay(); }}> +

Google Play

Service account JSON key with access to your apps in Play Console.

+
+ + setPlayLabel(e.target.value)} placeholder="Studio Play account" maxLength={60} autoComplete="off" /> +
+
+
+
{ e.preventDefault(); addApple(); }}> +

App Store Connect

Team API key (.p8) from Users and Access, Integrations.

+
+ + setAppleLabel(e.target.value)} placeholder="Studio App Store key" maxLength={60} autoComplete="off" /> +
+
+ + setKeyId(e.target.value.toUpperCase())} placeholder="10 letters or digits" maxLength={10} autoComplete="off" spellCheck={false} /> +
+
+ + setIssuerId(e.target.value)} placeholder="UUID shown above the keys" maxLength={36} autoComplete="off" spellCheck={false} /> +
+
+
+
+
+ + ); +} diff --git a/apps/desktop/src/screens/AppFolders.tsx b/apps/desktop/src/screens/AppFolders.tsx new file mode 100644 index 0000000..5b44722 --- /dev/null +++ b/apps/desktop/src/screens/AppFolders.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react'; +import { api, errorText, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api'; +import type { Data } from '../App'; +import { shortPath, tildeText } from '../paths'; + +interface Run { title: string; project: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string } + +/** Workspace: the app folders (one storebound.config.ts per app) and which store accounts each app uses. */ +export function AppFolders({ data }: { data: Data }) { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [run, setRun] = useState(null); + const logRef = useRef(null); + + useEffect(() => { + const un = onProgress(p => { if (p.line !== undefined) setRun(r => (r ? { ...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) => { + setBusy(key); setError(null); + try { await fn(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); } + }; + const add = () => act('add', async () => { if (await api.addProject()) await data.reload(); }); + // after an account change the portfolio may read differently + const reloadAll = async () => { await data.reload(); }; + const remove = (p: Project) => act(`rm-${p.id}`, async () => { await api.removeProject(p.id); await data.reload(); }); + const setAcc = (p: Project, play: string | null, apple: string | null) => act(`acc-${p.id}`, async () => { await api.setProjectAccounts(p.id, play, apple); await reloadAll(); }); + + const check = (p: Project) => act(`check-${p.id}`, async () => { + setRun({ title: 'Offline check', project: p.name, lines: [] }); + try { const r = await api.call('check', p.id); setRun(x => x && { ...x, result: r }); } catch (e) { setRun(x => x && { ...x, error: errorText(e) }); } + }); + const gallery = (p: Project) => act(`gallery-${p.id}`, async () => { + setRun({ title: 'Review gallery (local file)', project: p.name, lines: [] }); + try { const o = await api.writeWithConfirm('shots.gallery', p.id); 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 = data.projects[0]; + if (!import.meta.env.DEV || import.meta.env.VITE_AUTORUN !== 'gallery' || !first || autorun.current) return; + autorun.current = true; + gallery(first); + }, [data.projects]); // eslint-disable-line react-hooks/exhaustive-deps + + const play = data.accounts.filter(a => a.kind === 'play'); + const apple = data.accounts.filter(a => a.kind === 'apple'); + + return ( + <> +
+
+

App folders

+

One folder per app, with its storebound.config.ts (iOS and Android in the same config). Pick which store accounts each app uses; the keys stay in the system keychain.

+
+
+
+
+ {error &&
{error}
} + {!data.projects.length ? ( +

No app folders

Choose a folder that contains storebound.config.ts (run storebound init there first if it has none).

+ ) : ( +
+ + + + {data.projects.map(p => ( + + + + + + + ))} + +
AppGoogle Play accountApp Store Connect accountActions
{p.name}
{shortPath(p.path, data.home)}
+ + + + +
+ + + +
+
+
+ )} + {run && ( +
+
+

{run.title} · {run.project}

+

+ {run.error ? 'Failed' : !run.result ? 'Running' : run.outcome + ? (run.outcome.confirmed ? 'Applied after your confirmation' : run.outcome.stage === 'dialog' ? 'Cancelled in the dialog: nothing was written' : 'The dry run failed: nothing was written') + : run.result.ok ? 'Done' : `Exit ${run.result.exitCode}`} + {run.result && <> · {run.result.command}} +

+
+ {run.error &&
{run.error}
} +
{tildeText(run.result?.output ?? run.lines.join('\n'), data.home) || ' '}
+
+ )} +
+ + ); +} diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx new file mode 100644 index 0000000..e544f4c --- /dev/null +++ b/apps/desktop/src/screens/AppView.tsx @@ -0,0 +1,92 @@ +import type { Project, ReleaseTrain } from '../api'; +import type { Data } from '../App'; +import { phaseText } from './Portfolio'; + +export type AppTab = 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight'; +export const TABS: [AppTab, string][] = [['releases', 'Releases'], ['catalog', 'Catalog'], ['listings', 'Listings'], ['health', 'Health'], ['reviews', 'Reviews'], ['testflight', 'TestFlight']]; + +/** What each tab will show, and the CLI command that has it today. */ +const NEXT: Record, [string, string]> = { + listings: ['Store listing text, screenshots and previews per language and store, with the dry-run diff before any upload.', 'storebound check · storebound shots gallery'], + health: ['Crash and ANR rates against the store thresholds, worst devices and top issues.', 'storebound health'], + reviews: ['Recent reviews on both stores, reply drafts and what still needs an answer.', 'storebound reviews list'], + testflight: ['Beta groups, testers and builds with their beta review state.', 'storebound apple testflight status'], +}; + +function Train({ title, t }: { title: string; t: ReleaseTrain | null }) { + return ( +
+

{title}

{t ? `Version ${t.version}` : 'No version'}

+ {t && ( +
+ {(['ios', 'android'] as const).map(s => { + const r = t[s]; + const ph = phaseText(r); + return [ +
{s === 'ios' ? 'App Store' : 'Google Play'}
, +
{ph.text}{r?.build && build {r.build}}{r?.track && r.track !== 'production' && · {r.track}}
, + ]; + })} +
+ )} +
+ ); +} + +export function AppView({ data, project, tab, setTab }: { data: Data; project: Project; tab: AppTab; setTab: (t: AppTab) => void }) { + const e = data.statusOf(project); + const ios = e?.app?.platforms.ios, android = e?.app?.platforms.android; + return ( + <> +
+
+

{e?.app?.name ?? project.name}

+

+ iOS {ios ? ios.bundleId : 'not set up'} + Android {android ? android.packageName : 'not set up'} +

+
+
+ +
+
+
+ {TABS.map(([t, label]) => )} +
+
+ {data.portfolioError &&
{data.portfolioError}
} + {tab === 'releases' && ( + e?.release.source === 'live' ? ( +
+ ) : ( +

Release trains

One version across the App Store and Google Play: build, review, TestFlight, phased release and rollout state. Map store accounts to this app in App folders, then read the stores.

+ ) + )} + {tab === 'catalog' && ( +
+

In-app catalog

Products and subscriptions are defined once in the config and created on both stores; parity compares them.

+ {e?.catalog ? ( +
+
Products
{e.catalog.products}
+
Subscriptions
{e.catalog.subscriptions}
+
Compared with
{e.catalog.checked === 'live' ? 'both stores' : 'the config only (read the stores to compare)'}
+ {e.catalog.checked === 'live' && <> +
Missing on iOS
{e.catalog.missingIos.join(', ') || 'none'}
+
Missing on Android
{e.catalog.missingAndroid.join(', ') || 'none'}
+
Only on one store
{[...e.catalog.onlyIos.map(x => `${x} (iOS)`), ...e.catalog.onlyAndroid.map(x => `${x} (Android)`)].join(', ') || 'none'}
+ } +
+ ) :

Not checked yet.

} +
+ )} + {tab !== 'releases' && tab !== 'catalog' && ( +
+

{TABS.find(x => x[0] === tab)![1]}

+

{NEXT[tab][0]}

+

Today in a terminal: {NEXT[tab][1]}

+
+ )} +
+ + ); +} diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx new file mode 100644 index 0000000..8b252ed --- /dev/null +++ b/apps/desktop/src/screens/Dependencies.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react'; +import { api, errorText, type ToolStatus } from '../api'; +import type { Data } from '../App'; +import { shortPath } from '../paths'; + +/** Status tag of one tool. */ +export function toolState(t: ToolStatus): { text: string; tone: 'ok' | 'warn' | 'local' } { + if (!t.found) return { text: 'missing', tone: 'warn' }; + return t.source === 'app' ? { text: 'app copy', tone: 'local' } : { text: 'installed', tone: 'ok' }; +} + +/** + * Local tools storebound uses for screenshots, previews, icons and captures. ffmpeg and Chrome for Testing will be + * downloadable into the app's data folder (checksum or signature verified, never system-wide, never bundled); + * Xcode and the Android SDK are link-out only. Sources and licenses: docs/desktop.md. + */ +export function Dependencies({ data }: { data: Data }) { + const [tools, setTools] = useState(data.sidecar?.tools?.tools ?? null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const refresh = useCallback(async () => { + setBusy(true); setError(null); + try { setTools((await api.call<{ tools: ToolStatus[] }>('system.tools')).tools); } catch (e) { setError(errorText(e)); } finally { setBusy(false); } + }, []); + useEffect(() => { if (!tools) refresh(); }, [tools, refresh]); + const open = (url: string) => api.openExternal(url).catch(e => setError(errorText(e))); + + return ( + <> +
+
+

Dependencies

+

Local tools for screenshots, app previews, icons and simulator or emulator captures. Store work (listings, products, releases) needs none of them.

+
+
+
+
+ {error &&
{error}
} +
+ + + + {(tools ?? []).map(t => { + const st = toolState(t); + return ( + + + + + + + + ); + })} + {!tools && } + +
ToolStatusVersionUsed forGet it
{t.name}
{t.path &&
{shortPath(t.path, data.home)}
}
{st.text}{t.version ?? ''}{t.usedFor} + {t.install.kind === 'download' + ? + : } +
Checking...
+
+
+ ffmpeg and Chrome for Testing will download into this app's data folder only, verified before use, and are never part of the installer. Xcode and the Android SDK come from Apple and Google. +
+
+ + ); +} diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx new file mode 100644 index 0000000..1f8987a --- /dev/null +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -0,0 +1,79 @@ +import type { AppStatus, Project, ReleaseTrain, StoreRelease } from '../api'; +import type { Data } from '../App'; +import { shortPath } from '../paths'; + +export type Tone = 'ok' | 'warn' | 'bad' | 'muted' | 'local'; + +/** Tag text and tone of one app's offline config check. */ +export function checkState(e: Pick): { text: string; tone: Tone } { + if (e.errors.length || !e.check) return { text: 'not loaded', tone: 'bad' }; + if (e.check.problems.length) return { text: `${e.check.problems.length} problem${e.check.problems.length === 1 ? '' : 's'}`, tone: 'bad' }; + if (e.check.warnings.length) return { text: `${e.check.warnings.length} warning${e.check.warnings.length === 1 ? '' : 's'}`, tone: 'warn' }; + return { text: 'config ok', tone: 'ok' }; +} + +const PHASE_TONE: Record = { live: 'ok', rollout: 'local', halted: 'bad', approved: 'local', review: 'warn', rejected: 'bad', testing: 'local', draft: 'muted', other: 'muted' }; + +/** "live", "rollout 20%", "review" ... for one store of a train. */ +export function phaseText(r: StoreRelease | null): { text: string; tone: Tone } { + if (!r) return { text: 'not on this store', tone: 'muted' }; + const pct = r.fraction !== null && r.phase === 'rollout' ? ` ${Math.round(r.fraction * 100)}%` : ''; + return { text: `${r.phase}${pct}`, tone: PHASE_TONE[r.phase] }; +} + +export function trainText(t: ReleaseTrain | null, source: 'none' | 'live'): string { + if (source === 'none') return 'not read'; + return t ? t.version : 'none'; +} + +export function Portfolio({ data, open, go }: { data: Data; open: (p: Project) => void; go: (s: 'apps') => void }) { + const busy = data.portfolioBusy; + return ( + <> +
+
+

Portfolio

+

Every app in this workspace on iOS and Android: identity, release train (current and next version on both stores), in-app catalog and config check.

+
+
+ + +
+
+ {data.portfolioError &&
{data.portfolioError}
} + {!data.projects.length ? ( +
+

No apps yet

+

Add an app folder (one storebound.config.ts per app) to see it here.

+ +
+ ) : ( +
+ + + + {data.projects.map(p => { + const e = data.statusOf(p); + const st = e ? checkState(e) : { text: busy ? 'checking' : 'not checked', tone: 'muted' as const }; + const ios = e?.app?.platforms.ios, android = e?.app?.platforms.android; + const cat = e?.catalog; + return ( + + + + + + + + + + ); + })} + +
AppiOSAndroidLiveNextCatalogConfig
{shortPath(p.path, data.home)}
{ios ? ios.bundleId : none}{e?.storeErrors.ios &&
unreadable
}
{android ? android.packageName : none}{e?.storeErrors.android &&
unreadable
}
{e ? trainText(e.release.current, e.release.source) : ''}{e ? trainText(e.release.next, e.release.source) : ''}{cat ? {cat.products} products · {cat.subscriptions} subs : ''}{st.text}
+
+ )} + {data.portfolio &&

Last read {new Date(data.portfolio.at).toLocaleString()}

} + + ); +} diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx new file mode 100644 index 0000000..bf5c7d5 --- /dev/null +++ b/apps/desktop/src/screens/Settings.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { type AppInfo, api, errorText } from '../api'; +import type { Data } from '../App'; +import { applyPrefs, loadPrefs, type Motion, type Prefs, savePrefs, type Theme } from '../prefs'; + +function Seg({ label, value, options, onChange }: { label: string; value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {label} +
+ {options.map(([v, text]) => )} +
+
+ ); +} + +export function Settings({ data }: { data: Data }) { + const [prefs, setPrefs] = useState(loadPrefs); + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { api.appInfo().then(setInfo, e => setError(errorText(e))); }, []); + const update = (p: Prefs) => { setPrefs(p); savePrefs(p); applyPrefs(p); }; + const tools = data.sidecar?.tools; + + return ( + <> +

Settings

Appearance and what this app runs on.

+
+ {error &&
{error}
} +
+

Appearance

+ label="Theme" value={prefs.theme} options={[['system', 'System'], ['light', 'Light'], ['dark', 'Dark']]} onChange={theme => update({ ...prefs, theme })} /> + label="Motion" value={prefs.motion} options={[['system', 'Follow the system'], ['reduce', 'Reduce']]} onChange={motion => update({ ...prefs, motion })} /> +
+
+

About

+
+
App
{info ? `storebound desktop ${info.version}${info.debug ? ' (debug build)' : ''}` : ''}
+
CLI
{data.sidecar ? `storebound ${data.sidecar.version} · ${data.sidecar.protocol} · ${data.sidecar.methods.length} methods` : data.sidecarError ?? 'starting'}
+
Local tools
+
{tools ? `${tools.tools.filter(t => t.found).length} of ${tools.tools.length} found (see Dependencies)` : '...'}
+
Telemetry
None. The app only talks to the store APIs through storebound, with your keys.
+
+
+ +
+
+
+ + ); +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css new file mode 100644 index 0000000..5e9d49d --- /dev/null +++ b/apps/desktop/src/styles.css @@ -0,0 +1,175 @@ +/* Tokens shared with storebound.dev (site/style.css): slate neutrals, indigo accent, API / LOCAL / UI / YOU tags. */ +:root { + --bg: #f8fafc; + --bg-alt: #f1f5f9; + --surface: #ffffff; + --ink: #0f172a; + --ink-2: #334155; + --muted: #475569; + --line: #e2e8f0; + --line-2: #cbd5e1; + --accent: #4f46e5; + --accent-ink: #ffffff; + --accent-soft: #eef2ff; + --code-bg: #0f172a; + --code-ink: #e2e8f0; + --ok-bg: #dcfce7; --ok-ink: #166534; + --local-bg: #e0f2fe; --local-ink: #075985; + --warn-bg: #fef3c7; --warn-ink: #92400e; + --bad-bg: #fce7f3; --bad-ink: #9d174d; + --shadow: 0 1px 2px rgba(15, 23, 42, .06), 0 8px 24px -12px rgba(15, 23, 42, .18); + --radius: 12px; + --font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --speed: .14s; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0b1120; --bg-alt: #0f172a; --surface: #131c2f; + --ink: #f1f5f9; --ink-2: #cbd5e1; --muted: #a5b4c8; --line: #1e293b; --line-2: #334155; + --accent: #818cf8; --accent-ink: #0b1120; --accent-soft: #1e1b4b; + --code-bg: #060a14; + --ok-bg: #14532d; --ok-ink: #bbf7d0; --local-bg: #0c4a6e; --local-ink: #bae6fd; + --warn-bg: #451a03; --warn-ink: #fde68a; --bad-bg: #500724; --bad-ink: #fbcfe8; + --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -16px rgba(0, 0, 0, .7); + color-scheme: dark; + } +} +:root[data-theme="dark"] { + --bg: #0b1120; --bg-alt: #0f172a; --surface: #131c2f; + --ink: #f1f5f9; --ink-2: #cbd5e1; --muted: #a5b4c8; --line: #1e293b; --line-2: #334155; + --accent: #818cf8; --accent-ink: #0b1120; --accent-soft: #1e1b4b; + --code-bg: #060a14; + --ok-bg: #14532d; --ok-ink: #bbf7d0; --local-bg: #0c4a6e; --local-ink: #bae6fd; + --warn-bg: #451a03; --warn-ink: #fde68a; --bad-bg: #500724; --bad-ink: #fbcfe8; + --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -16px rgba(0, 0, 0, .7); + color-scheme: dark; +} + +@media (prefers-reduced-motion: reduce) { :root { --speed: 0s; } } +:root[data-motion="reduce"] { --speed: 0s; } + +*, *::before, *::after { box-sizing: border-box; } +html, body, #root { height: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 14px/1.5 var(--font); + -webkit-font-smoothing: antialiased; + user-select: none; + -webkit-user-select: none; + cursor: default; +} +input, textarea, .selectable, pre, code { user-select: text; -webkit-user-select: text; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; } +h1, h2, h3 { margin: 0; line-height: 1.2; letter-spacing: -.015em; } +p { margin: 0; } +code, pre { font-family: var(--mono); font-size: 12.5px; } + +.app { display: grid; grid-template-columns: 220px 1fr; height: 100%; } + +/* sidebar */ +.side { background: var(--bg-alt); border-right: 1px solid var(--line); display: flex; flex-direction: column; padding: 16px 12px; gap: 4px; } +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; padding: 4px 8px 16px; } +.brand svg { width: 26px; height: 26px; flex: none; } +.nav { all: unset; display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; color: var(--ink-2); font-weight: 550; cursor: pointer; transition: background-color var(--speed) ease; } +.nav svg { width: 16px; height: 16px; flex: none; } +.nav:hover { background: var(--line); } +.nav[aria-current="page"] { background: var(--accent-soft); color: var(--ink); } +.nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; } +.side-label { margin: 14px 10px 4px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); } +.side-apps { display: grid; gap: 2px; max-height: 40vh; overflow: auto; } +.side-empty { color: var(--muted); font-size: 12.5px; padding: 4px 10px; } +.nav-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.side-foot { margin-top: auto; padding: 10px 8px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; display: grid; gap: 4px; } +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; background: var(--line-2); } +.dot.on { background: #16a34a; } +.dot.off { background: #dc2626; } + +/* main */ +.main { overflow: auto; padding: 28px 32px 40px; } +.head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; } +.head h1 { font-size: 22px; } +.head p { color: var(--muted); margin-top: 4px; max-width: 64ch; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); } +.card + .card { margin-top: 16px; } +.card-pad { padding: 18px 20px; } +.card h2 { font-size: 15px; margin-bottom: 4px; } +.card .sub { color: var(--muted); font-size: 13px; } + +.btn { all: unset; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 0 14px; border-radius: 8px; font-weight: 600; font-size: 13px; cursor: pointer; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); transition: background-color var(--speed) ease, border-color var(--speed) ease; white-space: nowrap; } +.btn:hover { border-color: var(--muted); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.btn[disabled] { opacity: .55; cursor: not-allowed; } +.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; } +.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, var(--ink)); border-color: transparent; } +.btn-quiet { border-color: transparent; background: transparent; color: var(--muted); } +.btn-quiet:hover { color: var(--ink); background: var(--bg-alt); border-color: transparent; } + +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; } +th { font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 650; background: var(--bg-alt); } +th:first-child { border-top-left-radius: var(--radius); } +th:last-child { border-top-right-radius: var(--radius); } +tr:last-child td { border-bottom: 0; } +td .path { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; } +.name { font-weight: 650; } + +.nowrap { white-space: nowrap; } +.tag { white-space: nowrap; display: inline-block; font: 700 10.5px/1 var(--mono); letter-spacing: .05em; padding: 5px 8px; border-radius: 999px; text-transform: uppercase; } +.tag-ok { background: var(--ok-bg); color: var(--ok-ink); } +.tag-local { background: var(--local-bg); color: var(--local-ink); } +.tag-warn { background: var(--warn-bg); color: var(--warn-ink); } +.tag-bad { background: var(--bad-bg); color: var(--bad-ink); } +.tag-muted { background: var(--bg-alt); color: var(--muted); border: 1px solid var(--line); } + +.empty { text-align: center; padding: 48px 24px; color: var(--muted); } +.empty h2 { color: var(--ink); margin-bottom: 6px; } +.empty .btn { margin-top: 16px; } + +.form { display: grid; gap: 12px; max-width: 520px; align-content: start; } +.field { display: grid; gap: 5px; } +.field label, .legend { font-weight: 600; font-size: 13px; } +.field .hint { color: var(--muted); font-size: 12px; } +input[type="text"], select { + font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line-2); border-radius: 8px; min-height: 34px; padding: 0 10px; +} +input[type="text"]:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +select { padding-right: 6px; max-width: 220px; } + +.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; align-items: start; } +.grid2 > .card + .card { margin-top: 0; } +.seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 9px; overflow: hidden; } +.seg button { all: unset; padding: 7px 14px; cursor: pointer; font-weight: 600; font-size: 13px; color: var(--ink-2); } +.seg button + button { border-left: 1px solid var(--line-2); } +.seg button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } +.seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + +.notice { border-radius: 10px; padding: 10px 14px; font-size: 13px; } +.notice-bad { background: var(--bad-bg); color: var(--bad-ink); } +.notice-ok { background: var(--ok-bg); color: var(--ok-ink); } +.notice-info { background: var(--accent-soft); color: var(--ink); } +.stack { display: grid; gap: 12px; } + +.log { background: var(--code-bg); color: var(--code-ink); border-radius: 10px; padding: 12px 14px; max-height: 240px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; } +.kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 16px; font-size: 13px; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; } +.link { all: unset; cursor: pointer; color: var(--ink); } +.link:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } +.link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.muted { color: var(--muted); } +.foot-note { margin-top: 10px; color: var(--muted); font-size: 12px; } +.platforms { margin-top: 8px; } +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 16px; } +.tab { all: unset; padding: 8px 12px; font-weight: 600; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color var(--speed) ease; } +.tab:hover { color: var(--ink); } +.tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); } +.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.empty p + p { margin-top: 8px; } +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } diff --git a/apps/desktop/src/vite-env.d.ts b/apps/desktop/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/desktop/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/desktop/tests/prefs.test.ts b/apps/desktop/tests/prefs.test.ts new file mode 100644 index 0000000..5ad5336 --- /dev/null +++ b/apps/desktop/tests/prefs.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { shortPath, tildeText } from '../src/paths'; +import { applyPrefs, DEFAULT_PREFS, parsePrefs } from '../src/prefs'; +import { checkState, phaseText, trainText } from '../src/screens/Portfolio'; + +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"}')).toEqual({ theme: 'dark', motion: 'reduce' }); + expect(parsePrefs('{"theme":"neon","motion":"fast"}')).toEqual(DEFAULT_PREFS); + }); + test('apply sets and clears data attributes', () => { + const root = { dataset: {} as DOMStringMap }; + applyPrefs({ theme: 'light', motion: 'reduce' }, root); + expect(root.dataset).toEqual({ theme: 'light', motion: 'reduce' }); + applyPrefs(DEFAULT_PREFS, root); + expect(root.dataset).toEqual({}); + }); +}); + +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('portfolio', () => { + const base = { project: '/g', ok: false, summary: null, errors: [] as string[] }; + test('check states', () => { + expect(checkState({ ...base, check: null })).toEqual({ text: '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, check: { ok: true, problems: [], warnings: [] } })).toEqual({ text: 'config ok', tone: 'ok' }); + }); + test('release phases and trains', () => { + const r = { store: 'android' as const, version: '2.3.0', build: '231', state: 'inProgress', phase: 'rollout' as const, fraction: 0.2, track: 'production' }; + expect(phaseText(r)).toEqual({ text: 'rollout 20%', tone: 'local' }); + expect(phaseText(null)).toEqual({ text: 'not on this store', tone: 'muted' }); + expect(trainText(null, 'none')).toBe('not read'); + expect(trainText(null, 'live')).toBe('none'); + expect(trainText({ version: '2.3.0', ios: null, android: r }, 'live')).toBe('2.3.0'); + }); +}); + +describe('webview rules', () => { + const src = (f: string) => readFileSync(join(import.meta.dir, '..', f), 'utf8'); + test('only app commands: no plugin JS APIs, no direct network', () => { + for (const f of ['src/api.ts', 'src/App.tsx', 'src/main.tsx', 'src/screens/Portfolio.tsx', 'src/screens/AppView.tsx', 'src/screens/AppFolders.tsx', 'src/screens/Accounts.tsx', 'src/screens/Settings.tsx', 'src/screens/Dependencies.tsx']) { + const s = src(f); + expect(s).not.toMatch(/@tauri-apps\/plugin-/); + expect(s).not.toMatch(/\bfetch\(|XMLHttpRequest|WebSocket|eval\(|new Function|dangerouslySetInnerHTML/); + } + 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 never appears in the webview code', () => { + expect(src('src/api.ts')).not.toMatch(/confirmToken|credentials/); + }); +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..6b90d8f --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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"] +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts new file mode 100644 index 0000000..2ceaa3e --- /dev/null +++ b/apps/desktop/vite.config.ts @@ -0,0 +1,18 @@ +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, + }, +}); diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..c471e43 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,85 @@ +# storebound desktop (apps/desktop) + +A Tauri 2 window around the storebound CLI: every app in a workspace, on iOS and Android, in one place. It is in +development; this page is for contributors. + +## Shape + +- **One source of logic.** The app never calls a store itself. It runs the storebound CLI as a sidecar + (`storebound serve --stdio`, compiled with `bun tools/build-sidecars.ts`) and asks it for everything. +- **App-centric.** One app = one product with up to two platforms (iOS bundle id / App Store Connect app id, Android + package name), one `storebound.config.ts` per app folder, many apps per workspace. Navigation: Portfolio -> App + (Releases, Catalog, Listings, Health, Reviews, TestFlight) plus the workspace screens (App folders, Accounts, + Dependencies, Settings). +- **Release trains.** A version (for example 2.3.0) spans both stores: iOS build and Android versionCode, per-store + state (TestFlight, review, live, phased release, rollout fraction). `portfolio.status` returns per app the current + train (highest version live on a store) and the next one (highest version above it anywhere). +- **Catalog parity.** Products (and subscriptions, once the config has them) are defined once; `portfolio.status` + compares the config with both stores (ids missing on a store, ids on one store only). + +Types of the protocol: `cli/lib/desktop-protocol.ts`; pure portfolio logic: `cli/lib/portfolio.ts`. + +## Safety model + +- The sidecar's first request is `session.hello` with a 32-byte secret the Rust host makes per start and writes to + the sidecar's stdin. Write methods are dry runs unless the call carries a confirm token: HMAC-SHA256 over the + method, the canonical `{ project, args }` and a 60 s expiry, single use. The host mints one only after the owner + pressed Apply in a native dialog that shows the summary and the dry run (`write_with_confirm`). +- No method submits for review, releases, publishes, resumes or completes a rollout. Under `serve`, the CLI's own + terminal confirmations always answer no. +- The webview gets no plugin API. It may call the app commands (each re-checks the window label and the page origin + in Rust) and listen to two events. Strict CSP, no remote content. `src-tauri/tests/security.rs` pins all of it. +- Store keys (Play service account JSON, App Store Connect key id, issuer id and .p8) are picked in native dialogs, + stored in the OS keychain (`keyring`), never shown again and passed to the sidecar per call in memory only. + +## Dependencies screen + +storebound needs no local tool for store work. Screenshots, app previews, icons and captures use: + +| tool | used for | how the app gets it | +|---|---|---| +| ffmpeg, ffprobe | icons, renders, app previews, frame crops | download into the app data folder (planned, see below) | +| Chrome / Chrome for Testing | screenshot renders, captions, web captures | system Chrome, or Chrome for Testing downloaded into the app data folder (planned) | +| Xcode command line tools, simctl | iOS builds, Simulator captures | link to Apple only | +| adb | Android emulator / device captures | link to the Android platform tools only | +| git | release notes, analytics compare | link only | + +`system.tools` reports each with its path, version and source (`app` or `system`). The host passes app-managed +copies to the sidecar: `STOREBOUND_TOOL_PATH` (app data `tools/bin`, put in front of PATH for every command) and +`STOREBOUND_CHROME` (app data `tools/chrome/...`, used as Playwright's `executablePath`). Nothing is installed +system-wide and nothing is bundled in the installer. + +Download sources (decided; the one-click download lands in the next desktop phase): + +- **ffmpeg, macOS (arm64, x64):** release builds from ffmpeg.martin-riedl.de (static, notarized zip per tool). +- **ffmpeg, Linux (x64, arm64) and Windows (x64):** `ffmpeg-n-latest--gpl` release builds from + github.com/BtbN/FFmpeg-Builds. +- **Verification:** the app ships a manifest with the exact URL and SHA-256 of each archive, updated with each + desktop release; a download whose hash differs is deleted. The publisher's own checksum files are not trusted on + their own (same host as the archive). +- **License:** these ffmpeg builds are GPL (v3 for the BtbN `gpl` variants). The app downloads them only on the + owner's click, from the original publisher, shows the license and the source link next to the button, and never + redistributes them, so the app itself stays MIT. +- **Chrome for Testing:** Google's official JSON endpoints + (googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json, Stable channel), archives + from storage.googleapis.com over HTTPS. Those endpoints publish no hashes, so the app checks the code signature after + unpacking (macOS `codesign --verify` with Google's team, Windows Authenticode); on Linux it checks the version banner. + Chrome for Testing is under Google's terms for Chrome / Chromium (BSD-style Chromium license plus third-party notices). +- **Progress and cancel** go through the same progress events as sidecar jobs; a cancelled download removes its + temporary file. + +## Develop + +```sh +bun install +bun tools/build-sidecars.ts --host # apps/desktop/src-tauri/binaries/storebound- +cd apps/desktop && bunx tauri dev # Vite on 127.0.0.1:1420 + the Rust host +cd apps/desktop/src-tauri && cargo test && cargo clippy --all-targets -- -D warnings +``` + +Dev-only switches (ignored in release builds): `VITE_START=accounts|app:catalog|...` opens a screen first, +`VITE_THEME=light|dark` forces the theme, `VITE_AUTORUN=gallery` starts the confirm flow on the first app. +Gotchas kept from earlier Tauri apps: Cmd+Q quits and the Window menu keeps an entry for the hidden window; platform +configs (`tauri..conf.json`) must not set `app.windows` (Tauri replaces the whole array); Linux sets +`WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user set it; a `bun build --compile` binary needs an ad-hoc re-sign on +macOS or the kernel kills it on start. diff --git a/package.json b/package.json index 9af7e85..15459e2 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "CHANGELOG.md", "docs/agents-core.md" ], + "workspaces": [ + "apps/*" + ], "engines": { "bun": ">=1.1" }, diff --git a/tools/ci.ts b/tools/ci.ts index d761bdf..8f4d7ca 100644 --- a/tools/ci.ts +++ b/tools/ci.ts @@ -1,16 +1,29 @@ #!/usr/bin/env bun // Everything CI runs, in order; stops at the first failure. bun run ci -const steps: [string, string[]][] = [ +// `bun test` covers the CLI and the desktop webview (apps/desktop/tests). The desktop Rust host (cargo test, clippy +// with warnings as errors) runs when cargo is installed; set STOREBOUND_CI_RUST=0 to skip it on a machine without Rust. +import { join } from 'node:path'; + +const root = new URL('..', import.meta.url).pathname; +const tauri = join(root, 'apps/desktop/src-tauri'); +const cargo = process.env.STOREBOUND_CI_RUST === '0' ? null : Bun.which('cargo'); +const steps: [string, string[], string?][] = [ ['adapters', ['bun', 'tools/gen-adapters.ts', '--check']], ['tests', ['bun', 'test']], ['typecheck', ['bunx', 'tsc', '--noEmit']], + ['desktop typecheck', ['bunx', 'tsc', '--noEmit', '-p', 'apps/desktop']], + ['desktop build', ['bun', 'run', '--cwd', 'apps/desktop', 'build']], + ...(cargo ? [ + ['desktop rust tests', [cargo, 'test', '--quiet'], tauri], + ['desktop clippy', [cargo, 'clippy', '--all-targets', '--quiet', '--', '-D', 'warnings'], tauri], + ] as [string, string[], string][] : []), ['scrub', ['bun', 'tools/scrub.ts']], ]; -const root = new URL('..', import.meta.url).pathname; -for (const [name, cmd] of steps) { +if (!cargo) console.log('ci: cargo not found (or STOREBOUND_CI_RUST=0): desktop Rust tests and clippy skipped'); +for (const [name, cmd, cwd] of steps) { const t = Date.now(); console.log(`\n== ${name}: ${cmd.join(' ')}`); - const r = Bun.spawnSync(cmd, { cwd: root, stdout: 'inherit', stderr: 'inherit' }); + const r = Bun.spawnSync(cmd, { cwd: cwd ?? root, stdout: 'inherit', stderr: 'inherit' }); if (r.exitCode !== 0) { console.error(`\nci: ${name} failed (exit ${r.exitCode})`); process.exit(1); } console.log(`== ${name} ok (${((Date.now() - t) / 1000).toFixed(1)}s)`); } diff --git a/tools/scrub.ts b/tools/scrub.ts index 14aa95c..e4d0ea9 100644 --- a/tools/scrub.ts +++ b/tools/scrub.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun -// Secrets / personal data scrub over every tracked (and staged-new) text file. Fails on any hit. +// Secrets / personal data scrub over every tracked (and staged-new) text file, the desktop app (apps/desktop) +// included. Fails on any hit. // bun tools/scrub.ts // Patterns are built from parts so this file does not match itself. import { readFileSync } from 'node:fs'; @@ -10,7 +11,7 @@ export interface Rule { id: string; re: RegExp; files?: RegExp; allow?: (m: stri const at = '@'; export const RULES: Rule[] = [ { id: 'email', re: new RegExp(`[A-Za-z0-9._%+-]+${at}[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`, 'g'), - allow: m => /@example\.(com|org|net)$/i.test(m) || /@users\.noreply\./.test(m) }, + allow: m => /@example\.(com|org|net)$/i.test(m) || /@users\.noreply\./.test(m) || /@[0-9]x\.(png|jpe?g|webp)$/.test(m) }, { id: 'private-key', re: new RegExp('-----BEGIN [A-Z ]*PRIVATE ' + 'KEY-----\\s*[A-Za-z0-9+/=]{20,}', 'g'), wholeFile: true }, { id: 'private_key-json', re: new RegExp('"private' + '_key"\\s*:\\s*"-----BEGIN', 'g') }, { id: 'asc-key-file', re: new RegExp('AuthKey' + '_[A-Z0-9]{10}\\.p8', 'g') }, @@ -21,9 +22,11 @@ export const RULES: Rule[] = [ { id: 'ip-address', re: /(? ['127.0.0.1', '0.0.0.0'].includes(m) }, { id: 'private-name', re: new RegExp(['Yon', 'ga'].join(''), 'gi') }, { id: 'google-api-key', re: new RegExp('AI' + 'za[0-9A-Za-z_-]{35}', 'g') }, + // absolute home folders leak a machine's account name (placeholders like /Users/you are fine) + { id: 'home-path', re: /(?:\/Users\/|\/home\/|[A-Z]:\\Users\\)(?!(?:you|me|user|runner|name|example)\b)[A-Za-z0-9._-]+/g }, ]; -const SKIP = /(^|\/)(bun\.lock|LICENSE)$|\.(png|jpe?g|webp|gif|ico|woff2?|ttf|otf|p8|p12|aab|ipa|zip)$/i; +const SKIP = /(^|\/)(bun\.lock|Cargo\.lock|LICENSE)$|\.(png|jpe?g|webp|gif|ico|icns|woff2?|ttf|otf|p8|p12|aab|ipa|zip)$/i; export function scan(files: { path: string; text: string }[]): { file: string; line: number; rule: string; match: string }[] { const hits: { file: string; line: number; rule: string; match: string }[] = []; From 94d2f3d6ca3d5e98b33314c1f1c9b46687601da6 Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 11 Oct 2026 02:15:48 +0800 Subject: [PATCH 4/8] Desktop: adapt to 0.5.0 (app.platforms, subscriptions, catalog diff), Dependencies polish, ~ for the home folder - inspect reports platforms, subscription ids and the catalog each store should sell; app identity and parity follow app.platforms and per-item platforms; catalog.diff joins the JSON methods - Portfolio: one Platforms column, compact catalog, paths truncated and shown with ~; the confirm dialog and run logs show ~ instead of the home folder --- CHANGELOG.md | 9 ++++++++- README.md | 3 ++- apps/desktop/src/screens/AppFolders.tsx | 2 +- apps/desktop/src/screens/Portfolio.tsx | 14 ++++++++++---- apps/desktop/src/styles.css | 6 +++++- cli/catalog.ts | 4 ++-- cli/inspect.ts | 14 +++++++++++--- cli/lib/desktop-protocol.ts | 9 +++++++-- cli/lib/portfolio.ts | 11 ++++++----- cli/serve.ts | 2 +- tests/portfolio.test.ts | 8 ++++++-- tests/serve.test.ts | 2 +- 12 files changed, 60 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 528072f..73ddfd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,19 @@ - **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` (many projects; `live` adds App Store and Play status). Writes are dry runs unless the host + 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. - `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. diff --git a/README.md b/README.md index 6022cea..cf6c315 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,8 @@ The first request is `session.hello` with a per-session secret only the host kno 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`). +`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 diff --git a/apps/desktop/src/screens/AppFolders.tsx b/apps/desktop/src/screens/AppFolders.tsx index 5b44722..3bb37ee 100644 --- a/apps/desktop/src/screens/AppFolders.tsx +++ b/apps/desktop/src/screens/AppFolders.tsx @@ -83,7 +83,7 @@ export function AppFolders({ data }: { data: Data }) { -
+
diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 1f8987a..c43153b 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -50,7 +50,7 @@ export function Portfolio({ data, open, go }: { data: Data; open: (p: Project) = ) : (
- + {data.projects.map(p => { const e = data.statusOf(p); @@ -60,11 +60,17 @@ export function Portfolio({ data, open, go }: { data: Data; open: (p: Project) = return ( - - + - + ); diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 5e9d49d..52d5d17 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -117,10 +117,14 @@ th { font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; color: th:first-child { border-top-left-radius: var(--radius); } th:last-child { border-top-right-radius: var(--radius); } tr:last-child td { border-bottom: 0; } -td .path { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; } +td .path { color: var(--muted); font-family: var(--mono); font-size: 12px; max-width: 220px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +td:first-child { min-width: 180px; } +.card:has(> table) { overflow-x: auto; } .name { font-weight: 650; } .nowrap { white-space: nowrap; } +.plat { display: grid; gap: 6px; font-size: 13px; } +.plat .tag { min-width: 64px; text-align: center; margin-right: 6px; } .tag { white-space: nowrap; display: inline-block; font: 700 10.5px/1 var(--mono); letter-spacing: .05em; padding: 5px 8px; border-radius: 999px; text-transform: uppercase; } .tag-ok { background: var(--ok-bg); color: var(--ok-ink); } .tag-local { background: var(--local-bg); color: var(--local-ink); } diff --git a/cli/catalog.ts b/cli/catalog.ts index 3de5fff..84f6904 100644 --- a/cli/catalog.ts +++ b/cli/catalog.ts @@ -9,7 +9,7 @@ import { appleCurrent, pages, playProduct } from './prices'; import { ascClient, type J } from './apple/asc'; import { type CatalogRow, catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from './lib/catalog'; import type { Ctx } from './lib/ctx'; -import { done, log } from './lib/log'; +import { done, log, printJson } from './lib/log'; import { fromMoney } from './lib/money'; import { listOneTimeProducts } from './play/products'; import { playClient } from './play/client'; @@ -94,7 +94,7 @@ export async function run(ctx: Ctx): Promise { const play = (!store || store === 'play') && config.play ? await playItems(ctx) : null; const { rows, parity } = catalogDiff(want, { apple, play }, { threshold: a.num('threshold', 10) / 100 }); const errors = rows.filter(r => r.level === 'error').length; - if (a.has('json')) console.log(JSON.stringify({ errors, rows, parity }, null, 2)); + if (a.has('json')) printJson({ errors, rows, parity }); else { console.log(formatRows(rows)); if (parity.length) console.log(`\nprice differences between the stores (same currency, over ${a.num('threshold', 10)} %):\n` + diff --git a/cli/inspect.ts b/cli/inspect.ts index 56ae93d..ec36d6b 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -1,11 +1,13 @@ // Offline summary of the project config: app name, store ids, languages, products, shots. No network, no credentials. // storebound inspect [--json] +import { platformsOf, sellsOn } from './lib/config'; import type { Ctx } from './lib/ctx'; import type { ProjectSummary } from './lib/desktop-protocol'; import { log, printJson } from './lib/log'; export function summarize(ctx: Ctx): ProjectSummary { const { config, root, path } = ctx.loaded; + const subs = (config.subscriptions ?? []).flatMap(g => g.subscriptions); return { dir: root, config: path, @@ -13,10 +15,16 @@ export function summarize(ctx: Ctx): ProjectSummary { languages: config.languages.map(l => l.id), play: config.play ? { packageName: config.play.packageName } : null, apple: config.apple ? { bundleId: config.apple.bundleId, appId: config.apple.appId ?? null } : null, + platforms: platformsOf(config), products: config.products?.length ?? 0, productIds: (config.products ?? []).map(p => p.id), - // subscriptions arrive in the config schema separately; counted when present - subscriptions: ((config as { subscriptions?: unknown[] }).subscriptions ?? []).length, + subscriptions: subs.length, + subscriptionIds: subs.map(s => s.id), + // the catalog each store should sell (items can be limited to one platform) + catalog: { + ios: config.apple ? [...(config.products ?? []), ...subs].filter(x => sellsOn(config, x, 'apple')).map(x => x.id) : [], + android: config.play ? [...(config.products ?? []), ...subs].filter(x => sellsOn(config, x, 'play')).map(x => x.id) : [], + }, shots: !!config.shots, testflightGroups: config.apple?.testflight?.groups?.length ?? 0, }; @@ -25,7 +33,7 @@ export function summarize(ctx: Ctx): ProjectSummary { export async function run(ctx: Ctx): Promise { const s = summarize(ctx); if (ctx.args.has('json')) { printJson(s); return; } - log('inspect', `${s.name} · ${s.languages.length} languages · ${s.products} products`); + log('inspect', `${s.name} · ${s.platforms.join(' + ')} · ${s.languages.length} languages · ${s.products} products · ${s.subscriptions} subscriptions`); log('inspect', `play ${s.play?.packageName ?? '-'} · apple ${s.apple?.bundleId ?? '-'}${s.apple?.appId ? ` (${s.apple.appId})` : ''}`); log('inspect', `config ${s.config}`); } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index abddce3..40a182b 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -78,11 +78,16 @@ export interface ProjectSummary { languages: string[]; play: { packageName: string } | null; apple: { bundleId: string; appId: string | null } | null; - /** One-time in-app products in the config (shared by both stores). */ + /** app.platforms (default: from the apple / play sections). */ + platforms: ('ios' | 'android')[]; + /** One-time in-app products in the config (one shared catalog). */ products: number; productIds: string[]; - /** Subscriptions in the config (0 until the config defines them). */ + /** Auto-renewable subscriptions in the config (all groups). */ subscriptions: number; + subscriptionIds: string[]; + /** Product and subscription ids each store should sell (items may be limited to one platform). */ + catalog: { ios: string[]; android: string[] }; shots: boolean; testflightGroups: number; } diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts index 4153ac7..76d2e64 100644 --- a/cli/lib/portfolio.ts +++ b/cli/lib/portfolio.ts @@ -10,8 +10,8 @@ export function appIdentity(s: ProjectSummary): AppIdentity { id: s.dir, name: s.name, platforms: { - ios: s.apple ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null, - android: s.play ? { packageName: s.play.packageName } : null, + ios: s.apple && s.platforms.includes('ios') ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null, + android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName } : null, }, }; } @@ -86,12 +86,13 @@ export const playProductIds = (data: unknown): string[] => (((data as J)?.produc * and config ids missing from a store the app is on. */ export function catalogParity(s: ProjectSummary, apple?: string[], play?: string[]): CatalogParity { - const ids = s.productIds; const live = apple !== undefined || play !== undefined; const onlyIos = apple && play ? apple.filter(x => !play.includes(x)) : []; const onlyAndroid = apple && play ? play.filter(x => !apple.includes(x)) : []; - const missingIos = s.apple && apple ? ids.filter(x => !apple.includes(x)) : []; - const missingAndroid = s.play && play ? ids.filter(x => !play.includes(x)) : []; + // the status reads list one-time products; subscriptions are compared by `catalog diff` (catalog.diff method) + const sold = (ids: string[]) => ids.filter(x => s.productIds.includes(x)); + const missingIos = apple ? sold(s.catalog.ios).filter(x => !apple.includes(x)) : []; + const missingAndroid = play ? sold(s.catalog.android).filter(x => !play.includes(x)) : []; return { checked: live ? 'live' : 'config', products: s.products, subscriptions: s.subscriptions, onlyIos, onlyAndroid, missingIos, missingAndroid, diff --git a/cli/serve.ts b/cli/serve.ts index 8db2777..46dced0 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -37,7 +37,7 @@ export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|com /** Read tools whose command prints a structured result with --json. */ export const JSON_TOOLS = new Set(['check', 'check_aso', 'validate', 'health', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status', - 'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status']); + 'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff']); /** `apple_testflight_status` -> `apple.testflight_status`: first underscore becomes the namespace dot. */ export const methodName = (tool: string): string => tool.replace('_', '.'); diff --git a/tests/portfolio.test.ts b/tests/portfolio.test.ts index cbd0837..d7e6ec7 100644 --- a/tests/portfolio.test.ts +++ b/tests/portfolio.test.ts @@ -5,7 +5,8 @@ import { appIdentity, applePhase, appleProductIds, appleReleases, catalogParity, const summary: ProjectSummary = { dir: '/games/demo', config: '/games/demo/storebound.config.ts', name: 'Demo', languages: ['en'], play: { packageName: 'com.example.demo' }, apple: { bundleId: 'com.example.demo', appId: null }, - products: 2, productIds: ['coins', 'no_ads'], subscriptions: 0, shots: false, testflightGroups: 0, + platforms: ['ios', 'android'], products: 2, productIds: ['coins', 'no_ads'], subscriptions: 1, subscriptionIds: ['pro_monthly'], + catalog: { ios: ['coins', 'no_ads', 'pro_monthly'], android: ['coins', 'no_ads', 'pro_monthly'] }, shots: false, testflightGroups: 0, }; const apple = { @@ -29,6 +30,7 @@ describe('portfolio (apps, release trains, catalog parity)', () => { test('identity: one app, both platforms', () => { expect(appIdentity(summary)).toEqual({ id: '/games/demo', name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo', ascAppId: null }, android: { packageName: 'com.example.demo' } } }); expect(appIdentity({ ...summary, apple: null }).platforms.ios).toBeNull(); + expect(appIdentity({ ...summary, platforms: ['android'] }).platforms.ios).toBeNull(); }); test('versions and phases', () => { expect(cmpVersion('2.10.0', '2.9.1')).toBeGreaterThan(0); @@ -59,9 +61,11 @@ describe('portfolio (apps, release trains, catalog parity)', () => { expect(onlyNext.next?.version).toBe('2.3.0'); }); test('catalog parity: config only offline, both stores live', () => { - expect(catalogParity(summary)).toEqual({ checked: 'config', products: 2, subscriptions: 0, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true }); + expect(catalogParity(summary)).toEqual({ checked: 'config', products: 2, subscriptions: 1, onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [], ok: true }); const live = catalogParity(summary, appleProductIds(apple), playProductIds(play)); expect(live).toMatchObject({ checked: 'live', onlyIos: ['ios_only'], onlyAndroid: ['no_ads', 'android_only'], missingIos: ['no_ads'], missingAndroid: [], ok: false }); + // an item sold on Android only is not missing on iOS + expect(catalogParity({ ...summary, catalog: { ios: ['coins'], android: ['coins', 'no_ads'] } }, appleProductIds(apple), playProductIds(play)).missingIos).toEqual([]); // one store unreadable: no cross-store claims expect(catalogParity(summary, undefined, playProductIds(play))).toMatchObject({ onlyIos: [], onlyAndroid: [], missingIos: [], missingAndroid: [] }); }); diff --git a/tests/serve.test.ts b/tests/serve.test.ts index f0cea41..93b7601 100644 --- a/tests/serve.test.ts +++ b/tests/serve.test.ts @@ -163,7 +163,7 @@ describe('serve --stdio: session', () => { expect(errOf(await pending).code).toBe(ERR.cancelled); }); test('portfolio.status: apps across platforms, release trains, catalog parity, per-app errors, progress n/total', async () => { - const summary = { dir: DEMO, name: 'Demo', apple: { bundleId: 'com.example.demo', appId: null }, play: { packageName: 'com.example.demo' }, products: 2, productIds: ['coins', 'no_ads'], subscriptions: 0 }; + const summary = { dir: DEMO, name: 'Demo', apple: { bundleId: 'com.example.demo', appId: null }, play: { packageName: 'com.example.demo' }, platforms: ['ios', 'android'], products: 2, productIds: ['coins', 'no_ads'], subscriptions: 0, subscriptionIds: [], catalog: { ios: ['coins', 'no_ads'], android: ['coins', 'no_ads'] } }; const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary } : argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } } : argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' } From 0ace432e87c394a3f1a40aa1d1ce830a0c9c80e6 Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 11 Oct 2026 03:34:45 +0800 Subject: [PATCH 5/8] CLI for desktop D2: doctor, inspect --icon / --listings, reviews drafts / approve, init --dry from embedded templates, iOS phased release share, playwright as optional peer (sidecar bundles playwright-core) --- CHANGELOG.md | 15 +++ README.md | 1 + cli/apple/status.ts | 12 ++- cli/doctor.ts | 52 +++++++++++ cli/index.ts | 1 + cli/init.ts | 35 ++++--- cli/inspect.ts | 16 +++- cli/lib/desktop-protocol.ts | 58 +++++++++++- cli/lib/portfolio.ts | 33 ++++++- cli/lib/project-files.ts | 180 ++++++++++++++++++++++++++++++++++++ cli/lib/template-files.ts | 51 ++++++++++ cli/lib/tools.ts | 25 +++++ cli/reviews.ts | 36 +++++++- cli/serve.ts | 44 ++++++++- cli/shots/browser.ts | 11 ++- cli/text-modules.d.ts | 4 + package.json | 9 ++ tests/desktop-d2.test.ts | 165 +++++++++++++++++++++++++++++++++ tests/portfolio.test.ts | 21 ++++- tests/serve.test.ts | 38 +++++++- tools/build-sidecars.ts | 38 +++++++- tsconfig.json | 3 +- 22 files changed, 808 insertions(+), 40 deletions(-) create mode 100644 cli/doctor.ts create mode 100644 cli/lib/project-files.ts create mode 100644 cli/lib/template-files.ts create mode 100644 cli/text-modules.d.ts create mode 100644 tests/desktop-d2.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ddfd8..6d47dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,21 @@ 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 [--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. diff --git a/README.md b/README.md index cf6c315..6b431da 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ run in the background with a log (`storebound shots render > .storebound/render. | `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 ]` | local | draft player-facing release notes from git log into both listing files | | `privacy-manifest --ios ` | 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 | diff --git a/cli/apple/status.ts b/cli/apple/status.ts index 31ef125..74ee204 100644 --- a/cli/apple/status.ts +++ b/cli/apple/status.ts @@ -54,7 +54,17 @@ export async function run(ctx: Ctx): Promise { const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`)); const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`); const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`); - if (ctx.args.has('json')) { printJson({ app, versions, builds: builds.data, iaps, subs }); return; } + if (ctx.args.has('json')) { + // phased release of the newest versions that are live or close to it (rollout share on the desktop timeline) + const phased: Record = {}; + for (const v of versions.slice(0, 3)) { + const st = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? ''); + if (!/READY_FOR_SALE|READY_FOR_DISTRIBUTION|PENDING_DEVELOPER_RELEASE|PENDING_APPLE_RELEASE|ACCEPTED/.test(st)) continue; + phased[v.id] = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data ?? null; + } + printJson({ app, versions, builds: builds.data, iaps, subs, phased }); + return; + } console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`); for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`); diff --git a/cli/doctor.ts b/cli/doctor.ts new file mode 100644 index 0000000..0ddf74c --- /dev/null +++ b/cli/doctor.ts @@ -0,0 +1,52 @@ +// What this machine has for storebound, and how to get what is missing. The same detection as the desktop app's +// Dependencies screen (lib/tools.ts). Store work (listings, products, releases, reviews) needs none of these tools; +// screenshots, app previews, icons and simulator / emulator captures do. Read-only: it prints commands, it runs none. +// storebound doctor [--dir .] [--json] +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import pkg from '../package.json' with { type: 'json' }; +import type { Args } from './lib/args'; +import { CONFIG_NAMES } from './lib/config'; +import { log } from './lib/log'; +import { textTable } from './lib/table'; +import { detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools'; + +export interface DoctorRow { + id: ToolStatus['id'] | 'playwright'; + name: string; + found: boolean; + version: string | null; + path: string | null; + source: ToolStatus['source']; + usedFor: string; + /** Install commands for this OS (empty when found). */ + install: string[]; + url: string | null; +} +export interface DoctorResult { storebound: string; bun: string; platform: string; project: string | null; tools: DoctorRow[]; missing: number } + +export function doctor(cwd: string, env: Record = process.env): DoctorResult { + const tools: DoctorRow[] = detectTools(env).map(t => ({ id: t.id, name: t.name, found: t.found, version: t.version, path: t.path, source: t.source, usedFor: t.usedFor, + install: t.found ? [] : installCommands(t.id), url: t.install.url })); + const pw = findPlaywright([cwd, join(import.meta.dir, '..')]); + tools.splice(3, 0, { id: 'playwright', name: 'playwright', found: !!pw, version: null, path: pw, source: pw ? 'system' : null, + usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' }); + const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null; + return { storebound: pkg.version, bun: Bun.version, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length }; +} + +export async function run(args: Args): Promise { + const r = doctor(resolve(args.str('dir', '.'))); + if (args.has('json')) { console.log(JSON.stringify(r, null, 2)); return; } + log('doctor', `storebound ${r.storebound} · bun ${r.bun} · ${r.platform}${r.project ? ' · project config found' : ''}`); + console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor]))); + const missing = r.tools.filter(t => !t.found); + if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; } + console.log('\nTo install what is missing (pick one per tool; storebound never runs these for you):'); + for (const t of missing) { + console.log(` ${t.name}`); + for (const c of t.install) console.log(` ${c}`); + if (t.url) console.log(` ${t.url}`); + } + log('doctor', `${missing.length} missing. Store work needs none of them; screenshots, previews, icons and captures do.`); +} diff --git a/cli/index.ts b/cli/index.ts index 58e8553..9986970 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -13,6 +13,7 @@ const PLAIN: Record Promise, string]> = { 'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'], 'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'], 'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'], + 'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'], 'privacy-manifest': [() => import('./privacy-manifest'), 'iOS PrivacyInfo.xcprivacy + required-reason API + SDK manifest scan (--ios )'], }; const COMMANDS: Record Promise, string]> = { diff --git a/cli/init.ts b/cli/init.ts index e5ba529..4d224fe 100644 --- a/cli/init.ts +++ b/cli/init.ts @@ -1,15 +1,16 @@ // Copies the templates into the current project without overwriting anything, and makes sure .storebound/ is ignored. -// storebound init [--dir .] [--profile app|game|subscription-app] +// storebound init [--dir .] [--profile app|game|subscription-app] [--dry] // Profiles (default app): app = utility / productivity app with an optional account and analytics, no ads; // game = free-to-play game with ads and in-app purchases (the templates storebound shipped before 0.5.0); // subscription-app = accounts plus auto-renewable subscriptions. Each profile has its own config, declarations, // App Privacy answers, Data safety section, listing copy and screenshot copy; every answer is a starting point to edit. // Lookup per file: templates/profiles//, then the profile it builds on, then templates/. -import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; import type { Args } from './lib/args'; import { type AppProfile, PROFILES } from './lib/config'; import { log } from './lib/log'; +import { TEMPLATE_TEXT } from './lib/template-files'; const T = join(import.meta.dir, '..', 'templates'); export const FILES: [string, string][] = [ @@ -24,33 +25,43 @@ export const FILES: [string, string][] = [ ['testers.csv', '.storebound/testers.csv'], ]; -/** Template folders a profile reads from, most specific first. game = the root templates. */ +/** Template folders a profile reads from (relative to templates/), most specific first. game = the root templates. */ const CHAIN: Record = { - 'game': [T], - 'app': [join(T, 'profiles', 'app'), T], - 'subscription-app': [join(T, 'profiles', 'subscription-app'), join(T, 'profiles', 'app'), T], + 'game': [''], + 'app': ['profiles/app/', ''], + 'subscription-app': ['profiles/subscription-app/', 'profiles/app/', ''], }; -/** Source file of one template for a profile. Pure apart from existsSync. */ -export function templateFor(profile: AppProfile, name: string): string { - for (const dir of CHAIN[profile]) if (existsSync(join(dir, name))) return join(dir, name); +/** Key (path under templates/) of one template for a profile. Pure: looks it up in the embedded list. */ +export function templateKey(profile: AppProfile, name: string): string { + for (const dir of CHAIN[profile]) if (TEMPLATE_TEXT[dir + name] !== undefined) return dir + name; throw new Error(`template ${name} not found`); } +/** Source file of one template for a profile (in the storebound folder; the compiled sidecar has none). */ +export const templateFor = (profile: AppProfile, name: string): string => join(T, ...templateKey(profile, name).split('/')); + +/** Template text: the embedded copy (the same bytes as the file; tests/apps.test.ts checks it). */ +export const templateText = (profile: AppProfile, name: string): string => TEMPLATE_TEXT[templateKey(profile, name)]!; + export async function run(args: Args): Promise { const root = resolve(args.str('dir', '.')); const profile = args.str('profile', 'app') as AppProfile; if (!PROFILES.includes(profile)) throw new Error(`--profile ${profile}: one of ${PROFILES.join(', ')}`); - log('init', `profile ${profile} (templates are starting points: edit every answer to match your app)`); + const dry = args.has('dry'); + log('init', `profile ${profile}${dry ? ' (dry run: nothing is written)' : ''} (templates are starting points: edit every answer to match your app)`); for (const [from, to] of FILES) { const dest = join(root, to); + if (relative(root, dest).startsWith('..')) throw new Error(`template target outside the project: ${to}`); if (existsSync(dest)) { log('init', `keep ${to} (exists)`); continue; } + if (dry) { log('init', `would create ${to}`); continue; } mkdirSync(dirname(dest), { recursive: true }); - copyFileSync(templateFor(profile, from), dest); + writeFileSync(dest, templateText(profile, from)); log('init', `create ${to}`); } const gi = join(root, '.gitignore'); const cur = existsSync(gi) ? readFileSync(gi, 'utf8') : ''; + if (dry) { if (!/^\/?\.storebound\/?$/m.test(cur)) log('init', 'would add .storebound/ to .gitignore'); return; } if (!/^\/?\.storebound\/?$/m.test(cur)) { if (cur) appendFileSync(gi, `${cur.endsWith('\n') ? '' : '\n'}# storebound: credentials, fonts, temp files\n.storebound/\n`); else writeFileSync(gi, '# storebound: credentials, fonts, temp files\n.storebound/\n'); diff --git a/cli/inspect.ts b/cli/inspect.ts index ec36d6b..22520f3 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -1,9 +1,12 @@ // Offline summary of the project config: app name, store ids, languages, products, shots. No network, no credentials. // storebound inspect [--json] -import { platformsOf, sellsOn } from './lib/config'; +// storebound inspect --icon --json the app icon as a data: URL (for the desktop app) +// storebound inspect --listings [--json] listing completeness per language on both stores (+ rendered screenshots) +import { platformsOf, profileOf, sellsOn } from './lib/config'; import type { Ctx } from './lib/ctx'; import type { ProjectSummary } from './lib/desktop-protocol'; import { log, printJson } from './lib/log'; +import { findIcon, iconDataUrl, listingCompleteness } from './lib/project-files'; export function summarize(ctx: Ctx): ProjectSummary { const { config, root, path } = ctx.loaded; @@ -27,10 +30,21 @@ export function summarize(ctx: Ctx): ProjectSummary { }, shots: !!config.shots, testflightGroups: config.apple?.testflight?.groups?.length ?? 0, + profile: profileOf(config), + icon: findIcon(config, root), }; } export async function run(ctx: Ctx): Promise { + const { config, root } = ctx.loaded; + if (ctx.args.has('icon')) { printJson(iconDataUrl(findIcon(config, root))); return; } + if (ctx.args.has('listings')) { + const l = listingCompleteness(config, root); + if (ctx.args.has('json')) { printJson(l); return; } + for (const p of l.problems) log('inspect', p); + for (const x of l.locales) log('inspect', `${x.lang.padEnd(6)} ${Math.round(x.complete * 100)} % complete${x.screenshots === null ? '' : ` · ${x.screenshots} screenshots`}`); + return; + } const s = summarize(ctx); if (ctx.args.has('json')) { printJson(s); return; } log('inspect', `${s.name} · ${s.platforms.join(' + ')} · ${s.languages.length} languages · ${s.products} products · ${s.subscriptions} subscriptions`); diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 40a182b..ac8019c 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -90,6 +90,10 @@ export interface ProjectSummary { catalog: { ios: string[]; android: string[] }; shots: boolean; testflightGroups: number; + /** App profile from the config (app, game, subscription-app). */ + profile?: string; + /** Absolute path of the app icon the config or the usual places point at (project.icon reads it), or null. */ + icon?: string | null; } // ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ---- @@ -112,10 +116,14 @@ export interface StoreRelease { /** The store's own state word (READY_FOR_SALE, inProgress ...). */ state: string; phase: ReleasePhase; - /** Android staged rollout user fraction (0-1); iOS phased release percentage comes with D2. */ + /** Rollout share (0-1): Android staged rollout user fraction, iOS phased release day -> 1, 2, 5, 10, 20, 50, 100 %. */ fraction: number | null; /** Android track (production, beta, internal ...). */ track: string | null; + /** iOS phased release: day 1-7 and its state (ACTIVE, PAUSED, COMPLETE); null without one. */ + phased?: { state: string; day: number | null } | null; + /** When the store record was made (iOS version createdDate), if the store says. */ + date?: string | null; } /** A release train: one version (e.g. 2.3.0) across both stores. */ @@ -143,8 +151,8 @@ export interface AppStatus { app: AppIdentity | null; summary: ProjectSummary | null; check: { ok: boolean; problems: string[]; warnings: string[] } | null; - /** Release trains; `source: none` offline (no store data read). */ - release: { source: 'none' | 'live'; current: ReleaseTrain | null; next: ReleaseTrain | null }; + /** Release trains; `source: none` offline (no store data read). `all`: every store release read (live only). */ + release: { source: 'none' | 'live'; current: ReleaseTrain | null; next: ReleaseTrain | null; all?: StoreRelease[] }; catalog: CatalogParity | null; /** live only: per-platform read errors ("unknown, never false"). */ storeErrors: { ios?: string; android?: string }; @@ -152,3 +160,47 @@ export interface AppStatus { } export interface PortfolioResult { apps: AppStatus[]; at: string } + +// ---- Offline project reads (D2) ---- + +/** One listing field of one language on one store: length against the store limit. */ +export interface ListingField { field: string; length: number; limit: number; required: boolean; state: 'ok' | 'missing' | 'over' | 'empty-optional' } + +/** Listing completeness of one language (config language id) on both stores. */ +export interface ListingLocale { + lang: string; + rtl: boolean; + play: { code: string | null; fields: ListingField[] } | null; + apple: { code: string | null; fields: ListingField[] } | null; + /** Rendered screenshots found for this language (renders folder), null when there is no renders folder. */ + screenshots: number | null; + /** Required fields present and within limits, 0-1 (both stores). */ + complete: number; +} + +export interface ListingsResult { + files: { play: string | null; apple: string | null }; + /** File-level problems (missing file, unreadable section). */ + problems: string[]; + locales: ListingLocale[]; +} + +/** A local review reply draft (.storebound/replies/-.md) with its checks. */ +export interface ReplyDraft { + file: string; + store: 'apple' | 'play'; + reviewId: string; + approved: boolean; + sent: string | null; + reply: string; + rating: number | null; + title: string | null; + body: string | null; + date: string | null; + issues: { level: 'error' | 'warn'; msg: string }[]; +} + +export interface DraftsResult { dir: string; drafts: ReplyDraft[]; problems: string[] } + +/** project.icon: the icon as a data: URL (PNG / JPEG / WebP / SVG, at most 1.5 MB), or null. */ +export interface IconResult { path: string | null; dataUrl: string | null } diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts index 76d2e64..848b326 100644 --- a/cli/lib/portfolio.ts +++ b/cli/lib/portfolio.ts @@ -33,13 +33,36 @@ export function applePhase(state: string): ReleasePhase { return 'other'; } +/** Apple's phased release schedule: share of users with automatic updates on day 1-7. */ +export const PHASED_SCHEDULE = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1] as const; + +/** Phased release of a version (`apple status --json` phased[versionId]) -> rollout share, or null. */ +export function phasedFraction(p: { state?: string; currentDayNumber?: number | null } | null | undefined): number | null { + if (!p?.state) return null; + if (p.state === 'COMPLETE') return 1; + const d = typeof p.currentDayNumber === 'number' ? Math.min(7, Math.max(1, Math.round(p.currentDayNumber))) : null; + return d ? PHASED_SCHEDULE[d - 1]! : p.state === 'INACTIVE' ? 0 : null; +} + export function appleReleases(data: unknown): StoreRelease[] { const versions = ((data as J)?.versions ?? []) as J[]; const builds = new Map((((data as J)?.builds ?? []) as J[]).map(b => [b.id, b.attributes?.version] as const)); + const phasedById = (((data as J)?.phased ?? {}) as Record); return versions.filter(v => typeof v?.attributes?.versionString === 'string' && (v.attributes.platform ?? 'IOS') === 'IOS').map(v => { const state = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? 'UNKNOWN'); const buildId = v.relationships?.build?.data?.id; - return { store: 'ios', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase: applePhase(state), fraction: null, track: null }; + let phase = applePhase(state); + const pr = (typeof v.id === 'string' ? phasedById[v.id] : null) ?? null; + const prState = typeof pr?.attributes?.phasedReleaseState === 'string' ? String(pr.attributes.phasedReleaseState) : null; + const day = typeof pr?.attributes?.currentDayNumber === 'number' ? pr.attributes.currentDayNumber as number : null; + const fraction = phase === 'live' && prState ? phasedFraction({ state: prState, currentDayNumber: day }) : null; + // live but still rolling out (phased release on, not complete): the same as a Play staged rollout + if (phase === 'live' && prState === 'ACTIVE') phase = 'rollout'; + if (phase === 'live' && prState === 'PAUSED') phase = 'halted'; + const r: StoreRelease = { store: 'ios', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase, fraction, track: null }; + if (prState) r.phased = { state: prState, day }; + if (typeof v.attributes.createdDate === 'string') r.date = v.attributes.createdDate; + return r; }); } @@ -65,14 +88,14 @@ const PHASE_RANK: Record = { live: 0, rollout: 1, halted: const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase])[0] ?? null; /** - * Release trains: one version across both stores. current = the highest version that is live (fully) on a store; - * next = the highest version above it on either store (review, testing, rollout, draft ...). A store without that - * version has null in the train. + * Release trains: one version across both stores. current = the highest version that reaches users on a store (live, + * or rolling out: a staged rollout or an iOS phased release, also when halted / paused); next = the highest version + * above it on either store (review, testing, draft ...). A store without that version has null in the train. */ export function releaseTrains(ios: StoreRelease[], android: StoreRelease[]): { current: ReleaseTrain | null; next: ReleaseTrain | null } { const all = [...ios, ...android].filter((r): r is StoreRelease & { version: string } => !!r.version); const train = (v: string): ReleaseTrain => ({ version: v, ios: pick(all.filter(r => r.store === 'ios' && r.version === v)), android: pick(all.filter(r => r.store === 'android' && r.version === v)) }); - const live = all.filter(r => r.phase === 'live').map(r => r.version).sort(cmpVersion).at(-1) ?? null; + const live = all.filter(r => r.phase === 'live' || r.phase === 'rollout' || r.phase === 'halted').map(r => r.version).sort(cmpVersion).at(-1) ?? null; const above = all.filter(r => !live || cmpVersion(r.version, live) > 0).map(r => r.version).sort(cmpVersion).at(-1) ?? null; return { current: live ? train(live) : null, next: above ? train(above) : null }; } diff --git a/cli/lib/project-files.ts b/cli/lib/project-files.ts new file mode 100644 index 0000000..eb6276e --- /dev/null +++ b/cli/lib/project-files.ts @@ -0,0 +1,180 @@ +// Offline reads of a project's own files for the desktop app (serve.ts): the app icon, listing completeness per +// language on both stores, and the local review reply drafts. Tolerant on purpose: a broken file becomes a problem +// line, never an exception, so one bad section does not hide the rest ("unknown, never false"). +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { extname, isAbsolute, join, resolve } from 'node:path'; +import { type ShipConfig, playCodes } from './config'; +import { APPLE_LIMITS, len, PLAY_LIMITS } from './copy'; +import type { DraftsResult, IconResult, ListingField, ListingLocale, ListingsResult, ReplyDraft } from './desktop-protocol'; +import { parseFrontMatter, stringifyFrontMatter } from './frontmatter'; +import { checkReply, parseDraft } from './reviews'; + +const IMAGE = /\.(png|jpe?g|webp|svg)$/i; +export const ICON_MAX_BYTES = 1_500_000; + +/** Icon candidates, most specific first: icons.composite, the rendered store icons, play.icon, the usual places. */ +export function iconCandidates(config: Pick, root: string): string[] { + const r = (p: string) => (isAbsolute(p) ? p : resolve(root, p)); + const out: string[] = []; + if (config.icons?.composite) out.push(r(config.icons.composite)); + if (config.icons?.out) out.push(r(join(config.icons.out, 'app-store-1024.png')), r(join(config.icons.out, 'play-512.png'))); + if (config.play?.icon) out.push(r(config.play.icon)); + for (const p of ['store/icons/app-store-1024.png', 'store/icons/play-512.png', 'store/icon.png', 'store/icon-1024.png', 'icon.png', 'assets/icon.png', + 'resources/icon.png', 'assets/icon-only.png', 'public/icon-512.png', 'web/icon-512.png']) out.push(r(p)); + return [...new Set(out)]; +} + +/** First candidate that exists and is an image file, or null. */ +export function findIcon(config: Pick, root: string): string | null { + for (const p of iconCandidates(config, root)) { + try { if (IMAGE.test(p) && statSync(p).isFile()) return p; } catch { /* next */ } + } + return null; +} + +const MIME: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml' }; + +/** The icon as a data: URL for the desktop window (its CSP allows data: images only). Too big or unreadable -> null. */ +export function iconDataUrl(path: string | null): IconResult { + if (!path) return { path: null, dataUrl: null }; + try { + const st = statSync(path); + if (!st.isFile() || st.size > ICON_MAX_BYTES) return { path, dataUrl: null }; + const mime = MIME[extname(path).toLowerCase()]; + if (!mime) return { path, dataUrl: null }; + return { path, dataUrl: `data:${mime};base64,${readFileSync(path).toString('base64')}` }; + } catch { return { path, dataUrl: null }; } +} + +/** "### heading" sub-sections of a "## " section with their first fenced block (like lib/copy.ts, never throws). */ +function blocks(section: string): { heading: string; text: string }[] { + const out: { heading: string; text: string }[] = []; + for (const part of section.split(/^### /m).slice(1)) { + const heading = part.split('\n')[0]!.trim(); + const m = /^```[^\n]*\n([\s\S]*?)\n```/m.exec(part); + out.push({ heading, text: m ? m[1]!.trim() : '' }); + } + return out; +} + +function field(name: string, text: string | undefined, limit: number, required: boolean): ListingField { + const n = text ? len(text) : 0; + const state: ListingField['state'] = !n ? (required ? 'missing' : 'empty-optional') : n > limit ? 'over' : 'ok'; + return { field: name, length: n, limit, required, state }; +} + +/** Play sections by Play language code: { title, short, full, whatsNew } texts. */ +export function playSections(md: string): Map> { + const out = new Map>(); + for (const sec of md.split(/^## /m).slice(1)) { + const m = /^$/m.exec(sec); + if (!m) continue; + const bs = blocks(sec); + const find = (re: RegExp): string | undefined => bs.find(b => re.test(b.heading))?.text; + const f = { title: find(/\btitle\b/i), short: find(/\bshort\b/i), full: find(/\bfull\b/i), whatsNew: find(/what'?s new|release notes/i) }; + for (const lang of m[1]!.split(',').map(s => s.trim()).filter(Boolean)) out.set(lang, f); + } + return out; +} + +/** App Store sections by App Store Connect locale: field -> text. */ +export function appleSections(md: string): Map> { + const out = new Map>(); + for (const sec of md.split(/^## /m).slice(1)) { + const locale = sec.split(/[\s·]/)[0]!.trim(); + if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(locale)) continue; + const f: Record = {}; + for (const b of blocks(sec)) if (b.heading in APPLE_LIMITS) f[b.heading] = b.text; + out.set(locale, f); + } + return out; +} + +function countImages(dir: string): number { + let n = 0; + try { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) n += countImages(join(dir, e.name)); + else if (/\.(png|jpe?g)$/i.test(e.name)) n++; + } + } catch { /* unreadable: 0 */ } + return n; +} + +/** Listing completeness per config language, both stores, plus rendered screenshots per language. */ +export function listingCompleteness(config: ShipConfig, root: string): ListingsResult { + const problems: string[] = []; + const read = (rel: string | undefined, fallback: string): { path: string | null; text: string | null } => { + const p = resolve(root, rel ?? fallback); + if (!existsSync(p)) { problems.push(`${rel ?? fallback}: file not found`); return { path: null, text: null }; } + try { return { path: p, text: readFileSync(p, 'utf8') }; } catch (e) { problems.push(`${rel ?? fallback}: ${(e as Error).message}`); return { path: p, text: null }; } + }; + const playFile = config.play ? read(config.play.listing, 'store/listing.md') : { path: null, text: null }; + const appleFile = config.apple ? read(config.apple.listing, 'store/appstore.md') : { path: null, text: null }; + const play = playFile.text !== null ? playSections(playFile.text) : null; + const apple = appleFile.text !== null ? appleSections(appleFile.text) : null; + const rendersDir = resolve(root, config.renders ?? config.shots?.out ?? 'store/renders'); + const hasRenders = existsSync(rendersDir); + const locales: ListingLocale[] = config.languages.map(l => { + let req = 0, good = 0; + const tally = (fs: ListingField[]) => { for (const f of fs) if (f.required) { req++; if (f.state === 'ok') good++; } return fs; }; + const pc = config.play ? playCodes(l)[0] ?? null : null; + const ps = pc && play ? play.get(pc) : undefined; + const playPart = config.play ? { + code: pc, + fields: tally([ + field('title', ps?.title ?? (ps ? config.app.name : undefined), PLAY_LIMITS.title, true), + field('short', ps?.short, PLAY_LIMITS.short, true), + field('full', ps?.full, PLAY_LIMITS.full, true), + field('whatsNew', ps?.whatsNew, PLAY_LIMITS.whatsNew, false), + ]), + } : null; + const ac = config.apple ? l.apple ?? null : null; + const as = ac && apple ? apple.get(ac) : undefined; + const applePart = config.apple ? { + code: ac, + fields: tally([ + field('name', as?.name ?? (as ? config.app.name : undefined), APPLE_LIMITS.name, true), + field('subtitle', as?.subtitle, APPLE_LIMITS.subtitle, true), + field('promotionalText', as?.promotionalText, APPLE_LIMITS.promotionalText, true), + field('keywords', as?.keywords, APPLE_LIMITS.keywords, true), + field('description', as?.description, APPLE_LIMITS.description, true), + field('whatsNew', as?.whatsNew, APPLE_LIMITS.whatsNew, false), + ]), + } : null; + const screenshots = hasRenders ? countImages(join(rendersDir, l.id)) : null; + return { lang: l.id, rtl: !!l.rtl, play: playPart, apple: applePart, screenshots, complete: req ? good / req : 0 }; + }); + return { files: { play: playFile.path, apple: appleFile.path }, problems, locales }; +} + +/** Local reply drafts with their send checks. Unparsable files become problems. */ +export function readReplyDrafts(dir: string): DraftsResult { + const out: DraftsResult = { dir, drafts: [], problems: [] }; + if (!existsSync(dir)) return out; + for (const name of readdirSync(dir).filter(n => n.endsWith('.md')).sort()) { + const file = join(dir, name); + try { + const text = readFileSync(file, 'utf8'); + const d = parseDraft(file, text); + const { data, body } = parseFrontMatter(text); + const quote = body.split(/^## Reply\s*$/m)[0]!.split('\n').filter(l => l.startsWith('>')).map(l => l.replace(/^>\s?/, '')).join('\n').trim(); + const num = (v: unknown): number | null => (typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : null); + const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null); + const draft: ReplyDraft = { + file, store: d.store, reviewId: d.reviewId, approved: d.approved, sent: d.sent, reply: d.reply, + rating: num(data.rating), title: str(data.title) ?? (/^# [★☆]*\s*(.+)$/m.exec(body)?.[1]?.trim() || null), body: quote || null, date: str(data.date), + issues: d.reply ? checkReply(d.reply, d.store) : [], + }; + out.drafts.push(draft); + } catch (e) { out.problems.push(`${name}: ${(e as Error).message}`); } + } + return out; +} + +/** Front matter `approved: ` set in a draft file text; the body unchanged. */ +export function setApproved(text: string, approved: boolean): string { + const { data, body } = parseFrontMatter(text); + if (data.store !== 'apple' && data.store !== 'play') throw new Error('not a review reply draft (front matter "store" missing)'); + return stringifyFrontMatter({ ...data, approved }, body); +} diff --git a/cli/lib/template-files.ts b/cli/lib/template-files.ts new file mode 100644 index 0000000..9ba695a --- /dev/null +++ b/cli/lib/template-files.ts @@ -0,0 +1,51 @@ +// Every file under templates/, embedded as text so `storebound init` also works from the compiled desktop sidecar +// (a `bun build --compile` binary has no templates folder next to it). tests/apps.test.ts checks this list against the +// folder: add a line here when a template is added. +import t0 from '../../templates/app-privacy.md' with { type: 'text' }; +import t1 from '../../templates/appstore.md' with { type: 'text' }; +import t2 from '../../templates/credentials.example.json' with { type: 'text' }; +import t3 from '../../templates/data-safety.csv' with { type: 'text' }; +import t4 from '../../templates/declarations.md' with { type: 'text' }; +import t5 from '../../templates/listing.md' with { type: 'text' }; +import t6 from '../../templates/privacy-policy.html' with { type: 'text' }; +import t7 from '../../templates/profiles/app/app-privacy.md' with { type: 'text' }; +import t8 from '../../templates/profiles/app/appstore.md' with { type: 'text' }; +import t9 from '../../templates/profiles/app/declarations.md' with { type: 'text' }; +import t10 from '../../templates/profiles/app/listing.md' with { type: 'text' }; +import t11 from '../../templates/profiles/app/privacy-policy.html' with { type: 'text' }; +import t12 from '../../templates/profiles/app/shots-copy.json' with { type: 'text' }; +import t13 from '../../templates/profiles/app/storebound.config.ts' with { type: 'text' }; +import t14 from '../../templates/profiles/subscription-app/app-privacy.md' with { type: 'text' }; +import t15 from '../../templates/profiles/subscription-app/appstore.md' with { type: 'text' }; +import t16 from '../../templates/profiles/subscription-app/declarations.md' with { type: 'text' }; +import t17 from '../../templates/profiles/subscription-app/listing.md' with { type: 'text' }; +import t18 from '../../templates/profiles/subscription-app/storebound.config.ts' with { type: 'text' }; +import t19 from '../../templates/shots-copy.json' with { type: 'text' }; +import t20 from '../../templates/storebound.config.ts' with { type: 'text' }; +import t21 from '../../templates/testers.csv' with { type: 'text' }; + +/** Template text by its path under templates/ (forward slashes). */ +export const TEMPLATE_TEXT: Record = { + 'app-privacy.md': t0 as unknown as string, + 'appstore.md': t1 as unknown as string, + 'credentials.example.json': t2 as unknown as string, + 'data-safety.csv': t3 as unknown as string, + 'declarations.md': t4 as unknown as string, + 'listing.md': t5 as unknown as string, + 'privacy-policy.html': t6 as unknown as string, + 'profiles/app/app-privacy.md': t7 as unknown as string, + 'profiles/app/appstore.md': t8 as unknown as string, + 'profiles/app/declarations.md': t9 as unknown as string, + 'profiles/app/listing.md': t10 as unknown as string, + 'profiles/app/privacy-policy.html': t11 as unknown as string, + 'profiles/app/shots-copy.json': t12 as unknown as string, + 'profiles/app/storebound.config.ts': t13 as unknown as string, + 'profiles/subscription-app/app-privacy.md': t14 as unknown as string, + 'profiles/subscription-app/appstore.md': t15 as unknown as string, + 'profiles/subscription-app/declarations.md': t16 as unknown as string, + 'profiles/subscription-app/listing.md': t17 as unknown as string, + 'profiles/subscription-app/storebound.config.ts': t18 as unknown as string, + 'shots-copy.json': t19 as unknown as string, + 'storebound.config.ts': t20 as unknown as string, + 'testers.csv': t21 as unknown as string, +}; diff --git a/cli/lib/tools.ts b/cli/lib/tools.ts index a3960cb..2e145c2 100644 --- a/cli/lib/tools.ts +++ b/cli/lib/tools.ts @@ -88,3 +88,28 @@ export function detectTools(env: Record = process.en out.push(bin('git', 'git', 'release notes from history, analytics compare', { kind: 'link', url: 'https://git-scm.com/downloads', note: 'Install git from your OS package manager.' }, ['--version'])); return out; } + +/** + * Commands that install a tool on this OS (shown by `storebound doctor` and the desktop Dependencies screen, never + * run by storebound). Package managers only; the desktop app can also download ffmpeg and Chrome for Testing itself. + */ +export function installCommands(id: ToolId | 'playwright', platform: NodeJS.Platform = process.platform): string[] { + const mac = platform === 'darwin', win = platform === 'win32'; + switch (id) { + case 'ffmpeg': case 'ffprobe': return mac ? ['brew install ffmpeg'] : win ? ['winget install --id Gyan.FFmpeg'] : ['sudo apt install ffmpeg', 'sudo dnf install ffmpeg']; + case 'chrome': return ['bunx playwright install chromium', ...(mac ? ['brew install --cask google-chrome'] : win ? ['winget install --id Google.Chrome'] : [])]; + case 'playwright': return ['bun add -d playwright']; + case 'xcode': return mac ? ['xcode-select --install'] : []; + case 'simctl': return mac ? ['Install Xcode from the App Store, then: sudo xcode-select -s /Applications/Xcode.app'] : []; + case 'adb': return mac ? ['brew install --cask android-platform-tools'] : win ? ['winget install --id Google.PlatformTools'] : ['sudo apt install adb']; + case 'git': return mac ? ['xcode-select --install', 'brew install git'] : win ? ['winget install --id Git.Git'] : ['sudo apt install git']; + } +} + +/** Where playwright resolves from (the project, then the storebound folder), or null. */ +export function findPlaywright(bases: string[]): string | null { + for (const base of bases) { + try { return Bun.resolveSync('playwright', base); } catch { /* next */ } + } + return null; +} diff --git a/cli/reviews.ts b/cli/reviews.ts index 0df436a..832bbb5 100644 --- a/cli/reviews.ts +++ b/cli/reviews.ts @@ -2,6 +2,8 @@ // storebound reviews list [--store apple|play] [--days 14] [--unanswered] [--rating 1,2] [--json] // storebound reviews draft [--store apple|play] [--days 14] [--rating 1,2,3] [--all] [--dir .storebound/replies] [--dry] // storebound reviews send [--dir .storebound/replies] [--dry] [--yes] +// storebound reviews drafts [--json] the local drafts with the checks `send` applies (offline) +// storebound reviews approve [--undo] [--dry] sets approved: true (false) in one draft (local file) // `draft` writes one Markdown file per unanswered review into .storebound/replies/ (gitignored): front matter // (store, review_id, rating, approved: false), the review, and an empty "## Reply" section. Existing files are never // overwritten (your edits stay). The owner or an agent with the review-replies skill writes the reply; the owner sets @@ -9,7 +11,7 @@ // confirmation, then writes `sent:
AppiOSAndroidLiveNextCatalogConfig
AppPlatformsLiveNextCatalogConfig
{shortPath(p.path, data.home)}
{ios ? ios.bundleId : none}{e?.storeErrors.ios &&
unreadable
}
{android ? android.packageName : none}{e?.storeErrors.android &&
unreadable
}
+ {e && ( +
+
iOS {ios?.bundleId ?? 'not set up'}{e.storeErrors.ios && unreadable}
+
Android {android?.packageName ?? 'not set up'}{e.storeErrors.android && unreadable}
+
+ )} +
{e ? trainText(e.release.current, e.release.source) : ''} {e ? trainText(e.release.next, e.release.source) : ''}{cat ? {cat.products} products · {cat.subscriptions} subs : ''}{cat ? {cat.products} · {cat.subscriptions} : ''} {st.text}