Compare commits
No commits in common. "main" and "v0.8.2" have entirely different histories.
|
|
@ -9,7 +9,7 @@
|
|||
"name": "storebound",
|
||||
"source": ".",
|
||||
"description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.",
|
||||
"version": "0.8.77",
|
||||
"version": "0.8.2",
|
||||
"category": "development",
|
||||
"tags": [
|
||||
"mobile",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "storebound",
|
||||
"version": "0.8.77",
|
||||
"version": "0.8.2",
|
||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||
"author": {
|
||||
"name": "CRTkafa"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "storebound",
|
||||
"version": "0.8.77",
|
||||
"version": "0.8.2",
|
||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||
"author": {
|
||||
"name": "CRTkafa"
|
||||
|
|
|
|||
|
|
@ -20,16 +20,6 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
|
|||
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
|
||||
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
|
||||
these you prepare everything, show the dry run, and the owner presses one button or says yes.
|
||||
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
|
||||
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
|
||||
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
|
||||
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
|
||||
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
|
||||
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
|
||||
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
|
||||
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
|
||||
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
|
||||
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
|
||||
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
|
||||
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
|
||||
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
|
||||
|
|
@ -78,9 +68,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
@ -104,7 +91,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
|
||||
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default
|
||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||
- `skills/game-services/SKILL.md`: Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section
|
||||
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
|
||||
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
||||
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# `bun run ci` after .forgejo/actions/linux-setup: the target / timings caches, bun install, Node 20 (the npm bundle's oldest supported Node) and the full CI. Shared by ci.yml and release.yml.
|
||||
# `bun run ci` after .forgejo/actions/linux-setup: the target / timings / Playwright caches, bun install, headless
|
||||
# Chromium, Node 20 (the npm bundle's oldest supported Node) and the full CI. Shared by ci.yml and release.yml.
|
||||
name: ci-run
|
||||
description: storebound's full CI (bun run ci) on the Linux runner
|
||||
runs:
|
||||
|
|
@ -20,12 +21,25 @@ runs:
|
|||
run: |
|
||||
bun --version && cargo --version
|
||||
bun install --frozen-lockfile
|
||||
# Playwright's Chromium and headless shell (~280 MB), keyed by the browser builds the installed playwright-core
|
||||
# wants (its browsers.json); the system libraries and fonts (--with-deps) are installed on every run, the job
|
||||
# container starts clean
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('node_modules/playwright-core/browsers.json') }}
|
||||
restore-keys: playwright-${{ runner.os }}-
|
||||
# the job runs as root in a container: storebound adds --no-sandbox to the Chromium it starts itself there
|
||||
# (cli/shots/browser.ts sandboxArgs), Playwright's own launches already pass it
|
||||
- name: Headless Chromium (browser tests, desktop boot check)
|
||||
shell: bash
|
||||
run: bunx playwright install --with-deps chromium
|
||||
# the npm bundle runs on Node 20+: tools/ci.ts smoke-tests dist/storebound.mjs with the oldest supported Node
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
# no browser tests in CI (tests/browser/ runs only with `bun run test:browser`); the changes of this push (against
|
||||
# its parent; the checkout has fetch-depth 2) decide the Rust steps, as on a laptop against origin/main
|
||||
# the runner sets CI=true in the job and it wins over the workflow env above: set CI=full for the command
|
||||
# itself, or tools/ci.ts skips the desktop Rust tests and clippy when the push did not touch src-tauri
|
||||
- name: bun run ci
|
||||
shell: bash
|
||||
run: STOREBOUND_CI_BASE=HEAD~1 bun run ci
|
||||
run: CI=full bun run ci
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ runs:
|
|||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
~/.cache/tauri
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
|
|
@ -36,7 +35,7 @@ runs:
|
|||
shell: bash
|
||||
run: |
|
||||
P="${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
bash tools/ci-install-bun.sh
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
echo "$HOME/.bun/bin" >> "$P"
|
||||
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$P"
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
# The release acceptance matrix on demand (the same jobs as release.yml's acceptance + acceptance-report, which gate
|
||||
# every release): every user-facing command from the packed npm tarball on Linux and Windows x Node 20 / Node 22 / Bun,
|
||||
# then the markdown matrix (artifact storebound-acceptance). Run it from Actions > acceptance > Run workflow, or with
|
||||
# the API (POST /repos/{owner}/{repo}/actions/workflows/acceptance.yml/dispatches). macOS: tools/acceptance.ts on the Mac.
|
||||
name: acceptance
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
BUN_VERSION: '1.4.0'
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
|
||||
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- name: Bun (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
bash tools/ci-install-bun.sh
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
- name: Acceptance matrix (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
node --version && bun --version
|
||||
bun install --frozen-lockfile
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
|
||||
- name: Acceptance matrix (Windows)
|
||||
if: ${{ matrix.platform == 'Windows' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
node --version; bun --version
|
||||
bun install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { throw "bun install failed" }
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
|
||||
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
|
||||
path: acceptance/
|
||||
retention-days: 30
|
||||
|
||||
acceptance-report:
|
||||
needs: [acceptance]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Linux-node20
|
||||
path: acceptance-runs/Linux-node20
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Linux-node22
|
||||
path: acceptance-runs/Linux-node22
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Linux-bun
|
||||
path: acceptance-runs/Linux-bun
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Windows-node20
|
||||
path: acceptance-runs/Windows-node20
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Windows-node22
|
||||
path: acceptance-runs/Windows-node22
|
||||
- uses: actions/download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acceptance-Windows-bun
|
||||
path: acceptance-runs/Windows-bun
|
||||
- name: Matrix (markdown), red cells fail the job
|
||||
run: |
|
||||
bash tools/ci-install-bun.sh
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
mkdir -p out
|
||||
bun tools/acceptance.ts report acceptance-runs docs/acceptance --md "out/storebound_${V}_acceptance.md" --expect 9 --strict
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: storebound-acceptance
|
||||
path: out/
|
||||
retention-days: 30
|
||||
|
|
@ -1,24 +1,14 @@
|
|||
# Every push to main is a release candidate (docs/desktop-release.md, "Release candidates"). In parallel:
|
||||
# ci bun run ci (adapters, desktop version, tests, typecheck, desktop i18n / typecheck / build, the boot check
|
||||
# in headless Chromium, desktop Rust tests and clippy, scrub); tools/ci.ts runs it as a task graph with
|
||||
# parallel test shards on the one Linux runner; no browser test runs here (tests/browser/, `bun run
|
||||
# test:browser` by hand). The Rust steps run when the push changed a Rust input (STOREBOUND_CI_BASE).
|
||||
# acceptance the release acceptance matrix (tools/acceptance.ts): every user-facing command from the packed npm
|
||||
# tarball with real piped stdin, Linux and Windows x Node 20 / Node 22 / Bun.
|
||||
# linux AppImage + deb (path-free build), self-tests, both started under Xvfb, privacy scan.
|
||||
# windows NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
|
||||
# record when all are green (the installers already scanned with the private terms): the draft release rc-<sha> with the installers, the acceptance runs and matrix and
|
||||
# green.json (tools/release-candidate.ts record). A v* tag on this commit only promotes it (release.yml).
|
||||
# gate decides first: a commit that differs from its green parent only in version copies + CHANGELOG (a release's
|
||||
# version bump, proven file by file: tools/release-candidate.ts gate) reuses the parent's ci and acceptance results
|
||||
# and builds only the installers, whose embedded version changed.
|
||||
# Pull requests run ci + acceptance (no installers, no record). rehearsal/* branches are release rehearsals
|
||||
# (tools/release.ts --rehearsal). site/ is still published by Jenkins.
|
||||
# bun run ci on every push to main and every pull request into main: adapters, desktop version, tests, typecheck,
|
||||
# desktop i18n / typecheck / build, desktop boot check (headless Chromium), desktop Rust tests and clippy, scrub. Runs on the VDS runner (docs/desktop-release.md).
|
||||
# tools/ci.ts runs the steps as a task graph and the tests as parallel shards (CPU cores - 1, max 8) on the one
|
||||
# self-hosted runner, so a job matrix would only queue; CI=full makes it run the Rust steps on every push.
|
||||
# Publishes nothing: site/ is still published by Jenkins; releases come from release.yml (a v* tag).
|
||||
# release.yml's ci job runs the same steps (the setup is shared: .forgejo/actions/linux-setup).
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'rehearsal/**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
|
@ -27,402 +17,19 @@ env:
|
|||
BUN_VERSION: '1.4.0'
|
||||
CARGO_TERM_COLOR: never
|
||||
CARGO_INCREMENTAL: '0'
|
||||
CI: full
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-22.04
|
||||
# the self-hosted runners only run code from this repo: pull requests from forks are skipped
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
reuse: ${{ steps.gate.outputs.reuse }}
|
||||
from: ${{ steps.gate.outputs.from }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Version-only commit of a green parent?
|
||||
id: gate
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
# tests never gate a release (the owner's decision 2026-10-16): no waiting on the parent's run, which the
|
||||
# release's own bump push cancels; every commit builds its installers and runs the tests in the background
|
||||
echo "reuse=false" >> "$GITHUB_OUTPUT"; echo "from=" >> "$GITHUB_OUTPUT"; echo "tests run in the background; installers build for this commit"
|
||||
|
||||
# background tests (the owner's decision 2026-10-16): they run on every push and report, but the record job (the
|
||||
# release candidate) never waits for them; `bun tools/release.ts` and `bun tools/ci-status.ts` print a red result
|
||||
ci:
|
||||
needs: gate
|
||||
if: github.event_name != 'pull_request' || true
|
||||
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04
|
||||
runs-on: ubuntu-22.04
|
||||
# the self-hosted runner only runs code from this repo: pull requests from forks are skipped
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
cache-key: ci
|
||||
rust-components: clippy
|
||||
- uses: ./.forgejo/actions/ci-run
|
||||
|
||||
# The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed
|
||||
# npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22
|
||||
# (npx --package=<tgz>) / Bun (bun x). One red cell and the commit is no candidate. macOS runs on the owner's Mac
|
||||
# (tools/release.ts, cached per CLI key).
|
||||
# after the installers: the one Windows runner builds the release installer first, the background tests wait
|
||||
# (0.8.13's Windows installer queued behind six acceptance jobs)
|
||||
acceptance:
|
||||
needs: [gate, linux, windows]
|
||||
if: github.event_name != 'pull_request' || true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 }
|
||||
- { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '20', name: node20 }
|
||||
- { os: windows, platform: Windows, runtime: node, node: '22', name: node22 }
|
||||
- { os: windows, platform: Windows, runtime: bun, node: '22', name: bun }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- name: Bun (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
bash tools/ci-install-bun.sh
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
- name: Acceptance matrix (Linux)
|
||||
if: ${{ matrix.platform == 'Linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
node --version && bun --version
|
||||
# npm pack runs prepack, which builds the package's declarations with the pinned TypeScript
|
||||
bun install --frozen-lockfile
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json
|
||||
- name: Acceptance matrix (Windows)
|
||||
if: ${{ matrix.platform == 'Windows' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
node --version; bun --version
|
||||
# npm pack runs prepack, which builds the package's declarations with the pinned TypeScript
|
||||
bun install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { throw "bun install failed" }
|
||||
bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json
|
||||
if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" }
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: acceptance-${{ matrix.platform }}-${{ matrix.name }}
|
||||
path: acceptance/
|
||||
retention-days: 14
|
||||
|
||||
linux:
|
||||
needs: gate
|
||||
if: github.event_name != 'pull_request'
|
||||
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
|
||||
# deb then run on Ubuntu 22.04 and newer, Debian 12)
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# the path-free build keeps the target folder outside every home folder
|
||||
CARGO_TARGET_DIR: /tmp/storebound-target
|
||||
# linuxdeploy (AppImage) runs without FUSE inside the container
|
||||
APPIMAGE_EXTRACT_AND_RUN: '1'
|
||||
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
|
||||
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
|
||||
NO_STRIP: '1'
|
||||
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
|
||||
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
cache-key: desktop
|
||||
packages: patchelf file squashfs-tools p7zip-full xz-utils
|
||||
# the release target folder (dependencies compiled once; a version bump recompiles only the app crate)
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/storebound-target
|
||||
key: desktop-target-${{ runner.os }}-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
|
||||
restore-keys: desktop-target-${{ runner.os }}-
|
||||
- name: Install and check
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-linux-x64
|
||||
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
|
||||
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
|
||||
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
|
||||
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
||||
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
|
||||
- name: Build AppImage and deb (path-free)
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
rm -rf "$CARGO_TARGET_DIR/release/bundle"
|
||||
# the AppImage tools come from GitHub releases, which answer 504 now and then (cached in ~/.cache/tauri)
|
||||
bash ../../tools/ci-tauri-tools.sh
|
||||
for i in 1 2 3; do bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci && break; [ $i = 3 ] && exit 1; sleep 30; done
|
||||
- name: Self-test the exact artifacts
|
||||
run: |
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
B="$CARGO_TARGET_DIR/release/bundle"
|
||||
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
|
||||
chmod +x out/*.AppImage
|
||||
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
||||
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
|
||||
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
|
||||
- name: Start the app under Xvfb (AppImage and deb)
|
||||
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
|
||||
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
|
||||
run: |
|
||||
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
smoke() { # <label> <executable>
|
||||
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
|
||||
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
|
||||
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
|
||||
local pid=$! ok=0
|
||||
for i in $(seq 1 90); do
|
||||
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
|
||||
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
|
||||
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
|
||||
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
|
||||
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
|
||||
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
|
||||
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
|
||||
echo "$1: window started, UI reached the sidecar"
|
||||
}
|
||||
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
|
||||
smoke deb /tmp/deb/usr/bin/storebound-desktop
|
||||
- name: Privacy scan (with the private terms; never printed)
|
||||
env:
|
||||
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
|
||||
run: |
|
||||
[ -n "$FORBIDDEN_TERMS" ] || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
|
||||
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
|
||||
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out/ || rc=$?
|
||||
rm -f "$RUNNER_TEMP/terms.txt"; exit $rc
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: out/
|
||||
retention-days: 7
|
||||
# dev channel (tools/desktop-dev.ts): the same commit again with the version <next patch>-dev.<commit UTC>, only in
|
||||
# this workspace (never committed); a failure here never touches the release candidate above
|
||||
- name: Dev build (AppImage + deb, dev version)
|
||||
id: dev
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
env:
|
||||
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V=$(bun tools/desktop-dev.ts set)
|
||||
bun tools/build-sidecars.ts --target bun-linux-x64
|
||||
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
||||
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
|
||||
bash tools/ci-tauri-tools.sh
|
||||
for i in 1 2 3; do (cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci) && break; [ $i = 3 ] && exit 1; sleep 30; done
|
||||
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
|
||||
chmod +x dev/*.AppImage
|
||||
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
||||
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
|
||||
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts dev/ || rc=$?
|
||||
rm -f "$RUNNER_TEMP/terms.txt"; git checkout -- . ; exit $rc
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: steps.dev.outcome == 'success'
|
||||
with:
|
||||
name: storebound-desktop-dev-linux
|
||||
path: dev/
|
||||
retention-days: 3
|
||||
|
||||
windows:
|
||||
needs: gate
|
||||
if: github.event_name != 'pull_request'
|
||||
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
|
||||
runs-on: windows
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TARGET_DIR: C:\storebound-target
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Install and check
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-windows-x64
|
||||
- name: Node bundle on Windows (npx storebound) - stdin prompts read with node, clean exit
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun tools/build-node.ts
|
||||
node --version
|
||||
bun tools/node-smoke.ts --only prompt
|
||||
if ($LASTEXITCODE -ne 0) { throw "node prompt smoke failed" }
|
||||
"n" | node dist/storebound.mjs --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "node dist/storebound.mjs --version failed" }
|
||||
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
|
||||
shell: pwsh
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
|
||||
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
|
||||
- name: Install per user, self-test, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$V = bun tools/desktop-version.ts --print
|
||||
New-Item -ItemType Directory -Force out | Out-Null
|
||||
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
|
||||
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
|
||||
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
|
||||
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
|
||||
& $exe --self-test | Tee-Object -Variable st
|
||||
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
|
||||
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
|
||||
- name: Privacy scan (no .pdb, no paths; with the private terms, never printed)
|
||||
shell: pwsh
|
||||
env:
|
||||
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
|
||||
run: |
|
||||
if (-not $env:FORBIDDEN_TERMS) { throw "secret FORBIDDEN_TERMS is missing" }
|
||||
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
|
||||
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
|
||||
$env:STOREBOUND_FORBIDDEN_TERMS = $t
|
||||
bun tools/privacy-scan.ts out
|
||||
$rc = $LASTEXITCODE
|
||||
Remove-Item -Force $t
|
||||
if ($rc -ne 0) { throw "privacy scan failed" }
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: out/
|
||||
retention-days: 7
|
||||
# dev channel (tools/desktop-dev.ts): see the linux job
|
||||
- name: Dev build (NSIS, dev version)
|
||||
id: dev
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$V = bun tools/desktop-dev.ts set
|
||||
if ($LASTEXITCODE -ne 0) { throw "dev version stamp refused" }
|
||||
bun tools/build-sidecars.ts --target bun-windows-x64
|
||||
Remove-Item -Recurse -Force "$env:CARGO_TARGET_DIR\release\bundle\nsis" -ErrorAction SilentlyContinue
|
||||
Push-Location apps/desktop
|
||||
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
|
||||
if ($LASTEXITCODE -ne 0) { throw "dev build failed" }
|
||||
Pop-Location
|
||||
New-Item -ItemType Directory -Force dev | Out-Null
|
||||
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" dev\
|
||||
Start-Process -Wait -FilePath "dev\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
|
||||
& "$env:LOCALAPPDATA\storebound\storebound-desktop.exe" --self-test
|
||||
if ($LASTEXITCODE -ne 0) { throw "dev self-test failed" }
|
||||
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
|
||||
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
|
||||
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
|
||||
$env:STOREBOUND_FORBIDDEN_TERMS = $t
|
||||
bun tools/privacy-scan.ts dev
|
||||
$rc = $LASTEXITCODE
|
||||
Remove-Item -Force $t
|
||||
git checkout -- .
|
||||
if ($rc -ne 0) { throw "privacy scan failed" }
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: steps.dev.outcome == 'success'
|
||||
with:
|
||||
name: storebound-desktop-dev-windows
|
||||
path: dev/
|
||||
retention-days: 3
|
||||
|
||||
# dev channel: the dev bundles of both jobs go to a Forgejo draft dev-<version> (no tag; the newest 5 kept); the VDS
|
||||
# (tools/vds/storebound-desktop-dev, a root timer) signs them with the updater key and publishes
|
||||
# storebound.dev/desktop/dev/latest.json. macOS has no dev build: Developer ID signing needs the Mac's GUI session.
|
||||
dev:
|
||||
needs: [linux, windows]
|
||||
if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.linux.result == 'success' && needs.windows.result == 'success' }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tools
|
||||
run: |
|
||||
bash tools/ci-install-bun.sh
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-dev-linux
|
||||
path: dev
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-dev-windows
|
||||
path: dev
|
||||
- name: Draft dev-<version> (newest 5 kept), then wait for the VDS to publish it
|
||||
run: |
|
||||
ls -la dev
|
||||
bun tools/desktop-dev.ts record --dir dev --yes
|
||||
V=$(ls dev | sed -n 's/^storebound_\(.*\)_x64-setup\.exe$/\1/p')
|
||||
bun tools/desktop-dev.ts wait "$V" --minutes 20
|
||||
|
||||
record:
|
||||
needs: [gate, linux, windows]
|
||||
if: ${{ always() && github.event_name != 'pull_request' && needs.gate.result == 'success' && needs.linux.result == 'success' && needs.windows.result == 'success' }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
SHA: ${{ github.sha }}
|
||||
REUSE: ${{ needs.gate.outputs.reuse }}
|
||||
FROM: ${{ needs.gate.outputs.from }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# no apt: the installers were scanned with the private terms in their own jobs; this job only records
|
||||
- name: Tools
|
||||
run: |
|
||||
bash tools/ci-install-bun.sh
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: cand
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: cand
|
||||
- name: Checksums file of the build jobs is not shipped as is
|
||||
run: rm -f cand/SHA256SUMS
|
||||
- name: Record the candidate (draft rc-<sha>, green.json last)
|
||||
run: |
|
||||
bun tools/release-candidate.ts record --sha "$SHA" --run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_NUMBER" --dir cand ${FROM:+--reused-from "$FROM"} --yes
|
||||
bun tools/release-candidate.ts prune --keep 12 --yes
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
# The release of storebound: a version tag (v0.8.5) promotes the release candidate of its commit. Nothing is rebuilt
|
||||
# or retested here: ci.yml ran the full CI, the acceptance matrix (Linux + Windows x Node 20 / 22 / Bun) and built,
|
||||
# self-tested and privacy-scanned the Linux and Windows installers for that very commit (for a version-only bump
|
||||
# commit: the installers, with the parent's CI and matrix, proven version-only file by file), and stored them as the
|
||||
# draft rc-<sha> with green.json (tools/release-candidate.ts).
|
||||
# The release of storebound: one version tag (v0.8.0) -> one Forgejo release with the Linux and Windows installers.
|
||||
#
|
||||
# push of a tag v* checks (tag = package.json version, every version copy equal, the CHANGELOG section), waits
|
||||
# for the commit's candidate (heartbeat; fails at once when the commit is red or has no ci run,
|
||||
# with the run's link; times out after 40 min), then ONE release "storebound <version>" with the
|
||||
# candidate's installers, the acceptance matrix, SHA256SUMS and short notes. Stable versions
|
||||
# then delete the older release entries (tags stay). A version with a "-" (0.0.0-rehearsal.1)
|
||||
# is a prerelease: never pruning, never the stable release.
|
||||
# workflow_dispatch tag: the same for an existing tag; dry (default): only the plan.
|
||||
# push of a tag v* checks (tag = package.json version, every version copy equal), the full CI, the
|
||||
# AppImage + deb (Linux runner, Docker) and the NSIS installer (Windows runner, VM), the
|
||||
# privacy scan of every artifact, then ONE release "storebound <version>" with short notes
|
||||
# (tools/forgejo-release.ts notes: the CHANGELOG summary, install line, links), the
|
||||
# installers and SHA256SUMS; older release entries are deleted afterwards (tags stay).
|
||||
# workflow_dispatch dry (default): the same jobs on this commit (or on `tag`), a DRAFT release test-<sha>
|
||||
# with the real files, read back, then deleted. dry off + tag: (re)make that tag's release.
|
||||
#
|
||||
# Not here: macOS. tools/release.ts on the owner's Mac builds, signs and notarizes the DMG in parallel and adds it to
|
||||
# this release, then publishes storebound.dev (docs/desktop-release.md). The updater signing key never reaches a runner.
|
||||
# Secrets: RELEASE_TOKEN (scope write:repository), FORGEJO_ORIGIN (the server's IP: uploads over 100 MB bypass
|
||||
# Cloudflare). The private terms scan of the installers ran in ci.yml's build jobs (FORBIDDEN_TERMS).
|
||||
# Not here: macOS. The DMG is built, signed and notarized on the owner's Mac and added to this release with
|
||||
# `bun tools/desktop-release.ts upload-forgejo --out <dir> --yes`; then `publish` (storebound.dev) and `npm publish`
|
||||
# (the owner's step). Order and details: docs/desktop-release.md. The updater signing key never reaches a runner.
|
||||
# Secrets (repo Settings > Actions > Secrets): RELEASE_TOKEN (token, scope write:repository: releases and assets),
|
||||
# FORBIDDEN_TERMS (the private terms list of the privacy scan; never printed), FORGEJO_ORIGIN (the server's IP: asset
|
||||
# uploads over 100 MB bypass Cloudflare).
|
||||
# Runners on the VDS (labels ubuntu-22.04 and windows): docs/desktop-release.md.
|
||||
name: release
|
||||
|
||||
on:
|
||||
|
|
@ -24,55 +23,283 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Version tag to release (v0.8.5)
|
||||
description: Version tag to build (v0.8.0); empty = the selected branch's commit
|
||||
type: string
|
||||
default: ''
|
||||
dry:
|
||||
description: Dry run (only the plan)
|
||||
description: Dry run (a draft release test-<sha> with the real files, deleted at the end)
|
||||
type: boolean
|
||||
default: true
|
||||
windows:
|
||||
description: Build Windows too (always on for a tag push)
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
BUN_VERSION: '1.4.0'
|
||||
CARGO_TERM_COLOR: never
|
||||
CARGO_INCREMENTAL: '0'
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
prepare:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
ref: ${{ steps.mode.outputs.ref }}
|
||||
tag: ${{ steps.check.outputs.tag }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
dry: ${{ steps.mode.outputs.dry }}
|
||||
steps:
|
||||
- name: Mode
|
||||
id: mode
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_DRY: ${{ inputs.dry }}
|
||||
run: |
|
||||
[ -n "$TAG" ] || { echo "input tag is required" >&2; exit 1; }
|
||||
case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) dry=false;; *) dry=true;; esac
|
||||
[ "$EVENT" = push ] && dry=false
|
||||
echo "dry=$dry" >> "$GITHUB_OUTPUT"; echo "tag=$TAG dry=$dry"
|
||||
# a boolean input arrives as true / false (API: also "on" / "1"); only an explicit no is a real release
|
||||
case "$(echo "${IN_DRY:-true}" | tr A-Z a-z)" in false|0|off|no) want_dry=false;; *) want_dry=true;; esac
|
||||
echo "inputs: tag='$IN_TAG' dry='$IN_DRY'"
|
||||
if [ "$EVENT" = push ]; then ref="refs/tags/$REF_NAME"; dry=false
|
||||
elif [ -n "$IN_TAG" ]; then ref="refs/tags/$IN_TAG"; dry=$want_dry
|
||||
else ref="$SHA"; dry=true
|
||||
[ "$want_dry" = true ] || { echo "a real release needs a tag (input tag), or push the tag" >&2; exit 1; }
|
||||
fi
|
||||
echo "ref=$ref" >> "$GITHUB_OUTPUT"; echo "dry=$dry" >> "$GITHUB_OUTPUT"
|
||||
echo "event=$EVENT ref=$ref dry=$dry"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ env.TAG }}
|
||||
- name: Tag, package.json version, every version copy and the CHANGELOG agree
|
||||
ref: ${{ steps.mode.outputs.ref }}
|
||||
- name: Tag, package.json version and every version copy agree
|
||||
id: check
|
||||
env:
|
||||
TAG_IN: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
|
||||
DRY: ${{ steps.mode.outputs.dry }}
|
||||
run: |
|
||||
bash tools/ci-install-bun.sh
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
bun tools/desktop-version.ts
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
[ "$TAG" = "v$V" ] || { echo "tag $TAG is not v$V (package.json): bun tools/release.ts <version> makes the bump commit and the tag" >&2; exit 1; }
|
||||
case "$V" in *-*) echo "prerelease $V: the CHANGELOG section is optional";; *) grep -q "^## $V" CHANGELOG.md || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; };; esac
|
||||
if [ -n "$TAG_IN" ] && [ "$TAG_IN" != "v$V" ]; then echo "tag $TAG_IN is not v$V (package.json): bun tools/desktop-version.ts --set <version>, commit, tag that commit" >&2; exit 1; fi
|
||||
grep -q "^## $V" CHANGELOG.md || { [ "$DRY" = true ] && echo "note: no CHANGELOG section for $V (the notes use Unreleased)"; } || { echo "CHANGELOG.md has no '## $V' section" >&2; exit 1; }
|
||||
if [ "$DRY" = true ]; then tag="test-$(git rev-parse --short=10 HEAD)"; else tag="$TAG_IN"; fi
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"; echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "release $tag (version $V, dry=$DRY)"
|
||||
|
||||
ci:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CI: full
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
cache-key: ci
|
||||
rust-components: clippy
|
||||
- uses: ./.forgejo/actions/ci-run
|
||||
|
||||
linux:
|
||||
needs: prepare
|
||||
# runner label: ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04 (glibc 2.35: the AppImage and the
|
||||
# deb then run on Ubuntu 22.04 and newer, Debian 12)
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# the path-free build keeps the target folder outside every home folder
|
||||
CARGO_TARGET_DIR: /tmp/storebound-target
|
||||
# linuxdeploy (AppImage) runs without FUSE inside the container
|
||||
APPIMAGE_EXTRACT_AND_RUN: '1'
|
||||
# linuxdeploy must not strip: the storebound sidecar is a `bun build --compile` executable whose JS payload strip
|
||||
# removes (it then starts as plain bun). The Rust binary is already stripped by Cargo (profile.release strip).
|
||||
NO_STRIP: '1'
|
||||
# WebKitGTK's DMABUF renderer leaves the window blank on some GPU / VM setups; the app sets this itself at
|
||||
# start (src-tauri/src/lib.rs linux_webkit_env) unless the user did; set here too for anything run in CI
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- uses: ./.forgejo/actions/linux-setup
|
||||
with:
|
||||
cache-key: desktop
|
||||
packages: patchelf file squashfs-tools p7zip-full xz-utils
|
||||
- name: Install and check
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-linux-x64
|
||||
# linuxdeploy (AppImage) sets the rpath of every executable with its own patchelf, which corrupts the bun
|
||||
# sidecar (segfault, then ldd fails in the gtk plugin). With the rpath already set (Ubuntu's patchelf, which
|
||||
# keeps it working), linuxdeploy leaves the file as it is. The sidecar needs only libc, so the rpath is inert.
|
||||
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
||||
apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu version
|
||||
- name: Build AppImage and deb (path-free)
|
||||
working-directory: apps/desktop
|
||||
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
|
||||
- name: Self-test the exact artifacts
|
||||
run: |
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
B="$CARGO_TARGET_DIR/release/bundle"
|
||||
mkdir -p out && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" out/
|
||||
chmod +x out/*.AppImage
|
||||
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
||||
rm -rf /tmp/deb && dpkg-deb -x "out/storebound_${V}_amd64.deb" /tmp/deb && /tmp/deb/usr/bin/storebound-desktop --self-test
|
||||
dpkg-deb -I "out/storebound_${V}_amd64.deb" | sed -n '1,20p'
|
||||
- name: Start the app under Xvfb (AppImage and deb)
|
||||
# --self-test checks only the sidecar; this opens the real window (GTK, WebKitGTK, the bundled libraries of the
|
||||
# AppImage) on a virtual display. "sidecar started" in host.log means the webview ran the UI and called the host.
|
||||
run: |
|
||||
sudo apt-get install -y -qq --no-install-recommends xvfb xauth dbus-x11 >/dev/null
|
||||
V=$(bun tools/desktop-version.ts --print)
|
||||
smoke() { # <label> <executable>
|
||||
local d; d=$(mktemp -d); local log="$d/data/dev.storebound.desktop/logs/host.log"
|
||||
XDG_DATA_HOME="$d/data" XDG_CONFIG_HOME="$d/config" XDG_CACHE_HOME="$d/cache" \
|
||||
xvfb-run -a dbus-run-session -- "$2" >"$d/out.txt" 2>&1 &
|
||||
local pid=$! ok=0
|
||||
for i in $(seq 1 90); do
|
||||
if grep -q 'sidecar started' "$log" 2>/dev/null; then ok=1; break; fi
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
sleep 3; kill -0 "$pid" 2>/dev/null || ok=0
|
||||
pkill -TERM -f storebound-desktop || true; wait "$pid" 2>/dev/null || true
|
||||
echo "--- $1 host.log"; cat "$log" 2>/dev/null || true
|
||||
local wv="$d/data/dev.storebound.desktop/logs/webview.log"
|
||||
if [ -f "$wv" ]; then echo "--- $1 webview.log"; cat "$wv"; grep -qE '^\S+ error ' "$wv" && ok=0; fi
|
||||
grep -qE '^\S+ error |panicked' "$log" 2>/dev/null && ok=0
|
||||
if [ "$ok" != 1 ]; then echo "--- $1 output"; tail -n 60 "$d/out.txt"; echo "$1: the window did not start the UI" >&2; return 1; fi
|
||||
echo "$1: window started, UI reached the sidecar"
|
||||
}
|
||||
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
|
||||
smoke deb /tmp/deb/usr/bin/storebound-desktop
|
||||
- name: Privacy scan
|
||||
run: bun tools/privacy-scan.ts out/
|
||||
- name: Checksums
|
||||
run: (cd out && sha256sum * | tee SHA256SUMS)
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: out/
|
||||
retention-days: 14
|
||||
|
||||
|
||||
windows:
|
||||
needs: prepare
|
||||
# a tag push always builds Windows; a manual run unless "windows" is unticked
|
||||
if: ${{ github.event_name == 'push' || inputs.windows }}
|
||||
# the Windows runner on the VDS ("windows" label, host mode): Rust (MSVC), Bun, 7-Zip, pwsh
|
||||
runs-on: windows
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TARGET_DIR: C:\storebound-target
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- name: Install and check
|
||||
shell: pwsh
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tools/desktop-version.ts
|
||||
bun tools/build-sidecars.ts --target bun-windows-x64
|
||||
- name: Build NSIS installer (per user, WebView2 bootstrapper, path-free)
|
||||
shell: pwsh
|
||||
working-directory: apps/desktop
|
||||
run: bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles nsis --ci
|
||||
- name: Install per user, self-test, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$V = bun tools/desktop-version.ts --print
|
||||
New-Item -ItemType Directory -Force out | Out-Null
|
||||
Copy-Item "$env:CARGO_TARGET_DIR\release\bundle\nsis\storebound_${V}_x64-setup.exe" out\
|
||||
Start-Process -Wait -FilePath "out\storebound_${V}_x64-setup.exe" -ArgumentList '/S'
|
||||
$exe = "$env:LOCALAPPDATA\storebound\storebound-desktop.exe"
|
||||
if (-not (Test-Path $exe)) { throw "not installed per user at $exe" }
|
||||
& $exe --self-test | Tee-Object -Variable st
|
||||
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
|
||||
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
|
||||
- name: Privacy scan (no .pdb, no paths)
|
||||
shell: pwsh
|
||||
run: bun tools/privacy-scan.ts out
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: out/
|
||||
retention-days: 14
|
||||
|
||||
release:
|
||||
needs: [prepare, ci, linux, windows]
|
||||
# a real release needs every platform; a dry run may skip Windows
|
||||
if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
# uploads over 100 MB go to the origin directly (Cloudflare refuses them): tools/forgejo-release.ts
|
||||
FORGEJO_ORIGIN: ${{ secrets.FORGEJO_ORIGIN }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
DRY: ${{ needs.prepare.outputs.dry }}
|
||||
SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.ref }}
|
||||
- name: Tools
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq --no-install-recommends squashfs-tools p7zip-full xz-utils binutils unzip file >/dev/null
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
- name: Promote the commit's green candidate
|
||||
run: bun tools/release-candidate.ts promote --tag "$TAG" --sha "${{ steps.check.outputs.sha }}" --wait 40 ${{ steps.mode.outputs.dry == 'false' && '--yes' || '' }}
|
||||
- name: Keep only the newest release (stable versions; tags stay)
|
||||
if: ${{ steps.mode.outputs.dry == 'false' && !contains(steps.check.outputs.version, '-') }}
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storebound-desktop-linux
|
||||
path: dist/linux
|
||||
- uses: actions/download-artifact@v3
|
||||
if: ${{ needs.windows.result == 'success' }}
|
||||
with:
|
||||
name: storebound-desktop-windows
|
||||
path: dist/windows
|
||||
- name: Collect, privacy scan (private terms), checksums
|
||||
env:
|
||||
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
|
||||
run: |
|
||||
mkdir -p out
|
||||
find dist -type f ! -name SHA256SUMS -exec cp {} out/ \;
|
||||
for f in out/*; do case "$f" in *"_${VERSION}_"*) ;; *) echo "$f is not version $VERSION" >&2; exit 1;; esac; done
|
||||
[ -n "$FORBIDDEN_TERMS" ] || { [ "$DRY" = true ] && echo "note: secret FORBIDDEN_TERMS is missing: generic checks only"; } || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
|
||||
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
|
||||
STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out
|
||||
rm -f "$RUNNER_TEMP/terms.txt"
|
||||
(cd out && sha256sum * | tee ../SHA256SUMS.local)
|
||||
ls -la out
|
||||
- name: Notes
|
||||
run: |
|
||||
bun tools/forgejo-release.ts notes "$VERSION" --tag "$([ "$DRY" = true ] && echo "v$VERSION" || echo "$TAG")" | tee notes.md
|
||||
[ "$(wc -l < notes.md)" -le 8 ] || { echo "notes are longer than 8 lines" >&2; exit 1; }
|
||||
- name: Release (create or update, attach, read back)
|
||||
run: |
|
||||
if [ "$DRY" = true ]; then
|
||||
bun tools/forgejo-release.ts upsert --tag "$TAG" --target "$SHA" --title "$TAG (dry run, deleted at the end)" --notes-file notes.md --draft --yes out/*
|
||||
else
|
||||
bun tools/forgejo-release.ts upsert --tag "$TAG" --title "storebound $VERSION" --notes-file notes.md --yes out/*
|
||||
fi
|
||||
- name: Keep only the newest release (tags stay)
|
||||
if: ${{ needs.prepare.outputs.dry != 'true' }}
|
||||
run: bun tools/forgejo-release.ts prune --keep "$TAG" --yes
|
||||
- name: Dry run - what prune would delete
|
||||
if: ${{ needs.prepare.outputs.dry == 'true' }}
|
||||
run: |
|
||||
KEEP=$(curl -fsS -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_URL/api/v1/repos/$GITHUB_REPOSITORY/releases?limit=1&draft=false" | bun -e 'const r=await Bun.stdin.json(); console.log(r[0]?.tag_name ?? "")')
|
||||
[ -z "$KEEP" ] || bun tools/forgejo-release.ts prune --keep "$KEEP"
|
||||
- name: Dry run - delete the test draft
|
||||
if: ${{ always() && needs.prepare.outputs.dry == 'true' }}
|
||||
run: bun tools/forgejo-release.ts delete --tag "$TAG" --yes
|
||||
|
|
|
|||
13
.gitattributes
vendored
|
|
@ -1,13 +0,0 @@
|
|||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.ttf binary
|
||||
*.woff2 binary
|
||||
*.mp4 binary
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
# kept byte-for-byte as published upstream (CRLF)
|
||||
cli/data/play-data-safety-form.csv -text
|
||||
**/icons/LICENSE.txt -text
|
||||
14
.github/copilot-instructions.md
vendored
|
|
@ -20,16 +20,6 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
|
|||
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
|
||||
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
|
||||
these you prepare everything, show the dry run, and the owner presses one button or says yes.
|
||||
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
|
||||
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
|
||||
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
|
||||
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
|
||||
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
|
||||
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
|
||||
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
|
||||
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
|
||||
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
|
||||
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
|
||||
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
|
||||
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
|
||||
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
|
||||
|
|
@ -78,9 +68,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
@ -106,7 +93,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
|
||||
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default |
|
||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||
| `game-services` | Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section |
|
||||
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
|
||||
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
||||
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"storebound": {
|
||||
"command": "sh",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/bin/storebound",
|
||||
"args": [
|
||||
"-c",
|
||||
"r=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$r\" ] && [ -x \"$r/bin/storebound\" ]; then exec \"$r/bin/storebound\" mcp; fi; if command -v storebound >/dev/null 2>&1; then exec storebound mcp; fi; exec npx -y storebound@latest mcp"
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
AGENTS.md
|
|
@ -21,16 +21,6 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
|
|||
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
|
||||
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
|
||||
these you prepare everything, show the dry run, and the owner presses one button or says yes.
|
||||
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
|
||||
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
|
||||
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
|
||||
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
|
||||
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
|
||||
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
|
||||
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
|
||||
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
|
||||
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
|
||||
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
|
||||
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
|
||||
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
|
||||
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
|
||||
|
|
@ -79,9 +69,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
@ -105,7 +92,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
|
||||
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default
|
||||
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
|
||||
- `skills/game-services/SKILL.md`: Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section
|
||||
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
|
||||
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`
|
||||
- `skills/play-first-release/SKILL.md`: Step-by-step first (and later) Google Play release of a mobile app or game
|
||||
|
|
@ -138,6 +124,4 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
- Inner loop: `bun run ci:quick` (typecheck, the tests related to your change against origin/main, scrub; seconds).
|
||||
Before every push: the full `bun run ci` (every check and test, in parallel shards), and check its exit code.
|
||||
- Skills are the single source: edit `skills/*/SKILL.md` or `docs/agents-core.md`, then `bun tools/gen-adapters.ts`.
|
||||
- Every check, rule, template line, message and desktop view covers both stores (App Store and Google Play) and every
|
||||
surface (CLI, MCP, desktop, skills); when a store has no equivalent field, say where the same thing goes there.
|
||||
- Never commit credentials, account / team / issuer / key / app ids or personal data; placeholders use example.com.
|
||||
|
|
|
|||
729
CHANGELOG.md
|
|
@ -1,733 +1,5 @@
|
|||
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.8.77 · 2026-11-08
|
||||
|
||||
- **iPhone Duo:** outer and inner files are no longer cut to the first 10: the limit comes from Apple's reference data, and over it nothing changes and nothing is dropped. `apple screenshots --duo-only` writes only the Duo set. `apple cpp` no longer lists creative-assets flags.
|
||||
|
||||
## 0.8.76 · 2026-11-08
|
||||
|
||||
- 0.8.75 was tagged but not published (GitHub 502/504 in the Linux AppImage bundling). CI now takes the AppImage tools from a checked mirror instead of GitHub.
|
||||
|
||||
## 0.8.75 · 2026-11-08
|
||||
|
||||
- 0.8.74 was tagged but not published (GitHub release downloads answered 504 during the Linux AppImage bundling); 0.8.75 ships its section.
|
||||
|
||||
## 0.8.74 · 2026-11-08
|
||||
|
||||
- 0.8.73 was tagged but not published (a GitHub 504 in the Linux AppImage bundling); 0.8.74 ships its section below. CI caches the AppImage tools and retries the Linux bundle.
|
||||
|
||||
## 0.8.73 · 2026-11-08
|
||||
|
||||
- **iPhone Duo:** the live API names the platform IPHONE_APP_STORE (not IOS); accepted. A Duo problem now warns and the other screenshot sets still upload.
|
||||
|
||||
## 0.8.72 · 2026-11-08
|
||||
|
||||
- **iPhone Duo screenshots upload.** `apple screenshots` now sends them through the App Asset Library (display class
|
||||
IPHONE_DUO, placement APP_SCREENSHOT) instead of skipping them as "not in the API" (pitfall 130).
|
||||
|
||||
## 0.8.71 · 2026-11-07
|
||||
|
||||
- 0.8.69 and 0.8.70 were tagged but not published (the desktop release check stopped them); 0.8.71 ships both sections below. Desktop Portfolio: the Play state of an app uses the same words as Releases ("Not live yet: in review or blocked before review").
|
||||
|
||||
## 0.8.70 · 2026-11-07
|
||||
|
||||
- Public MCP server: the bare host is the endpoint too. `POST /` answers MCP like `/mcp` (someone who pastes `https://mcp.example.com` into a connector gets connected), `GET /` sends a browser to the landing page and names the connect URL to any other client. A signed-in server keeps `/` closed.
|
||||
|
||||
## 0.8.69 · 2026-11-07
|
||||
|
||||
- Play: `play status` and `validate` list every build still active on a testing track below the production build, with the `play release --version-code <n> --<track> completed` that replaces it (an old build with a policy issue, e.g. READ_MEDIA_IMAGES, blocks Send changes for review for the whole app). A completed production release the store page does not show yet reads "Not live yet: in review or blocked before review" instead of "In review" (CLI and desktop). A commit with `--not-sent-for-review` that Play answers with 400 "Changes are sent for review automatically" is redone without the parameter (pitfalls #129).
|
||||
|
||||
## 0.8.68 · 2026-11-07
|
||||
|
||||
- Desktop app: `storebound` works in a terminal with only the app installed. On every start the app puts a launcher for its own CLI on the PATH (Windows: `%LOCALAPPDATA%\storebound\bin`, added to the user PATH once; macOS / Linux: `~/.local/bin`, added to `~/.zprofile` / `~/.profile` when missing). An npm / bun install stays first, a file that is not the app's launcher is never overwritten, AppImage runs skip it, `STOREBOUND_NO_CLI_SHIM=1` turns it off.
|
||||
- `apple resubmit` / `apple submit --platform macos`: `apple.macos.noIap` leaves every in-app purchase out of the macOS submission and, without `apple.macos.gameCenter: true`, the Game Center switch is not touched (no `gameCenterAppVersions` call); the dry run names what it left out and why (pitfalls #128).
|
||||
|
||||
## 0.8.67 · 2026-11-03
|
||||
|
||||
- `apple build --platform macos`: runs `apple.macos.prebuild` (new) before `tauri build`, no longer `build.prebuild` / `apple.prebuild`, which belong to the iOS project (a Capacitor sync or OTA gate ran for a Tauri Mac build); the dry run names the skipped one.
|
||||
|
||||
## 0.8.66 · 2026-11-03
|
||||
|
||||
- `apple.macos: { listing, reviewNotes, gameCenter, noIap }`: an app with iOS and macOS versions gets its own Mac listing file and review notes (`apple listing --platform macos`, `apple review-details --platform macos`, new there); `validate` warns about iOS-only claims in a macOS version's texts (Game Center, tap / swipe, iPhone / iPad, in-app purchases with `noIap`).
|
||||
- `apple upload <pkg>`: reading the package is bounded (pkgutil signature check 90 s, expand 5 min, then the `apple build` .json) and logged; the processing wait finds macOS builds (it looked only at iOS and sat silently until --timeout); every slow read prints a heartbeat; an SSH / non-GUI session is warned about up front.
|
||||
- `apple build --platform macos`: `--tauri` takes a `tauri.<name>.conf.json` overlay (merged over tauri.conf.json and passed to `tauri build --config`; `tauri.appstore.conf.json` / `tauri.mas.conf.json` next to it are used by themselves); the Mac App Store entitlements come from `--entitlements`, `apple.macos.entitlements` or the merged config (a two-channel project signed the App Store app with its Developer ID plist); the plan lists the final keys, `get-task-allow` / `cs.disable-library-validation` stop the build (`--allow-entitlements`), other hardened-runtime exceptions warn.
|
||||
- MCP `apple_review_details` takes `platform`.
|
||||
- `apple release`: a 409 on a release type change is recognized again (a stray control character had broken the pattern).
|
||||
|
||||
## 0.8.65 · 2026-11-03
|
||||
|
||||
- `validate`: in-app purchases removed from sale (DEVELOPER_REMOVED_FROM_SALE, REMOVED_FROM_SALE) are no blockers; a warning only when the config still sells one.
|
||||
- `shots capture --device landscape`: Mac App Store / Chromebook frames from a web build (desktop viewport at `shots.landscape.size`, default 1440x900 @2x) into `<lang>/landscape/`; on by default when the project renders a landscape size. The simulator and emulator sources skip it with a warning.
|
||||
- `shots render` / `capture`: "playwright not found" now lists each place looked at and why it failed (an import error was reported as not found).
|
||||
|
||||
## 0.8.64 · 2026-11-02
|
||||
|
||||
- `validate` / `release plan`: a macOS version is checked against Mac screenshots (APP_DESKTOP, with `apple.mac`), not the iPhone / iPad sets; with no editable version, iOS is picked over macOS.
|
||||
- `validate --play`: reads go through the locked read-only edit and retry once on a fresh edit when Play ends it ("This Edit has been deleted" was shown as a blocker; it is now a warning to re-run at most).
|
||||
- Build numbers for macOS (`apple build --platform macos`, `apple upload` of a .pkg) come from every build of the app, as `apple next-build` does (a macOS-only list proposed a number an iOS build already had).
|
||||
- `apple build --platform macos` runs `build.prebuild` / `apple.prebuild` before `tauri build` (`--no-prebuild` skips it), and the new `apple.macAppCheck` command on the built .app before productbuild ({file} = the .app; a nonzero exit stops the build).
|
||||
- `apple signing status` / the Mac build dry run on Windows or Linux say the keys live on the Mac that made the certificates (build there) instead of suggesting a new certificate.
|
||||
- Flows: a dry `flow run` plans the upload, TestFlight wait and distribute steps after a dry build instead of failing on the missing IPA; `beta` and `release` need VERSION (no silent 1.0.0); `beta` distributes to the config's internal TestFlight group when GROUP is empty; `release` has optional macOS steps (MAC_TAURI), PLAY_TRACK (e.g. beta = open testing) and a promote owner step.
|
||||
## 0.8.63 · 2026-11-02
|
||||
|
||||
- Desktop: a second start at logon (`--hidden`, e.g. a Startup folder shortcut next to the Run entry) keeps the app in the tray instead of opening its window.
|
||||
|
||||
## 0.8.62 · 2026-10-29
|
||||
|
||||
- `doctor` warns when storebound runs from a folder macOS guards (~/Desktop, ~/Documents, ~/Downloads, iCloud Drive; e.g. a `bun link` to a checkout there): a launchd job or CI agent without access to it blocks in open() with no output.
|
||||
- `apple build` prints a line before each slow step (App Store Connect reads, device families, web build), so a run without a terminal that stalls shows where.
|
||||
## 0.8.61 · 2026-10-29
|
||||
|
||||
- TestFlight: `apple.testflight.description` takes a language id or its App Store locale (`en` or `en-US`; an `en-US` key was skipped silently), `check` names an unknown key, and `distribute --submit-review` stops (dry run: warns) when the app's primary locale has no beta localization (Beta App Review answers 422 MISSING_BETA_APP_LOCALIZATION).
|
||||
## 0.8.60 · 2026-10-29
|
||||
|
||||
- `apple listing` creates a new version with the release type of the newest version before it (MANUAL or AFTER_APPROVAL) when `apple.releaseType` is not set; a first version stays MANUAL. The plan names where the type came from, and warns when `apple.releaseType` differs from the previous version's.
|
||||
- `play release <aab> --dry` uploads nothing: it names the versionCode it would upload and the track changes it would make (the bundle went into a discarded edit, and a long upload could outlive the edit: "This edit has expired").
|
||||
- `play build`: an absolute `--dir` / `play.build.dir` stays absolute (it was taken as a folder under the project and lost its leading slash); a `gradlew` without the exec bit (committed from Windows) is made executable with a note to commit the mode. The desktop release signs by key path only.
|
||||
|
||||
## 0.8.59 · 2026-10-29
|
||||
|
||||
- `mcp --http`: a `tools/call` that accepts `text/event-stream` gets SSE keepalive comments every 20 s and then its result, so long calls survive proxies that cut silent responses. The public server's App Store search keeps one budget of 20 calls a minute (Apple's limit).
|
||||
- `storebound mcp --http --public`: a public, key-less MCP server (no sign-in, no store credentials, no project folders, no child processes, read-only). Tools: `skills_list`, `skill_get`, `pitfalls_search`, `store_limits`, `listing_lint` and `aso_search` (the public iTunes Search API, the only network call). Rate limit per client (`--rate`, 429 with Retry-After; `--trust-proxy` for X-Forwarded-For), 64 KB request bodies, CORS preflight. docs/mcp.md.
|
||||
|
||||
## 0.8.58 · 2026-10-29
|
||||
|
||||
- `check` and `scan` are fast on big repos: project walks skip dot folders (tool state, worktree copies), linked git worktrees and the folders git ignores (Pods, Carthage, SourcePackages and ios / android stay in), and read entry types from the listing instead of a stat per file. A game repo with worktree copies went from 244 s to 46 s for `check`.
|
||||
- `storebound mcp --http`: one Streamable HTTP MCP server that every agent session of a machine shares (no process per session). Each tool gets a `project` argument (absolute path inside `--roots`); auth is a local bearer token (`~/.storebound/mcp-token`, made on first start) or, for a server behind TLS, OAuth bearer tokens checked by introspection (RFC 7662) with protected resource metadata (RFC 9728). docs/mcp.md.
|
||||
|
||||
## 0.8.57 · 2026-10-27
|
||||
|
||||
- `apple upload` finds the IPA of a build made with `--out`: `apple build` notes its IPA and out folder in `.storebound/last-apple-build.json`, and upload searches `--out` (new flag), then that last build's out, then `apple.build.out` / `build/ios`. When nothing is found, the error names every folder it searched and the last build's IPA.
|
||||
|
||||
## 0.8.56 · 2026-10-27
|
||||
|
||||
- Release type. `apple.releaseType: afterApproval | manual | scheduled` (+ `apple.earliestReleaseDate`): `apple listing` creates versions with it (MANUAL when unset, existing versions untouched) and `apple submit` / `resubmit` set it before submitting when it differs, the dry run showing old -> new. New `apple release --after-approval | --manual | --scheduled <ISO date> [--platform] [--version] [--first-release] --yes` changes it on a version App Review has not approved yet (clear refusal otherwise, with `--manual-release` as the way out); exit 7 when it already has that type; an update is an agent step, after-approval on the first version of an app is exit 3 OWNER_STEP. Pitfall 125.
|
||||
- Device families. `validate` and `apple build` read the device families of the last live version (build bundle `deviceProtocols`, device models, iPad screenshot sets) and compare them with TARGETED_DEVICE_FAMILY in the Xcode project and `apple.ipad`; a gap stops `apple build` before the archive with the exact fix (ITMS-90101 otherwise comes after the build; `--allow-device-gap` overrides). Offline `check` warns when the live families are unknown or the config and project disagree. Pitfall 126.
|
||||
- TestFlight. `apple testflight testers --invite` checks that the tester's group has an installable build (past Beta App Review for external groups); without one it adds the tester, skips the invite and prints when it can go out (exit 0). Apple's 409 NO_INSTALLABLE_BUILDS is handled the same way; `distribute --submit-review` prints the hint for testers not invited yet. Pitfall 127.
|
||||
|
||||
## 0.8.55 · 2026-10-26
|
||||
|
||||
- `apple build --path-free`: a checkout in a shared build folder outside the home folder is no longer a leak needle (dsymutil and the module cache record it; only home and user-name paths count).
|
||||
|
||||
## 0.8.54 · 2026-10-26
|
||||
|
||||
- `apple build` is hermetic and can be path-free. `--derived-data <dir>` (`apple.build.derivedData`) is xcodebuild `-derivedDataPath`; with `--out` (or `apple.build.out`) it defaults to `<out>/DerivedData` instead of `~/Library/Developer/Xcode/DerivedData` (without `--out` Xcode's folder stays). `--path-free` (`apple.build.pathFree`) remaps the home folder, checkout, out and DerivedData out of the app binary and the archive's dSYMs / Symbols (Swift `-debug-prefix-map` / `-file-prefix-map`, clang `-ffile-prefix-map`; dwarf-with-dsym stays, symbol upload keeps working), scans the archive and stops before the export with the hit count and the first file + offsets (never the paths). `--scan-paths` only scans and warns. A `--out` outside the home folder prints a hint. `--json` / `latest.json` carry `derivedData`, `pathFree` and `leaks`. Store-pitfalls 124.
|
||||
|
||||
## 0.8.53 · 2026-10-26
|
||||
|
||||
- Updates are agent steps (the owner's standing rule), first releases stay the owner's. `play release <aab> --production completed|inProgress --yes`, `play upload --track production --yes`, `apple submit --build <n> --yes` and `apple release --manual-release --yes` run without an owner gate, in a terminal and in a pipeline, when the store says the app is already live (Play: the production track holds a completed / inProgress / halted release; App Store: a version of the platform is or was `READY_FOR_SALE` / `READY_FOR_DISTRIBUTION` / `REPLACED_WITH_NEW_VERSION`; `cli/lib/release-kind.ts`). The result line carries `releaseKind`; a re-run is exit 7. The first-ever release of an app with `--yes` is exit 3 `OWNER_STEP` ("first release of this app: the owner approves it once"), approved by `--first-release` (after the owner's yes) or bypass mode; a person answering the prompt (no `--yes`) is the approval too. `apple release` is now a ship step (`--json`, `--result-file`, exit 7 when the version is released already). Policy in docs/bypass.md ("Updates are agent steps"), docs/ship-step.md, the release-management and play-first-release skills, AGENTS.md; pitfall 123 / 96.
|
||||
|
||||
## 0.8.52 · 2026-10-26
|
||||
|
||||
- `analytics snapshot` and `reports` for a collector: a store-only config may name the App Store app by `apple.appId` alone (`bundleId` null or absent; every write still needs it); the Play reports bucket also comes from env `PLAY_REPORTS_BUCKET` or the credential profile's `reportsBucket` when `play.reportsBucket` is missing; `analytics snapshot --json` prints the snapshot as one JSON document on stdout (also when every store failed, with the exit code 1) and `--no-write` skips the file.
|
||||
- Snapshots carry active-user numbers per day: App Store App Usage (`sessions`, `activeDevices`, `sessionSeconds`, `crashes` from the App Sessions and App Crashes reports; a missing usage report is a `notes` line, not a failure) and Play `activeDeviceInstalls` and `totalUserInstalls` (installs overview file, else the country file summed) next to `installs` (Daily User Installs).
|
||||
- `reports apple-sales --days N | --from YYYY-MM-DD [--to ...] --json` reads the daily SALES reports of a range into one JSON (per-day summary and totals per currency, range totals per currency, `missing` days listed, never errors); `reports play-sales --days N | --from ...` gives the daily revenue per currency from the estimated sales reports (orders, refunds, charged, refunded, net, by order charged date). MCP `reports` takes `days`, `from`, `to`.
|
||||
- Release: the dev-channel CI job no longer gates a release.
|
||||
|
||||
## 0.8.51 · 2026-10-26
|
||||
|
||||
- Tagged but not published (the dev-channel job hit a CDN 502); everything ships in the next version.
|
||||
|
||||
## 0.8.50 · 2026-10-25
|
||||
|
||||
- App Groups fail early under automatic signing too: `apple signing status` / `ensure` and `apple build` read the App ID's APP_GROUPS capability settings and the newest profile the API lists for each bundle id (an Xcode-managed one too) and compare it with the target's `com.apple.security.application-groups`. A profile without the group (or the capability off) stops `signing ensure` and `apple build` before the archive with the exact portal steps (Certificates, Identifiers & Profiles > Identifiers > the ID > App Groups > Configure > tick the group > Continue > Save), the deep link `.../identifiers/bundleId/edit/<resource id>`, the plan command and `signing ensure --refresh-profile`; a group nothing can prove only warns with the same steps. `apple build --allow-app-group-gap` overrides.
|
||||
- New `storebound console plan apple-app-group [--bundle-id <id>] [--groups group.a,group.b] [--lookup]` (MCP `console_plan`: `bundleId`, `groups`, `lookup`): the agent-first developer portal plan to assign an App Group to an extension's App ID (start URL per bundle id resource id, portal labels, answer sheet per extension, never-press list: Remove / Delete / Revoke). Its final Save is the agent's own (`buttons.finalByAgent`: a capability edit, not a store submission). `console verify apple-app-group` (MCP `console_verify`, API only) re-reads the App ID and the newest profile and fails while a group is missing or cannot be proven.
|
||||
- Google access through a credential broker (`~/.storebound/broker.json`: `url`, `apiKeyFile`; env `STOREBOUND_BROKER_URL` / `STOREBOUND_BROKER_KEY_FILE`): Play tokens come from the broker's `POST /api/google/token` (in memory until 5 minutes before expiry, one request per account and scope, never logged; ya29. tokens and the API key redacted in errors), the local service account key stays the fallback when the broker does not answer or does not know the account; a broker that refuses the API key stops without retry. `PLAY_SERVICE_ACCOUNT_EMAIL` names the account (env, profile, project file); a profile opts out with `"broker": false`. The key file must be mode 600 on macOS / Linux. Play write commands read the package's Play permissions and enabled APIs from the broker first and stop before any Play call on a missing one (`CAN_MANAGE_TRACK_APKS`, `CAN_MANAGE_PUBLIC_LISTING`, `CAN_MANAGE_ORDERS`, `CAN_MANAGE_APP_CONTENT`, the Games Configuration API) with the exact permission or API and its link; `validate` lists them (gaps are blockers, SDK config drift a warning) and `--json` carries `credentials.play.source`; `doctor`, `accounts` and `apps check` / the desktop's Accounts view say whether tokens come from the broker or the key file. A Google 403 SERVICE_DISABLED / PERMISSION_DENIED in the Play client names the API or grant with its fix link and is never retried (pitfall 122, docs/credential-broker.md).
|
||||
|
||||
## 0.8.49 · 2026-10-25
|
||||
|
||||
- `play listing` for a language Play does not have yet uploads, in the same edit as the text, everything Play requires to commit it: icon, feature graphic, 2+ phone screenshots from `store/renders/<lang>/` and the tablet sets when the other languages have them (a set the folder lacks falls back to the default language's). The dry run lists the uploads or refuses with the missing file, instead of a 403 "too few screenshots" at commit. `play images` creates a Play listing that does not exist yet (text from the listing file) together with its images instead of skipping the language (pitfall 102).
|
||||
|
||||
## 0.8.48 · 2026-10-24
|
||||
|
||||
- Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged.
|
||||
- Desktop right-click menus in the window work the same way: a menu's pin button keeps it open as a floating panel, a submenu can be torn off (its pin button, Ctrl+click or a middle click), pinned panels survive the next menu, can be dragged, and update in place when the data behind them changes ("Gone" when the thing they were for left the screen).
|
||||
- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas.
|
||||
- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them).
|
||||
- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size.
|
||||
|
||||
- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table.
|
||||
- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/<id>`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides.
|
||||
- `apple build` over SSH / in CI: the refusal now names the fixes (GUI session `.command` file, a CI runner with an unlocked keychain, a build keychain). New `--keychain-unlock` (keychain password from `STOREBOUND_KEYCHAIN_PASSWORD`, never printed; runs `security unlock-keychain` and `set-key-partition-list`) and `--keychain <path>` (sign with a build keychain).
|
||||
- `storebound api` writes: the usage, the dry-run message and the refusals show the agent form with an example (`--yes --confirm <operationId>`), and the store-api skill says both flags are needed.
|
||||
- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch).
|
||||
- `apple upload` / `play upload` `--pre-check "<cmd with {file}>"` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence <path.json>` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`.
|
||||
- New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate "<cmd>"`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language.
|
||||
- `apple game-center --locale-map en=en-GB` (config `games.localeMap`) maps a config language to the live Game Center locale; the dry run suggests it when the live Game Center has exactly one variant of the language.
|
||||
- `play games`: a 403 SERVICE_DISABLED / accessNotConfigured names the Google Play Game Services Publishing API, the enable URL for the project and `console plan play-games-services`.
|
||||
|
||||
- `apple build --platform macos --tauri <src-tauri> [--path-free] [--unsigned]`: Mac App Store build of a Tauri app. Generated sandbox entitlements, Mac App Store profile (installed, `--profile` or from the API), Apple Distribution / Mac Installer Distribution identities, `tauri build --bundles app`, checks of the .app, `productbuild` to a signed .pkg, `pkgutil --check-signature`; `--path-free` remaps local paths in rustc and fails on any leak of the home folder or user name. `apple upload <pkg> --platform macos` uploads it (platform MAC_OS). New certificate types MAC_APP_DISTRIBUTION and MAC_INSTALLER_DISTRIBUTION. Pitfall 119.
|
||||
- Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `<next patch>-dev.<UTC commit time>` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session).
|
||||
- `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `<frames>/<lang>/<size>/<name>.png` (feature: `<lang>/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`.
|
||||
- `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID.
|
||||
|
||||
## 0.8.47 · 2026-10-23
|
||||
|
||||
- `storebound desktop`: an update no longer reports "installed" while the old app stays. It stops the running app first (exact install path only, graceful close, then by PID), checks the installed exe's own version (Windows PE version resource instead of the registry entry; Info.plist on macOS) and fails with what it found, and prints "opened" only when a visible (not `--hidden`) process started from the installed path, otherwise it names the app's log.
|
||||
- `play products` / `play subscriptions`: regions outside Play's min / max (a 649.99 USD product in KR: "must be between ₩70 and ₩600,000") are flagged in the dry run (old -> new) and clamped to the bound on write with a warning; `--skip-out-of-range-regions` leaves them out. Limits Play reports in a 400 are learned (`.storebound/play-price-limits.json`) and the write is retried. A product or subscription that fails no longer stops the others: per-product results, exit 1 at the end.
|
||||
- `apple iap` / `apple subscriptions`: edits to approved (ACTIVE) localizations, which App Store Connect refuses with 409 UNMODIFIABLE, are marked "not editable now" in the dry run and skipped with a warning on write; prices and the other changes still apply. One IAP failing no longer stops the rest (results at the end, exit 1).
|
||||
|
||||
## 0.8.46 · 2026-10-23
|
||||
|
||||
- `storebound watch` has an `ingest` sink (`--notify ingest`, `ingest` in ~/.storebound/watch.json): a JSON POST per event to a push ingest endpoint with a Bearer token read from a file at send time (refused when group or world readable), 8 KiB body cap, 60 sends a minute, one retry on 5xx; `watch test` covers it.
|
||||
- Ship-step contract for release pipelines (docs/ship-step.md): `play build | upload | release`, `apple build | upload | submit | resubmit` exit 0 ok, 1 failure, 2 usage, 3 needs the owner (with `error.code` and the console deep link), 4 store refused, 7 nothing to do; `--json` ends with exactly one result line on stdout, `--result-file` writes it atomically (also on failure), `--progress json` gives one JSON line per phase on stderr.
|
||||
- `--env-file <path>` loads KEY=VALUE secrets before the credentials resolve (POSIX: mode 600 or stricter) and every known secret value is scrubbed from stdout, stderr and the result file; `--version-from version.json` takes the version and build ({version, build}, {versionName, versionCode}, {marketing, build}).
|
||||
- Re-runs never upload twice: `play upload` with a versionCode Play has and `apple upload` with a build number App Store Connect has end with exit 7 and what is there; `play release` with the same release and `apple submit` of an already submitted build too.
|
||||
- `play upload --track production` (the pipeline's update step; `--status draft` keeps it for the Console) and `--version-code N` to pick the AAB.
|
||||
- New `storebound status [--app <name|store id|folder>] [--json]`: per platform the live and next version, build, track, review state and rollout %, read-only. `release status --app` also takes a folder or a store id.
|
||||
- Desktop: every read goes through one registry: one store call per read however many buttons are clicked, overlapping buttons (Refresh all, card refresh, Check again, Import, Test) are busy and disabled together, a read just finished is not repeated, and all reads share the scheduler's rate limits and backoff.
|
||||
- Desktop: tray mini card on left click (rejections, releases moving, crash alerts, recent low reviews, refresh, quick actions); right click keeps the menu.
|
||||
- `apple build` / `apple upload`: `--allow-version-mismatch` is accepted (it was missing from the flag list); 1.0 and 1.0.0 count as the same version train, as on App Store Connect.
|
||||
- Desktop: custom context menus everywhere (apps, releases, art, reviews, checks, fields, products, accounts, folders) with keyboard navigation, Shift+F10 and submenus; the WebView2 browser menu and browser shortcuts are off.
|
||||
- Desktop: each app shows once again (a folder project no longer appears a second time as a store-only app); a store app with a folder's bundle id or package is never listed separately.
|
||||
- Desktop UX pass on every screen: app tabs fit the window, slim app header, secondary actions in "…" menus, Ctrl+K lists app actions, Alt+Up/Down switches apps, accounts and folders decluttered.
|
||||
- Play Console links open the app's own page: developer id per credential profile (`storebound accounts play-developer <profile> <id>`, editable in Studios & accounts), one link builder for desktop, CLI and watch.
|
||||
- Desktop: tabs in one row with a More menu, one Refresh, reviews keyboard (J/K, R, C, O), Settings and More tools search, sticky first column and compact density for wide tables, styled in-app dry-run preview before every write.
|
||||
- Checks: "Open guide" opens the matching skill for every finding.
|
||||
|
||||
## 0.8.45 · 2026-10-23
|
||||
|
||||
- Tagged but not published (the release upload could not reach the server after an IP change); everything ships in the next version.
|
||||
|
||||
## 0.8.44 · 2026-10-23
|
||||
|
||||
- Tagged but not published (desktop clippy red on the bump commit); everything ships in the next version.
|
||||
|
||||
## 0.8.43 · 2026-10-23
|
||||
|
||||
- Tagged but not published (CI red: scrub hits in test fixtures, a ship-step test); everything ships in the next version.
|
||||
|
||||
## 0.8.42 · 2026-10-23
|
||||
|
||||
- Tagged but not published (generated API tables differed on Linux CI); everything ships in the next version.
|
||||
|
||||
## 0.8.41 · 2026-10-23
|
||||
|
||||
- Tagged but not published (a generated API table was stale in CI); everything ships in the next version.
|
||||
|
||||
## 0.8.40 · 2026-10-23
|
||||
|
||||
- Tagged but not published (the desktop boot check stopped it); everything ships in the next version.
|
||||
|
||||
## 0.8.39 · 2026-10-23
|
||||
|
||||
- Tagged but not published; everything ships in the next version.
|
||||
|
||||
## 0.8.38 · 2026-10-23
|
||||
|
||||
- Desktop: the app hero no longer shows a cropped second copy of the app icon.
|
||||
- `play build` passes the upload key (ANDROID_KEYSTORE, ...) to Gradle as injected signing properties (store file and alias as `-P` arguments, the passwords only through `ORG_GRADLE_PROJECT_*` environment variables, never on argv), so a build file that signs only from a local keystore.properties no longer gives an unsigned AAB in CI (pitfall 107).
|
||||
- `play build`, `play upload` and `play release` of a local AAB verify it is signed (META-INF entries, `jarsigner -verify` when a JDK exists) and fail with the fix before any upload.
|
||||
- `apple build --version` and `apple upload` refuse an IPA whose CFBundleShortVersionString is not the intended / editable App Store version; `--allow-version-mismatch` overrides (pitfall 108).
|
||||
- Play release notes that name iOS, iPhone or the App Store are caught by `storebound check` and `play build`, not only at release time (pitfall 109).
|
||||
|
||||
## 0.8.37 · 2026-10-22
|
||||
|
||||
- `storebound apps ignore|unignore [--store apple|play]`: hide dormant apps, or one store side of an app, from watch, reads and the desktop (MCP apps_ignore / apps_unignore).
|
||||
- Reviews: a store read that fails is an error, not an empty "no new reviews" list; the Reviews tab notes Apple API lag and Play's 7-day window (pitfall 106).
|
||||
- Desktop: live store thumbnails load right away, also in a hidden window.
|
||||
|
||||
## 0.8.36 · 2026-10-22
|
||||
|
||||
- `storebound watch`: a store-only App Store app added without a bundle id is no longer skipped; the bundle id is read from the API with the app's own key.
|
||||
|
||||
## 0.8.35 · 2026-10-22
|
||||
|
||||
- Desktop v2 ships (0.8.33 and 0.8.34 stopped at the desktop boot check, which still expected the old markup).
|
||||
|
||||
## 0.8.34 · 2026-10-22
|
||||
|
||||
- Desktop v2 ships (0.8.33 stopped at the desktop boot check, which still expected the old markup).
|
||||
|
||||
## 0.8.33 · 2026-10-22
|
||||
|
||||
- **Desktop v2**: every screen redesigned in one language: portfolio home, releases (store cards, stage stepper,
|
||||
rollout ring), submit checklist, TestFlight, listings as store pages with completeness, framed screenshots with issue
|
||||
badges, products and price matrix, health tiles, review feed with rating distribution, growth funnel, readiness score
|
||||
with fix-first, tools, settings, accounts, folders, dependencies and onboarding.
|
||||
- Desktop: tray with a context menu, autostart, silent auto-update and notifications by event kind (rejections too),
|
||||
also while the window is hidden.
|
||||
|
||||
## 0.8.32 · 2026-10-22
|
||||
|
||||
- Node: a `storebound.config.json` (and every store-only app) loads again; it needed an import attribute. Pitfall 105.
|
||||
|
||||
## 0.8.31 · 2026-10-22
|
||||
|
||||
- **`storebound watch`**: store events for every app on the machine (rejected, approved, live, rollout halted, crash
|
||||
threshold, low or unanswered reviews), sent to ntfy (phone), Telegram, a webhook or an OS notification; runs as a loop
|
||||
(`--every 15m`) or `--once`; a PM2 recipe for a server is in docs/watch.md. `storebound events` and the MCP tool
|
||||
`events_recent` let people and agents read what happened.
|
||||
|
||||
- **Desktop notifications that arrive.** The app reads the stores in the background from the tray too (it skipped
|
||||
every read while hidden, so a rejection went unnoticed), every 15 minutes with backoff. Notifications by kind, each
|
||||
with a switch: rejected, approved, live, halted, crash / ANR, low-rated reviews; quiet hours and mute for an hour.
|
||||
Opening the window after a notification lands on that app.
|
||||
- **Tray:** Open, Read the stores now, an Apps menu with each app's state on both stores, Mute, Check for updates,
|
||||
Settings, Quit; a tooltip with what is in review and what needs you; a red dot on the icon after a rejection or halt.
|
||||
|
||||
- `apple listing` refused by an approved version waiting for a manual release now names the way out:
|
||||
`storebound apple release --manual-release --version <v>`. `play release --dry` shows the staged rollout share.
|
||||
|
||||
## 0.8.30 · 2026-10-22
|
||||
|
||||
- **Desktop: a new home.** Store-style app cards (your rendered screenshots, or the icon on its tint), a release
|
||||
pipeline per store (Build -> Testing -> Review -> Live -> Rollout), the crash curve and one next-step button per app;
|
||||
below, What changed and Fix first. The table view stays one switch away.
|
||||
|
||||
## 0.8.29 · 2026-10-22
|
||||
|
||||
- **Existing store items are compared with the config, field by field** (pitfall 104). The dry run shows every
|
||||
difference (old -> new); texts are sent, money and availability need a flag, fields Apple cannot edit are named.
|
||||
- `apple iap`: price (`--allow-price-changes`), localization names and descriptions, reference name, review note,
|
||||
family sharing (reported), territories.
|
||||
- `apple subscriptions`: prices in the dry run, localizations, group names, reference name, review note, level,
|
||||
territories; intro offers, period and family sharing reported.
|
||||
- `play subscriptions`: offer phases and prices, other-regions price, regions no longer sold (all price-gated),
|
||||
grace period, languages missing from the config kept unless `--prune-languages`.
|
||||
- `apple appinfo`: app price (`--allow-price-changes`) and territories (removals `--allow-availability-changes`);
|
||||
`apple review-details --dry` shows old -> new.
|
||||
- Offer-code and win-back campaigns that differ are named; in-app event attributes patched on drafts; a removed
|
||||
custom page deep link cleared; config languages without a version localization named.
|
||||
- `prices diff` (the `catalog diff` report).
|
||||
|
||||
## 0.8.28 · 2026-10-22
|
||||
|
||||
- Windows: no false "entitlements not referenced by CODE_SIGN_ENTITLEMENTS" warning (paths compared with forward slashes). Pitfall 103.
|
||||
|
||||
- **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at
|
||||
the same app on this machine (by its path or its app name), ids kept so the desktop keeps its accounts and settings;
|
||||
`apps folders` says so when entries are missing. `apps add` names an app after its config's app name.
|
||||
|
||||
## 0.8.27 · 2026-10-21
|
||||
|
||||
- **Desktop: tray app that starts at logon and updates itself.** First run registers a logon entry (`--hidden`): the app
|
||||
starts silently in the tray; closing the window keeps it there, the tray menu quits. A signed update is checked a
|
||||
minute after start and every 30 minutes, installed silently, and the app restarts as it was.
|
||||
|
||||
- **App Privacy console plan follows today's page** (verified 2026-10-22): on an app published before, Edit opens the
|
||||
Data Collection question first and the type list ends in Continue; each type's last step is Publish (it goes live,
|
||||
no draft). Publish stays the owner's unless bypass mode is on.
|
||||
|
||||
- **A new Play language in one run.** `play listing` uploads the hi-res icon (and the feature graphic when rendered) for a
|
||||
language Play does not have yet, in the same edit (Play refused the commit with 403 otherwise). Pitfall 102.
|
||||
- Script traces skip test files (`*.test.*`, `*.spec.*`).
|
||||
|
||||
- **CI-friendly Play output.** `storebound aab <file.aab> --json` reads packageName, versionCode, versionName, SDKs and
|
||||
sha256 offline (no config, no upload); `play release ... --json` prints {versionCode, sha256, name, tracks[{track,
|
||||
status, userFraction}]}. `play status --json` already lists each track's releases.
|
||||
|
||||
- **`apple build` stops at once over SSH** (codesign fails there with errSecInternalComponent) and names how to run it
|
||||
in the logged-in session; relative `ASC_P8` / `PLAY_SERVICE_ACCOUNT` paths are read from the project folder and
|
||||
xcodebuild gets an absolute key path. Pitfall 101.
|
||||
|
||||
## 0.8.26 · 2026-10-21
|
||||
|
||||
- **Bypass mode (owner opt-in, default off).** `storebound settings bypass on` (or "y" in `storebound init`) lets the AI
|
||||
agent press the owner-only buttons too: MCP `owner_step` (submit, release, rollout, testers, replies, prices, ...;
|
||||
dry run first), owner-only `storebound api` operations with `--yes --confirm <operationId>`, console plans'
|
||||
`buttons.final`. Every action is logged to `.storebound/owner-actions.log`; `bypass off` restores the old behaviour.
|
||||
docs/bypass.md.
|
||||
|
||||
- **Build dependencies in `doctor` and the desktop Dependencies screen.** JDK (with the version the project needs from
|
||||
the Android Gradle plugin / jvmToolchain, "too old" when below it), Android SDK command-line tools, CocoaPods, Node.js,
|
||||
Bun and bundletool join ffmpeg, Chrome, Xcode, adb and git. `storebound doctor --install <tool> --yes` runs this OS's
|
||||
package-manager command (winget / brew); sudo and admin commands are printed, never run.
|
||||
|
||||
- **A livelier terminal.** At a terminal, lines get colors and status emoji (✅ ⚠️ ❌ 📦 🚀 🧪) and every wait ("waiting
|
||||
for ...", uploads, downloads, progress) is one live spinner line with the elapsed time. Piped, CI, `NO_COLOR`,
|
||||
`TERM=dumb`, `--json` and MCP keep the plain lines exactly as before.
|
||||
|
||||
- **MCP `console_verify` reads multi-line page text whole** (it kept only the first line on Windows), and long JSON
|
||||
output such as `console_plan` keeps its start and names a file with the whole output. Pitfall 100.
|
||||
|
||||
- **Windows builds.** `play build` runs `gradlew.bat` through `cmd /c` (it was looked up on PATH only) and takes
|
||||
`C:/...` keystore paths as absolute. Pitfall 98.
|
||||
- **`check` verifies credential files.** Every credential path that does not exist on this machine and an upload keystore
|
||||
the stored password / alias do not open (keytool) are named by field. Pitfall 99.
|
||||
- `play build` / `apple build` write `<out>/latest.json` with the AAB / IPA path for CI; CI install from a git clone documented.
|
||||
|
||||
- **Scripts that call the store APIs themselves are named.** `androidpublisher.googleapis.com`, the googleapis
|
||||
androidpublisher client, `api.appstoreconnect.apple.com`, altool and Transporter in CI files and scripts: `check` and
|
||||
`release status` warn, `scan` reports `direct-store-api`, each with file:line and the storebound command. Pitfall 97.
|
||||
|
||||
## 0.8.25 · 2026-10-21
|
||||
|
||||
- **fastlane left in a project is named.** `check` and `release status` warn and `scan` reports `fastlane-upload`
|
||||
when a Fastfile / Appfile or a CI file or script still runs `fastlane supply`, `deliver` or `pilot`, with the
|
||||
file:line and the way over (`storebound import fastlane`). Pitfall 96.
|
||||
|
||||
## 0.8.24 · 2026-10-21
|
||||
|
||||
- **Windows: the MCP server starts.** `storebound install` writes `cmd /c npx -y storebound@<version> mcp` (or
|
||||
`bun <toolchain>/cli/index.ts mcp`) on Windows, where `npx.cmd` and the sh launcher cannot be spawned. Pitfall 94.
|
||||
- **`storebound desktop` no longer waits forever on a stalled download.** Progress every 10 %, and an error after
|
||||
60 s without data. Pitfall 95.
|
||||
- Windows checkouts keep LF line endings (`.gitattributes`); README badges: live npm version, real skill count.
|
||||
|
||||
## 0.8.23 · 2026-10-21
|
||||
|
||||
- **`apple submit --yes` submits a draft prepared with `--no-submit`.** The second step of the two-step flow
|
||||
ended with "nothing to submit" when the draft already held the version and every item; it now sends the submit
|
||||
(same "not ready yet" retry). `iap-submit` the same for a draft that holds the asked-for items. Pitfall 90.
|
||||
|
||||
## 0.8.22 · 2026-10-20
|
||||
|
||||
- **`apple submit` / `resubmit` switch Game Center on first.** A build with the Game Center entitlement and an app
|
||||
without a Game Center detail was refused with 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED; submit,
|
||||
resubmit and `apple signing ensure` now create `gameCenterDetails` and the version's `gameCenterAppVersions` first
|
||||
(dry run shows it), and `validate` blocks while either is missing. Pitfall 89.
|
||||
- **`apple testflight distribute` creates missing configured groups first** (only the asked-for ones, never removes
|
||||
one); tester invites stay the owner's.
|
||||
- **Console plans follow the live pages (2026-10-21 run).** Play health: "My app does not have any health features"
|
||||
(verify accepts it, "Other" stays a fallback); Sign in details: "Is any part of your app restricted?" Yes / No and
|
||||
"Add details" (old labels kept as `altLabels`); finance: the boxes on step 1, step 2 information only; App Privacy:
|
||||
the "Additional Setup Required" OK that opens the first type's setup by itself is in the steps and UI facts.
|
||||
- **Save after every page.** Every Play plan step that answers a page with a Save next to Next now says Save, wait
|
||||
for "Your changes have been saved", then Next; the rule line says it too.
|
||||
- **IARC by exact labels.** "Purchases of digital goods" is ticked after Yes on "Digital purchases" (the loot-box and
|
||||
trading follow-ups show only then); the in-page helper matches exact labels and questions and refuses patterns,
|
||||
radios without their question and blanket group answers.
|
||||
- **`console verify` reads input states.** The Play declaration plans' `verify.capture` returns every radio /
|
||||
checkbox with aria-checked / checked / aria-selected as JSON; `console verify <form> --from <file|->` takes it.
|
||||
- **`apple review-details` finds the contact** in parent folders' `.storebound/credentials.json` and the credential
|
||||
profile, under more key shapes, and lists every file and key checked when there is none.
|
||||
- **One-line errors for missing required values:** `apple listing` without `--version` says "--version <x.y.z> is
|
||||
required (storebound apple status shows the editable version)"; the same for privacy-manifest `--ios`, adhoc
|
||||
export `--archive`, edit `--ops` and resubmit `--build`. A flag given without a value counts as missing.
|
||||
- **Every CLI command is reachable in the desktop app.** A new **More tools** tab gives each command without a
|
||||
screen of its own (play build / upload / share / app-id, apple build / upload / signing / adhoc, bundle, IAP review,
|
||||
Header and Search Results, custom product pages, in-app events, pre-orders, what's new, listing translate / check,
|
||||
ASO rank / competitors / suggest, captures, fonts, JPEG copies, triage, originality, privacy manifest, ads scan,
|
||||
Apple Ads reports, web, RevenueCat diff, PPP sheet, workflows, install for an AI tool) one typed form with the
|
||||
command's own options, built from the method's schema; never a free-text shell. Reads run there; writes show the
|
||||
dry run, then the native dialog, then run with the confirm token. Owner-only steps (offer codes, win-back,
|
||||
promoted enable, pre-order enable / end, custom page and event submit, TestFlight expire, Apple Ads changes,
|
||||
RevenueCat sync, shots login) are listed with their terminal command to copy. New desktop methods in `serve`:
|
||||
`play.build`, `play.upload`, `play.share`, `play.app_id`, `apple.build`, `apple.upload`, `apple.signing_ensure`,
|
||||
`apple.adhoc_register`, `apple.adhoc_export`, `shots.fonts`, `shots.jpeg`, `agent.install`.
|
||||
- **docs/desktop-coverage.md** (generated by `bun tools/gen-desktop-coverage.ts` from `cli/lib/desktop-coverage.ts`):
|
||||
every command with its window place and methods; 92 commands, 88 covered, 4 not for the desktop (`mcp`, `serve`,
|
||||
`desktop`, `api`). `tests/desktop-coverage.test.ts` fails when a command has no place, names a method the window
|
||||
never calls, or is marked not-for-desktop without being on the short list.
|
||||
- **Game Center and Play Games Services.** A `games` config section (switches per store and one shared list of
|
||||
leaderboards and achievements). `storebound apple game-center` switches Game Center on for the app and the version
|
||||
and upserts leaderboards, achievements and their localizations by vendor id; `apple signing ensure` adds the
|
||||
GAME_CENTER capability. `storebound play games` upserts the Play drafts through the Games Configuration API, and
|
||||
`console plan play-games-services` walks an agent through the games project, the Android credential with the app
|
||||
signing SHA-1, the web OAuth client and the owner's Publish. `scan`, `check` and `validate` compare the entitlement,
|
||||
the App ID and the config, the games_APP_ID resource with the Gradle dependency and the config, and remind the User
|
||||
ID declarations; `games status`, four MCP tools and a Game services card in the desktop's App info tab. Skill
|
||||
game-services, pitfall 88.
|
||||
|
||||
## 0.8.21 · 2026-10-20
|
||||
|
||||
- **`shots record --source web` films at device pixels.** Chromium's screencast always sent frames at the page's CSS
|
||||
size (430x932 for a 430x932 @2x frame), so a 1080x1920 preview was a 2.5x upscale. The recorder now takes
|
||||
`Page.captureScreenshot` frames with the clip scaled to the frame's dpr (860x1864 there; long side capped at 1920,
|
||||
the tallest store video), keeps 3 requests in flight (~45-55 frames/s) and joins them at their real timings as
|
||||
before. It warns when the first frame is smaller than asked or the rate drops under 20 frames/s.
|
||||
|
||||
## 0.8.20 · 2026-10-20
|
||||
|
||||
- **Tooling: the desktop updater signatures of the Linux and Windows installers are made on the VDS.** The release
|
||||
no longer downloads them to the Mac: the VDS fetches them from the Forgejo release, checks their SHA-256 against the
|
||||
candidate, signs them with its copy of the updater key and sends back only the .sig texts, which the Mac verifies
|
||||
against the app's public key before publishing. `desktop-release.ts vds-key` installs or rotates that key;
|
||||
`release.ts --sign-local` keeps the old path.
|
||||
|
||||
## 0.8.19 · 2026-10-20
|
||||
|
||||
- **`shots capture` / `render` find Playwright's chromium in a project's CommonJS playwright** whether it comes on the
|
||||
namespace or under `default`; when the module has no `chromium.launch`, the error names the resolved file and its
|
||||
exports (was "Cannot read properties of undefined (reading 'launch')").
|
||||
- **Heavy app-style headlines again:** `shots fonts` downloads the weights the templates use (the full `wght` axis of a
|
||||
variable Google family, else the used range, else the static weights), so a family like Plus Jakarta Sans no longer
|
||||
falls back to its thin 400 instance. `shots fonts` warns about weights a family or a local file (`{ weight }`) lacks;
|
||||
`shots check` warns per image when a title was drawn at another weight (`font-weight`).
|
||||
- **palette.bg as a string:** one colour (flat) or a CSS gradient string now renders as given instead of a white
|
||||
background; anything else fails config validation ("palette.bg must be a colour, a gradient or an array of 2-3
|
||||
colours"). `shots check` warns when the title ink has low contrast against the pixels behind it (`contrast`).
|
||||
- **Photo backgrounds:** `palette.image` (+ `overlay`) and `shots.feature.background` put a cover-cropped photo under a
|
||||
dark overlay (the Play feature graphic, optionally the screenshots); `shots check` checks the file and its size.
|
||||
|
||||
## 0.8.18 · 2026-10-20
|
||||
|
||||
- **No more "Reparsing as ES module" warning** for a `.ts` config in a CommonJS project (Node's
|
||||
MODULE_TYPELESS_PACKAGE_JSON); the app does not need `"type": "module"`.
|
||||
- **Capture sign-in keeps App Review's account apart:** the docs use `captureAuth` for a local demo sign-in, and
|
||||
`check` notes a capture against localhost that uses `reviewDemoAccount` (the account `apple review-details` sends).
|
||||
|
||||
## 0.8.17 · 2026-10-20
|
||||
|
||||
- `shots capture --source web` can sign in first: `shots.capture.auth` is a sign-in request (run once per browser
|
||||
context, cookies kept; body and headers use `${cred:NAME}` from `.storebound/credentials.json` or `${env:NAME}`,
|
||||
literal secrets are refused, a failed status stops with the status only) or a `storageState` file. `storebound shots
|
||||
login` opens a fresh visible browser for a demo sign-in by hand and saves `.storebound/auth.json` (0600, counts
|
||||
only). A shot that lands on a sign-in page warns "session expired? run storebound shots login again". Secrets and
|
||||
cookie values are masked in logs and the capture report.
|
||||
|
||||
## 0.8.16 · 2026-10-20
|
||||
|
||||
- `apps folders add [<folder>]` adds the folder (it listed and added nothing); a template config is added with a note,
|
||||
a folder without a config is refused with the reason and the fix (pitfall 81).
|
||||
- MCP: the plugin's `.mcp.json` no longer spawns a plugin path that may be gone (ENOENT, CONNECTION_CLOSED): it falls
|
||||
back to `storebound` on PATH, then `npx -y storebound@latest`; `mcp` and `serve` skip the self-update (pitfall 82).
|
||||
- Store build minimums in one dated table (`cli/lib/store-minimums.ts`): `play upload` / `play release` refuse an AAB
|
||||
below Play's target API level (36 since 2026-08-31) and `apple upload` an IPA built below Xcode 26 / the 26 SDKs
|
||||
(since 2026-04-28), with the file to change; `scan` / `validate` report the same (`play-target-sdk`, `apple-min-sdk`)
|
||||
(pitfall 83).
|
||||
|
||||
## 0.8.15 · 2026-10-19
|
||||
|
||||
- **Mac App Store screenshots.** `apple.mac: true` adds the `mac` size (2880 x 1800; `shots check` takes exactly
|
||||
Apple's four 16:10 sizes, no alpha) and `apple screenshots --platform macos` uploads it as `APP_DESKTOP` to the macOS
|
||||
version. `validate` notes a macOS version without Mac screenshots. Art pull maps `APP_DESKTOP` to `mac/`.
|
||||
- **Landscape layout** in both templates: copy column plus the frame in a Mac window or bare (`shots.landscape`), for
|
||||
Mac, Play Chromebook (`play.chromebook`; no API type, `play images` links the console) and, with
|
||||
`shots.landscape.playTablets`, Play's 7" / 10" tablet slots.
|
||||
- **Safe margins:** text and logo keep 7 % of the width left / right and 5 % of the height at the top in every
|
||||
template (feature graphics too); `shots check` fails boxes under 6 % / 4 %, measured from the render's layout data.
|
||||
- **Contact sheet:** the `sheet.png` wait scales with the images, sizes and machine load, with a heartbeat, instead of
|
||||
a flat 30 s.
|
||||
- iPhone Duo skip message says plainly that the API has no Duo type and names the console page.
|
||||
|
||||
## 0.8.14 · 2026-10-19
|
||||
|
||||
- **Header and Search Results: upload, add for review, place once approved.** `storebound apple creative-assets`
|
||||
uploads each file once into the Asset Library (MD5 upsert: a rejected file or one that failed processing twice is
|
||||
not uploaded again, an archived copy is unarchived), and places it on the version as soon as Apple approved it: the
|
||||
version page's slots offer only approved assets. Until then every locale and placement says "uploaded <date>,
|
||||
waiting for Apple's approval; run `storebound apple creative-assets` again later", and a re-run places it (an
|
||||
approved old asset stays shown meanwhile). `--add-for-review` adds the unreviewed assets to the app's draft review
|
||||
submission (never submits; Submit for Review stays the owner's); `--verify` (MCP `apple_creative_assets_verify`)
|
||||
reads every slot back read-only with the same waiting state, and `validate` / the report list waiting files. A live
|
||||
app without an editable version uses the live version; if App Store Connect refuses a placement there, the run says
|
||||
so, leaves the live page unchanged (the new placement goes first, the old one is removed only after Apple accepted
|
||||
it), links the live version page and does not retry the other live slots. Art without words needs no copy per
|
||||
locale: `<renders>/creative/header.png` / `search.*` fill every language without its own file, uploaded once and
|
||||
placed per locale (the dry run counts files, not slots). Pitfall 76 rewritten.
|
||||
- **iPhone Duo.** Apple's published App Store Connect API spec (re-checked: still the 2026-10-06 file) has no Duo
|
||||
screenshot or preview type, so `apple screenshots` keeps skipping the Duo sets with the console link; the check /
|
||||
validate note now says the console takes Duo and the API does not yet.
|
||||
|
||||
- **macOS (tvOS, visionOS) versions are no longer invisible.** `apple status`, `listing`, `listing pull`, `resubmit`,
|
||||
`submit`, `release` and `screenshots` take `--platform ios|macos|tvos|visionos` (MCP tools and the desktop's owner
|
||||
actions: `platform`); without it they use the platform that has the version they need (a rejected one, an
|
||||
UNRESOLVED_ISSUES submission, an editable one) and refuse, naming both, when several platforms do. Every version
|
||||
and review submission lookup is per platform, so `resubmit` finds the macOS submission back from App Review and
|
||||
`listing --version 0.8.7` works on the rejected macOS 0.8.7 while the iOS 0.8.7 is live. `apple status`, `validate`,
|
||||
`release status`, the report and the desktop label every version with its platform; validate and release status warn
|
||||
about another platform's editable or rejected version. `whats-new apply --platform` names it in the push command.
|
||||
Pitfall 79.
|
||||
- **Other platforms in store text are refused (both stores).** One checker (`cli/lib/cross-store.ts`) finds Android,
|
||||
Google Play (Games), Play Store, Galaxy Store, Amazon Appstore, Steam, Microsoft Store, AppGallery (and localized
|
||||
forms) in App Store text (Guideline 2.3.10) and iOS, iPhone, iPad, App Store, Apple Arcade, Game Center, TestFlight in
|
||||
Play text (Metadata policy), per locale, field and line with a fix. `apple listing`, `play listing`, `play release` /
|
||||
`play upload` release notes, `listing set`, `whats-new check` / `apply`, `storebound edit` and the desktop editor
|
||||
refuse before anything is sent (dry runs show the hits); `check` and `validate` report blockers; `scan` reports
|
||||
`other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). A functional mention stays only through the
|
||||
owner's `listing.allowPlatformMentions: [{ store, text, reason }]`.
|
||||
- **Per-store release notes.** Lines tagged `[apple]` / `[play]` (or a `[play]` line heading a block) in a shared notes
|
||||
source go only into that store's notes; the draft leaves untagged other-platform lines out of the wrong store and
|
||||
says so; each store's limit counts its own notes.
|
||||
- **`storebound api` writes piped or redirected are refused loudly.** `--yes` with stdout not a TTY now exits 1 with
|
||||
"refused: writes through `storebound api` need an interactive terminal ... Nothing was sent." on stderr instead of
|
||||
a silent dry run.
|
||||
|
||||
## 0.8.13 · 2026-10-18
|
||||
|
||||
- **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853)
|
||||
sets, portrait or landscape, to `shots render`, `shots check` (exact sizes, no alpha) and the gallery. App Store
|
||||
Connect API 4.5.1 has no Duo `ScreenshotDisplayType`, so `apple screenshots` skips them with a message and the deep
|
||||
link to the version page's App Previews and Screenshots tab (one constant, `IPHONE_DUO_DISPLAY`, takes the enum
|
||||
names once Apple adds them). `check` / `validate` note a missing Duo set: optional now, required from April 2027 for
|
||||
iOS 27.1 SDK builds (a warning after that date, never a blocker), and point Play to the tablet sets (no foldable slot).
|
||||
- **Required iPhone class follows the console.** `validate` now requires "iPhone with Dynamic Island (medium display)"
|
||||
(`APP_IPHONE_61`) per locale, met by a 6.9" or 6.5" set that App Store Connect scales down (an ok row, not a
|
||||
warning). Labels match the console: "iPhone with Dynamic Island (medium display)", "iPhone Duo", "iPad 13" display".
|
||||
|
||||
- **App Store Header and Search Results.** `storebound apple creative-assets [--dry]` puts a product page header
|
||||
(3840 x 1646, 21:9) and a search results asset (3:2, 1920 x 1280 to 3840 x 2560), image or video (5-30 s, 30/60
|
||||
fps), per locale from `<renders>/<lang>/creative/`, or one universal 5244 x 2950 PNG, into the App Asset Library and
|
||||
selects them (`PRODUCT_PAGE_HEADER_ASSET`, `APP_STORE_SEARCH_RESULTS_ASSET`) once Apple approved them; MD5 upsert,
|
||||
read back. Local checks: size, no alpha, video length / fps / container (ffprobe, else skipped with a note).
|
||||
`check` and `validate` note when there is none (Play: the feature graphic and promo video are the counterparts).
|
||||
MCP `apple_creative_assets`; config `apple.creativeAssets`; pitfall 76.
|
||||
|
||||
## 0.8.12 · 2026-10-17
|
||||
|
||||
- **Shareable readiness report.** `storebound report` puts `scan`, `check` and `validate` into one self-contained HTML
|
||||
page (`.storebound/reports/readiness-<date>.html`, inline CSS, no scripts, light / dark, print-friendly) and opens it
|
||||
in the browser: a verdict per store ("Ready to submit", "N blockers", "Not checked" with the reason when a store key
|
||||
is missing), findings per store by severity with guideline, evidence, fix and an Open in App Store Connect / Play
|
||||
Console button, and the questions only the owner can answer as a checklist. Credentials, key ids, emails and the
|
||||
review demo account are masked. `--md`, `--json`, `--no-open`, `--offline`, `--out`. The desktop's Checks tab has a
|
||||
Report button (same code path) and its Markdown export uses the same renderer; MCP gets a read-only `report` tool.
|
||||
|
||||
- **Remote config and remote paywalls (App Review 2.3.1, Play Deceptive behavior).** `storebound scan` (and
|
||||
`validate`; `check` as a warning) reports `remote-features` for both stores when the app ships a remote config /
|
||||
feature flag SDK (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely, PostHog feature flags,
|
||||
Unleash, ConfigCat, Amplitude Experiment, Split) or a remote paywall (RevenueCat Paywalls, Superwall, Adapty,
|
||||
Qonversion, Purchasely, Apphud) and the review notes do not mention it. Warn only: storebound never writes the note;
|
||||
the config templates carry a commented example line. Code obfuscators are a low note, minify alone nothing
|
||||
(store-pitfalls 74).
|
||||
|
||||
## 0.8.11 · 2026-10-17
|
||||
|
||||
- **Guest-play apps rejected as a login wall (Play).** `reviewAccess.instructions` with `signInRequired: false` holds
|
||||
guest steps: `storebound check` warns when a Play app has a first-run gate (name entry, code / transfer entry) or
|
||||
in-app purchases and no steps, and the `play-sign-in-details` plan then declares one entry without credentials,
|
||||
the dialog's required full-access box as the owner's step (store-pitfalls 73).
|
||||
|
||||
## 0.8.10 · 2026-10-15
|
||||
|
||||
- **Neutral names only.** Docs, pitfalls, tests and comments use example names instead of the projects that reported
|
||||
issues; the history was rewritten the same way. Older npm versions are withdrawn.
|
||||
- **Releases:** a candidate whose build jobs were cancelled by a newer push says so and gives the tag move and
|
||||
`--resume` line.
|
||||
|
||||
## 0.8.9 · 2026-10-15
|
||||
|
||||
- **Play edits Play will not send for review are committed anyway.** After a rejection, with other pending changes or
|
||||
managed publishing, Play answers validate / commit with 400 "Changes cannot be sent for review automatically. Please
|
||||
set the query parameter changesNotSentForReview to true". storebound now commits the same edit again (edits.commit only: validate refuses the parameter with 400 "Unknown name")
|
||||
with that parameter (a dry run validates that way and says the real run will commit so), then prints "committed;
|
||||
Play did not send it for review: press Send changes for review in Publishing overview: <link>" (the owner's button).
|
||||
`--not-sent-for-review` on `play release`, `upload`, `rollout`, `listing` and `images` (MCP / desktop
|
||||
`not_sent_for_review`) sets it from the start. The API cannot read that changes wait to be sent, so storebound records
|
||||
its commit in `.storebound/` and `play status` shows "changes committed <time>, waiting for Send changes for review"
|
||||
(`--review-sent` forgets it). Pitfall 72.
|
||||
|
||||
## 0.8.8 · 2026-10-15
|
||||
|
||||
- **Console pages as absolute deep links.** Every finding, fix hint, error and plan step that points to an App Store
|
||||
Connect or Play Console page now prints the readable path plus the absolute URL of that page for the app
|
||||
(`cli/lib/console-links.ts`; apple.appId, play.developerId and play.appId pick the app, the app list otherwise).
|
||||
validate, scan, declarations and release plan JSON carry `url`, and the desktop shows it as an Open button. A test
|
||||
fails on a plain console direction without a URL; AGENTS.md rule 11 and pitfall 71 tell agents the same.
|
||||
|
||||
## 0.8.7 · 2026-10-15
|
||||
|
||||
- **First in-app purchases left out of a review are caught.** Apple reviews the first in-app purchase of a type (and
|
||||
the first subscription of a group) only together with an app version. `validate` and `release plan` now block when
|
||||
one that never passed review is not in the open review submission, and `apple submit` refuses to leave one out;
|
||||
`apple status`, `release status` and the desktop warn when a version waits for or is in review without it ("not
|
||||
sellable after approval; add it with the next version, or withdraw the submission and resubmit with it").
|
||||
`apple iap-review` says "ready, not submitted yet" instead of calling READY_TO_SUBMIT complete.
|
||||
|
||||
## 0.8.6 · 2026-10-14
|
||||
|
||||
- **Desktop: Fix with AI** on scan findings that need a code change (NSAllowsArbitraryLoads in Info.plist, a missing
|
||||
foregroundServiceType, purpose strings, target SDK, ...). The app runs your local Claude Code headless in the app
|
||||
folder with file tools only (no commands, no network, no git push), streams its progress, stops after 10 minutes or
|
||||
on Cancel, and shows the diff with Keep and Undo (exactly the files it changed), then rescans. It refuses while the
|
||||
folder has uncommitted files. Without Claude Code installed the button copies the prompt instead (Copy for AI).
|
||||
- **Desktop: a Fix button on findings** that storebound can fix itself. Scan and validate findings carry a
|
||||
machine-readable `action` (`--json`); the Checks tab runs it as a write with its dry run in the native dialog
|
||||
before anything changes. privacy-labels writes the detected answers into the config (`scan --write-only`, the
|
||||
dialog shows the config diff); missing Play listings, a missing App Store localization or copyright and the App
|
||||
Review contact push the listing files / config to the store (text only). Nothing submits, releases or publishes.
|
||||
- **Desktop:** long findings (a file path in a build folder) wrap inside the Store readiness card instead of pushing
|
||||
the store chips out of it; the release timeline's rail no longer breaks after the current step; the rollout bar
|
||||
stays under its stage.
|
||||
- **`scan`: function words of the listing languages** (German "eine", "der", French "les", Turkish "bir", ...) no
|
||||
longer count as repeated keywords in the Play full description.
|
||||
|
||||
- **`import type { ShipConfig } from 'storebound'` type-checks outside Bun.** The package ships declarations
|
||||
for ES module and CommonJS projects (`dist/types`, `dist/types-cjs`) and a JS entry for Node; Bun keeps the .ts source. 0.8.5 named only
|
||||
`cli/lib/config.ts`, so tsc (`next build`, vue-tsc, ts-loader, `tsc --noEmit`) failed on a scaffolded
|
||||
storebound.config.ts and a working Next app stopped building. A test packs the entry and type-checks the template
|
||||
in both project types under bundler, node16, nodenext and node10 with no @types/node and skipLibCheck off, and loads it with import() and require() under Node. Pitfall 69.
|
||||
- **`check` fails on the scaffolder's own placeholders**: "2026 Example Studio" and example.com / .org / .net URLs and
|
||||
addresses in `app`, `accounts` and `reviewAccess` would reach the stores as the app's copyright, contact and legal
|
||||
pages.
|
||||
|
||||
## 0.8.5 · 2026-10-14
|
||||
|
||||
Releases take one command and minutes: every push to main is a release candidate (full CI, the acceptance matrix and
|
||||
the Linux / Windows installers stored per commit), a version tag only promotes it, and `bun tools/release.ts` does
|
||||
the rest on the Mac.
|
||||
|
||||
- **A Chrome storebound starts never outlives its run.** The console window's Chrome is ended on exit, closed
|
||||
gracefully on Ctrl+C / SIGTERM / SIGHUP, and ended by a watchdog when the run dies in any way (SIGKILL included).
|
||||
Every start first ends headless storebound Chromes earlier runs left behind (an orphan kept the owner's own Chrome
|
||||
from opening from the macOS Dock) and names orphaned windows. Headless runs on macOS prefer Chromium / Chrome for
|
||||
Testing over Google Chrome.app. `console fill` closes the window on an error too. Pitfall 68.
|
||||
- **Every push to main is a release candidate.** ci.yml runs the full CI, the acceptance matrix (Linux + Windows x
|
||||
Node 20 / 22 / Bun) and the installer builds in parallel and stores the green result as the draft `rc-<sha>`.
|
||||
A version-only bump commit (proven file by file) reuses its parent's CI and matrix and rebuilds only the
|
||||
installers. A `v*` tag promotes the candidate (no rebuild, no retest) and fails at once on a red commit.
|
||||
- **`bun tools/release.ts <version> --yes`:** bump and CHANGELOG cut, fast gate, commit, push, tag; the Mac build and
|
||||
notarization in parallel with Forgejo; macOS acceptance cached per CLI key; add, verify, publish (each Mac file
|
||||
crosses the owner's uplink once); /Applications/storebound.app updated; the npm commands printed. `--rehearsal`
|
||||
runs it all as a throwaway prerelease and deletes it afterwards.
|
||||
|
||||
## 0.8.4 · 2026-10-14
|
||||
|
||||
Existing products' prices never change without `--allow-price-changes`; PPP pricing compares like with like and keeps
|
||||
high-income markets at par. `--json` prints only JSON. Automatic signing creates no profiles, and capabilities are
|
||||
verified against the App ID, the config and the build. `check` requires a copyright; What's new drafts come from
|
||||
release notes and flag jargon.
|
||||
|
||||
- **`apple signing ensure` with automatic signing creates no profiles or certificates.** With `signing.style:
|
||||
automatic` (the default) it only adds missing capabilities to the App ID; Xcode owns certificates and profiles, and
|
||||
the dry run says so. Provisioning profiles storebound made that nothing uses are listed by name with the exact
|
||||
delete command (never run by storebound).
|
||||
- **Capability writes are read back.** After adding a capability, `ensure` reads the App ID again (a few tries,
|
||||
about 45 s at most) and fails with the API's answers when one is missing, instead of reporting success.
|
||||
- **Capabilities compared in three places.** `apple status`, `apple signing status` / `ensure --dry` and `validate`
|
||||
compare the App ID, `apple.signing.capabilities` and the iOS project's `.entitlements` (push, Game Center,
|
||||
associated domains, Sign in with Apple, iCloud, app groups, ...; also an entitlements file not set in
|
||||
CODE_SIGN_ENTITLEMENTS) and name what is missing where.
|
||||
- **No false alarm for entitlements on another branch.** A checkout without `.entitlements` is a warning about this
|
||||
checkout only ("built from another branch?"), naming the current branch and the local branches / worktrees that
|
||||
have one; it never claims the build lacks them. With a built IPA in build/ios/export, its real entitlements
|
||||
(codesign, or the Mach-O signature when codesign is missing) are compared as "uploaded build N"; without one the
|
||||
output says the App Store Connect API does not expose a build's entitlements.
|
||||
- **`storebound api` writes outside a terminal** say that they need an interactive terminal by design and name the
|
||||
dedicated command for common operations (bundle id capabilities: `apple signing ensure`, IAPs, listing, ...).
|
||||
- **`--json` prints only JSON on stdout.** Every command run with `--json` sends its log, step and table lines to
|
||||
stderr (one place: `withJsonOutput` in cli/lib/log.ts, wrapped around every command); the last commands that printed
|
||||
JSON with console.log use `printJson`. A test runs every command that reads `--json` against the fake stores and
|
||||
parses its stdout (`prices ppp --json` printed log lines first).
|
||||
- **PPP prices compare like with like.** `prices ppp` takes the store's own conversion of the US price (VAT included,
|
||||
today's rate: Play convertRegionPrices, Apple equalizations; offline base x rate x (1 + VAT)) as par and applies the
|
||||
PPP price level to it. Defaults: only lower prices (`--only-decreases`), only economies with a price level at most
|
||||
50 % of the US (`--max-level`), World Bank high-income economies keep par (`--include-high-income` to include
|
||||
them), mainland China at par on Apple and skipped on Play, never above par (`--max-ratio`, `--min-ratio`; the old
|
||||
`--floor` / `--ceiling` still work). Germany keeps EUR 5.99 for USD 4.99 (was EUR 3.59). A top-10 revenue market
|
||||
that drops more than 20 % is warned about (from your sales reports, else a static list). Regions that keep their
|
||||
price stay out of the sheet (`kept` in `--json`).
|
||||
- **Creating products never changes a live price.** `play products`, `prices import`, `play subscriptions` and `apple
|
||||
subscriptions` change prices of existing products only with `--allow-price-changes`, in a terminal too: one "y"
|
||||
covered them before. With the flag they get their own question listing each old -> new; dry runs show a separate
|
||||
"price changes on existing products (held)" section.
|
||||
|
||||
- **`whats-new` drafts from player-facing notes before the git log.** It takes `whatsNew.source`, then
|
||||
`docs/RELEASE-<version>.md`, then the CHANGELOG.md section of the version (else Unreleased); version from
|
||||
`--release`, else package.json. Only without one (or with `--from-git`) it drafts from commit messages, and says so.
|
||||
- **Internal jargon warnings in release notes.** The draft and `whats-new check` name SDK / vendor names, developer
|
||||
and version-control words, file paths, issue ids and code identifiers, each with a plain alternative; warnings never
|
||||
block. One list in cli/lib/jargon.ts; `whatsNew.allowWords` keeps a word. The whats-new skill says to write from what
|
||||
players feel, never from commit messages.
|
||||
|
||||
## 0.8.3 · 2026-10-14
|
||||
|
||||
Docs: no private account names in the published package. `npx storebound` prompts work on Node again (the [Y/n]
|
||||
prompt of `storebound desktop` crashed on Windows).
|
||||
|
||||
- **`npx storebound` prompts work on Node.** `storebound desktop` (and every [y/N] or type-the-id prompt) crashed on
|
||||
Node at the question with "console is not async iterable" (Windows also hit a libuv assertion at exit): prompts now
|
||||
read stdin through `rt.readLine` (node:readline, closed after one line, EOF is "no"). A test keeps Bun-only stdin
|
||||
iteration out of cli/, and the Windows release job runs the Node prompt smoke on the runner.
|
||||
- **Release privacy scan of signed macOS builds leaves out Apple code signatures only.** The Developer ID certificate
|
||||
in a signature names the developer; the .app, DMG and updater bundle are scanned as copies without signatures, and
|
||||
any hit elsewhere still fails the release.
|
||||
|
||||
## 0.8.2 · 2026-10-14
|
||||
|
||||
The desktop app edits everything the consoles do (listings, app info, catalog and prices, TestFlight, store art), with
|
||||
|
|
@ -805,6 +77,7 @@ errors are clear messages instead of raw API text.
|
|||
- **One row per release across the stores.** Versions are matched without trailing .0 parts (App Store "1.0" and Play
|
||||
"1.0.0" are one row and one timeline); each side still shows the store's own string.
|
||||
|
||||
|
||||
## 0.8.1 · 2026-10-13
|
||||
|
||||
`apple build` exports each build into its own folder and fails loudly on any export error; `apple upload` refuses a
|
||||
|
|
|
|||
|
|
@ -9,22 +9,9 @@ but fixes, store rule updates and new engines / AI tools are very welcome.
|
|||
bun install # Bun 1.1+; playwright is a dev dependency (renders, web capture)
|
||||
bun run ci:quick # inner loop: typecheck + the tests your change touches + scrub (seconds)
|
||||
bun run ci # full gate before pushing: every check and test (adapters, API tables, typecheck,
|
||||
# desktop build, Rust host when it changed, scrub); no browser
|
||||
bun run test:browser # only by hand: the suites that start Chrome / Chromium (tests/browser/*.browser.ts)
|
||||
# desktop build and boot check, Rust host when it changed, scrub)
|
||||
```
|
||||
|
||||
**Windows.** `.gitattributes` keeps every text file LF in the working tree (with `core.autocrlf=true` the skills
|
||||
came out CRLF and `bun tools/gen-adapters.ts` failed with "no frontmatter"). On a checkout made before that file
|
||||
existed, run `git rm -r --cached -q . && git reset --hard` once on a clean tree. The desktop app builds with
|
||||
`cd apps/desktop && bun run sidecar && bunx tauri build` (Rust via rustup; WiX and NSIS are downloaded by Tauri) into
|
||||
`src-tauri/target/release/bundle/nsis/*-setup.exe` and `bundle/msi/*.msi`.
|
||||
|
||||
**Browser tests run only by hand.** The suites that launch a browser (console fill / flow / session / persist, web
|
||||
capture, glyphs and ligatures, local fonts, feature-graphic fit, site overflow) live in `tests/browser/` as
|
||||
`*.browser.ts`, which `bun test`, `ci`, `ci:quick` and the Forgejo workflows never pick up. Run `bun run test:browser`
|
||||
when you change that code. The one browser step left in any automatic path is the desktop boot check inside
|
||||
`tools/desktop-release.ts mac` at release time: the built window must be seen rendering before a build ships.
|
||||
|
||||
**Which one when.** Run `bun run ci:quick` while you work: it diffs against `origin/main` (committed, staged,
|
||||
unstaged and new files), maps the changed files to the tests that import them (directly, through other modules, or
|
||||
by naming a fixture or page) and runs those, plus `tsc` (incremental) and the scrub. A change to `package.json`,
|
||||
|
|
@ -34,10 +21,10 @@ push and check its exit code: it is the gate, `ci:quick` is not.
|
|||
`tools/ci.ts` runs the steps as a task graph (independent steps at the same time, output prefixed with the step
|
||||
name, the first failure stops the rest, a time-per-step summary at the end). The tests run as parallel shards
|
||||
(CPU cores - 1, at most 8; `STOREBOUND_CI_SHARDS=n` overrides) balanced by the durations of the last run, kept in
|
||||
`node_modules/.cache/storebound-ci/`. So tests must be shard-safe: temp folders
|
||||
`node_modules/.cache/storebound-ci/`; the browser suites get a shard each. So tests must be shard-safe: temp folders
|
||||
from `mkdtempSync`, servers on port 0, no fixed sleeps (wait for the event), no state shared through files in the
|
||||
checkout. The desktop Rust host (`cargo test`, `clippy`) runs only when `apps/desktop/src-tauri/` or
|
||||
`tools/build-sidecars.ts` changed against `origin/main` (Forgejo CI: against the pushed commit's parent).
|
||||
`tools/build-sidecars.ts` changed against `origin/main`; `CI=full bun run ci` always runs it (Forgejo CI does).
|
||||
|
||||
Try the screenshot and video pipeline on the demo game without any store account: see
|
||||
[examples/demo-game/README.md](examples/demo-game/README.md).
|
||||
|
|
|
|||
14
GEMINI.md
|
|
@ -19,16 +19,6 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
|
|||
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
|
||||
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
|
||||
these you prepare everything, show the dry run, and the owner presses one button or says yes.
|
||||
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
|
||||
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
|
||||
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
|
||||
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
|
||||
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
|
||||
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
|
||||
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
|
||||
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
|
||||
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to
|
||||
`.storebound/owner-actions.log`. Legal agreements, passwords and 2FA stay with the owner if your own rules say so.
|
||||
3. **Ask the owner only for facts that no code, config or doc can prove:** compliance answers such as Data safety,
|
||||
App Privacy, age rating, export compliance, partial data deletion. Collect ALL of them in ONE batch at the start
|
||||
(`storebound check` and `storebound validate` list them), never one at a time during the run. Reuse answers that
|
||||
|
|
@ -77,9 +67,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
Submit, Create, a final Save) and tell the owner exactly what to press, then `storebound console verify <form>`.
|
||||
Never sign in, accept an agreement or grant a permission (a service account's app access) for the owner.
|
||||
`storebound console fill` is the optional scripted fast path; it never blocks you.
|
||||
11. **Console pages: always the absolute deep link.** When you send the owner to a console page, give the absolute
|
||||
deep link (storebound prints it; `storebound console plan` has start URLs), never just "go to App Store Connect"
|
||||
or "App Store Connect > App Information" alone.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
@ -105,7 +92,6 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
|
|||
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
|
||||
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default |
|
||||
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
|
||||
| `game-services` | Set up Game Center (App Store) and Play Games Services (Google Play) for a mobile game from one config section |
|
||||
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
|
||||
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |
|
||||
| `play-first-release` | Step-by-step first (and later) Google Play release of a mobile app or game |
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ in-app products and prices, the compliance forms, even the consoles that have no
|
|||
|
||||
[](https://www.npmjs.com/package/storebound)
|
||||
[](https://git.crt.fyi/CRT/storebound/src/branch/main/LICENSE)
|
||||
[](https://git.crt.fyi/CRT/storebound/src/branch/main/CHANGELOG.md)
|
||||
[](#install-and-start)
|
||||
[](https://git.crt.fyi/CRT/storebound/src/branch/main/package.json)
|
||||
[](https://git.crt.fyi/CRT/storebound/src/branch/main/skills)
|
||||
[](https://git.crt.fyi/CRT/storebound/src/branch/main/skills)
|
||||
[](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/mcp.md)
|
||||
|
||||
**Website: [storebound.dev](https://storebound.dev) · Docs: [docs/](https://git.crt.fyi/CRT/storebound/src/branch/main/docs/README.md) · `npx storebound init`**
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>storebound</title>
|
||||
</head>
|
||||
<body class="tc-page">
|
||||
<div id="card"></div>
|
||||
<script type="module" src="/src/card/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@storebound/desktop",
|
||||
"version": "0.8.77",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
119
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -227,20 +227,6 @@ version = "1.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto-launch"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"os_info",
|
||||
"smappservice-rs",
|
||||
"thiserror 2.0.21",
|
||||
"windows-registry",
|
||||
"windows-result",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
|
|
@ -869,15 +855,6 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "7.0.0"
|
||||
|
|
@ -2469,18 +2446,6 @@ dependencies = [
|
|||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.18.2"
|
||||
|
|
@ -2759,30 +2724,6 @@ dependencies = [
|
|||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-security"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-service-management"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
|
|
@ -2877,21 +2818,6 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix 0.31.3",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
|
|
@ -4000,18 +3926,6 @@ version = "1.16.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
|
||||
|
||||
[[package]]
|
||||
name = "smappservice-rs"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-service-management",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
|
|
@ -4096,13 +4010,12 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|||
|
||||
[[package]]
|
||||
name = "storebound-desktop"
|
||||
version = "0.8.77"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"keyring",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
|
|
@ -4111,7 +4024,6 @@ dependencies = [
|
|||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
|
|
@ -4124,9 +4036,6 @@ dependencies = [
|
|||
"tempfile",
|
||||
"tokio",
|
||||
"url",
|
||||
"webview2-com",
|
||||
"windows-core",
|
||||
"winreg",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
|
@ -4340,7 +4249,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs 7.0.0",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
|
|
@ -4390,7 +4299,7 @@ checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs 7.0.0",
|
||||
"dirs",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
|
|
@ -4460,20 +4369,6 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-autostart"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.6.1"
|
||||
|
|
@ -4636,7 +4531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs 7.0.0",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
|
@ -5122,7 +5017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs 7.0.0",
|
||||
"dirs",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
|
|
@ -5945,7 +5840,7 @@ dependencies = [
|
|||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs 7.0.0",
|
||||
"dirs",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
|
|
@ -6059,7 +5954,7 @@ dependencies = [
|
|||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand 0.8.8",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "storebound-desktop"
|
||||
version = "0.8.77"
|
||||
version = "0.8.2"
|
||||
description = "storebound desktop: every game's releases on both stores in one window"
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
|
@ -19,7 +19,7 @@ path = "src/main.rs"
|
|||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.12", features = ["tray-icon"] }
|
||||
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"] }
|
||||
|
|
@ -31,7 +31,6 @@ tauri-plugin-dialog = "2"
|
|||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-autostart = "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"] }
|
||||
|
|
@ -49,13 +48,6 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
|
|||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] }
|
||||
|
||||
# Release builds turn off WebView2's browser context menu and accelerator keys (Back, Refresh, Print, Save as).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
webview2-com = "0.39"
|
||||
windows-core = "0.62"
|
||||
# reads Startup apps' StartupApproved value (already in the tree through the autostart plugin)
|
||||
winreg = "0.56"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
|
|
@ -65,6 +57,3 @@ lto = true
|
|||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ include!("src/commands_list.rs");
|
|||
fn main() {
|
||||
tauri_build::try_build(
|
||||
tauri_build::Attributes::new()
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS, MENU_COMMANDS].concat().into_boxed_slice()))),
|
||||
.app_manifest(tauri_build::AppManifest::new().commands(COMMANDS)),
|
||||
)
|
||||
.expect("tauri build script failed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "card-window",
|
||||
"description": "The tray card (a small window next to the tray icon) with the app's own bundled card page only. It may listen to app events (the main window's snapshot) and call the two card commands: read the last snapshot and send a button (hide, open the main window or a route in it, refresh and mute through the main window, open a store console, quit). It never reads the stores or reaches the sidecar; every command re-checks the window label and page origin in Rust.",
|
||||
"windows": ["card"],
|
||||
"local": true,
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"allow-card-get",
|
||||
"allow-card-action"
|
||||
]
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@
|
|||
"allow-studio-remove",
|
||||
"allow-studio-logo",
|
||||
"allow-account-update",
|
||||
"allow-account-set-play-developer",
|
||||
"allow-account-check",
|
||||
"allow-store-apps-refresh",
|
||||
"allow-store-apps-cached",
|
||||
|
|
@ -44,8 +43,6 @@
|
|||
"allow-cache-info",
|
||||
"allow-data-reveal",
|
||||
"allow-notify",
|
||||
"allow-tray-update",
|
||||
"allow-card-snapshot",
|
||||
"allow-app-reset",
|
||||
"allow-update-check",
|
||||
"allow-update-install",
|
||||
|
|
@ -56,9 +53,6 @@
|
|||
"allow-project-reveal",
|
||||
"allow-project-locate",
|
||||
"allow-state-reset",
|
||||
"allow-log-webview",
|
||||
"allow-ai-fix-info",
|
||||
"allow-ai-fix-run",
|
||||
"allow-ai-fix-undo"
|
||||
"allow-log-webview"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "menu-window",
|
||||
"description": "The tray menu panels (menu-1, menu-2...: small frameless windows the app opens on a right click on the tray icon, one per open or pinned submenu) with the app's own bundled tray-menu page only. They may listen to app events (the live menu tree) and call the menu commands: read their panel, report their size, open, pin and close panels, pick a row, which runs the same tray action as the native menu, and start a drag of their own window (a pinned panel's header). They never read the stores or reach the sidecar; every command re-checks the window label and page origin in Rust.",
|
||||
"windows": ["menu-*"],
|
||||
"local": true,
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-start-dragging",
|
||||
"allow-menu-state",
|
||||
"allow-menu-ready",
|
||||
"allow-menu-open-sub",
|
||||
"allow-menu-pin-sub",
|
||||
"allow-menu-close-subs",
|
||||
"allow-menu-close",
|
||||
"allow-menu-dismiss",
|
||||
"allow-menu-pin",
|
||||
"allow-menu-select"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 119 KiB |
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-account-set-play-developer"
|
||||
description = "Enables the account_set_play_developer command without any pre-configured scope."
|
||||
commands.allow = ["account_set_play_developer"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-account-set-play-developer"
|
||||
description = "Denies the account_set_play_developer command without any pre-configured scope."
|
||||
commands.deny = ["account_set_play_developer"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-ai-fix-info"
|
||||
description = "Enables the ai_fix_info command without any pre-configured scope."
|
||||
commands.allow = ["ai_fix_info"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-ai-fix-info"
|
||||
description = "Denies the ai_fix_info command without any pre-configured scope."
|
||||
commands.deny = ["ai_fix_info"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-ai-fix-run"
|
||||
description = "Enables the ai_fix_run command without any pre-configured scope."
|
||||
commands.allow = ["ai_fix_run"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-ai-fix-run"
|
||||
description = "Denies the ai_fix_run command without any pre-configured scope."
|
||||
commands.deny = ["ai_fix_run"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-ai-fix-undo"
|
||||
description = "Enables the ai_fix_undo command without any pre-configured scope."
|
||||
commands.allow = ["ai_fix_undo"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-ai-fix-undo"
|
||||
description = "Denies the ai_fix_undo command without any pre-configured scope."
|
||||
commands.deny = ["ai_fix_undo"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-card-action"
|
||||
description = "Enables the card_action command without any pre-configured scope."
|
||||
commands.allow = ["card_action"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-card-action"
|
||||
description = "Denies the card_action command without any pre-configured scope."
|
||||
commands.deny = ["card_action"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-card-get"
|
||||
description = "Enables the card_get command without any pre-configured scope."
|
||||
commands.allow = ["card_get"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-card-get"
|
||||
description = "Denies the card_get command without any pre-configured scope."
|
||||
commands.deny = ["card_get"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-card-snapshot"
|
||||
description = "Enables the card_snapshot command without any pre-configured scope."
|
||||
commands.allow = ["card_snapshot"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-card-snapshot"
|
||||
description = "Denies the card_snapshot command without any pre-configured scope."
|
||||
commands.deny = ["card_snapshot"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-close"
|
||||
description = "Enables the menu_close command without any pre-configured scope."
|
||||
commands.allow = ["menu_close"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-close"
|
||||
description = "Denies the menu_close command without any pre-configured scope."
|
||||
commands.deny = ["menu_close"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-close-subs"
|
||||
description = "Enables the menu_close_subs command without any pre-configured scope."
|
||||
commands.allow = ["menu_close_subs"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-close-subs"
|
||||
description = "Denies the menu_close_subs command without any pre-configured scope."
|
||||
commands.deny = ["menu_close_subs"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-dismiss"
|
||||
description = "Enables the menu_dismiss command without any pre-configured scope."
|
||||
commands.allow = ["menu_dismiss"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-dismiss"
|
||||
description = "Denies the menu_dismiss command without any pre-configured scope."
|
||||
commands.deny = ["menu_dismiss"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-open-sub"
|
||||
description = "Enables the menu_open_sub command without any pre-configured scope."
|
||||
commands.allow = ["menu_open_sub"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-open-sub"
|
||||
description = "Denies the menu_open_sub command without any pre-configured scope."
|
||||
commands.deny = ["menu_open_sub"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-pin"
|
||||
description = "Enables the menu_pin command without any pre-configured scope."
|
||||
commands.allow = ["menu_pin"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-pin"
|
||||
description = "Denies the menu_pin command without any pre-configured scope."
|
||||
commands.deny = ["menu_pin"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-pin-sub"
|
||||
description = "Enables the menu_pin_sub command without any pre-configured scope."
|
||||
commands.allow = ["menu_pin_sub"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-pin-sub"
|
||||
description = "Denies the menu_pin_sub command without any pre-configured scope."
|
||||
commands.deny = ["menu_pin_sub"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-ready"
|
||||
description = "Enables the menu_ready command without any pre-configured scope."
|
||||
commands.allow = ["menu_ready"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-ready"
|
||||
description = "Denies the menu_ready command without any pre-configured scope."
|
||||
commands.deny = ["menu_ready"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-select"
|
||||
description = "Enables the menu_select command without any pre-configured scope."
|
||||
commands.allow = ["menu_select"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-select"
|
||||
description = "Denies the menu_select command without any pre-configured scope."
|
||||
commands.deny = ["menu_select"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-menu-state"
|
||||
description = "Enables the menu_state command without any pre-configured scope."
|
||||
commands.allow = ["menu_state"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-menu-state"
|
||||
description = "Denies the menu_state command without any pre-configured scope."
|
||||
commands.deny = ["menu_state"]
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-tray-update"
|
||||
description = "Enables the tray_update command without any pre-configured scope."
|
||||
commands.allow = ["tray_update"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-tray-update"
|
||||
description = "Denies the tray_update command without any pre-configured scope."
|
||||
commands.deny = ["tray_update"]
|
||||
|
|
@ -31,10 +31,6 @@ pub struct Account {
|
|||
/// None only for an account of an earlier version whose keychain entry has not moved yet.
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
/// Play only: the developer account id (PLAY_DEVELOPER_ID in its credential profile, shared with the CLI), for
|
||||
/// Play Console deep links. Read from the profile on every list; never a secret.
|
||||
#[serde(default, rename = "developerId", skip_serializing_if = "Option::is_none")]
|
||||
pub developer_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The keychain entry of one account (JSON).
|
||||
|
|
|
|||
|
|
@ -1,471 +0,0 @@
|
|||
//! "Fix with AI": one scan finding handed to the owner's local Claude Code CLI, run headless in the app folder.
|
||||
//!
|
||||
//! The host spawns exactly two programs here, never anything the webview names: the `claude` binary found on PATH
|
||||
//! (or its usual install folders) and `git`. claude runs with `-p` and only the file tools (Read, Edit, Write, Grep,
|
||||
//! Glob); Bash, the web tools, sub-agents and notebooks are disallowed, so it cannot run commands, reach the network
|
||||
//! or push. The prompt is built here from the finding's fields (the webview cannot pass free text). A run needs the app
|
||||
//! folder to be clean in git, so everything changed afterwards is the run's own work: the window shows that diff with
|
||||
//! Keep and Undo, and Undo restores exactly the files this run changed (recorded here, not sent by the webview).
|
||||
//! Every run has a 10 minute timeout with a heartbeat line every 15 s; a cancel or the app's exit kills the process
|
||||
//! group by PID.
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The only tools the agent gets.
|
||||
pub const ALLOWED_TOOLS: &str = "Read,Edit,Write,Grep,Glob";
|
||||
/// Tools it never gets (commands, network, sub-agents, notebooks), even if a settings file allows them.
|
||||
pub const DISALLOWED_TOOLS: &str = "Bash,WebFetch,WebSearch,Task,Agent,NotebookEdit,KillShell,BashOutput";
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(600);
|
||||
pub const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// The finding as the scan reports it (only these fields; lengths checked).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AiFinding {
|
||||
pub rule: String,
|
||||
pub guideline: String,
|
||||
pub store: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub evidence: Vec<Evidence>,
|
||||
pub fix: String,
|
||||
pub skill: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Evidence {
|
||||
pub file: String,
|
||||
pub line: u64,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
fn short(s: &str, max: usize) -> String {
|
||||
let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
let t = one.trim();
|
||||
if t.chars().count() > max {
|
||||
format!("{}...", t.chars().take(max).collect::<String>())
|
||||
} else {
|
||||
t.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn ident(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// A path inside the app folder: relative, no `..`, no option-looking start.
|
||||
pub fn safe_rel(p: &str) -> bool {
|
||||
!p.is_empty()
|
||||
&& p.len() <= 1000
|
||||
&& !p.starts_with('-')
|
||||
&& !p.starts_with('/')
|
||||
&& !p.starts_with('\\')
|
||||
&& !p.contains(':')
|
||||
&& !p.chars().any(|c| c.is_control())
|
||||
&& !p.split(['/', '\\']).any(|seg| seg == "..")
|
||||
}
|
||||
|
||||
pub fn check_finding(f: &AiFinding) -> Result<(), String> {
|
||||
if !ident(&f.rule) || !ident(&f.skill) {
|
||||
return Err("bad rule or skill".into());
|
||||
}
|
||||
if f.store != "apple" && f.store != "play" {
|
||||
return Err("bad store".into());
|
||||
}
|
||||
if f.title.is_empty() || f.title.len() > 2000 || f.fix.len() > 4000 || f.guideline.len() > 200 || f.evidence.len() > 20 {
|
||||
return Err("finding too long".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The prompt for one finding. The same text is what "Copy for AI" copies.
|
||||
pub fn build_prompt(f: &AiFinding) -> Result<String, String> {
|
||||
check_finding(f)?;
|
||||
let store = if f.store == "apple" { "App Store" } else { "Google Play" };
|
||||
let mut ev = String::new();
|
||||
for e in f.evidence.iter().filter(|e| safe_rel(&e.file)).take(5) {
|
||||
ev.push_str(&format!("- {}:{}", e.file, e.line));
|
||||
if let Some(t) = e.text.as_deref().filter(|t| !t.trim().is_empty()) {
|
||||
ev.push_str(&format!(" ({})", short(t, 200)));
|
||||
}
|
||||
ev.push('\n');
|
||||
}
|
||||
if ev.is_empty() {
|
||||
ev.push_str("- (no file named: find where it lives first)\n");
|
||||
}
|
||||
Ok(format!(
|
||||
"Fix one {store} rejection risk that `storebound scan` found in this app folder.\n\n\
|
||||
Finding ({rule}, {guideline}): {title}\n\
|
||||
Evidence:\n{ev}\
|
||||
Suggested fix: {fix}\n\
|
||||
Background: the storebound skill \"{skill}\" covers this topic.\n\n\
|
||||
Rules:\n\
|
||||
- Change only what this finding needs, in as few files and lines as possible. No refactoring, reformatting or unrelated edits.\n\
|
||||
- Only read and edit files in this folder. Do not run commands, install packages, use the network, commit or push.\n\
|
||||
- Never invent an answer only the owner can give (export compliance, Data safety, App Privacy, age rating, a purpose the app does not have). If the fix needs one, change nothing and say what the owner must decide.\n\
|
||||
- Keep the project's style (indentation, quoting, file format).\n\
|
||||
- When done, say in one to three lines what you changed and why.\n",
|
||||
rule = f.rule,
|
||||
guideline = short(&f.guideline, 200),
|
||||
title = short(&f.title, 600),
|
||||
fix = short(&f.fix, 1500),
|
||||
skill = f.skill,
|
||||
))
|
||||
}
|
||||
|
||||
// ---- finding claude ----
|
||||
|
||||
/// Folders a GUI app's PATH usually lacks but a terminal install of Claude Code uses.
|
||||
fn extra_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(h) = home {
|
||||
v.push(h.join(".claude/local"));
|
||||
v.push(h.join(".local/bin"));
|
||||
v.push(h.join(".npm-global/bin"));
|
||||
v.push(h.join(".bun/bin"));
|
||||
v.push(h.join(".volta/bin"));
|
||||
}
|
||||
v.push(PathBuf::from("/opt/homebrew/bin"));
|
||||
v.push(PathBuf::from("/usr/local/bin"));
|
||||
v
|
||||
}
|
||||
|
||||
/// The search PATH: the process PATH plus the usual install folders (node for an npm install lives there too).
|
||||
pub fn search_path(path_env: Option<&str>, home: Option<&Path>) -> Vec<PathBuf> {
|
||||
let mut v: Vec<PathBuf> = path_env.map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
|
||||
for d in extra_dirs(home) {
|
||||
if !v.contains(&d) {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn find_claude_in(dirs: &[PathBuf]) -> Option<PathBuf> {
|
||||
let names: &[&str] = if cfg!(windows) { &["claude.exe", "claude.cmd"] } else { &["claude"] };
|
||||
dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn home() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub fn find_claude() -> Option<PathBuf> {
|
||||
find_claude_in(&search_path(std::env::var("PATH").ok().as_deref(), home().as_deref()))
|
||||
}
|
||||
|
||||
/// The arguments claude runs with (the prompt is the only variable part).
|
||||
pub fn claude_args(prompt: &str) -> Vec<String> {
|
||||
[
|
||||
"-p", prompt, "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", ALLOWED_TOOLS,
|
||||
"--disallowedTools", DISALLOWED_TOOLS,
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The claude process for one prompt in the app folder: only a binary named claude, only `claude_args`.
|
||||
pub fn claude_command(claude: &Path, dir: &Path, prompt: &str) -> Result<Command, String> {
|
||||
let name = claude.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if !matches!(name, "claude" | "claude.exe" | "claude.cmd") || !claude.is_absolute() {
|
||||
return Err("not the claude binary".into());
|
||||
}
|
||||
let mut cmd = Command::new(claude);
|
||||
cmd.current_dir(dir).args(claude_args(prompt));
|
||||
if let Ok(p) = std::env::join_paths(search_path(std::env::var("PATH").ok().as_deref(), home().as_deref())) {
|
||||
cmd.env("PATH", p);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
// ---- git ----
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Change {
|
||||
/// Path relative to the repository root.
|
||||
pub path: String,
|
||||
/// Porcelain status: "M", "D", "A" or "??" (new, untracked).
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("git").current_dir(dir).args(args).stdin(Stdio::null()).output().map_err(|e| format!("git: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
/// `git status --porcelain -z` of the app folder (renames reported as their new path).
|
||||
pub fn parse_status(z: &str) -> Vec<Change> {
|
||||
let mut v = Vec::new();
|
||||
let mut parts = z.split('\0').filter(|s| !s.is_empty());
|
||||
while let Some(e) = parts.next() {
|
||||
if e.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let (xy, path) = (&e[..2], &e[3..]);
|
||||
if xy.starts_with('R') || xy.starts_with('C') {
|
||||
parts.next(); // the old path
|
||||
}
|
||||
let status = if xy == "??" { "??".to_string() } else { xy.trim().chars().next().map(String::from).unwrap_or_default() };
|
||||
v.push(Change { path: path.to_string(), status });
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Repository root and the app folder's dirty files. Err when the folder is not in a git repository.
|
||||
pub fn status(dir: &Path) -> Result<(PathBuf, Vec<Change>), String> {
|
||||
let top = git(dir, &["rev-parse", "--show-toplevel"]).map_err(|_| "the app folder is not in a git repository: Undo needs git".to_string())?;
|
||||
let top = PathBuf::from(top.trim());
|
||||
let z = git(dir, &["status", "--porcelain", "-z", "--untracked-files=all", "--", "."])?;
|
||||
Ok((top, parse_status(&z)))
|
||||
}
|
||||
|
||||
/// What Undo does with the changes of one run: tracked files are checked out again, files the run created are removed.
|
||||
/// Only paths of that run, only relative ones inside the repository.
|
||||
pub fn undo_plan(run: &[Change]) -> (Vec<String>, Vec<String>) {
|
||||
let mut restore = Vec::new();
|
||||
let mut remove = Vec::new();
|
||||
for c in run.iter().filter(|c| safe_rel(&c.path)) {
|
||||
if c.status == "??" || c.status == "A" {
|
||||
remove.push(c.path.clone());
|
||||
} else {
|
||||
restore.push(c.path.clone());
|
||||
}
|
||||
}
|
||||
(restore, remove)
|
||||
}
|
||||
|
||||
pub fn undo(top: &Path, run: &[Change]) -> Result<Vec<String>, String> {
|
||||
let (restore, remove) = undo_plan(run);
|
||||
if !restore.is_empty() {
|
||||
let mut a: Vec<&str> = vec!["checkout", "HEAD", "--"];
|
||||
a.extend(restore.iter().map(String::as_str));
|
||||
git(top, &a)?;
|
||||
}
|
||||
for p in &remove {
|
||||
let abs = top.join(p);
|
||||
if abs.starts_with(top) && abs.is_file() {
|
||||
std::fs::remove_file(&abs).map_err(|e| format!("{p}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(restore.into_iter().chain(remove).collect())
|
||||
}
|
||||
|
||||
/// The diff of a run's changes (new files shown whole), at most 200 kB.
|
||||
pub fn diff(top: &Path, run: &[Change]) -> String {
|
||||
let tracked: Vec<&str> = run.iter().filter(|c| c.status != "??" && safe_rel(&c.path)).map(|c| c.path.as_str()).collect();
|
||||
let mut out = String::new();
|
||||
if !tracked.is_empty() {
|
||||
let mut a = vec!["diff", "--no-color", "HEAD", "--"];
|
||||
a.extend(tracked);
|
||||
out.push_str(&git(top, &a).unwrap_or_default());
|
||||
}
|
||||
for c in run.iter().filter(|c| c.status == "??" && safe_rel(&c.path)) {
|
||||
let text = std::fs::read_to_string(top.join(&c.path)).unwrap_or_default();
|
||||
out.push_str(&format!("--- /dev/null\n+++ b/{}\n{}\n", c.path, text.lines().map(|l| format!("+{l}")).collect::<Vec<_>>().join("\n")));
|
||||
}
|
||||
if out.len() > 200_000 {
|
||||
out.truncate(out.char_indices().take_while(|(i, _)| *i < 200_000).last().map(|(i, _)| i).unwrap_or(0));
|
||||
out.push_str("\n... (cut)");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- running with a timeout, a heartbeat and kill by PID ----
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub code: Option<i32>,
|
||||
pub timed_out: bool,
|
||||
pub cancelled: bool,
|
||||
/// The agent's final words (stream-json "result"), else the last output lines.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Running agents by job: PID and the cancel flag.
|
||||
type Running = Mutex<HashMap<String, (u32, Arc<AtomicBool>)>>;
|
||||
static RUNNING: OnceLock<Running> = OnceLock::new();
|
||||
fn running() -> &'static Running {
|
||||
RUNNING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Kills the whole process group (claude starts helpers) of a PID.
|
||||
pub fn kill_tree(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// the agent runs in its own process group (pgid = its pid); never signal any other group, least of all ours
|
||||
// (a CI run once died of SIGTERM in the middle of these tests)
|
||||
let pgid = pid as libc::pid_t;
|
||||
// SAFETY: getpgrp has no preconditions; killpg only sends a signal to the group named
|
||||
let own = unsafe { libc::getpgrp() };
|
||||
if pgid <= 1 || pgid == own {
|
||||
return;
|
||||
}
|
||||
unsafe { libc::killpg(pgid, libc::SIGTERM) };
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
unsafe { libc::killpg(pgid, libc::SIGKILL) };
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = Command::new("taskkill").args(["/T", "/F", "/PID", &pid.to_string()]).status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the run of this job; true when one was running.
|
||||
pub fn cancel(job: &str) -> bool {
|
||||
let hit = running().lock().ok().and_then(|m| m.get(job).cloned());
|
||||
if let Some((pid, flag)) = hit {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
kill_tree(pid);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// App exit: no agent outlives the window.
|
||||
pub fn kill_all() {
|
||||
let all: Vec<(u32, Arc<AtomicBool>)> = running().lock().map(|m| m.values().cloned().collect()).unwrap_or_default();
|
||||
for (pid, flag) in all {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
kill_tree(pid);
|
||||
}
|
||||
}
|
||||
|
||||
/// One progress line of claude's stream-json output (None: nothing worth showing).
|
||||
pub fn progress_line(line: &str) -> Option<String> {
|
||||
let v: Value = serde_json::from_str(line).ok()?;
|
||||
match v["type"].as_str()? {
|
||||
"assistant" => {
|
||||
let mut out = Vec::new();
|
||||
for c in v["message"]["content"].as_array()? {
|
||||
match c["type"].as_str() {
|
||||
Some("text") => out.push(short(c["text"].as_str().unwrap_or_default().lines().next().unwrap_or_default(), 200)),
|
||||
Some("tool_use") => {
|
||||
let i = &c["input"];
|
||||
let what = i["file_path"].as_str().or(i["pattern"].as_str()).or(i["path"].as_str()).unwrap_or_default();
|
||||
out.push(short(&format!("{} {}", c["name"].as_str().unwrap_or("tool"), what), 200));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let s = out.into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
"result" => Some(short(v["result"].as_str().unwrap_or("done"), 400)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` in its own process group, calls `on_line` with each progress line (and a heartbeat while it is quiet),
|
||||
/// kills it on timeout or when `job` is cancelled.
|
||||
pub fn run(mut cmd: Command, job: &str, timeout: Duration, heartbeat: Duration, mut on_line: impl FnMut(&str)) -> Result<Outcome, String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| format!("could not start: {e}"))?;
|
||||
let pid = child.id();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
if let Ok(mut m) = running().lock() {
|
||||
m.insert(job.to_string(), (pid, flag.clone()));
|
||||
}
|
||||
let (tx, rx) = mpsc::channel::<String>();
|
||||
let out = child.stdout.take();
|
||||
let tx2 = tx.clone();
|
||||
let reader = std::thread::spawn(move || {
|
||||
if let Some(o) = out {
|
||||
for l in BufReader::new(o).lines().map_while(Result::ok) {
|
||||
let _ = tx2.send(l);
|
||||
}
|
||||
}
|
||||
});
|
||||
let err = child.stderr.take();
|
||||
let err_reader = std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
if let Some(mut e) = err {
|
||||
let _ = e.read_to_string(&mut s);
|
||||
}
|
||||
s
|
||||
});
|
||||
drop(tx);
|
||||
let start = Instant::now();
|
||||
let mut last = Instant::now();
|
||||
let mut summary = String::new();
|
||||
let mut tail: Vec<String> = Vec::new();
|
||||
let mut timed_out = false;
|
||||
let code = loop {
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(l) => {
|
||||
last = Instant::now();
|
||||
if let Some(p) = progress_line(&l) {
|
||||
if l.contains("\"type\":\"result\"") {
|
||||
summary = p.clone();
|
||||
}
|
||||
on_line(&p);
|
||||
} else if !l.trim_start().starts_with('{') && !l.trim().is_empty() {
|
||||
on_line(&short(&l, 300));
|
||||
tail.push(short(&l, 300));
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) | Err(mpsc::RecvTimeoutError::Disconnected) => {}
|
||||
}
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
kill_tree(pid);
|
||||
}
|
||||
if start.elapsed() > timeout && !timed_out {
|
||||
timed_out = true;
|
||||
on_line(&format!("timed out after {} s: stopping it", timeout.as_secs()));
|
||||
kill_tree(pid);
|
||||
let _ = child.kill();
|
||||
}
|
||||
if last.elapsed() >= heartbeat {
|
||||
last = Instant::now();
|
||||
on_line(&format!("still working ({} s)", start.elapsed().as_secs()));
|
||||
}
|
||||
if let Ok(Some(st)) = child.try_wait() {
|
||||
// drain what is left
|
||||
while let Ok(l) = rx.recv_timeout(Duration::from_millis(50)) {
|
||||
if let Some(p) = progress_line(&l) {
|
||||
if l.contains("\"type\":\"result\"") {
|
||||
summary = p.clone();
|
||||
}
|
||||
on_line(&p);
|
||||
}
|
||||
}
|
||||
break st.code();
|
||||
}
|
||||
};
|
||||
let _ = reader.join();
|
||||
let errs = err_reader.join().unwrap_or_default();
|
||||
if let Ok(mut m) = running().lock() {
|
||||
m.remove(job);
|
||||
}
|
||||
if summary.is_empty() {
|
||||
tail.extend(errs.lines().map(|l| short(l, 300)).filter(|l| !l.is_empty()));
|
||||
summary = tail.iter().rev().take(4).rev().cloned().collect::<Vec<_>>().join("\n");
|
||||
}
|
||||
Ok(Outcome { code, timed_out, cancelled: flag.load(Ordering::SeqCst), summary })
|
||||
}
|
||||
|
||||
// ---- runs recorded for Undo ----
|
||||
|
||||
/// Finished runs by id: repository root and the files the run changed (what Undo may restore).
|
||||
pub type Runs = Mutex<HashMap<String, (PathBuf, Vec<Change>)>>;
|
||||
static RUNS: OnceLock<Runs> = OnceLock::new();
|
||||
pub fn runs() -> &'static Runs {
|
||||
RUNS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
//! The tray card: a small frameless, always-on-top window that a left click on the tray icon shows next to the icon.
|
||||
//!
|
||||
//! It is created hidden at start (`create`) and only moved and shown on a click (`toggle`), so it opens instantly. The
|
||||
//! card never reads the stores: the main window pushes a snapshot of what it already knows (`card_snapshot`, kept here
|
||||
//! and forwarded as `card-snapshot`), and the card's buttons come back through `card_action`, which reuses the tray
|
||||
//! menu's paths (`tray-action` read / mute-toggle, `tray-route`), so Refresh is the main window's own Refresh all.
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub const LABEL: &str = "card";
|
||||
/// Logical size of the card.
|
||||
pub const WIDTH: f64 = 360.0;
|
||||
pub const HEIGHT: f64 = 480.0;
|
||||
/// Gap between the card and the tray icon / screen edge (logical px).
|
||||
pub const GAP: f64 = 8.0;
|
||||
/// A snapshot larger than this is refused (the card shows a handful of rows).
|
||||
pub const MAX_SNAPSHOT: usize = 64 * 1024;
|
||||
|
||||
/// A rectangle in physical pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub w: f64,
|
||||
pub h: f64,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
fn cx(&self) -> f64 { self.x + self.w / 2.0 }
|
||||
fn cy(&self) -> f64 { self.y + self.h / 2.0 }
|
||||
}
|
||||
|
||||
fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
|
||||
if hi < lo { lo } else { v.clamp(lo, hi) }
|
||||
}
|
||||
|
||||
/// Top-left corner of the card (physical px) for a tray icon at `tray`, inside `work` (the monitor's work area, without
|
||||
/// taskbar / menu bar / Dock). The card sits on the side of the icon that faces the screen: above a bottom taskbar,
|
||||
/// below the macOS menu bar, beside a left or right taskbar; centred on the icon along the bar, and always inside the
|
||||
/// work area.
|
||||
pub fn place(tray: Rect, work: Rect, card_w: f64, card_h: f64, gap: f64) -> (f64, f64) {
|
||||
let (cx, cy) = (tray.cx(), tray.cy());
|
||||
let (right, bottom) = (work.x + work.w, work.y + work.h);
|
||||
let (x, y) = if cy >= bottom {
|
||||
(cx - card_w / 2.0, tray.y - card_h - gap)
|
||||
} else if cy <= work.y {
|
||||
(cx - card_w / 2.0, tray.y + tray.h + gap)
|
||||
} else if cx <= work.x {
|
||||
(tray.x + tray.w + gap, cy - card_h / 2.0)
|
||||
} else if cx >= right {
|
||||
(tray.x - card_w - gap, cy - card_h / 2.0)
|
||||
} else if cy > work.y + work.h / 2.0 {
|
||||
// icon inside the work area (auto-hidden taskbar, overflow flyout): open towards the middle
|
||||
(cx - card_w / 2.0, tray.y - card_h - gap)
|
||||
} else {
|
||||
(cx - card_w / 2.0, tray.y + tray.h + gap)
|
||||
};
|
||||
(clamp(x, work.x + gap, right - card_w - gap).round(), clamp(y, work.y + gap, bottom - card_h - gap).round())
|
||||
}
|
||||
|
||||
/// Whether a card action word is one the card may send.
|
||||
pub fn valid_action(a: &str) -> bool {
|
||||
matches!(a, "hide" | "open" | "route" | "refresh" | "mute" | "quit" | "external" | "ready" | "size")
|
||||
}
|
||||
|
||||
// ---- the last snapshot ----
|
||||
|
||||
static SNAPSHOT: Mutex<Option<Value>> = Mutex::new(None);
|
||||
|
||||
/// Keeps the main window's snapshot (an object, at most MAX_SNAPSHOT bytes). False when refused.
|
||||
pub fn set_snapshot(v: Value) -> bool {
|
||||
if !v.is_object() || v.to_string().len() > MAX_SNAPSHOT {
|
||||
return false;
|
||||
}
|
||||
if let Ok(mut s) = SNAPSHOT.lock() {
|
||||
*s = Some(v);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn snapshot() -> Option<Value> {
|
||||
SNAPSHOT.lock().ok().and_then(|s| s.clone())
|
||||
}
|
||||
|
||||
// ---- show / hide ----
|
||||
|
||||
static HIDDEN_AT: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
/// The card lost focus or was closed: remembered so that the click on the tray icon that caused the blur does not
|
||||
/// open it again at once (a second click closes it).
|
||||
pub fn note_hidden() {
|
||||
if let Ok(mut h) = HIDDEN_AT.lock() {
|
||||
*h = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn just_hidden() -> bool {
|
||||
HIDDEN_AT.lock().ok().and_then(|h| *h).is_some_and(|t| t.elapsed() < Duration::from_millis(300))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
mod window {
|
||||
use super::*;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
/// The hidden card window, made once at start.
|
||||
pub fn create<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
|
||||
let b = WebviewWindowBuilder::new(app, LABEL, WebviewUrl::App("card.html".into()))
|
||||
.title("storebound")
|
||||
.inner_size(WIDTH, HEIGHT)
|
||||
.resizable(false)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.shadow(true);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let b = b.transparent(true);
|
||||
b.build()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The icon rectangle of the last show, so a size change keeps the card anchored to the icon.
|
||||
static LAST_ICON: Mutex<Option<Rect>> = Mutex::new(None);
|
||||
|
||||
/// Card height in CSS px (what the page measured), clamped; the window keeps WIDTH.
|
||||
fn height_px(h: f64) -> f64 { h.clamp(120.0, HEIGHT) }
|
||||
|
||||
/// Puts the card next to `tray` at its current height: its edge GAP from the icon, inside the work area.
|
||||
fn anchor<R: Runtime>(app: &AppHandle<R>, w: &tauri::WebviewWindow<R>, tray: Rect, css_h: f64) {
|
||||
let monitor = app.monitor_from_point(tray.x + tray.w / 2.0, tray.y + tray.h / 2.0).ok().flatten().or_else(|| w.primary_monitor().ok().flatten());
|
||||
if let Some(m) = monitor {
|
||||
let s = m.scale_factor();
|
||||
let wa = m.work_area();
|
||||
let work = Rect { x: wa.position.x as f64, y: wa.position.y as f64, w: wa.size.width as f64, h: wa.size.height as f64 };
|
||||
let _ = w.set_size(tauri::PhysicalSize::new((WIDTH * s).round() as u32, (css_h * s).round() as u32));
|
||||
let (x, y) = place(tray, work, WIDTH * s, css_h * s, GAP * s);
|
||||
let _ = w.set_position(PhysicalPosition::new(x as i32, y as i32));
|
||||
}
|
||||
}
|
||||
|
||||
/// The page measured its height: resize the window to it and keep it anchored (the bottom edge stays above a bottom
|
||||
/// taskbar, no empty band under the content).
|
||||
pub fn resize<R: Runtime>(app: &AppHandle<R>, css_h: f64) {
|
||||
let Some(w) = app.get_webview_window(LABEL) else { return };
|
||||
let h = height_px(css_h);
|
||||
if let Ok(mut c) = CURRENT_H.lock() { *c = h; }
|
||||
if let Some(tray) = LAST_ICON.lock().ok().and_then(|t| *t) {
|
||||
anchor(app, &w, tray, h);
|
||||
}
|
||||
}
|
||||
|
||||
static CURRENT_H: Mutex<f64> = Mutex::new(HEIGHT);
|
||||
|
||||
/// A left click on the tray icon: shows the card next to the icon, or hides it when it is open.
|
||||
pub fn toggle<R: Runtime>(app: &AppHandle<R>, tray: Rect) {
|
||||
let Some(w) = app.get_webview_window(LABEL) else { return };
|
||||
if w.is_visible().unwrap_or(false) {
|
||||
let _ = w.hide();
|
||||
note_hidden();
|
||||
return;
|
||||
}
|
||||
if just_hidden() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut t) = LAST_ICON.lock() { *t = Some(tray); }
|
||||
let h = CURRENT_H.lock().map(|c| *c).unwrap_or(HEIGHT);
|
||||
anchor(app, &w, tray, h);
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
let _ = app.emit_to(LABEL, "card-shown", ());
|
||||
// the main window refreshes the parts the card shows that it keeps per app (reviews, health from the cache)
|
||||
let _ = app.emit_to(crate::policy::MAIN, "card-request", ());
|
||||
}
|
||||
|
||||
pub fn hide<R: Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(w) = app.get_webview_window(LABEL) {
|
||||
let _ = w.hide();
|
||||
note_hidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub use window::{create, hide, resize, toggle};
|
||||
|
|
@ -12,7 +12,6 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
|||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
|
||||
use crate::ai_fix;
|
||||
use crate::profiles::{self, ProfileStore};
|
||||
use crate::registry;
|
||||
use crate::cache::{Cache, Entry};
|
||||
|
|
@ -44,16 +43,6 @@ fn guard(w: &WebviewWindow) -> Res<()> {
|
|||
policy::require_main(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
|
||||
}
|
||||
|
||||
fn guard_card(w: &WebviewWindow) -> Res<()> {
|
||||
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
|
||||
policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
|
||||
}
|
||||
|
||||
fn guard_menu(w: &WebviewWindow) -> Res<()> {
|
||||
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
|
||||
policy::require_menu(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
|
||||
}
|
||||
|
||||
fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> {
|
||||
s.saved.lock().map_err(|_| Fail::host("state poisoned"))
|
||||
}
|
||||
|
|
@ -83,8 +72,7 @@ pub fn sync_machine(s: &AppState) -> Res<()> {
|
|||
false
|
||||
}
|
||||
};
|
||||
let ignored = registry::pull_ignored(&mut st, &s.registry);
|
||||
if report.changed() || folders || ignored {
|
||||
if report.changed() || folders {
|
||||
persist(s, &st)?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -268,7 +256,7 @@ pub async fn job_cancel(window: WebviewWindow, state: State<'_, AppState>, job:
|
|||
if !valid_job(&job) {
|
||||
return Err(Fail::host("bad job id"));
|
||||
}
|
||||
let mut hit = state.downloads.cancel(&job) | ai_fix::cancel(&job);
|
||||
let mut hit = state.downloads.cancel(&job);
|
||||
if let Some(r) = state.sidecar.current().await {
|
||||
hit |= r.cancel_job(&job) > 0;
|
||||
hit |= r.cancel_job(&format!("{job}:p")) > 0;
|
||||
|
|
@ -813,11 +801,7 @@ pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, i
|
|||
pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res<Vec<Account>> {
|
||||
guard(&window)?;
|
||||
sync_machine(&state)?;
|
||||
let mut out = saved(&state)?.accounts.clone();
|
||||
for a in out.iter_mut().filter(|a| a.kind == accounts::AccountKind::Play) {
|
||||
a.developer_id = a.profile.as_deref().and_then(|p| state.profiles.play_developer_id(p));
|
||||
}
|
||||
Ok(out)
|
||||
Ok(saved(&state)?.accounts.clone())
|
||||
}
|
||||
|
||||
fn check_studio(state: &AppState, studio: &Option<String>) -> Res<()> {
|
||||
|
|
@ -848,7 +832,7 @@ fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option<Str
|
|||
}
|
||||
let id = profiles::account_id(secret.kind(), &profile);
|
||||
let id = if st.accounts.iter().any(|a| a.id == id) { store::new_id()? } else { id };
|
||||
let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile), developer_id: None };
|
||||
let acc = Account { id, label, kind: secret.kind(), created: store::now_iso(), studio, profile: Some(profile) };
|
||||
st.accounts.push(acc.clone());
|
||||
persist(state, &st)?;
|
||||
Ok(acc)
|
||||
|
|
@ -1149,23 +1133,6 @@ pub fn account_update(window: WebviewWindow, state: State<'_, AppState>, id: Str
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Sets or clears the Play developer account id of a Play account (PLAY_DEVELOPER_ID in its credential profile, the
|
||||
/// same field `storebound accounts play-developer` writes): Play Console links then open each app's own page.
|
||||
#[tauri::command]
|
||||
pub fn account_set_play_developer(window: WebviewWindow, state: State<'_, AppState>, id: String, developer_id: Option<String>) -> Res<Account> {
|
||||
guard(&window)?;
|
||||
let st = saved(&state)?;
|
||||
let mut a = st.accounts.iter().find(|a| a.id == id).cloned().ok_or_else(|| Fail::host("unknown account"))?;
|
||||
if a.kind != accounts::AccountKind::Play {
|
||||
return Err(Fail::host("a Play account only"));
|
||||
}
|
||||
let profile = a.profile.clone().ok_or_else(|| Fail::host("this account has no credential profile yet"))?;
|
||||
let v = developer_id.as_deref().map(str::trim).filter(|v| !v.is_empty());
|
||||
state.profiles.set_play_developer_id(&profile, v).map_err(Fail::host)?;
|
||||
a.developer_id = state.profiles.play_developer_id(&profile);
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Does the account's key work, and can it see apps (sidecar `account.check`). Not cached: it is a live answer.
|
||||
#[tauri::command]
|
||||
pub async fn account_check(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String, job: Option<String>) -> Res<Value> {
|
||||
|
|
@ -1238,10 +1205,6 @@ pub fn overrides_set(window: WebviewWindow, state: State<'_, AppState>, override
|
|||
let mut st = saved(&state)?;
|
||||
st.overrides = overrides.clone();
|
||||
persist(&state, &st)?;
|
||||
// apps the shared list knows carry their ignore flags there (the CLI's watch and portfolio reads honour them)
|
||||
if let Err(e) = registry::push_ignored(&st, &state.registry) {
|
||||
crate::logs::host("warn", &format!("ignored apps not written to the app list: {e}"));
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
|
|
@ -1251,10 +1214,6 @@ pub fn settings_set(window: WebviewWindow, state: State<'_, AppState>, settings:
|
|||
guard(&window)?;
|
||||
workspace::check_settings(&settings).map_err(Fail::host)?;
|
||||
let mut st = saved(&state)?;
|
||||
if st.settings.channel != settings.channel {
|
||||
// a switched channel is checked right away by the background updater (lib.rs auto_update)
|
||||
crate::updates::kick();
|
||||
}
|
||||
st.settings = settings.clone();
|
||||
persist(&state, &st)?;
|
||||
drop(st);
|
||||
|
|
@ -1299,90 +1258,11 @@ pub fn data_reveal(window: WebviewWindow, app: AppHandle, state: State<'_, AppSt
|
|||
/// A desktop notification (review state changed, a new 1-2 star review, a crash rate over the threshold). The window
|
||||
/// decides when, from Settings > Notifications; text is plain and short.
|
||||
#[tauri::command]
|
||||
pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String, target: Option<String>) -> Res<()> {
|
||||
pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String) -> Res<()> {
|
||||
guard(&window)?;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::<String>();
|
||||
app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string()))?;
|
||||
// desktop notifications have no click callback: remember where this one points; the window goes there when it is next shown
|
||||
if let Some(t) = target {
|
||||
crate::tray::set_pending(&t);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window's summary for the tray (tooltip, attention flag, apps with their state, menu words) after a read.
|
||||
#[tauri::command]
|
||||
pub fn tray_update(window: WebviewWindow, app: AppHandle, summary: crate::tray::TraySummary) -> Res<()> {
|
||||
guard(&window)?;
|
||||
crate::tray::apply(&app, &summary).map_err(|e| Fail::host(e.to_string()))
|
||||
}
|
||||
|
||||
/// The main window's snapshot for the tray card (what it already read: releases, reviews, health, last read, busy).
|
||||
/// Kept in the host so the card shows it the moment it opens; forwarded to the card as `card-snapshot`.
|
||||
#[tauri::command]
|
||||
pub fn card_snapshot(window: WebviewWindow, app: AppHandle, snapshot: Value) -> Res<()> {
|
||||
guard(&window)?;
|
||||
if !crate::card::set_snapshot(snapshot.clone()) {
|
||||
return Err(Fail::host("card snapshot refused"));
|
||||
}
|
||||
let _ = app.emit_to(crate::card::LABEL, "card-snapshot", snapshot);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The card: the last snapshot of the main window (null before the first one).
|
||||
#[tauri::command]
|
||||
pub fn card_get(window: WebviewWindow) -> Res<Option<Value>> {
|
||||
guard_card(&window)?;
|
||||
Ok(crate::card::snapshot())
|
||||
}
|
||||
|
||||
/// The card's buttons. `refresh` and `mute` go to the main window as the tray menu's `tray-action` (its own Refresh
|
||||
/// all and mute), `route` shows the main window on a route (`tray-route`), `external` opens a store console.
|
||||
#[tauri::command]
|
||||
pub fn card_action(window: WebviewWindow, app: AppHandle, action: String, arg: Option<String>) -> Res<()> {
|
||||
guard_card(&window)?;
|
||||
if !crate::card::valid_action(&action) {
|
||||
return Err(Fail::host("unknown card action"));
|
||||
}
|
||||
let main = || {
|
||||
if let Some(w) = app.get_webview_window(policy::MAIN) {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
};
|
||||
match action.as_str() {
|
||||
"hide" => crate::card::hide(&app),
|
||||
"ready" => { let _ = app.emit_to(policy::MAIN, "card-request", ()); }
|
||||
"size" => {
|
||||
let h: f64 = arg.as_deref().unwrap_or_default().parse().map_err(|_| Fail::host("bad size"))?;
|
||||
crate::card::resize(&app, h);
|
||||
}
|
||||
"open" => { crate::card::hide(&app); main(); }
|
||||
"refresh" => { let _ = app.emit_to(policy::MAIN, "tray-action", "read"); }
|
||||
"mute" => { let _ = app.emit_to(policy::MAIN, "tray-action", "mute-toggle"); }
|
||||
"quit" => app.exit(0),
|
||||
"route" => {
|
||||
let r = arg.unwrap_or_default();
|
||||
if !crate::tray::valid_route(&r) {
|
||||
return Err(Fail::host("bad route"));
|
||||
}
|
||||
crate::card::hide(&app);
|
||||
main();
|
||||
let _ = app.emit_to(policy::MAIN, "tray-route", r);
|
||||
}
|
||||
"external" => {
|
||||
let u = url::Url::parse(arg.as_deref().unwrap_or_default()).map_err(|_| Fail::host("not a URL"))?;
|
||||
if !policy::external_allowed(&u) {
|
||||
return Err(Fail::host("only the store consoles open from here"));
|
||||
}
|
||||
crate::card::hide(&app);
|
||||
app.opener().open_url(u.as_str(), None::<&str>).map_err(|e| Fail::host(e.to_string()))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_string()))
|
||||
}
|
||||
|
||||
/// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders
|
||||
|
|
@ -1493,7 +1373,7 @@ pub struct UpdateInfo {
|
|||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_updater::Update>> {
|
||||
async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plugin_updater::Update>> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
let url = url::Url::parse(updates::endpoint(channel)).map_err(|e| Fail::host(e.to_string()))?;
|
||||
if !updates::endpoint_allowed(&url) {
|
||||
|
|
@ -1504,8 +1384,6 @@ pub async fn find_update(app: &AppHandle, channel: &str) -> Res<Option<tauri_plu
|
|||
.endpoints(vec![url])
|
||||
.map_err(|e| Fail::host(e.to_string()))?
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// the no-downgrade rule (updates::should_install), also after a switch from dev back to stable
|
||||
.version_comparator(|current, remote| updates::should_install(¤t.to_string(), &remote.version.to_string()))
|
||||
.build()
|
||||
.map_err(|e| Fail::host(updates::error_text(&e.to_string())))?;
|
||||
updater.check().await.map_err(|e| Fail::host(updates::error_text(&e.to_string())))
|
||||
|
|
@ -1611,142 +1489,3 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
|
|||
let _ = app.emit_to(policy::MAIN, "deep-link-folder", id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Fix with AI (src/ai_fix.rs): the owner's local Claude Code CLI on one scan finding ----
|
||||
|
||||
fn project_dir(state: &AppState, project_id: &str) -> Res<PathBuf> {
|
||||
let p = saved(state)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
|
||||
Ok(PathBuf::from(p.path))
|
||||
}
|
||||
|
||||
/// Whether claude is installed, the prompt (also what "Copy for AI" copies) and what is dirty in git.
|
||||
#[tauri::command]
|
||||
pub async fn ai_fix_info(window: WebviewWindow, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
|
||||
let dir = project_dir(&state, &project_id)?;
|
||||
let claude = ai_fix::find_claude();
|
||||
let (git, dirty) = match tauri::async_runtime::spawn_blocking(move || ai_fix::status(&dir)).await.map_err(|e| Fail::host(e.to_string()))? {
|
||||
Ok((_, d)) => (Ok(()), d),
|
||||
Err(e) => (Err(e), Vec::new()),
|
||||
};
|
||||
Ok(json!({ "claude": claude.is_some(), "prompt": prompt, "git": git.is_ok(), "gitError": git.err(), "dirty": dirty }))
|
||||
}
|
||||
|
||||
/// Runs claude on one finding in the app folder (clean git tree only), streams "ai-fix" lines, returns the run's changes.
|
||||
#[tauri::command]
|
||||
pub async fn ai_fix_run(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding, job: String) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
if !valid_job(&job) {
|
||||
return Err(Fail::host("bad job id"));
|
||||
}
|
||||
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
|
||||
let dir = project_dir(&state, &project_id)?;
|
||||
let claude = ai_fix::find_claude().ok_or_else(|| Fail::host("Claude Code (claude) was not found on PATH"))?;
|
||||
let handle = app.clone();
|
||||
let res = tauri::async_runtime::spawn_blocking(move || -> Result<Value, String> {
|
||||
let (top, dirty) = ai_fix::status(&dir)?;
|
||||
if !dirty.is_empty() {
|
||||
return Ok(json!({ "refused": "dirty", "dirty": dirty }));
|
||||
}
|
||||
let cmd = ai_fix::claude_command(&claude, &dir, &prompt)?;
|
||||
let emit_job = job.clone();
|
||||
let out = ai_fix::run(cmd, &job, ai_fix::TIMEOUT, ai_fix::HEARTBEAT, |line| {
|
||||
let _ = handle.emit_to(policy::MAIN, "ai-fix", json!({ "job": emit_job, "line": line }));
|
||||
})?;
|
||||
let (_, changes) = ai_fix::status(&dir)?;
|
||||
let diff = ai_fix::diff(&top, &changes);
|
||||
let run_id = job.clone();
|
||||
if let Ok(mut m) = ai_fix::runs().lock() {
|
||||
m.insert(run_id.clone(), (top, changes.clone()));
|
||||
}
|
||||
Ok(json!({ "runId": run_id, "outcome": out, "changes": changes, "diff": diff }))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Fail::host(e.to_string()))?;
|
||||
res.map_err(Fail::host)
|
||||
}
|
||||
|
||||
/// Undo of one run: restores exactly the files that run changed (recorded by the host) and removes the ones it created.
|
||||
#[tauri::command]
|
||||
pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<String>> {
|
||||
guard(&window)?;
|
||||
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
|
||||
}
|
||||
|
||||
// ---- the tray menu panels (menu_panel.rs; only `menu-*` windows) ----
|
||||
|
||||
/// A panel: what it shows (the whole tree and its path into it).
|
||||
#[tauri::command]
|
||||
pub fn menu_state(window: WebviewWindow) -> Res<Option<crate::menu_panel::PanelInfo>> {
|
||||
guard_menu(&window)?;
|
||||
Ok(crate::menu_panel::panel_state(&window))
|
||||
}
|
||||
|
||||
/// A panel measured itself (CSS px): sized, placed inside the work area and shown.
|
||||
#[tauri::command]
|
||||
pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::ready(&window, width, height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens a submenu beside this panel at the row whose top is `top` (CSS px).
|
||||
#[tauri::command]
|
||||
pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::open_sub(&window, &key, top, focus, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ctrl / middle click on a submenu row: opens it as its own pinned panel.
|
||||
#[tauri::command]
|
||||
pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::open_sub(&window, &key, top, false, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The pointer moved to a plain row: this panel's submenus close.
|
||||
#[tauri::command]
|
||||
pub fn menu_close_subs(window: WebviewWindow) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::close_subs(&window);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
|
||||
#[tauri::command]
|
||||
pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::close(&window, refocus_parent);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Esc on the root menu: every transient panel closes.
|
||||
#[tauri::command]
|
||||
pub fn menu_dismiss(window: WebviewWindow) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::dismiss(window.app_handle());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pins or unpins this panel.
|
||||
#[tauri::command]
|
||||
pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
crate::menu_panel::pin(&window, pinned);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused).
|
||||
#[tauri::command]
|
||||
pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> {
|
||||
guard_menu(&window)?;
|
||||
if id.len() > 300 || crate::tray::action_of(&id).is_none() {
|
||||
return Err(Fail::host("unknown menu item"));
|
||||
}
|
||||
crate::menu_panel::select(&window, id);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ pub const COMMANDS: &[&str] = &[
|
|||
"studio_remove",
|
||||
"studio_logo",
|
||||
"account_update",
|
||||
"account_set_play_developer",
|
||||
"account_check",
|
||||
"store_apps_refresh",
|
||||
"store_apps_cached",
|
||||
|
|
@ -36,8 +35,6 @@ pub const COMMANDS: &[&str] = &[
|
|||
"cache_info",
|
||||
"data_reveal",
|
||||
"notify",
|
||||
"tray_update",
|
||||
"card_snapshot",
|
||||
"app_reset",
|
||||
"update_check",
|
||||
"update_install",
|
||||
|
|
@ -49,13 +46,4 @@ pub const COMMANDS: &[&str] = &[
|
|||
"project_locate",
|
||||
"state_reset",
|
||||
"log_webview",
|
||||
"ai_fix_info",
|
||||
"ai_fix_run",
|
||||
"ai_fix_undo",
|
||||
];
|
||||
|
||||
/// The tray card window's commands (capabilities/card.json grants them to the `card` window only).
|
||||
pub const CARD_COMMANDS: &[&str] = &["card_get", "card_action"];
|
||||
|
||||
/// The tray menu panels' commands (capabilities/menu.json grants them to the `menu-*` windows only).
|
||||
pub const MENU_COMMANDS: &[&str] = &["menu_state", "menu_ready", "menu_open_sub", "menu_pin_sub", "menu_close_subs", "menu_close", "menu_dismiss", "menu_pin", "menu_select"];
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
//! 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 ai_fix;
|
||||
pub mod cache;
|
||||
pub mod card;
|
||||
pub mod commands;
|
||||
pub mod confirm;
|
||||
pub mod downloads;
|
||||
pub mod logs;
|
||||
pub mod menu_panel;
|
||||
pub mod policy;
|
||||
pub mod profiles;
|
||||
pub mod registry;
|
||||
|
|
@ -16,7 +13,6 @@ pub mod selftest;
|
|||
pub mod updates;
|
||||
pub mod sidecar;
|
||||
pub mod store;
|
||||
pub mod tray;
|
||||
pub mod workspace;
|
||||
|
||||
include!("commands_list.rs");
|
||||
|
|
@ -24,9 +20,7 @@ include!("commands_list.rs");
|
|||
use std::sync::Mutex;
|
||||
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent};
|
||||
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
|
||||
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
|
||||
|
|
@ -39,178 +33,11 @@ pub fn linux_webkit_env() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Started by the logon entry (`--hidden`): the app runs in the tray and shows no window.
|
||||
fn started_hidden() -> bool {
|
||||
std::env::args().any(|a| a == "--hidden")
|
||||
}
|
||||
|
||||
/// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with
|
||||
/// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit,
|
||||
/// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window
|
||||
/// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu,
|
||||
/// rendered from the same tree, stays on the icon there (and anywhere the panels fail).
|
||||
fn tray(app: &tauri::App) -> tauri::Result<()> {
|
||||
let handle = app.handle();
|
||||
let panels = handle.state::<menu_panel::MenuPanels>();
|
||||
if let Ok(mut n) = panels.nodes.lock() {
|
||||
*n = tray::initial_nodes();
|
||||
}
|
||||
let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false);
|
||||
if cfg!(target_os = "linux") {
|
||||
panels.native.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?);
|
||||
}
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
b = b.icon(icon.clone());
|
||||
}
|
||||
b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref()))
|
||||
.on_tray_icon_event(|t, e| {
|
||||
let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return };
|
||||
let app = t.app_handle();
|
||||
// both open from the icon's rectangle (not the click point): their edge sits next to the icon, above a
|
||||
// bottom taskbar, so nothing starts inside the taskbar
|
||||
let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0);
|
||||
let p = rect.position.to_physical::<f64>(scale);
|
||||
let s = rect.size.to_physical::<f64>(scale);
|
||||
let icon = if s.width > 0.0 && s.height > 0.0 { card::Rect { x: p.x, y: p.y, w: s.width, h: s.height } } else { card::Rect { x: position.x, y: position.y, w: 1.0, h: 1.0 } };
|
||||
match button {
|
||||
MouseButton::Right => menu_panel::open_root(app, menu_panel::Anchor::Tray { x: icon.x, y: icon.y, w: icon.w, h: icon.h }),
|
||||
MouseButton::Left => {
|
||||
if app.get_webview_window(card::LABEL).is_none() {
|
||||
show_main(app);
|
||||
return;
|
||||
}
|
||||
card::toggle(app, icon);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does.
|
||||
/// Whether the person turned storebound off in Windows' Startup apps (Settings / Task Manager), from the
|
||||
/// HKCU\...\Explorer\StartupApproved\Run value named like the Run entry: its first byte is even when enabled (02, 06),
|
||||
/// odd when disabled (03, 07). No value: not turned off. Pure.
|
||||
pub fn startup_approved_off(value: &[u8]) -> bool {
|
||||
value.first().is_some_and(|b| b & 1 == 1)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn startup_disabled_by_user() -> bool {
|
||||
use winreg::{enums::HKEY_CURRENT_USER, RegKey};
|
||||
RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run")
|
||||
.and_then(|k| k.get_raw_value("storebound"))
|
||||
.map(|v| startup_approved_off(&v.bytes))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn startup_disabled_by_user() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn dispatch_menu<R: Runtime>(app: &AppHandle<R>, id: &str) {
|
||||
match tray::action_of(id) {
|
||||
Some(tray::Action::Open) => show_main(app),
|
||||
Some(tray::Action::Quit) => app.exit(0),
|
||||
Some(tray::Action::Event(word)) => {
|
||||
let _ = app.emit_to(policy::MAIN, "tray-action", word);
|
||||
}
|
||||
Some(tray::Action::Route(route)) => {
|
||||
show_main(app);
|
||||
let _ = app.emit_to(policy::MAIN, "tray-route", route);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is
|
||||
/// downloaded, installed and the app restarts with the same arguments (`--hidden` stays hidden). Errors are logged.
|
||||
/// Background updater (docs/desktop-release.md, "Dev channel"): asks the chosen channel's latest.json at start, every
|
||||
/// 15 minutes, right after a resume from sleep and when Settings switched the channel; installs a strictly newer, signed
|
||||
/// build silently (the plugin checks the minisign signature first), deferred while the main window is in use (at most
|
||||
/// updates::MAX_DEFERS checks in a row). Every step goes to host.log as `auto-update ...`.
|
||||
fn auto_update(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
let mut first = true;
|
||||
let mut last_check = Instant::now();
|
||||
let mut last_wall = SystemTime::now();
|
||||
let mut defers = 0u32;
|
||||
loop {
|
||||
let now_wall = SystemTime::now();
|
||||
let gap = now_wall.duration_since(last_wall).map(|d| d.as_secs()).unwrap_or(0);
|
||||
last_wall = now_wall;
|
||||
let kicked = updates::take_kick();
|
||||
if updates::check_due(first, kicked, gap, last_check.elapsed().as_secs()) {
|
||||
let why = if first { "start" } else if kicked { "channel switched" } else if gap > updates::TICK_SECS + 60 { "resume" } else { "every 15 min" };
|
||||
first = false;
|
||||
last_check = Instant::now();
|
||||
defers = auto_update_once(&app, why, defers).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(updates::TICK_SECS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One check of the background updater; returns the new count of deferred installs.
|
||||
async fn auto_update_once(app: &AppHandle, why: &str, defers: u32) -> u32 {
|
||||
let state = app.state::<commands::AppState>();
|
||||
let channel = match state.saved.lock() {
|
||||
Ok(s) => s.settings.channel.clone(),
|
||||
Err(_) => return defers,
|
||||
};
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
let update = match commands::find_update(app, &channel).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
logs::host("info", &format!("auto-update ({why}): {current} is current on {channel}"));
|
||||
return 0;
|
||||
}
|
||||
Err(e) => {
|
||||
logs::host("warn", &format!("auto-update ({why}): check of {channel} failed: {}", e.message));
|
||||
return defers;
|
||||
}
|
||||
};
|
||||
if !updates::should_install(current, &update.version) {
|
||||
logs::host("info", &format!("auto-update ({why}): {channel} offers {}, not newer than {current}: no downgrade", update.version));
|
||||
return 0;
|
||||
}
|
||||
let main = app.get_webview_window(policy::MAIN);
|
||||
let visible = main.as_ref().and_then(|w| w.is_visible().ok()).unwrap_or(false);
|
||||
let focused = main.as_ref().and_then(|w| w.is_focused().ok()).unwrap_or(false);
|
||||
if updates::defer_install(visible, focused, defers) {
|
||||
logs::host("info", &format!("auto-update ({why}): {} on {channel} waits, the window is in use ({}/{})", update.version, defers + 1, updates::MAX_DEFERS));
|
||||
return defers + 1;
|
||||
}
|
||||
logs::host("info", &format!("auto-update ({why}): installing {} from {channel} (this is {current})", update.version));
|
||||
// the sidecar binary lives inside the app bundle that is about to be replaced
|
||||
state.sidecar.shutdown().await;
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => {
|
||||
logs::host("info", &format!("auto-update: {} installed, restarting", update.version));
|
||||
app.restart()
|
||||
}
|
||||
Err(e) => {
|
||||
logs::host("warn", &format!("auto-update: install of {} failed: {}", update.version, updates::error_text(&e.to_string())));
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main<R: Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(w) = app.get_webview_window(policy::MAIN) {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
// the last notification's target (the plugin has no click callback on desktop): the window goes there now
|
||||
if let Some(route) = tray::take_pending() {
|
||||
let _ = app.emit_to(policy::MAIN, "tray-route", route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,34 +128,13 @@ fn dev_seed(data_dir: &std::path::Path) {
|
|||
}
|
||||
}
|
||||
|
||||
/// One line before Tauri starts: a start at logon that dies early (before the host log exists) still leaves a trace
|
||||
/// with its arguments and working folder (a logon start once left no line at all). Best effort, never fails.
|
||||
fn early_trace() {
|
||||
use std::io::Write;
|
||||
let base = std::env::var_os("APPDATA").or_else(|| std::env::var_os("XDG_DATA_HOME")).map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share")));
|
||||
let Some(base) = base else { return };
|
||||
let dir = base.join("dev.storebound.desktop").join("logs");
|
||||
if std::fs::create_dir_all(&dir).is_err() { return }
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default();
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("start.log")) {
|
||||
let _ = writeln!(f, "{secs} start {} args={args:?} cwd={cwd} pid={}", env!("CARGO_PKG_VERSION"), std::process::id());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
early_trace();
|
||||
linux_webkit_env();
|
||||
let app = tauri::Builder::default()
|
||||
// single-instance first: a second start (or a storebound:// link) focuses the running app instead; a second
|
||||
// logon start (--hidden: the Run entry plus a Startup folder shortcut) stays in the tray
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| if !argv.iter().any(|a| a == "--hidden") { show_main(app) }))
|
||||
// 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())
|
||||
// never restore "visible": a --hidden start would otherwise pop the window up
|
||||
.plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build())
|
||||
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"])))
|
||||
.plugin(tauri_plugin_window_state::Builder::new().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
|
|
@ -336,10 +142,6 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.setup(|app| {
|
||||
#[cfg(all(windows, not(debug_assertions)))]
|
||||
if let Some(w) = app.get_webview_window(policy::MAIN) {
|
||||
harden_webview(&w);
|
||||
}
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
logs::init(&data_dir, app.path().home_dir().ok().map(|h| h.to_string_lossy().to_string()));
|
||||
logs::panic_hook();
|
||||
|
|
@ -375,29 +177,7 @@ pub fn run() {
|
|||
if let Err(e) = commands::sync_machine(&state) {
|
||||
logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message));
|
||||
}
|
||||
// start at logon, in the tray: rewritten on every start (Windows: the HKCU Run entry then points at the
|
||||
// running exe, so a moved or updated install fixes itself)
|
||||
// ...unless the person turned it off (Windows Settings > Apps > Startup / Task Manager): that choice stands
|
||||
if startup_disabled_by_user() {
|
||||
logs::host("info", "autostart at logon left off (turned off in Windows Startup apps)");
|
||||
} else {
|
||||
match app.autolaunch().enable() {
|
||||
Ok(()) => logs::host("info", "autostart at logon set (--hidden, tray)"),
|
||||
Err(e) => logs::host("warn", &format!("autostart not set: {e}")),
|
||||
}
|
||||
}
|
||||
app.manage(state);
|
||||
app.manage(menu_panel::MenuPanels::default());
|
||||
tray(app)?;
|
||||
// pinned tray menu panels of the last session come back
|
||||
menu_panel::restore(app.handle());
|
||||
if let Err(e) = card::create(app.handle()) {
|
||||
logs::host("warn", &format!("tray card not created: {e}"));
|
||||
}
|
||||
if !started_hidden() {
|
||||
show_main(app.handle());
|
||||
}
|
||||
auto_update(app.handle().clone());
|
||||
#[cfg(target_os = "macos")]
|
||||
app.set_menu(menu(app.handle())?)?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
|
@ -421,16 +201,9 @@ pub fn run() {
|
|||
Ok(())
|
||||
})
|
||||
.on_window_event(|w, e| {
|
||||
// the close button hides the window: the app keeps running in the tray (macOS: also the Dock); the tray's
|
||||
// Quit (macOS: Cmd+Q) really quits
|
||||
if w.label() == card::LABEL {
|
||||
match e {
|
||||
WindowEvent::Focused(false) => { let _ = w.hide(); card::note_hidden(); }
|
||||
WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let _ = w.hide(); card::note_hidden(); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if w.label() == policy::MAIN {
|
||||
// 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();
|
||||
|
|
@ -464,7 +237,6 @@ pub fn run() {
|
|||
commands::studio_remove,
|
||||
commands::studio_logo,
|
||||
commands::account_update,
|
||||
commands::account_set_play_developer,
|
||||
commands::account_check,
|
||||
commands::store_apps_refresh,
|
||||
commands::store_apps_cached,
|
||||
|
|
@ -473,8 +245,6 @@ pub fn run() {
|
|||
commands::cache_info,
|
||||
commands::data_reveal,
|
||||
commands::notify,
|
||||
commands::tray_update,
|
||||
commands::card_snapshot,
|
||||
commands::app_reset,
|
||||
commands::update_check,
|
||||
commands::update_install,
|
||||
|
|
@ -486,20 +256,6 @@ pub fn run() {
|
|||
commands::project_locate,
|
||||
commands::state_reset,
|
||||
commands::log_webview,
|
||||
commands::ai_fix_info,
|
||||
commands::ai_fix_run,
|
||||
commands::ai_fix_undo,
|
||||
commands::card_get,
|
||||
commands::card_action,
|
||||
commands::menu_state,
|
||||
commands::menu_ready,
|
||||
commands::menu_open_sub,
|
||||
commands::menu_pin_sub,
|
||||
commands::menu_close_subs,
|
||||
commands::menu_close,
|
||||
commands::menu_dismiss,
|
||||
commands::menu_pin,
|
||||
commands::menu_select,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("storebound desktop failed to start");
|
||||
|
|
@ -507,33 +263,11 @@ pub fn run() {
|
|||
app.run(|app, e| match e {
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen { .. } => show_main(app),
|
||||
// no window left is not a reason to quit (tray app); an explicit exit or the updater's restart carries a code
|
||||
RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(),
|
||||
RunEvent::Exit => {
|
||||
if let Some(s) = app.try_state::<commands::AppState>() {
|
||||
s.sidecar.shutdown_now();
|
||||
}
|
||||
ai_fix::kill_all();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
/// Release builds on Windows: no WebView2 browser context menu (Back, Refresh, Save as, Print, More tools) and no
|
||||
/// browser accelerator keys (Ctrl+R, Ctrl+P, F5, Ctrl+Shift+I...). The page draws its own menus; dev keeps devtools.
|
||||
#[cfg(all(windows, not(debug_assertions)))]
|
||||
fn harden_webview(w: &tauri::WebviewWindow) {
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3;
|
||||
use windows_core::Interface;
|
||||
let r = w.with_webview(|wv| unsafe {
|
||||
let Ok(core) = wv.controller().CoreWebView2() else { return };
|
||||
let Ok(s) = core.Settings() else { return };
|
||||
let _ = s.SetAreDefaultContextMenusEnabled(false);
|
||||
if let Ok(s3) = s.cast::<ICoreWebView2Settings3>() {
|
||||
let _ = s3.SetAreBrowserAcceleratorKeysEnabled(false);
|
||||
}
|
||||
});
|
||||
if let Err(e) = r {
|
||||
logs::host("warn", &format!("webview menus not turned off: {e}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,749 +0,0 @@
|
|||
//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/).
|
||||
//!
|
||||
//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label
|
||||
//! without the live part), so an open or pinned panel finds its submenu again after the values change.
|
||||
//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The
|
||||
//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it.
|
||||
//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child
|
||||
//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be
|
||||
//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json).
|
||||
//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls.
|
||||
//! - Idle: a closed panel is destroyed, never hidden.
|
||||
//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same
|
||||
//! tree) comes back. Linux always uses it (its tray sends no click events).
|
||||
//!
|
||||
//! The top half is pure and covered by tests/menu_panel.rs.
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::tray::Entry;
|
||||
|
||||
pub const LABEL_PREFIX: &str = "menu-";
|
||||
pub const PAGE: &str = "tray-menu.html";
|
||||
pub const MODEL_EVENT: &str = "menu-model";
|
||||
pub const PANEL_EVENT: &str = "menu-panel";
|
||||
/// Gap to the work area's edges (physical px at scale 1).
|
||||
pub const GAP: f64 = 8.0;
|
||||
/// The estimated size of a panel before it measured itself (logical px).
|
||||
pub const EST: (f64, f64) = (280.0, 220.0);
|
||||
|
||||
// ---------------------------------------------------------------- model
|
||||
|
||||
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Node {
|
||||
Item { id: String, label: String, enabled: bool },
|
||||
Sep,
|
||||
Sub { key: String, label: String, items: Vec<Node> },
|
||||
}
|
||||
|
||||
/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '('
|
||||
/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys.
|
||||
pub fn sub_key(label: &str) -> String {
|
||||
let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or("");
|
||||
let mut out = String::new();
|
||||
for c in head.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
out.extend(c.to_lowercase());
|
||||
} else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let out = out.trim_end_matches('-').to_string();
|
||||
if out.is_empty() { "sub".into() } else { out }
|
||||
}
|
||||
|
||||
/// Sibling submenus with the same key get "-2", "-3"...
|
||||
pub fn unique_keys(mut nodes: Vec<Node>) -> Vec<Node> {
|
||||
let mut seen: BTreeMap<String, u32> = BTreeMap::new();
|
||||
for n in nodes.iter_mut() {
|
||||
if let Node::Sub { key, .. } = n {
|
||||
let count = seen.entry(key.clone()).or_insert(0);
|
||||
*count += 1;
|
||||
if *count > 1 {
|
||||
*key = format!("{key}-{count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never
|
||||
/// changes it), else the key comes from its label.
|
||||
pub fn from_entries(entries: &[Entry]) -> Vec<Node> {
|
||||
let nodes = entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled },
|
||||
Entry::Separator => Node::Sep,
|
||||
Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) },
|
||||
})
|
||||
.collect();
|
||||
unique_keys(nodes)
|
||||
}
|
||||
|
||||
/// The items of the submenu at `path` (empty path: the root) and its label.
|
||||
pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> {
|
||||
let mut current = nodes;
|
||||
let mut label = None;
|
||||
for key in path {
|
||||
let (items, l) = current.iter().find_map(|n| match n {
|
||||
Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())),
|
||||
_ => None,
|
||||
})?;
|
||||
current = items;
|
||||
label = Some(l);
|
||||
}
|
||||
Some((current, label))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- placement
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Anchor {
|
||||
/// The tray click point (physical).
|
||||
Point { x: f64, y: f64 },
|
||||
/// The tray icon's rectangle (physical): the root opens from the icon's edge that faces the screen, like the card.
|
||||
Tray { x: f64, y: f64, w: f64, h: f64 },
|
||||
/// Beside a parent panel: its left and right edge and the row's top (physical).
|
||||
Beside { left: f64, right: f64, top: f64 },
|
||||
/// A remembered top-left (pinned panels).
|
||||
At { x: f64, y: f64 },
|
||||
}
|
||||
|
||||
/// x, y, width, height (physical px).
|
||||
pub type Rect = (f64, f64, f64, f64);
|
||||
|
||||
/// Keeps a panel of size w x h fully inside the work area (with `GAP`).
|
||||
pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) {
|
||||
let (wx, wy, ww, wh) = work;
|
||||
(x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP)))
|
||||
}
|
||||
|
||||
/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the
|
||||
/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot.
|
||||
pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) {
|
||||
let (w, h) = size;
|
||||
let (wx, wy, ww, wh) = work;
|
||||
let (x, y) = match anchor {
|
||||
Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }),
|
||||
// same rule as the card: the panel's edge sits GAP from the icon, never over the taskbar
|
||||
Anchor::Tray { x, y, w: iw, h: ih } => return crate::card::place(crate::card::Rect { x, y, w: iw, h: ih }, crate::card::Rect { x: wx, y: wy, w: ww, h: wh }, w, h, GAP),
|
||||
Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0),
|
||||
Anchor::At { x, y } => (x, y),
|
||||
};
|
||||
clamp_into(x, y, w, h, work)
|
||||
}
|
||||
|
||||
pub fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3
|
||||
}
|
||||
|
||||
/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column.
|
||||
/// Gives up after a while (a screen full of pins) and returns the clamped wish.
|
||||
pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) {
|
||||
let (mut x, mut y, w, h) = want;
|
||||
for _ in 0..64 {
|
||||
let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else {
|
||||
return clamp_into(x, y, w, h, work);
|
||||
};
|
||||
y = hit.1 + hit.3 + GAP;
|
||||
if y + h > work.1 + work.3 {
|
||||
y = work.1 + GAP;
|
||||
x += w + GAP;
|
||||
if x + w > work.0 + work.2 {
|
||||
x = work.0 + GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
clamp_into(want.0, want.1, w, h, work)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- panel book
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Panel {
|
||||
pub path: Vec<String>,
|
||||
pub pinned: bool,
|
||||
pub parent: Option<String>,
|
||||
pub anchor: Anchor,
|
||||
/// Measured and shown once.
|
||||
pub shown: bool,
|
||||
/// Physical top-left once shown (kept for pins).
|
||||
pub pos: Option<(f64, f64)>,
|
||||
/// Opened from the keyboard: takes focus and starts on its first row.
|
||||
pub focus: bool,
|
||||
}
|
||||
|
||||
/// A pinned panel as state.json keeps it (`menuPins`).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PinRecord {
|
||||
pub path: Vec<String>,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// Which panels are open, which are pinned, who is whose child.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Book {
|
||||
pub panels: BTreeMap<String, Panel>,
|
||||
next: u32,
|
||||
}
|
||||
|
||||
impl Book {
|
||||
fn add(&mut self, path: Vec<String>, pinned: bool, parent: Option<String>, anchor: Anchor) -> String {
|
||||
self.next += 1;
|
||||
let label = format!("{LABEL_PREFIX}{}", self.next);
|
||||
self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false });
|
||||
label
|
||||
}
|
||||
|
||||
fn remove_all(&mut self, labels: &[String]) {
|
||||
for l in labels {
|
||||
self.panels.remove(l);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transient(&self) -> Vec<String> {
|
||||
self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect()
|
||||
}
|
||||
|
||||
/// Transient panels below `label` (children first).
|
||||
pub fn descendants(&self, label: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut frontier = vec![label.to_string()];
|
||||
while let Some(l) = frontier.pop() {
|
||||
for (child, p) in &self.panels {
|
||||
if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) {
|
||||
out.push(child.clone());
|
||||
frontier.push(child.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones.
|
||||
pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec<String>) {
|
||||
let closes = self.transient();
|
||||
self.remove_all(&closes);
|
||||
(self.add(vec![], false, None, anchor), closes)
|
||||
}
|
||||
|
||||
/// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open
|
||||
/// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu).
|
||||
pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec<String>)> {
|
||||
let mut path = self.panels.get(parent)?.path.clone();
|
||||
path.push(key.to_string());
|
||||
if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) {
|
||||
return None;
|
||||
}
|
||||
let mut closes = self.descendants(parent);
|
||||
self.remove_all(&closes);
|
||||
if pinned {
|
||||
let old: Vec<String> = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect();
|
||||
for l in old {
|
||||
closes.extend(self.close(&l));
|
||||
}
|
||||
return Some((self.add(path, true, None, anchor), closes));
|
||||
}
|
||||
Some((self.add(path, false, Some(parent.to_string()), anchor), closes))
|
||||
}
|
||||
|
||||
/// Closes `label` and its transient children.
|
||||
pub fn close(&mut self, label: &str) -> Vec<String> {
|
||||
if !self.panels.contains_key(label) {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = self.descendants(label);
|
||||
out.push(label.to_string());
|
||||
self.remove_all(&out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Every transient panel closes (Esc on the root, a pick, focus left the menus).
|
||||
pub fn dismiss(&mut self) -> Vec<String> {
|
||||
let t = self.transient();
|
||||
self.remove_all(&t);
|
||||
t
|
||||
}
|
||||
|
||||
/// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children
|
||||
/// close (returned). A second pinned copy of the same submenu closes too.
|
||||
pub fn pin(&mut self, label: &str, on: bool) -> Vec<String> {
|
||||
let Some(p) = self.panels.get(label) else { return vec![] };
|
||||
let path = p.path.clone();
|
||||
if !on {
|
||||
if let Some(p) = self.panels.get_mut(label) {
|
||||
p.pinned = false;
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
let mut closes: Vec<String> = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect();
|
||||
if let Some(p) = self.panels.get_mut(label) {
|
||||
p.pinned = true;
|
||||
p.parent = None;
|
||||
}
|
||||
let mine = self.descendants(label);
|
||||
closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l)));
|
||||
self.remove_all(&closes);
|
||||
closes
|
||||
}
|
||||
|
||||
/// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show
|
||||
/// that their submenu is gone until it comes back (an app that is read again) or they are closed.
|
||||
pub fn prune(&mut self, nodes: &[Node]) -> Vec<String> {
|
||||
let gone: Vec<String> = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect();
|
||||
let mut out = Vec::new();
|
||||
for l in gone {
|
||||
if self.panels.contains_key(&l) {
|
||||
out.extend(self.close(&l));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pinned panels to save (only placed ones; in label order, so the order is stable).
|
||||
pub fn pins(&self) -> Vec<PinRecord> {
|
||||
self.panels
|
||||
.values()
|
||||
.filter(|p| p.pinned)
|
||||
.filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped).
|
||||
pub fn restore(&mut self, pins: &[PinRecord]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for r in pins.iter().take(MAX_PINS) {
|
||||
if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (r.x as f64, r.y as f64);
|
||||
let l = self.add(r.path.clone(), true, None, Anchor::At { x, y });
|
||||
if let Some(p) = self.panels.get_mut(&l) {
|
||||
p.pos = Some((x, y));
|
||||
}
|
||||
out.push(l);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many pinned panels are restored after a restart.
|
||||
pub const MAX_PINS: usize = 12;
|
||||
|
||||
/// A panel label the app made (`menu-` and a number), for the window / command checks.
|
||||
pub fn is_menu_label(label: &str) -> bool {
|
||||
label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- runtime
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PanelInfo {
|
||||
pub label: String,
|
||||
pub path: Vec<String>,
|
||||
pub pinned: bool,
|
||||
pub has_parent: bool,
|
||||
pub focus_first: bool,
|
||||
pub nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
/// The panels' shared state (managed by the app).
|
||||
#[derive(Default)]
|
||||
pub struct MenuPanels {
|
||||
pub book: Mutex<Book>,
|
||||
pub nodes: Mutex<Vec<Node>>,
|
||||
/// The native menu took over (a panel failed, or Linux).
|
||||
pub native: AtomicBool,
|
||||
/// Bumped on every move of a pinned panel; the save runs once the moves stop.
|
||||
moves: AtomicU64,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
mod runtime {
|
||||
use super::*;
|
||||
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
|
||||
fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> {
|
||||
app.state::<MenuPanels>()
|
||||
}
|
||||
|
||||
fn book<R>(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R {
|
||||
let s = state(app);
|
||||
let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(&mut b)
|
||||
}
|
||||
|
||||
fn nodes(app: &AppHandle) -> Vec<Node> {
|
||||
state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn native_fallback(app: &AppHandle) -> bool {
|
||||
state(app).native.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Puts the native menu (from the current tree) on the tray: the fallback, and Linux.
|
||||
pub fn set_native(app: &AppHandle) {
|
||||
let Some(tray) = app.tray_by_id("main") else { return };
|
||||
match crate::tray::native_menu(app, &nodes(app)) {
|
||||
Ok(m) => {
|
||||
let _ = tray.set_menu(Some(m));
|
||||
}
|
||||
Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the pinned panels into state.json (`menuPins`).
|
||||
fn save_pins(app: &AppHandle) {
|
||||
let pins = book(app, |b| b.pins());
|
||||
let Some(s) = app.try_state::<crate::commands::AppState>() else { return };
|
||||
let Ok(mut st) = s.saved.lock() else { return };
|
||||
if st.menu_pins == pins {
|
||||
return;
|
||||
}
|
||||
st.menu_pins = pins;
|
||||
if let Err(e) = crate::store::save(&s.data_dir, &st) {
|
||||
crate::logs::host("warn", &format!("menu pins not saved: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone
|
||||
/// close when transient. Cheap when nothing changed or no panel is open.
|
||||
pub fn set_nodes(app: &AppHandle, next: Vec<Node>) {
|
||||
let changed = {
|
||||
let s = state(app);
|
||||
let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let changed = *g != next;
|
||||
*g = next.clone();
|
||||
changed
|
||||
};
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::<Vec<_>>()));
|
||||
destroy(app, &closes);
|
||||
for l in open {
|
||||
let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next);
|
||||
}
|
||||
if native_fallback(app) {
|
||||
set_native(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy(app: &AppHandle, labels: &[String]) {
|
||||
for l in labels {
|
||||
if let Some(w) = app.get_webview_window(l) {
|
||||
let _ = w.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_window(app: &AppHandle, label: &str) -> tauri::Result<WebviewWindow> {
|
||||
let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into()))
|
||||
.title("storebound")
|
||||
.inner_size(EST.0, EST.1)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.shadow(true)
|
||||
.visible(false)
|
||||
.focused(false);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let b = b.transparent(true);
|
||||
let window = b.build()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = window.set_visible_on_all_workspaces(true);
|
||||
let handle = app.clone();
|
||||
let me = label.to_string();
|
||||
window.on_window_event(move |event| match event {
|
||||
tauri::WindowEvent::Focused(false) => {
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(160));
|
||||
close_transient_unless_focused(&handle);
|
||||
});
|
||||
}
|
||||
tauri::WindowEvent::Moved(pos) => {
|
||||
let pinned = book(&handle, |b| match b.panels.get_mut(&me) {
|
||||
Some(p) if p.shown => {
|
||||
p.pos = Some((pos.x as f64, pos.y as f64));
|
||||
p.pinned
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if pinned {
|
||||
// a drag sends many moves: save once they stop
|
||||
let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
if state(&handle).moves.load(Ordering::Relaxed) == n {
|
||||
save_pins(&handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// Focus left a menu window: every transient panel closes unless one of them has focus now.
|
||||
fn close_transient_unless_focused(app: &AppHandle) {
|
||||
let transient = book(app, |b| b.transient());
|
||||
if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
book(app, |b| {
|
||||
for l in &transient {
|
||||
b.panels.remove(l);
|
||||
}
|
||||
});
|
||||
destroy(app, &transient);
|
||||
}
|
||||
|
||||
/// The panels cannot be shown: the native menu goes on the tray and opens once now.
|
||||
fn native_popup(app: &AppHandle) {
|
||||
if !state(app).native.swap(true, Ordering::Relaxed) {
|
||||
crate::logs::host("warn", "tray menu panels unavailable: the native menu is used");
|
||||
}
|
||||
set_native(app);
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) {
|
||||
let _ = w.popup_menu(&menu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A right click on the tray icon: a fresh root menu next to the icon (`anchor`: Anchor::Tray, physical px).
|
||||
pub fn open_root(app: &AppHandle, anchor: Anchor) {
|
||||
if native_fallback(app) {
|
||||
return; // the native menu is on the tray and opens by itself
|
||||
}
|
||||
let (label, closes) = book(app, |b| b.open_root(anchor));
|
||||
destroy(app, &closes);
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = build_window(&handle, &label) {
|
||||
crate::logs::host("warn", &format!("tray menu panel not built: {e}"));
|
||||
book(&handle, |b| b.panels.remove(&label));
|
||||
native_popup(&handle);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2500));
|
||||
if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) {
|
||||
crate::logs::host("warn", "tray menu panel never reported ready");
|
||||
let closes = book(&handle, |b| b.close(&label));
|
||||
destroy(&handle, &closes);
|
||||
native_popup(&handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Brings back the pinned panels of the last session (state.json `menuPins`).
|
||||
pub fn restore(app: &AppHandle) {
|
||||
let pins = app.try_state::<crate::commands::AppState>().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default();
|
||||
if pins.is_empty() || native_fallback(app) {
|
||||
return;
|
||||
}
|
||||
let labels = book(app, |b| b.restore(&pins));
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
for l in labels {
|
||||
if build_window(&handle, &l).is_err() {
|
||||
book(&handle, |b| b.panels.remove(&l));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn work_area(app: &AppHandle, x: f64, y: f64) -> Option<Rect> {
|
||||
let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?;
|
||||
let w = m.work_area();
|
||||
Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64))
|
||||
}
|
||||
|
||||
fn anchor_point(a: Anchor) -> (f64, f64) {
|
||||
match a {
|
||||
Anchor::Point { x, y } | Anchor::At { x, y } => (x, y),
|
||||
Anchor::Tray { x, y, w, h } => (x + w / 2.0, y + h / 2.0),
|
||||
Anchor::Beside { right, top, .. } => (right, top),
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_rect(w: &WebviewWindow) -> Option<Rect> {
|
||||
let p = w.outer_position().ok()?;
|
||||
let s = w.outer_size().ok()?;
|
||||
Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64))
|
||||
}
|
||||
|
||||
fn pinned_rects(app: &AppHandle, skip: &str) -> Vec<Rect> {
|
||||
let labels: Vec<String> = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect());
|
||||
labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect()
|
||||
}
|
||||
|
||||
// ---- what the panel page calls (commands.rs checks the window first) ----
|
||||
|
||||
pub fn panel_state(window: &WebviewWindow) -> Option<PanelInfo> {
|
||||
let app = window.app_handle();
|
||||
let p = book(app, |b| b.panels.get(window.label()).cloned())?;
|
||||
Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) })
|
||||
}
|
||||
|
||||
/// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time.
|
||||
pub fn ready(window: &WebviewWindow, width: f64, height: f64) {
|
||||
let app = window.app_handle().clone();
|
||||
let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return };
|
||||
let scale = window.scale_factor().unwrap_or(1.0);
|
||||
let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil());
|
||||
let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor));
|
||||
let Some(work) = work_area(&app, ax, ay) else { return };
|
||||
let (x, y) = if panel.shown {
|
||||
let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0));
|
||||
clamp_into(x, y, size.0, size.1, work)
|
||||
} else if let Some((x, y)) = panel.pos {
|
||||
clamp_into(x, y, size.0, size.1, work)
|
||||
} else {
|
||||
place_menu(panel.anchor, size, work)
|
||||
};
|
||||
let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32));
|
||||
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
|
||||
if !panel.shown {
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(window.label()) {
|
||||
p.shown = true;
|
||||
p.pos = Some((x, y));
|
||||
}
|
||||
});
|
||||
let _ = window.show();
|
||||
if panel.focus || (panel.parent.is_none() && !panel.pinned) {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
if panel.pinned {
|
||||
save_pins(&app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle
|
||||
/// click), placed so it covers no other pinned panel.
|
||||
pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) {
|
||||
let app = window.app_handle().clone();
|
||||
let Some((wx, wy, ww, _)) = outer_rect(window) else { return };
|
||||
let scale = window.scale_factor().unwrap_or(1.0);
|
||||
let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale };
|
||||
let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return };
|
||||
destroy(&app, &closes);
|
||||
if pinned {
|
||||
let others = pinned_rects(&app, &label);
|
||||
if let Some(work) = work_area(&app, wx, wy) {
|
||||
let est = (EST.0 * scale, EST.1 * scale);
|
||||
let (x, y) = place_menu(anchor, est, work);
|
||||
let spot = free_spot((x, y, est.0, est.1), &others, work);
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(&label) {
|
||||
p.pos = Some(spot);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(&label) {
|
||||
p.focus = focus;
|
||||
}
|
||||
});
|
||||
let handle = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
if build_window(&handle, &label).is_err() {
|
||||
book(&handle, |b| b.panels.remove(&label));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The pointer moved to a plain row: this panel's transient submenus close.
|
||||
pub fn close_subs(window: &WebviewWindow) {
|
||||
let app = window.app_handle().clone();
|
||||
let closes = book(&app, |b| {
|
||||
let d = b.descendants(window.label());
|
||||
for l in &d {
|
||||
b.panels.remove(l);
|
||||
}
|
||||
d
|
||||
});
|
||||
destroy(&app, &closes);
|
||||
}
|
||||
|
||||
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
|
||||
pub fn close(window: &WebviewWindow, refocus_parent: bool) {
|
||||
let app = window.app_handle().clone();
|
||||
let (closes, parent, was_pinned) = book(&app, |b| {
|
||||
let p = b.panels.get(window.label()).cloned();
|
||||
(b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned))
|
||||
});
|
||||
if refocus_parent {
|
||||
if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) {
|
||||
let _ = w.set_focus();
|
||||
let _ = app.emit_to(w.label(), PANEL_EVENT, "focus");
|
||||
}
|
||||
}
|
||||
destroy(&app, &closes);
|
||||
if was_pinned {
|
||||
save_pins(&app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Esc on the root menu: every transient panel closes.
|
||||
pub fn dismiss(app: &AppHandle) {
|
||||
let closes = book(app, |b| b.dismiss());
|
||||
destroy(app, &closes);
|
||||
}
|
||||
|
||||
/// Pin or unpin this panel. Pinning steps it aside from the other pinned panels.
|
||||
pub fn pin(window: &WebviewWindow, on: bool) {
|
||||
let app = window.app_handle().clone();
|
||||
let label = window.label().to_string();
|
||||
let closes = book(&app, |b| b.pin(&label, on));
|
||||
destroy(&app, &closes);
|
||||
if on {
|
||||
let others = pinned_rects(&app, &label);
|
||||
if let Some(me) = outer_rect(window) {
|
||||
if let Some(work) = work_area(&app, me.0, me.1) {
|
||||
let (x, y) = free_spot(me, &others, work);
|
||||
if (x, y) != (me.0, me.1) {
|
||||
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
|
||||
}
|
||||
book(&app, |b| {
|
||||
if let Some(p) = b.panels.get_mut(&label) {
|
||||
p.pos = Some((x, y));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" });
|
||||
save_pins(&app);
|
||||
}
|
||||
|
||||
/// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place.
|
||||
pub fn select(window: &WebviewWindow, id: String) {
|
||||
let app = window.app_handle().clone();
|
||||
let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false));
|
||||
if !pinned {
|
||||
dismiss(&app);
|
||||
}
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub use runtime::*;
|
||||
|
|
@ -43,30 +43,6 @@ pub fn require_main(label: &str, url: &Url, dev: bool) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The tray card window (card.rs): it may call only the card commands (`card_get`, `card_action`).
|
||||
pub const CARD: &str = "card";
|
||||
|
||||
pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> {
|
||||
if label != CARD {
|
||||
return Err(format!("not allowed from window '{label}'"));
|
||||
}
|
||||
if !is_local_origin(url, dev) {
|
||||
return Err("not allowed from this page".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tray menu panels (`menu-1`, `menu-2`... menu_panel.rs): they may call only the menu commands.
|
||||
pub fn require_menu(label: &str, url: &Url, dev: bool) -> Result<(), String> {
|
||||
if !crate::menu_panel::is_menu_label(label) {
|
||||
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 {
|
||||
|
|
@ -198,14 +174,9 @@ pub fn project_relative(rel: &str) -> Option<std::path::PathBuf> {
|
|||
Some(p.to_path_buf())
|
||||
}
|
||||
|
||||
/// What `project_reveal` may open (not only show in the file manager): a folder, the gallery page storebound writes,
|
||||
/// or a readiness report page of `storebound report` (.storebound/reports/readiness-*.html, no scripts in it).
|
||||
/// What `project_reveal` may open (not only show in the file manager): a folder, or the gallery page storebound writes.
|
||||
pub fn openable(rel: &std::path::Path, is_dir: bool) -> bool {
|
||||
if is_dir || rel.file_name().and_then(|n| n.to_str()) == Some("index.html") {
|
||||
return true;
|
||||
}
|
||||
let parts: Vec<&str> = rel.components().filter_map(|c| c.as_os_str().to_str()).collect();
|
||||
matches!(parts.as_slice(), [".storebound", "reports", name] if name.starts_with("readiness-") && name.ends_with(".html") && name.len() <= 80)
|
||||
is_dir || rel.file_name().and_then(|n| n.to_str()) == Some("index.html")
|
||||
}
|
||||
|
||||
/// Hosts `open_external` may open in the browser: the two store consoles and storebound's own site.
|
||||
|
|
@ -214,8 +185,6 @@ pub const EXTERNAL_HOSTS: &[&str] = &[
|
|||
"appstoreconnect.apple.com",
|
||||
"developer.apple.com",
|
||||
"storebound.dev",
|
||||
// the guides (skills/<name>/SKILL.md) of check findings, in storebound's public repo
|
||||
"git.crt.fyi",
|
||||
// Dependencies screen link-outs
|
||||
"developer.android.com",
|
||||
"ffmpeg.org",
|
||||
|
|
|
|||
|
|
@ -56,11 +56,6 @@ pub fn slug(label: &str) -> String {
|
|||
|
||||
/// The CLI workspace's account id for a profile's store: sha256("<apple|play>:<profile>"), 16 hex (cli/lib/workspace.ts
|
||||
/// accountId), so the same account has the same id in `storebound.workspace.json` and in the app.
|
||||
/// A Play developer account id: 1-25 digits.
|
||||
pub fn valid_developer_id(v: &str) -> bool {
|
||||
!v.is_empty() && v.len() <= 25 && v.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub fn account_id(kind: AccountKind, profile: &str) -> String {
|
||||
let store = match kind {
|
||||
AccountKind::Apple => "apple",
|
||||
|
|
@ -144,24 +139,6 @@ impl ProfileStore {
|
|||
out
|
||||
}
|
||||
|
||||
/// The Play developer account id a profile keeps (PLAY_DEVELOPER_ID, digits), if any.
|
||||
pub fn play_developer_id(&self, name: &str) -> Option<String> {
|
||||
self.read(name).ok().and_then(|m| str_of(&m, "PLAY_DEVELOPER_ID").map(str::trim).filter(|v| valid_developer_id(v)).map(str::to_string))
|
||||
}
|
||||
|
||||
/// Sets or clears (None) PLAY_DEVELOPER_ID in an existing profile; every other field stays.
|
||||
pub fn set_play_developer_id(&self, name: &str, id: Option<&str>) -> Result<(), String> {
|
||||
let mut m = self.read(name)?;
|
||||
match id {
|
||||
Some(v) if valid_developer_id(v) => { m.insert("PLAY_DEVELOPER_ID".into(), Value::String(v.to_string())); }
|
||||
Some(_) => return Err("the Play developer id is the digits in play.google.com/console/u/0/developers/<id>".into()),
|
||||
None => { m.remove("PLAY_DEVELOPER_ID"); }
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + "
|
||||
";
|
||||
write_private(&self.dir.join(format!("{name}.json")), text.as_bytes())
|
||||
}
|
||||
|
||||
/// The label the profile carries, if any (written by the app; the CLI ignores it).
|
||||
pub fn label(&self, name: &str) -> Option<String> {
|
||||
self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string))
|
||||
|
|
@ -390,7 +367,7 @@ pub fn sync(accounts: &mut Vec<Account>, store: &ProfileStore, legacy: &dyn Secr
|
|||
if accounts.iter().any(|a| a.id == id) {
|
||||
continue;
|
||||
}
|
||||
accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()), developer_id: None });
|
||||
accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()) });
|
||||
r.added.push(format!("{id} ({name})"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,16 +76,6 @@ pub fn write(file: &Path, entries: &[Entry]) -> Result<(), String> {
|
|||
// the store-only entries the CLI wrote stay as they are
|
||||
let keep = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v.get("storeApps").cloned()).filter(|s| s.as_array().is_some_and(|a| !a.is_empty()));
|
||||
let mut doc = serde_json::json!({ "version": 1, "projects": entries });
|
||||
// the folders' `ignored` flags (written by the CLI or by push_ignored) stay with their folder
|
||||
let old = fs::read_to_string(file).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
|
||||
if let (Some(old), Some(list)) = (old.as_ref().and_then(|o| o.get("projects")).and_then(Value::as_array), doc.get_mut("projects").and_then(Value::as_array_mut)) {
|
||||
for e in list {
|
||||
let ig = e.get("path").and_then(Value::as_str).and_then(|path| old.iter().find(|o| o.get("path").and_then(Value::as_str) == Some(path))).and_then(|o| o.get("ignored")).cloned();
|
||||
if let (Some(ig), Some(o)) = (ig, e.as_object_mut()) {
|
||||
o.insert("ignored".into(), ig);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = keep {
|
||||
doc["storeApps"] = s;
|
||||
}
|
||||
|
|
@ -227,139 +217,3 @@ pub fn publish(state: &State, file: &Path, removed: &[String]) -> Result<(), Str
|
|||
}
|
||||
write(file, &list)
|
||||
}
|
||||
|
||||
// ---- ignored apps: the registry's `ignored` flags (cli/lib/registry.ts) <-> the app's ignore keys ----
|
||||
// projects: "ignored": ["apple", "play"] (both = the whole app) <-> "project:<id>" (both) / "project:<id>:ios" / "project:<id>:android"
|
||||
// storeApps: "ignored": ["apple"] <-> "app:ios:<account id>:<app id>" (android for play)
|
||||
// The registry is the truth for the apps it lists: `pull_ignored` (start, list shown) replaces the app's keys for those
|
||||
// apps with the registry's flags, `push_ignored` (the owner ignores or restores in the app) writes them back. Apps
|
||||
// that are not in the registry (listed by a store account only) keep their keys in the app's state alone.
|
||||
const SIDES: [&str; 2] = ["apple", "play"];
|
||||
|
||||
fn word_of(side: &str) -> &'static str {
|
||||
if side == "apple" { "ios" } else { "android" }
|
||||
}
|
||||
|
||||
fn sides_in(v: &Value) -> Vec<String> {
|
||||
let have: Vec<&str> = v.get("ignored").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
SIDES.iter().filter(|s| have.contains(s)).map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
fn is_project_key_of(k: &str, id: &str) -> bool {
|
||||
k == format!("project:{id}") || k.starts_with(&format!("project:{id}:"))
|
||||
}
|
||||
|
||||
/// The ignore keys of one folder for its ignored store sides (pure).
|
||||
pub fn project_keys(id: &str, sides: &[String]) -> Vec<String> {
|
||||
if SIDES.iter().all(|s| sides.iter().any(|x| x == s)) {
|
||||
return vec![format!("project:{id}")];
|
||||
}
|
||||
sides.iter().filter(|s| SIDES.contains(&s.as_str())).map(|s| format!("project:{id}:{}", word_of(s))).collect()
|
||||
}
|
||||
|
||||
/// The ignored store sides of a folder, from the app's ignore keys (pure).
|
||||
pub fn project_sides(keys: &[String], id: &str) -> Vec<String> {
|
||||
let mut on = [false, false];
|
||||
for k in keys {
|
||||
if *k == format!("project:{id}") {
|
||||
on = [true, true];
|
||||
} else if *k == format!("project:{id}:ios") {
|
||||
on[0] = true;
|
||||
} else if *k == format!("project:{id}:android") {
|
||||
on[1] = true;
|
||||
}
|
||||
}
|
||||
SIDES.iter().enumerate().filter(|(i, _)| on[*i]).map(|(_, s)| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Whether an ignore key "app:<ios|android>:<account>:<app id>" is this store app (pure).
|
||||
pub fn app_key_matches(k: &str, store: &str, app_id: &str) -> bool {
|
||||
let Some(rest) = k.strip_prefix("app:") else { return false };
|
||||
rest.split(':').next() == Some(word_of(store)) && rest.rsplit(':').next() == Some(app_id)
|
||||
}
|
||||
|
||||
/// The account (id) that reads a store-only entry: the one with its credential profile, else the only account of that store.
|
||||
pub fn account_for(accounts: &[crate::accounts::Account], store: &str, profile: Option<&str>) -> Option<String> {
|
||||
use crate::accounts::AccountKind;
|
||||
let kind = if store == "apple" { AccountKind::Apple } else { AccountKind::Play };
|
||||
let of_kind: Vec<_> = accounts.iter().filter(|a| a.kind == kind).collect();
|
||||
match profile {
|
||||
Some(p) => of_kind.iter().find(|a| a.profile.as_deref() == Some(p)).map(|a| a.id.clone()),
|
||||
None => (of_kind.len() == 1).then(|| of_kind[0].id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_doc(file: &Path) -> Option<Value> {
|
||||
serde_json::from_str::<Value>(&fs::read_to_string(file).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Registry flags -> the app's ignore keys. True when the keys changed.
|
||||
pub fn pull_ignored(state: &mut State, file: &Path) -> bool {
|
||||
let Some(doc) = parse_doc(file) else { return false };
|
||||
let before = state.overrides.ignored.clone();
|
||||
let mut keys = before.clone();
|
||||
for p in doc.get("projects").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(path) = p.get("path").and_then(Value::as_str) else { continue };
|
||||
let Some(id) = state.projects.iter().find(|x| x.path == path).map(|x| x.id.clone()) else { continue };
|
||||
keys.retain(|k| !is_project_key_of(k, &id));
|
||||
keys.extend(project_keys(&id, &sides_in(p)));
|
||||
}
|
||||
for a in doc.get("storeApps").and_then(Value::as_array).into_iter().flatten() {
|
||||
let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str), a.get("appId").and_then(Value::as_str)) else { continue };
|
||||
let Some(acct) = account_for(&state.accounts, store, a.get("account").and_then(Value::as_str)) else { continue };
|
||||
keys.retain(|k| !app_key_matches(k, store, app_id));
|
||||
if sides_in(a).iter().any(|s| s == store) {
|
||||
keys.push(format!("app:{}:{acct}:{app_id}", word_of(store)));
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
keys.retain(|k| seen.insert(k.clone()));
|
||||
if keys == before {
|
||||
return false;
|
||||
}
|
||||
state.overrides.ignored = keys;
|
||||
true
|
||||
}
|
||||
|
||||
/// The app's ignore keys -> registry flags (only the apps the registry lists). Writes when something changed.
|
||||
pub fn push_ignored(state: &State, file: &Path) -> Result<bool, String> {
|
||||
let Some(mut doc) = parse_doc(file) else { return Ok(false) };
|
||||
let keys = &state.overrides.ignored;
|
||||
let mut changed = false;
|
||||
let mut set = |entry: &mut Value, sides: Vec<String>| {
|
||||
if sides_in(entry) == sides {
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
if let Some(o) = entry.as_object_mut() {
|
||||
if sides.is_empty() {
|
||||
o.remove("ignored");
|
||||
} else {
|
||||
o.insert("ignored".into(), serde_json::json!(sides));
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(list) = doc.get_mut("projects").and_then(Value::as_array_mut) {
|
||||
for p in list {
|
||||
let Some(id) = p.get("path").and_then(Value::as_str).and_then(|path| state.projects.iter().find(|x| x.path == path)).map(|x| x.id.clone()) else { continue };
|
||||
set(p, project_sides(keys, &id));
|
||||
}
|
||||
}
|
||||
if let Some(list) = doc.get_mut("storeApps").and_then(Value::as_array_mut) {
|
||||
for a in list {
|
||||
let (Some(store), Some(app_id)) = (a.get("store").and_then(Value::as_str).map(str::to_string), a.get("appId").and_then(Value::as_str).map(str::to_string)) else { continue };
|
||||
if account_for(&state.accounts, &store, a.get("account").and_then(Value::as_str)).is_none() {
|
||||
continue;
|
||||
}
|
||||
let on = keys.iter().any(|k| app_key_matches(k, &store, &app_id));
|
||||
set(a, if on { vec![store] } else { vec![] });
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n";
|
||||
let tmp = file.with_extension(format!("tmp-{}", std::process::id()));
|
||||
fs::write(&tmp, text).map_err(|e| e.to_string())?;
|
||||
fs::rename(&tmp, file).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,14 +45,11 @@ pub struct State {
|
|||
pub overrides: Overrides,
|
||||
#[serde(default)]
|
||||
pub settings: Settings,
|
||||
/// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start.
|
||||
#[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")]
|
||||
pub menu_pins: Vec<crate::menu_panel::PinRecord>,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> State {
|
||||
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] }
|
||||
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +121,6 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec<String>) {
|
|||
studios: list(&o, "studios", &mut notes),
|
||||
overrides: one(&o, "overrides", &mut notes),
|
||||
settings: one(&o, "settings", &mut notes),
|
||||
menu_pins: list(&o, "menuPins", &mut notes),
|
||||
};
|
||||
// two entries with one id (hand-edited or merged files): the first one wins
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
|
|
|||
|
|
@ -1,286 +0,0 @@
|
|||
//! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification.
|
||||
//!
|
||||
//! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model
|
||||
//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), sets the tooltip and swaps the icon for one with a red dot while
|
||||
//! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and
|
||||
//! `tray-action` (`read`, `mute-toggle`).
|
||||
//!
|
||||
//! Clicking a desktop notification: the notification plugin has no click callback on Windows, macOS or Linux (its
|
||||
//! actions are mobile only). So `notify` remembers where the notification points (`set_pending`) and `show_main`
|
||||
//! hands it to the window as a `tray-route` the next time the window is shown from the tray or the Dock.
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use crate::menu_panel::{from_entries, Node};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
pub const ID_OPEN: &str = "tray-open";
|
||||
pub const ID_READ: &str = "tray-read";
|
||||
pub const ID_MUTE: &str = "tray-mute";
|
||||
pub const ID_UPDATE: &str = "tray-update";
|
||||
pub const ID_SETTINGS: &str = "tray-settings";
|
||||
pub const ID_QUIT: &str = "tray-quit";
|
||||
pub const ID_APP_PREFIX: &str = "tray-app:";
|
||||
|
||||
/// Apps listed in the submenu (more would not fit a menu anyway).
|
||||
pub const MAX_APPS: usize = 30;
|
||||
/// Windows cuts a tooltip at 127 UTF-16 units; stay under it everywhere.
|
||||
pub const MAX_TIP: usize = 120;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TrayApp {
|
||||
/// The app's key in the window (opens it).
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
/// Plain words: "App Store: In review · Play: Live".
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// The menu words in the window's language.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TrayLabels {
|
||||
pub open: String,
|
||||
pub read_now: String,
|
||||
pub apps: String,
|
||||
pub no_apps: String,
|
||||
pub mute: String,
|
||||
pub unmute: String,
|
||||
pub update: String,
|
||||
pub settings: String,
|
||||
pub quit: String,
|
||||
}
|
||||
|
||||
impl Default for TrayLabels {
|
||||
fn default() -> TrayLabels {
|
||||
TrayLabels {
|
||||
open: "Open storebound".into(),
|
||||
read_now: "Read the stores now".into(),
|
||||
apps: "Apps".into(),
|
||||
no_apps: "No apps yet".into(),
|
||||
mute: "Mute notifications for 1 hour".into(),
|
||||
unmute: "Unmute notifications".into(),
|
||||
update: "Check for updates".into(),
|
||||
settings: "Settings".into(),
|
||||
quit: "Quit storebound".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TraySummary {
|
||||
pub tooltip: String,
|
||||
/// Apps that need the owner (rejected, halted): above 0 the icon gets a red dot.
|
||||
pub attention: u32,
|
||||
/// Notifications are muted right now.
|
||||
pub muted: bool,
|
||||
pub apps: Vec<TrayApp>,
|
||||
pub labels: TrayLabels,
|
||||
}
|
||||
|
||||
/// One menu entry, before it is drawn.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Entry {
|
||||
Item { id: String, label: String, enabled: bool },
|
||||
Separator,
|
||||
/// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language.
|
||||
Sub { key: String, label: String, items: Vec<Entry> },
|
||||
}
|
||||
|
||||
fn clean(s: &str, max: usize) -> String {
|
||||
let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
let one = one.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if one.chars().count() <= max {
|
||||
return one;
|
||||
}
|
||||
let mut cut: String = one.chars().take(max.saturating_sub(1)).collect();
|
||||
cut.push('…');
|
||||
cut
|
||||
}
|
||||
|
||||
/// The tooltip: the window's text, one line, short; "storebound" when it sent none.
|
||||
pub fn tooltip_text(s: &TraySummary) -> String {
|
||||
let t = clean(&s.tooltip, MAX_TIP);
|
||||
if t.is_empty() { "storebound".into() } else { t }
|
||||
}
|
||||
|
||||
/// The context menu: Open, Read now, Apps (one entry per app with its state), Mute / Unmute, Check for updates,
|
||||
/// Settings, Quit.
|
||||
pub fn menu_model(s: &TraySummary) -> Vec<Entry> {
|
||||
let item = |id: &str, label: &str| Entry::Item { id: id.into(), label: clean(label, 90), enabled: true };
|
||||
let l = &s.labels;
|
||||
let apps: Vec<Entry> = if s.apps.is_empty() {
|
||||
vec![Entry::Item { id: "tray-none".into(), label: clean(&l.no_apps, 90), enabled: false }]
|
||||
} else {
|
||||
s.apps
|
||||
.iter()
|
||||
.filter(|a| !a.key.is_empty() && !a.key.chars().any(char::is_control))
|
||||
.take(MAX_APPS)
|
||||
.map(|a| {
|
||||
let name = clean(&a.name, 40);
|
||||
let state = clean(&a.state, 60);
|
||||
let label = if state.is_empty() { name } else { format!("{name} · {state}") };
|
||||
Entry::Item { id: format!("{ID_APP_PREFIX}{}", a.key.chars().take(200).collect::<String>()), label, enabled: true }
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
vec![
|
||||
item(ID_OPEN, &l.open),
|
||||
item(ID_READ, &l.read_now),
|
||||
Entry::Separator,
|
||||
Entry::Sub { key: "apps".into(), label: clean(&l.apps, 40), items: apps },
|
||||
Entry::Separator,
|
||||
item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }),
|
||||
item(ID_UPDATE, &l.update),
|
||||
item(ID_SETTINGS, &l.settings),
|
||||
Entry::Separator,
|
||||
item(ID_QUIT, &l.quit),
|
||||
]
|
||||
}
|
||||
|
||||
/// What a menu click does.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Show the window.
|
||||
Open,
|
||||
Quit,
|
||||
/// Tell the window (no window needed): `tray-action` with this word.
|
||||
Event(&'static str),
|
||||
/// Show the window on this route (JSON for `tray-route`).
|
||||
Route(String),
|
||||
}
|
||||
|
||||
pub fn action_of(id: &str) -> Option<Action> {
|
||||
match id {
|
||||
ID_OPEN => Some(Action::Open),
|
||||
ID_READ => Some(Action::Event("read")),
|
||||
ID_MUTE => Some(Action::Event("mute-toggle")),
|
||||
ID_UPDATE => Some(Action::Route(serde_json::json!({ "screen": "settings", "section": "updates" }).to_string())),
|
||||
ID_SETTINGS => Some(Action::Route(serde_json::json!({ "screen": "settings" }).to_string())),
|
||||
ID_QUIT => Some(Action::Quit),
|
||||
_ => id.strip_prefix(ID_APP_PREFIX).filter(|k| !k.is_empty()).map(|k| Action::Route(serde_json::json!({ "screen": "app", "appId": k, "tab": "releases" }).to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification target the window may be sent to later: a small JSON object of strings with a known `screen`.
|
||||
pub fn valid_route(json: &str) -> bool {
|
||||
if json.len() > 400 {
|
||||
return false;
|
||||
}
|
||||
let Ok(Value::Object(o)) = serde_json::from_str::<Value>(json) else { return false };
|
||||
let screens = ["portfolio", "app", "apps", "accounts", "ignored", "dependencies", "settings"];
|
||||
o.len() <= 4 && o.values().all(|v| v.as_str().is_some_and(|s| s.len() <= 300 && !s.chars().any(char::is_control))) && o.get("screen").and_then(Value::as_str).is_some_and(|s| screens.contains(&s))
|
||||
}
|
||||
|
||||
/// The default icon with a red dot (white ring) in the top right corner, for "something needs you". RGBA in, RGBA out.
|
||||
pub fn with_attention_dot(rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = rgba.to_vec();
|
||||
if w == 0 || h == 0 || out.len() != (w as usize) * (h as usize) * 4 {
|
||||
return out;
|
||||
}
|
||||
let side = w.min(h) as f32;
|
||||
let r = side * 0.27;
|
||||
let (cx, cy) = (w as f32 - r - side * 0.02, r + side * 0.02);
|
||||
let blend = |px: &mut [u8], color: [f32; 3], c: f32| {
|
||||
if c <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let a = px[3] as f32 / 255.0;
|
||||
let oa = a + (1.0 - a) * c;
|
||||
for i in 0..3 {
|
||||
// the dot covers what is under it by `c`; the alpha of the icon only grows
|
||||
px[i] = (px[i] as f32 * (1.0 - c) + color[i] * c).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
};
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
let px = &mut out[i..i + 4];
|
||||
blend(px, [255.0, 255.0, 255.0], (r + 0.5 - d).clamp(0.0, 1.0));
|
||||
blend(px, [229.0, 57.0, 53.0], (r * 0.78 + 0.5 - d).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- where the last notification pointed ----
|
||||
|
||||
static PENDING: Mutex<Option<(String, Instant)>> = Mutex::new(None);
|
||||
const PENDING_FOR: Duration = Duration::from_secs(2 * 3600);
|
||||
|
||||
/// Remembers the route (JSON) of the notification just shown. Ignores anything `valid_route` refuses.
|
||||
pub fn set_pending(route: &str) {
|
||||
if valid_route(route) {
|
||||
if let Ok(mut p) = PENDING.lock() {
|
||||
*p = Some((route.to_string(), Instant::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The remembered route, once (it is forgotten after it is handed out, and after two hours).
|
||||
pub fn take_pending() -> Option<String> {
|
||||
let (route, at) = PENDING.lock().ok()?.take()?;
|
||||
(at.elapsed() < PENDING_FOR).then_some(route)
|
||||
}
|
||||
|
||||
// ---- drawing ----
|
||||
|
||||
static LAST: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth.
|
||||
fn native_items<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Vec<Box<dyn IsMenuItem<R>>>> {
|
||||
let mut out: Vec<Box<dyn IsMenuItem<R>>> = Vec::new();
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)),
|
||||
Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)),
|
||||
Node::Sub { label, items, .. } => {
|
||||
let children = native_items(app, items)?;
|
||||
let refs: Vec<&dyn IsMenuItem<R>> = children.iter().map(|b| b.as_ref()).collect();
|
||||
out.push(Box::new(Submenu::with_items(app, label, true, &refs)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The native tray menu, rendered from the same tree as the panels.
|
||||
pub fn native_menu<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Menu<R>> {
|
||||
let items = native_items(app, nodes)?;
|
||||
let refs: Vec<&dyn IsMenuItem<R>> = items.iter().map(|b| b.as_ref()).collect();
|
||||
Menu::with_items(app, &refs)
|
||||
}
|
||||
|
||||
/// The tree before the window has sent anything (English words).
|
||||
pub fn initial_nodes() -> Vec<Node> {
|
||||
from_entries(&menu_model(&TraySummary::default()))
|
||||
}
|
||||
|
||||
/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu
|
||||
/// panels (and to the native menu when that is in use). Skips the work when nothing changed.
|
||||
pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> {
|
||||
let Some(tray) = app.tray_by_id("main") else { return Ok(()) };
|
||||
let key = format!("{s:?}");
|
||||
if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) {
|
||||
return Ok(());
|
||||
}
|
||||
tray.set_tooltip(Some(tooltip_text(s)))?;
|
||||
crate::menu_panel::set_nodes(app, from_entries(&menu_model(s)));
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() };
|
||||
tray.set_icon(Some(img))?;
|
||||
}
|
||||
if let Ok(mut l) = LAST.lock() {
|
||||
*l = Some(key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,111 +1,22 @@
|
|||
//! Signed updates from storebound.dev. The updater plugin checks `latest.json` of the chosen channel, downloads the
|
||||
//! platform's bundle and refuses it unless its minisign signature matches the public key in tauri.conf.json (the
|
||||
//! private key never leaves the release machine, ~/.storebound-keys). Nothing installs without the owner's click in
|
||||
//! a native dialog (commands::update_install), or by the background updater (lib.rs auto_update). Pure parts here so tests can pin them.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
//! a native dialog (commands::update_install). Pure parts here so tests can pin them.
|
||||
use url::Url;
|
||||
|
||||
/// Set when Settings > Updates switched the channel: the background updater checks on its next tick.
|
||||
pub static KICK: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn kick() {
|
||||
KICK.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn take_kick() -> bool {
|
||||
KICK.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// `latest.json` of each channel (static files on storebound.dev, published by tools/desktop-release.ts).
|
||||
pub const STABLE: &str = "https://storebound.dev/desktop/latest.json";
|
||||
pub const BETA: &str = "https://storebound.dev/desktop/beta/latest.json";
|
||||
/// Every push to main (Forgejo ci `dev` job, signed on the VDS): `<next patch>-dev.<UTC yyyymmddHHMMSS>`.
|
||||
pub const DEV: &str = "https://storebound.dev/desktop/dev/latest.json";
|
||||
|
||||
/// The channels Settings > Updates accepts (beta is kept for saved states; the window offers stable and dev).
|
||||
pub const CHANNELS: &[&str] = &["stable", "dev", "beta"];
|
||||
|
||||
/// The endpoint of a channel (Settings > Updates); anything unknown is the stable channel.
|
||||
pub fn endpoint(channel: &str) -> &'static str {
|
||||
match channel {
|
||||
"beta" => BETA,
|
||||
"dev" => DEV,
|
||||
_ => STABLE,
|
||||
if channel == "beta" {
|
||||
BETA
|
||||
} else {
|
||||
STABLE
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the background updater asks the channel (plus once at start and right after a resume from sleep).
|
||||
pub const CHECK_EVERY_SECS: u64 = 15 * 60;
|
||||
/// The background loop's tick; a wall-clock jump of more than this plus a minute between two ticks is a resume.
|
||||
pub const TICK_SECS: u64 = 5;
|
||||
/// An install waits while the main window is visible and focused, at most this many checks in a row (so an app that
|
||||
/// stays open still updates).
|
||||
pub const MAX_DEFERS: u32 = 4;
|
||||
|
||||
/// A semver version as (major, minor, patch) and its prerelease identifiers; None when it is not one.
|
||||
fn parse(v: &str) -> Option<([u64; 3], Vec<String>)> {
|
||||
let v = v.trim().trim_start_matches('v');
|
||||
let v = v.split('+').next()?;
|
||||
let (core, pre) = match v.split_once('-') {
|
||||
Some((c, p)) => (c, p.split('.').map(str::to_string).collect::<Vec<_>>()),
|
||||
None => (v, Vec::new()),
|
||||
};
|
||||
let mut n = core.split('.').map(|x| x.parse::<u64>().ok());
|
||||
let r = [n.next()??, n.next()??, n.next()??];
|
||||
if n.next().is_some() || pre.iter().any(String::is_empty) {
|
||||
return None;
|
||||
}
|
||||
Some((r, pre))
|
||||
}
|
||||
|
||||
/// semver 2.0 precedence, a > b: a release is above its own prereleases (0.8.48 > 0.8.48-dev.20261023120000), numeric
|
||||
/// identifiers compare as numbers and sort below alphanumeric ones, a longer prerelease is above its prefix.
|
||||
pub fn newer(a: &str, b: &str) -> bool {
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
let (Some((ac, ap)), Some((bc, bp))) = (parse(a), parse(b)) else { return false };
|
||||
match ac.cmp(&bc) {
|
||||
Greater => return true,
|
||||
Less => return false,
|
||||
Equal => {}
|
||||
}
|
||||
match (ap.is_empty(), bp.is_empty()) {
|
||||
(true, true) | (false, true) => return false,
|
||||
(true, false) => return true,
|
||||
_ => {}
|
||||
}
|
||||
for (x, y) in ap.iter().zip(bp.iter()) {
|
||||
let o = match (x.parse::<u64>(), y.parse::<u64>()) {
|
||||
(Ok(i), Ok(j)) => i.cmp(&j),
|
||||
(Ok(_), Err(_)) => Less,
|
||||
(Err(_), Ok(_)) => Greater,
|
||||
_ => x.cmp(y),
|
||||
};
|
||||
if o != Equal {
|
||||
return o == Greater;
|
||||
}
|
||||
}
|
||||
ap.len() > bp.len()
|
||||
}
|
||||
|
||||
/// The no-downgrade rule: an update installs only when the channel's version is strictly newer than this build. A dev
|
||||
/// install switched back to stable stays on its dev build until a stable release passes it.
|
||||
pub fn should_install(current: &str, offered: &str) -> bool {
|
||||
newer(offered, current)
|
||||
}
|
||||
|
||||
/// The background updater defers an install while the owner works in the main window (visible and focused), at most
|
||||
/// MAX_DEFERS checks in a row. A hidden window can report focus, so only a visible one counts.
|
||||
pub fn defer_install(visible: bool, focused: bool, defers_so_far: u32) -> bool {
|
||||
visible && focused && defers_so_far < MAX_DEFERS
|
||||
}
|
||||
|
||||
/// Whether the background updater asks the channel now: the first tick, a kick (channel switched), a resume from sleep
|
||||
/// (the wall clock moved much further than one tick), or CHECK_EVERY_SECS since the last check.
|
||||
pub fn check_due(first: bool, kicked: bool, wall_gap_secs: u64, since_check_secs: u64) -> bool {
|
||||
first || kicked || wall_gap_secs > TICK_SECS + 60 || since_check_secs >= CHECK_EVERY_SECS
|
||||
}
|
||||
|
||||
/// Only storebound.dev over HTTPS, without port or user info, may serve updates.
|
||||
pub fn endpoint_allowed(url: &Url) -> bool {
|
||||
url.scheme() == "https" && url.host_str() == Some("storebound.dev") && url.port().is_none() && url.username().is_empty() && url.password().is_none() && url.path().starts_with("/desktop/")
|
||||
|
|
|
|||
|
|
@ -269,39 +269,9 @@ pub struct CacheSettings {
|
|||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct NotifySettings {
|
||||
/// Other release state changes (submitted, in review).
|
||||
pub review_state: bool,
|
||||
/// A version the store rejected.
|
||||
pub rejected: bool,
|
||||
/// A version that passed review (ready for release).
|
||||
pub approved: bool,
|
||||
/// A version that went live or started rolling out.
|
||||
pub live: bool,
|
||||
pub low_reviews: bool,
|
||||
/// A Play crash / ANR rate over the threshold, or a halted rollout.
|
||||
pub crash: bool,
|
||||
pub quiet_hours: QuietHours,
|
||||
}
|
||||
|
||||
/// No notification between `from` and `to` (HH:MM, local time; the window may cross midnight).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct QuietHours {
|
||||
pub enabled: bool,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl Default for QuietHours {
|
||||
fn default() -> QuietHours {
|
||||
QuietHours { enabled: false, from: "23:00".into(), to: "08:00".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// "HH:MM" with a valid hour and minute.
|
||||
pub fn valid_hhmm(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
b.len() == 5 && b[2] == b':' && b.iter().enumerate().all(|(i, c)| i == 2 || c.is_ascii_digit()) && s[..2].parse::<u8>().is_ok_and(|h| h < 24) && s[3..].parse::<u8>().is_ok_and(|m| m < 60)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -310,7 +280,7 @@ pub struct Settings {
|
|||
pub refresh: RefreshSettings,
|
||||
pub cache: CacheSettings,
|
||||
pub notify: NotifySettings,
|
||||
/// Update channel (Settings > Updates): stable | dev (beta kept for saved states). crate::updates::endpoint.
|
||||
/// Update channel (placeholder until the updater ships): stable | beta.
|
||||
pub channel: String,
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +300,7 @@ impl Default for CacheSettings {
|
|||
|
||||
impl Default for NotifySettings {
|
||||
fn default() -> NotifySettings {
|
||||
NotifySettings { review_state: true, rejected: true, approved: true, live: true, low_reviews: true, crash: true, quiet_hours: QuietHours::default() }
|
||||
NotifySettings { review_state: true, low_reviews: true, crash: true }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,11 +327,8 @@ pub fn check_settings(s: &Settings) -> Result<(), String> {
|
|||
if !KEEP_DAYS.contains(&s.cache.keep_days) {
|
||||
return Err("keep saved reads: 0 (always), 7, 30, 90 or 365 days".into());
|
||||
}
|
||||
if !valid_hhmm(&s.notify.quiet_hours.from) || !valid_hhmm(&s.notify.quiet_hours.to) {
|
||||
return Err("quiet hours: times as HH:MM (00:00 to 23:59)".into());
|
||||
}
|
||||
if !crate::updates::CHANNELS.contains(&s.channel.as_str()) {
|
||||
return Err("channel: stable or dev".into());
|
||||
if !["stable", "beta"].contains(&s.channel.as_str()) {
|
||||
return Err("channel: stable or beta".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "storebound",
|
||||
"mainBinaryName": "storebound-desktop",
|
||||
"version": "0.8.77",
|
||||
"version": "0.8.2",
|
||||
"identifier": "dev.storebound.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
|
@ -20,8 +20,7 @@
|
|||
"minWidth": 880,
|
||||
"minHeight": 560,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"visible": false
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
|
@ -50,7 +49,7 @@
|
|||
"form-action": "'none'"
|
||||
},
|
||||
"freezePrototype": true,
|
||||
"capabilities": ["main-window", "card-window", "menu-window"]
|
||||
"capabilities": ["main-window"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
|
@ -88,7 +87,6 @@
|
|||
},
|
||||
"updater": {
|
||||
"endpoints": ["https://storebound.dev/desktop/latest.json"],
|
||||
"windows": { "installMode": "quiet" },
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PR
|
|||
|
||||
#[test]
|
||||
fn shown_metadata_carries_no_secret() {
|
||||
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()), developer_id: None };
|
||||
let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()) };
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
assert_eq!(v.as_object().unwrap().keys().cloned().collect::<Vec<_>>(), vec!["created", "id", "kind", "label", "profile", "studio"]);
|
||||
assert_eq!(v["kind"], "play");
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
//! Fix with AI (src/ai_fix.rs): the prompt, the dirty-tree refusal, Undo restricted to the run's own files, the
|
||||
//! timeout and kill by PID, and that claude only ever gets the file tools.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
#[cfg(unix)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use storebound_desktop::ai_fix::*;
|
||||
|
||||
fn finding() -> AiFinding {
|
||||
AiFinding {
|
||||
rule: "ats".into(),
|
||||
guideline: "Apple: App Transport Security".into(),
|
||||
store: "apple".into(),
|
||||
title: "NSAllowsArbitraryLoads is true".into(),
|
||||
evidence: vec![
|
||||
Evidence { file: "ios/App/Info.plist".into(), line: 42, text: Some("NSAllowsArbitraryLoads\ttrue".into()) },
|
||||
Evidence { file: "../outside.txt".into(), line: 1, text: None },
|
||||
],
|
||||
fix: "Remove NSAllowsArbitraryLoads; add exception domains only where needed.".into(),
|
||||
skill: "review-rejections".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("sb-aifix-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = Command::new("git").current_dir(dir).args(args).env("GIT_AUTHOR_NAME", "T").env("GIT_AUTHOR_EMAIL", "t@example.com").env("GIT_COMMITTER_NAME", "T").env("GIT_COMMITTER_EMAIL", "t@example.com").output().unwrap();
|
||||
assert!(ok.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&ok.stderr));
|
||||
}
|
||||
|
||||
fn repo(name: &str) -> PathBuf {
|
||||
let d = tmp(name);
|
||||
git(&d, &["init", "-q"]);
|
||||
fs::create_dir_all(d.join("app")).unwrap();
|
||||
fs::write(d.join("app/Info.plist"), "a\n").unwrap();
|
||||
fs::write(d.join("app/Other.swift"), "b\n").unwrap();
|
||||
git(&d, &["add", "."]);
|
||||
git(&d, &["commit", "-q", "-m", "init"]);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_names_the_finding_and_the_limits() {
|
||||
let p = build_prompt(&finding()).unwrap();
|
||||
for s in ["ats", "NSAllowsArbitraryLoads is true", "ios/App/Info.plist:42", "review-rejections", "Change only what this finding needs", "Do not run commands", "commit or push", "Never invent an answer"] {
|
||||
assert!(p.contains(s), "missing {s}");
|
||||
}
|
||||
// evidence outside the folder is left out; control characters are flattened
|
||||
assert!(!p.contains("outside.txt"));
|
||||
assert!(!p.contains('\t'));
|
||||
let mut bad = finding();
|
||||
bad.rule = "ats; rm -rf".into();
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
bad = finding();
|
||||
bad.store = "web".into();
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
bad = finding();
|
||||
bad.title = "x".repeat(5000);
|
||||
assert!(build_prompt(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_gets_only_the_file_tools() {
|
||||
let a = claude_args("hi");
|
||||
assert_eq!(a[0], "-p");
|
||||
let allowed = &a[a.iter().position(|x| x == "--allowedTools").unwrap() + 1];
|
||||
assert_eq!(allowed, "Read,Edit,Write,Grep,Glob");
|
||||
let denied = &a[a.iter().position(|x| x == "--disallowedTools").unwrap() + 1];
|
||||
for t in ["Bash", "WebFetch", "WebSearch", "Task"] {
|
||||
assert!(denied.split(',').any(|x| x == t), "{t} not denied");
|
||||
}
|
||||
assert!(!a.iter().any(|x| x.contains("dangerously") || x == "bypassPermissions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_claude_in_the_usual_folders() {
|
||||
let d = tmp("which");
|
||||
let home = d.join("home");
|
||||
fs::create_dir_all(home.join(".local/bin")).unwrap();
|
||||
let dirs = search_path(Some(""), Some(&home));
|
||||
assert!(find_claude_in(&dirs).is_none() || !find_claude_in(&dirs).unwrap().starts_with(&home));
|
||||
let name = if cfg!(windows) { "claude.exe" } else { "claude" };
|
||||
fs::write(home.join(".local/bin").join(name), "").unwrap();
|
||||
assert_eq!(find_claude_in(&dirs).unwrap(), home.join(".local/bin").join(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_tree_is_reported_and_clean_tree_is_empty() {
|
||||
let d = repo("dirty");
|
||||
let (_, clean) = status(&d.join("app")).unwrap();
|
||||
assert!(clean.is_empty());
|
||||
fs::write(d.join("app/Other.swift"), "changed\n").unwrap();
|
||||
fs::write(d.join("app/new.txt"), "n\n").unwrap();
|
||||
let (_, dirty) = status(&d.join("app")).unwrap();
|
||||
let mut paths: Vec<(String, String)> = dirty.iter().map(|c| (c.path.clone(), c.status.clone())).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec![("app/Other.swift".into(), "M".into()), ("app/new.txt".into(), "??".into())]);
|
||||
// not a repository: refused with a reason
|
||||
assert!(status(&tmp("nogit")).unwrap_err().contains("git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_only_the_runs_files() {
|
||||
let d = repo("undo");
|
||||
// the run changed Info.plist and created new.txt; Other.swift was changed later by the owner, not by the run
|
||||
fs::write(d.join("app/Info.plist"), "fixed\n").unwrap();
|
||||
fs::write(d.join("app/new.txt"), "n\n").unwrap();
|
||||
let (_, run) = status(&d.join("app")).unwrap();
|
||||
fs::write(d.join("app/Other.swift"), "owner edit\n").unwrap();
|
||||
let diff_text = diff(&d, &run);
|
||||
assert!(diff_text.contains("+fixed") && diff_text.contains("+++ b/app/new.txt"));
|
||||
let done = undo(&d, &run).unwrap();
|
||||
assert_eq!(done.len(), 2);
|
||||
assert_eq!(fs::read_to_string(d.join("app/Info.plist")).unwrap(), "a\n");
|
||||
assert!(!d.join("app/new.txt").exists());
|
||||
assert_eq!(fs::read_to_string(d.join("app/Other.swift")).unwrap(), "owner edit\n");
|
||||
// paths outside the repository never reach git or the file system
|
||||
let evil = vec![Change { path: "../x".into(), status: "M".into() }, Change { path: "/etc/hosts".into(), status: "??".into() }, Change { path: "-f".into(), status: "M".into() }];
|
||||
assert_eq!(undo_plan(&evil), (vec![], vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_parser_handles_renames_and_new_files() {
|
||||
let z = "R new.txt\0old.txt\0 M a b.txt\0?? n/x.txt\0";
|
||||
assert_eq!(parse_status(z), vec![
|
||||
Change { path: "new.txt".into(), status: "R".into() },
|
||||
Change { path: "a b.txt".into(), status: "M".into() },
|
||||
Change { path: "n/x.txt".into(), status: "??".into() },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_json_lines_become_progress() {
|
||||
assert_eq!(progress_line(r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"ios/App/Info.plist"}}]}}"#).unwrap(), "Edit ios/App/Info.plist");
|
||||
assert_eq!(progress_line(r#"{"type":"result","result":"Removed NSAllowsArbitraryLoads."}"#).unwrap(), "Removed NSAllowsArbitraryLoads.");
|
||||
assert!(progress_line(r#"{"type":"system","subtype":"init"}"#).is_none());
|
||||
assert!(progress_line("not json").is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn timeout_kills_the_process_group_with_heartbeats() {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", "sleep 30 & sleep 30; echo never"]);
|
||||
let t = Instant::now();
|
||||
let mut lines = Vec::new();
|
||||
let o = run(c, "t-timeout", Duration::from_millis(800), Duration::from_millis(300), |l| lines.push(l.to_string())).unwrap();
|
||||
assert!(t.elapsed() < Duration::from_secs(10));
|
||||
assert!(o.timed_out);
|
||||
assert!(lines.iter().any(|l| l.starts_with("still working")));
|
||||
assert!(lines.iter().any(|l| l.starts_with("timed out")));
|
||||
assert!(!lines.iter().any(|l| l.contains("never")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cancel_kills_by_pid() {
|
||||
let h = std::thread::spawn(|| {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", "sleep 30"]);
|
||||
run(c, "t-cancel", Duration::from_secs(60), Duration::from_secs(60), |_| {}).unwrap()
|
||||
});
|
||||
let end = Instant::now() + Duration::from_secs(5);
|
||||
while !cancel("t-cancel") {
|
||||
assert!(Instant::now() < end, "never started");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let o = h.join().unwrap();
|
||||
assert!(o.cancelled && !o.timed_out);
|
||||
assert!(!cancel("t-cancel"));
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
//! The tray card's placement next to the tray icon, always inside the monitor's work area.
|
||||
use storebound_desktop::card::*;
|
||||
|
||||
const W: f64 = 360.0;
|
||||
const H: f64 = 480.0;
|
||||
|
||||
fn r(x: f64, y: f64, w: f64, h: f64) -> Rect { Rect { x, y, w, h } }
|
||||
|
||||
fn inside(p: (f64, f64), work: Rect) {
|
||||
assert!(p.0 >= work.x && p.0 + W <= work.x + work.w, "x {p:?} outside {work:?}");
|
||||
assert!(p.1 >= work.y && p.1 + H <= work.y + work.h, "y {p:?} outside {work:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_bottom_taskbar_opens_above_the_icon() {
|
||||
let work = r(0.0, 0.0, 1920.0, 1032.0);
|
||||
let p = place(r(1700.0, 1040.0, 24.0, 32.0), work, W, H, 8.0);
|
||||
assert_eq!(p, (1532.0, 1032.0 - H - 8.0));
|
||||
inside(p, work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_in_the_corner_is_clamped_into_the_work_area() {
|
||||
let work = r(0.0, 0.0, 1920.0, 1032.0);
|
||||
let p = place(r(1900.0, 1040.0, 24.0, 32.0), work, W, H, 8.0);
|
||||
assert_eq!(p.0, 1920.0 - W - 8.0);
|
||||
inside(p, work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_menu_bar_opens_below_the_icon() {
|
||||
let work = r(0.0, 25.0, 1440.0, 875.0);
|
||||
let p = place(r(1200.0, 0.0, 22.0, 24.0), work, W, H, 8.0);
|
||||
assert_eq!(p.1, 33.0);
|
||||
inside(p, work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_taskbars_open_beside_the_icon() {
|
||||
let work = r(60.0, 0.0, 1860.0, 1080.0);
|
||||
let left = place(r(10.0, 900.0, 40.0, 40.0), work, W, H, 8.0);
|
||||
assert_eq!(left.0, 68.0);
|
||||
inside(left, work);
|
||||
let work = r(0.0, 0.0, 1860.0, 1080.0);
|
||||
let right = place(r(1870.0, 100.0, 40.0, 40.0), work, W, H, 8.0);
|
||||
assert_eq!(right.0, 1860.0 - W - 8.0);
|
||||
inside(right, work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_monitor_with_negative_origin() {
|
||||
let work = r(-2560.0, 0.0, 2560.0, 1400.0);
|
||||
let p = place(r(-200.0, 1410.0, 24.0, 30.0), work, W, H, 8.0);
|
||||
inside(p, work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_inside_the_work_area_and_a_tiny_screen() {
|
||||
let work = r(0.0, 0.0, 1920.0, 1080.0);
|
||||
inside(place(r(900.0, 1000.0, 24.0, 24.0), work, W, H, 8.0), work);
|
||||
inside(place(r(900.0, 20.0, 24.0, 24.0), work, W, H, 8.0), work);
|
||||
// smaller than the card: top-left of the work area, no panic
|
||||
let tiny = r(0.0, 0.0, 300.0, 300.0);
|
||||
assert_eq!(place(r(100.0, 310.0, 20.0, 20.0), tiny, W, H, 8.0), (8.0, 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_and_snapshots_are_checked() {
|
||||
for a in ["hide", "open", "route", "refresh", "mute", "quit", "external", "ready"] {
|
||||
assert!(valid_action(a));
|
||||
}
|
||||
assert!(!valid_action("sidecar_call"));
|
||||
assert!(!set_snapshot(serde_json::json!([1, 2])));
|
||||
assert!(!set_snapshot(serde_json::json!({ "x": "y".repeat(MAX_SNAPSHOT) })));
|
||||
assert!(set_snapshot(serde_json::json!({ "busy": false })));
|
||||
assert_eq!(snapshot().unwrap()["busy"], false);
|
||||
}
|
||||
|
|
@ -32,10 +32,6 @@ fn app_folder_paths() {
|
|||
assert!(openable(Path::new("store/renders/index.html"), false));
|
||||
assert!(openable(Path::new("store/renders"), true));
|
||||
assert!(!openable(Path::new("store/renders/run.sh"), false));
|
||||
assert!(openable(Path::new(".storebound/reports/readiness-2026-10-18.html"), false));
|
||||
assert!(!openable(Path::new(".storebound/reports/readiness-2026-10-18.sh"), false));
|
||||
assert!(!openable(Path::new(".storebound/credentials.json"), false));
|
||||
assert!(!openable(Path::new("other/reports/readiness-2026-10-18.html"), false));
|
||||
assert!(!openable(Path::new("store/prices.csv"), false));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,13 +41,10 @@ fn updater_has_a_real_minisign_key_and_only_storebound_dev() {
|
|||
assert_eq!(c["plugins"]["updater"]["endpoints"], serde_json::json!([updates::STABLE]));
|
||||
// updater bundles are signed by tools/desktop-release.ts on the release Mac, never by a CI runner
|
||||
assert_eq!(c["bundle"]["createUpdaterArtifacts"], false);
|
||||
assert_eq!(c["plugins"]["updater"]["windows"]["installMode"], "quiet");
|
||||
for ch in ["stable", "beta", "dev", "nightly", ""] {
|
||||
for ch in ["stable", "beta", "nightly", ""] {
|
||||
assert!(updates::endpoint_allowed(&Url::parse(updates::endpoint(ch)).unwrap()), "{ch}");
|
||||
}
|
||||
assert_eq!(updates::endpoint("beta"), updates::BETA);
|
||||
assert_eq!(updates::endpoint("dev"), updates::DEV);
|
||||
assert_eq!(updates::DEV, "https://storebound.dev/desktop/dev/latest.json");
|
||||
assert_eq!(updates::endpoint("anything"), updates::STABLE);
|
||||
for bad in ["http://storebound.dev/desktop/latest.json", "https://storebound.dev:8443/desktop/latest.json", "https://evil.test/desktop/latest.json", "https://storebound.dev/latest.json"] {
|
||||
assert!(!updates::endpoint_allowed(&Url::parse(bad).unwrap()), "{bad}");
|
||||
|
|
@ -58,12 +55,12 @@ fn updater_has_a_real_minisign_key_and_only_storebound_dev() {
|
|||
#[test]
|
||||
fn open_deep_link_takes_one_plain_absolute_path() {
|
||||
let p = |s: &str| deep_link_open_path(&Url::parse(s).unwrap());
|
||||
let abs = if cfg!(windows) { "C:%5Cgames%5Cdemo-app" } else { "%2FUsers%2Fyou%2Fgames%2Fdemo-app" };
|
||||
let want = if cfg!(windows) { "C:\\games\\demo-app" } else { "/Users/you/games/demo-app" };
|
||||
let abs = if cfg!(windows) { "C:%5Cgames%5CDemo App" } else { "%2FUsers%2Fyou%2Fgames%2FDemo App" };
|
||||
let want = if cfg!(windows) { "C:\\games\\Demo App" } else { "/Users/you/games/Demo App" };
|
||||
assert_eq!(p(&format!("storebound://open?path={abs}")), Some(PathBuf::from(want)));
|
||||
assert_eq!(p(&format!("storebound://open/?path={abs}")), Some(PathBuf::from(want)));
|
||||
for bad in [
|
||||
"storebound://open?path=games%2Fdemo-app".to_string(),
|
||||
"storebound://open?path=games%2FDemo App".to_string(),
|
||||
"storebound://open?path=".to_string(),
|
||||
"storebound://open".to_string(),
|
||||
format!("storebound://open?path={abs}&path={abs}"),
|
||||
|
|
@ -106,49 +103,3 @@ fn deep_links_to_folders_ask_before_adding() {
|
|||
let body = &cmds[at..];
|
||||
assert!(body.find("confirm_dialog_with(").unwrap() < body.find("download_and_install(").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_versions_sort_and_never_downgrade() {
|
||||
// a dev build of the next patch is above the current stable, below that next stable, and later stamps go up
|
||||
assert!(updates::newer("0.8.48-dev.20261023120000", "0.8.47"));
|
||||
assert!(updates::newer("0.8.48", "0.8.48-dev.20261023120000"));
|
||||
assert!(updates::newer("0.8.48-dev.20261023120001", "0.8.48-dev.20261023120000"));
|
||||
assert!(updates::newer("0.8.48-dev.20261024000000", "0.8.48-dev.9"), "numeric identifiers compare as numbers");
|
||||
assert!(updates::newer("0.8.49-dev.1", "0.8.48"));
|
||||
assert!(updates::newer("0.10.0", "0.9.9"));
|
||||
assert!(!updates::newer("0.8.48", "0.8.48"));
|
||||
assert!(!updates::newer("nonsense", "0.8.48"));
|
||||
// switched from dev back to stable: stays on the dev build until a stable release passes it
|
||||
assert!(!updates::should_install("0.8.48-dev.20261023120000", "0.8.47"));
|
||||
assert!(updates::should_install("0.8.48-dev.20261023120000", "0.8.48"));
|
||||
assert!(updates::should_install("0.8.47", "0.8.48-dev.20261023120000"));
|
||||
assert!(!updates::should_install("0.8.48-dev.20261023120000", "0.8.48-dev.20261023120000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_updater_schedule_and_defer() {
|
||||
assert!(updates::check_due(true, false, 0, 0), "at start");
|
||||
assert!(updates::check_due(false, true, 0, 0), "channel switched");
|
||||
assert!(updates::check_due(false, false, 3600, 5), "resume from sleep");
|
||||
assert!(updates::check_due(false, false, 5, updates::CHECK_EVERY_SECS));
|
||||
assert!(!updates::check_due(false, false, 5, 60));
|
||||
assert_eq!(updates::CHECK_EVERY_SECS, 900);
|
||||
assert!(updates::defer_install(true, true, 0));
|
||||
assert!(!updates::defer_install(true, true, updates::MAX_DEFERS), "an open window still updates after a few checks");
|
||||
assert!(!updates::defer_install(false, true, 0), "a hidden window can report focus");
|
||||
assert!(!updates::defer_install(true, false, 0));
|
||||
let _ = updates::take_kick();
|
||||
updates::kick();
|
||||
assert!(updates::take_kick());
|
||||
assert!(!updates::take_kick());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_apps_choice_is_read_from_startup_approved() {
|
||||
use storebound_desktop::startup_approved_off;
|
||||
assert!(!startup_approved_off(&[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]));
|
||||
assert!(!startup_approved_off(&[0x06, 0, 0, 0]));
|
||||
assert!(startup_approved_off(&[0x03, 0, 0, 0, 0x20, 0xD1, 0xD3, 0xB1, 0xF0, 0xB3, 0xDC, 0x01]));
|
||||
assert!(startup_approved_off(&[0x07]));
|
||||
assert!(!startup_approved_off(&[]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ impl SecretStore for MapStore {
|
|||
}
|
||||
|
||||
fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account {
|
||||
Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string), developer_id: None }
|
||||
Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -46,7 +46,7 @@ fn ids_match_the_cli() {
|
|||
// cli/lib/workspace.ts accountId and cli/lib/registry.ts folderId give the same values
|
||||
assert_eq!(profiles::account_id(AccountKind::Apple, "acme"), "747351dbf2569d17");
|
||||
assert_eq!(profiles::account_id(AccountKind::Play, "default"), "82a8c2561d950999");
|
||||
assert_eq!(registry::folder_id("/w/star-dash"), "c27311c5023dc3ba");
|
||||
assert_eq!(registry::folder_id("/w/Star Dash"), "f4994b9746e7eb18");
|
||||
assert!(profiles::valid_name("default") && profiles::valid_name("acme-games.2") && !profiles::valid_name("../x") && !profiles::valid_name("-a"));
|
||||
assert_eq!(profiles::slug("Acme Games: Play!"), "acme-games-play");
|
||||
assert_eq!(profiles::slug("???"), "account");
|
||||
|
|
@ -133,19 +133,19 @@ fn project(id: &str, path: &str, play: Option<&str>) -> Project {
|
|||
fn the_folder_list_is_the_machines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("projects.json");
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/star-dash", Some("acc"))], ..State::default() };
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", Some("acc"))], ..State::default() };
|
||||
// first start: the app's list becomes the shared one
|
||||
assert!(!registry::sync(&mut st, &file).unwrap());
|
||||
assert_eq!(registry::read(&file).unwrap()[0].id, "aaaaaaaaaaaaaaaa");
|
||||
// the CLI adds a folder (storebound desktop / apps add): the app shows it, the first keeps its accounts
|
||||
let mut list = registry::read(&file).unwrap();
|
||||
list.push(Entry { id: registry::folder_id("/w/demo-game"), path: "/w/demo-game".into(), name: "demo-game".into(), added: "t".into() });
|
||||
list.push(Entry { id: registry::folder_id("/w/a game"), path: "/w/a game".into(), name: "a game".into(), added: "t".into() });
|
||||
registry::write(&file, &list).unwrap();
|
||||
assert!(registry::sync(&mut st, &file).unwrap());
|
||||
assert_eq!(st.projects.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["/w/star-dash", "/w/demo-game"]);
|
||||
assert_eq!(st.projects.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["/w/Star Dash", "/w/a game"]);
|
||||
assert_eq!(st.projects[0].play_account.as_deref(), Some("acc"));
|
||||
// the CLI removes one (apps remove): it leaves the app with its overrides
|
||||
st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/demo-game")));
|
||||
st.overrides.ignored.push(format!("project:{}", registry::folder_id("/w/a game")));
|
||||
registry::write(&file, &list[..1]).unwrap();
|
||||
assert!(registry::sync(&mut st, &file).unwrap());
|
||||
assert_eq!(st.projects.len(), 1);
|
||||
|
|
@ -155,7 +155,7 @@ fn the_folder_list_is_the_machines() {
|
|||
registry::write(&file, &[list[0].clone(), Entry { id: registry::folder_id("/w/cli"), path: "/w/cli".into(), name: "cli".into(), added: String::new() }]).unwrap();
|
||||
registry::publish(&st, &file, &[]).unwrap();
|
||||
let paths: Vec<String> = registry::read(&file).unwrap().into_iter().map(|e| e.path).collect();
|
||||
assert_eq!(paths, vec!["/w/star-dash", "/w/third", "/w/cli"]);
|
||||
assert_eq!(paths, vec!["/w/Star Dash", "/w/third", "/w/cli"]);
|
||||
// bad entries are dropped, not the whole file
|
||||
assert_eq!(registry::parse(r#"{"projects":[{"path":"rel"},{"path":"/a","id":"XYZ"},{"path":"/a"}]}"#).iter().map(|e| e.id.clone()).collect::<Vec<_>>(), vec![registry::folder_id("/a")]);
|
||||
}
|
||||
|
|
@ -173,7 +173,7 @@ fn store_only_apps_join_their_profiles_account_and_survive_folder_writes() {
|
|||
assert_eq!(apps.iter().map(|a| a.app_id.as_str()).collect::<Vec<_>>(), vec!["6400000999", "com.example.legacy"]);
|
||||
assert_eq!(apps[1].bundle_id.as_deref(), Some("com.example.legacy"));
|
||||
// the app writes its folder list: the store-only entries stay
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/star-dash", None)], ..State::default() };
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", "/w/Star Dash", None)], ..State::default() };
|
||||
registry::publish(&st, &file, &[]).unwrap();
|
||||
assert_eq!(registry::read_store_apps(&file).len(), 2);
|
||||
assert!(registry::sync(&mut st, &file).is_ok());
|
||||
|
|
@ -207,56 +207,3 @@ fn a_folder_that_pins_another_storebound_says_so() {
|
|||
assert!(!pin_differs(spec, "0.8.0"), "{spec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_flags_travel_between_the_registry_and_the_apps_ignore_keys() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("projects.json");
|
||||
// as `storebound apps ignore` wrote it: one folder fully, one store side of another, one store-only app
|
||||
let (one, two) = (dir.path().join("one").to_string_lossy().to_string(), dir.path().join("two").to_string_lossy().to_string());
|
||||
let doc = json!({ "version": 1, "projects": [
|
||||
{ "id": "aaaaaaaaaaaaaaaa", "path": one, "name": "One", "added": "t", "ignored": ["apple", "play"] },
|
||||
{ "id": "bbbbbbbbbbbbbbbb", "path": two, "name": "Two", "added": "t", "ignored": ["play"] }],
|
||||
"storeApps": [{ "store": "apple", "appId": "6400000999", "account": "teamA", "name": "Demo", "ignored": ["apple"] }] });
|
||||
std::fs::write(&file, doc.to_string()).unwrap();
|
||||
let mut st = State { projects: vec![project("aaaaaaaaaaaaaaaa", &one, None), project("bbbbbbbbbbbbbbbb", &two, None)], ..State::default() };
|
||||
st.accounts = vec![account("acct1", "Team A", AccountKind::Apple, Some("teamA"))];
|
||||
st.overrides.ignored = vec!["account:zzz".into(), "app:ios:other:6400000999".into()];
|
||||
assert!(registry::pull_ignored(&mut st, &file));
|
||||
assert!(st.overrides.ignored.contains(&"account:zzz".to_string()));
|
||||
assert!(st.overrides.ignored.contains(&"project:aaaaaaaaaaaaaaaa".to_string()));
|
||||
assert!(st.overrides.ignored.contains(&"project:bbbbbbbbbbbbbbbb:android".to_string()));
|
||||
assert!(st.overrides.ignored.contains(&"app:ios:acct1:6400000999".to_string()));
|
||||
assert!(!st.overrides.ignored.contains(&"app:ios:other:6400000999".to_string()));
|
||||
assert!(!registry::pull_ignored(&mut st, &file), "second pull changes nothing");
|
||||
// the owner restores one folder's play side and ignores the other's apple side in the app
|
||||
st.overrides.ignored.retain(|k| k != "project:bbbbbbbbbbbbbbbb:android" && k != "app:ios:acct1:6400000999");
|
||||
st.overrides.ignored.push("project:bbbbbbbbbbbbbbbb:ios".into());
|
||||
assert!(registry::push_ignored(&st, &file).unwrap());
|
||||
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
||||
assert_eq!(doc["projects"][0]["ignored"], json!(["apple", "play"]));
|
||||
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
|
||||
assert!(doc["storeApps"][0].get("ignored").is_none());
|
||||
assert!(!registry::push_ignored(&st, &file).unwrap());
|
||||
// writing the folder list keeps the flags of the folders that stay
|
||||
let list = registry::read(&file).unwrap();
|
||||
registry::write(&file, &list).unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
||||
assert_eq!(doc["projects"][1]["ignored"], json!(["apple"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_play_developer_id_is_kept_in_the_profile_for_the_cli() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = ProfileStore::new(dir.path().join("credentials"), None);
|
||||
store.save("studio", "Studio", &Secret::Play { service_account: sa("bot@example.com") }).unwrap();
|
||||
assert_eq!(store.play_developer_id("studio"), None);
|
||||
store.set_play_developer_id("studio", Some("5550000000000000001")).unwrap();
|
||||
assert_eq!(store.play_developer_id("studio").as_deref(), Some("5550000000000000001"));
|
||||
let m = store.read("studio").unwrap();
|
||||
assert_eq!(m.get("PLAY_DEVELOPER_ID").and_then(|v| v.as_str()), Some("5550000000000000001"));
|
||||
assert!(m.contains_key("PLAY_SERVICE_ACCOUNT"));
|
||||
assert!(store.set_play_developer_id("studio", Some("abc")).is_err());
|
||||
store.set_play_developer_id("studio", None).unwrap();
|
||||
assert_eq!(store.play_developer_id("studio"), None);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,214 +0,0 @@
|
|||
//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the
|
||||
//! panel book (transient vs pinned, parents, restore after a restart).
|
||||
use storebound_desktop::menu_panel::*;
|
||||
use storebound_desktop::store::State;
|
||||
use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary};
|
||||
|
||||
const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0);
|
||||
const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 };
|
||||
|
||||
fn summary(state: &str, apps_label: &str) -> TraySummary {
|
||||
TraySummary {
|
||||
apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }],
|
||||
labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() },
|
||||
..TraySummary::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn tree() -> Vec<Node> {
|
||||
from_entries(&[
|
||||
Entry::Item { id: "open".into(), label: "Open".into(), enabled: true },
|
||||
Entry::Separator,
|
||||
Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] },
|
||||
Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] },
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_ignore_live_parts_and_stay_unique() {
|
||||
assert_eq!(sub_key("Profile: Work"), "profile");
|
||||
assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first");
|
||||
assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue");
|
||||
assert_eq!(sub_key("\u{2716}"), "sub");
|
||||
let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]);
|
||||
assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_walks_keys() {
|
||||
let t = tree();
|
||||
let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap();
|
||||
assert_eq!((items.len(), label), (0, Some("Recently lowered")));
|
||||
assert!(find(&t, &["nope".into()]).is_none());
|
||||
assert_eq!(find(&t, &[]).unwrap().0.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() {
|
||||
let en = from_entries(&menu_model(&summary("App Store: In review", "Apps")));
|
||||
let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar")));
|
||||
let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap();
|
||||
assert_eq!(key(&en), "apps");
|
||||
assert_eq!(key(&tr), "apps");
|
||||
// a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves
|
||||
assert_ne!(en, tr);
|
||||
let (items, _) = find(&tr, &["apps".into()]).unwrap();
|
||||
assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live")));
|
||||
// the same summary twice: the same tree (nothing is pushed)
|
||||
assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_serializes_as_the_panel_page_reads_it() {
|
||||
let v = serde_json::to_value(tree()).unwrap();
|
||||
assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true }));
|
||||
assert_eq!(v[1], serde_json::json!({ "kind": "sep" }));
|
||||
assert_eq!(v[2]["kind"], "sub");
|
||||
assert_eq!(v[2]["key"], "fleet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_opens_away_from_the_taskbar_and_stays_inside() {
|
||||
// bottom taskbar, icon at the right: above and left of the click
|
||||
let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK);
|
||||
assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0);
|
||||
// macOS menu bar at the top: below it
|
||||
let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0));
|
||||
assert!(y >= 25.0 + GAP);
|
||||
// a second monitor left of the primary (negative x)
|
||||
let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0));
|
||||
assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0);
|
||||
// a panel taller than the work area: pinned to the top edge, never above it
|
||||
let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK);
|
||||
assert_eq!(y, GAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_root_sits_just_above_the_icon_never_over_the_taskbar() {
|
||||
// icon in a bottom taskbar (the icon is below the work area): bottom edge exactly GAP above the icon's top
|
||||
let icon = Anchor::Tray { x: 1800.0, y: 1044.0, w: 32.0, h: 32.0 };
|
||||
let (_, y) = place_menu(icon, (260.0, 300.0), WORK);
|
||||
assert_eq!(y + 300.0, 1040.0 - GAP);
|
||||
// even when the reported work area wrongly includes the taskbar, the panel stays above the icon
|
||||
let (_, y) = place_menu(icon, (260.0, 300.0), (0.0, 0.0, 1920.0, 1080.0));
|
||||
assert!(y + 300.0 <= 1044.0 - GAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submenus_open_beside_and_flip_left_at_the_edge() {
|
||||
let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK);
|
||||
assert!(x + 280.0 <= 1602.0);
|
||||
let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK);
|
||||
assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y));
|
||||
// a row near the bottom: the submenu moves up into the work area
|
||||
let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK);
|
||||
assert!(y + 300.0 <= 1040.0 - GAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_panels_step_aside_and_stay_inside() {
|
||||
let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)];
|
||||
let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK);
|
||||
assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o)));
|
||||
assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0);
|
||||
// no room left below: the next column
|
||||
let col = [(8.0, 8.0, 280.0, 1000.0)];
|
||||
let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK);
|
||||
assert!(x >= 288.0);
|
||||
// nothing in the way: where it was
|
||||
assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_menus_open_and_pinned_ones_survive_a_new_menu() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
|
||||
assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op");
|
||||
let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap();
|
||||
assert_eq!(b.descendants(&root).len(), 2);
|
||||
// pinning the Fleet panel detaches it; the root closes, its own child stays its own
|
||||
let closes = b.pin(&fleet, true);
|
||||
assert_eq!(closes, vec![root.clone()]);
|
||||
assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none());
|
||||
assert_eq!(b.descendants(&fleet), vec![deep.clone()]);
|
||||
let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
assert_eq!(closes, vec![deep]);
|
||||
assert!(b.panels.contains_key(&fleet));
|
||||
assert_eq!(b.transient(), vec![root2.clone()]);
|
||||
// a pick / Esc: transient panels go, the pinned one stays
|
||||
assert_eq!(b.dismiss(), vec![root2]);
|
||||
assert_eq!(b.panels.len(), 1);
|
||||
// unpinned, it behaves like a menu again and goes with the next one
|
||||
b.pin(&fleet, false);
|
||||
let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
assert!(closes.contains(&fleet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_torn_off_submenu_keeps_one_pinned_copy() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap();
|
||||
let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap();
|
||||
assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1));
|
||||
assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]);
|
||||
// the root stays open (a tear-off is not a pick)
|
||||
assert!(b.panels.contains_key(&root));
|
||||
// closing a panel that is already gone does nothing
|
||||
assert!(b.close("menu-99").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_update_closes_nothing_that_still_exists() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap();
|
||||
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
|
||||
assert!(b.prune(&tree()).is_empty());
|
||||
// the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its
|
||||
// submenu goes too (it shows that it is gone)
|
||||
let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]);
|
||||
assert_eq!(b.prune(&smaller), vec![fleet]);
|
||||
assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_are_saved_in_the_state_and_restored_after_a_restart() {
|
||||
let mut b = Book::default();
|
||||
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap();
|
||||
// not placed yet: nothing to save
|
||||
assert!(b.pins().is_empty());
|
||||
b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6));
|
||||
let pins = b.pins();
|
||||
assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]);
|
||||
// through state.json
|
||||
let st = State { menu_pins: pins.clone(), ..State::default() };
|
||||
let text = serde_json::to_string(&st).unwrap();
|
||||
assert!(text.contains("\"menuPins\""));
|
||||
let back: State = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.menu_pins, pins);
|
||||
// an empty list is not written at all (older files stay as they were)
|
||||
assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins"));
|
||||
let mut fresh = Book::default();
|
||||
let mut saved = back.menu_pins.clone();
|
||||
saved.push(saved[0].clone()); // a duplicate is dropped
|
||||
let labels = fresh.restore(&saved);
|
||||
assert_eq!(labels.len(), 1);
|
||||
let r = &fresh.panels[&labels[0]];
|
||||
assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none());
|
||||
// a restored pin survives the next menu
|
||||
let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 });
|
||||
assert!(closes.is_empty());
|
||||
let many: Vec<PinRecord> = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect();
|
||||
assert_eq!(Book::default().restore(&many).len(), MAX_PINS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_labels_are_menu_and_a_number() {
|
||||
assert!(is_menu_label("menu-1") && is_menu_label("menu-204"));
|
||||
for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] {
|
||||
assert!(!is_menu_label(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serde_json::{json, Value};
|
||||
use storebound_desktop::policy::*;
|
||||
use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS};
|
||||
use storebound_desktop::COMMANDS;
|
||||
use url::Url;
|
||||
|
||||
fn root() -> PathBuf {
|
||||
|
|
@ -24,10 +24,9 @@ fn u(s: &str) -> Url {
|
|||
#[test]
|
||||
fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
|
||||
let dir = root().join("capabilities");
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
files.sort();
|
||||
assert_eq!(files.len(), 3, "main, card and menu capability files expected, found {files:?}");
|
||||
let cap = read_json(&dir.join("main.json"));
|
||||
let files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(files.len(), 1, "one capability file expected, found {files:?}");
|
||||
let cap = read_json(&files[0]);
|
||||
assert_eq!(cap["identifier"], "main-window");
|
||||
assert_eq!(cap["windows"], json!(["main"]));
|
||||
assert!(cap.get("webviews").is_none(), "no webview-scoped grants");
|
||||
|
|
@ -47,69 +46,6 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_window_gets_only_the_card_commands() {
|
||||
let cap = read_json(&root().join("capabilities/card.json"));
|
||||
assert_eq!(cap["identifier"], "card-window");
|
||||
assert_eq!(cap["windows"], json!(["card"]));
|
||||
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
|
||||
assert_ne!(cap["local"], json!(false));
|
||||
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
|
||||
let mut want: BTreeSet<String> = CARD_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);
|
||||
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
||||
for c in CARD_COMMANDS {
|
||||
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
|
||||
let body = &cmds[at..];
|
||||
let open = body.find('{').unwrap();
|
||||
assert!(body[open + 1..].trim_start().starts_with("guard_card(&window)?;"), "{c} must start with guard_card(&window)?");
|
||||
}
|
||||
assert!(require_card("card", &u("tauri://localhost/card.html"), false).is_ok());
|
||||
assert!(require_card("main", &u("tauri://localhost/"), false).is_err());
|
||||
assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_panels_get_only_the_menu_commands() {
|
||||
let cap = read_json(&root().join("capabilities/menu.json"));
|
||||
assert_eq!(cap["identifier"], "menu-window");
|
||||
assert_eq!(cap["windows"], json!(["menu-*"]));
|
||||
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
|
||||
assert_ne!(cap["local"], json!(false));
|
||||
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
|
||||
let mut want: BTreeSet<String> = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
|
||||
want.insert("core:event:allow-listen".into());
|
||||
want.insert("core:event:allow-unlisten".into());
|
||||
// a pinned panel is dragged by its header (data-tauri-drag-region): only its own window
|
||||
want.insert("core:window:allow-start-dragging".into());
|
||||
assert_eq!(got, want);
|
||||
// the menu commands never leak into the main or card capability
|
||||
for other in ["main.json", "card.json"] {
|
||||
let cap = read_json(&root().join("capabilities").join(other));
|
||||
let perms = cap["permissions"].to_string();
|
||||
for c in MENU_COMMANDS {
|
||||
assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}");
|
||||
}
|
||||
}
|
||||
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
||||
for c in MENU_COMMANDS {
|
||||
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
|
||||
let body = &cmds[at..];
|
||||
let open = body.find('{').unwrap();
|
||||
assert!(body[open + 1..].trim_start().starts_with("guard_menu(&window)?;"), "{c} must start with guard_menu(&window)?");
|
||||
}
|
||||
assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok());
|
||||
assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok());
|
||||
for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] {
|
||||
assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}");
|
||||
}
|
||||
assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err());
|
||||
assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
|
||||
assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_registered_command_is_in_the_list_and_nothing_else() {
|
||||
let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap();
|
||||
|
|
@ -120,7 +56,7 @@ fn every_registered_command_is_in_the_list_and_nothing_else() {
|
|||
.map(|s| s.trim().trim_start_matches("commands::").to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
assert_eq!(registered, COMMANDS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
// every command checks the caller first
|
||||
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
|
||||
for c in COMMANDS {
|
||||
|
|
@ -148,7 +84,7 @@ fn csp_is_strict_and_the_config_has_no_backdoors() {
|
|||
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", "card-window", "menu-window"]));
|
||||
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();
|
||||
|
|
@ -243,16 +179,6 @@ fn sidecar_starts_with_fixed_arguments_from_one_place() {
|
|||
assert!(src.contains("Command::new(&bin)\n .args(policy::SIDECAR_ARGS)"));
|
||||
continue;
|
||||
}
|
||||
// Fix with AI: only the claude binary (claude_command checks its name), git, and kill / taskkill by PID
|
||||
if p.ends_with("ai_fix.rs") {
|
||||
for (i, _) in src.match_indices("Command::new(") {
|
||||
let arg = &src[i + "Command::new(".len()..];
|
||||
let ok = ["claude)", "\"git\")", "\"kill\")", "\"taskkill\")"];
|
||||
assert!(ok.iter().any(|o| arg.starts_with(o)), "unexpected program: {}", &arg[..arg.find(')').unwrap_or(20).min(40)]);
|
||||
}
|
||||
assert!(src.contains("if !matches!(name, \"claude\" | \"claude.exe\" | \"claude.cmd\")"));
|
||||
continue;
|
||||
}
|
||||
// other programs: only the download checks (unpack, signature, version banner), with fixed program names
|
||||
if src.contains("Command::new(") {
|
||||
assert!(p.ends_with("downloads.rs"), "{} starts a program", p.display());
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
//! The tray's pure parts: menu model, tooltip, click actions, notification targets, the attention dot, settings.
|
||||
use storebound_desktop::tray::*;
|
||||
use storebound_desktop::workspace::{check_settings, valid_hhmm, Settings};
|
||||
|
||||
fn summary() -> TraySummary {
|
||||
TraySummary {
|
||||
tooltip: "storebound · 2 in review · 1 needs you".into(),
|
||||
attention: 1,
|
||||
muted: false,
|
||||
apps: vec![
|
||||
TrayApp { key: "k1".into(), name: "First".into(), state: "App Store: In review · Play: Live".into() },
|
||||
TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() },
|
||||
],
|
||||
labels: TrayLabels::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(m: &[Entry]) -> Vec<String> {
|
||||
m.iter().filter_map(|e| if let Entry::Item { id, .. } = e { Some(id.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_has_every_entry_in_order() {
|
||||
let m = menu_model(&summary());
|
||||
assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]);
|
||||
let Entry::Sub { label, items, .. } = &m[3] else { panic!("the Apps submenu comes after the first separator") };
|
||||
assert_eq!(label, "Apps");
|
||||
assert_eq!(items.len(), 2);
|
||||
let Entry::Item { id, label, enabled } = &items[0] else { panic!() };
|
||||
assert_eq!((id.as_str(), *enabled), ("tray-app:k1", true));
|
||||
assert!(label.contains("First") && label.contains("In review"), "{label}");
|
||||
let Entry::Item { label, .. } = &items[1] else { panic!() };
|
||||
assert_eq!(label, "Second", "an app without a state shows its name only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_entry_follows_the_state_and_empty_apps_say_so() {
|
||||
let mut s = summary();
|
||||
s.muted = true;
|
||||
s.apps.clear();
|
||||
let m = menu_model(&s);
|
||||
let Entry::Item { label, .. } = m.iter().find(|e| matches!(e, Entry::Item { id, .. } if id == ID_MUTE)).unwrap() else { panic!() };
|
||||
assert_eq!(label, "Unmute notifications");
|
||||
let Entry::Sub { items, .. } = &m[3] else { panic!() };
|
||||
assert!(matches!(&items[0], Entry::Item { enabled: false, label, .. } if label == "No apps yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_is_one_short_line() {
|
||||
assert_eq!(tooltip_text(&summary()), "storebound · 2 in review · 1 needs you");
|
||||
let mut s = summary();
|
||||
s.tooltip = "a\nb\t c".into();
|
||||
assert_eq!(tooltip_text(&s), "a b c");
|
||||
s.tooltip = "x".repeat(500);
|
||||
assert_eq!(tooltip_text(&s).chars().count(), MAX_TIP);
|
||||
s.tooltip = " ".into();
|
||||
assert_eq!(tooltip_text(&s), "storebound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_map_to_actions() {
|
||||
assert_eq!(action_of(ID_OPEN), Some(Action::Open));
|
||||
assert_eq!(action_of(ID_QUIT), Some(Action::Quit));
|
||||
assert_eq!(action_of(ID_READ), Some(Action::Event("read")));
|
||||
assert_eq!(action_of(ID_MUTE), Some(Action::Event("mute-toggle")));
|
||||
assert_eq!(action_of("tray-app:k1"), Some(Action::Route(r#"{"appId":"k1","screen":"app","tab":"releases"}"#.into())));
|
||||
assert_eq!(action_of("tray-app:"), None);
|
||||
assert_eq!(action_of("other"), None);
|
||||
let Some(Action::Route(r)) = action_of(ID_UPDATE) else { panic!() };
|
||||
assert!(valid_route(&r) && r.contains("updates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_known_small_routes_are_remembered() {
|
||||
assert!(valid_route(r#"{"screen":"app","appId":"k1","tab":"releases"}"#));
|
||||
assert!(valid_route(r#"{"screen":"portfolio"}"#));
|
||||
assert!(!valid_route(r#"{"screen":"evil"}"#));
|
||||
assert!(!valid_route(r#"{"screen":"app","appId":5}"#));
|
||||
assert!(!valid_route("[]"));
|
||||
assert!(!valid_route("not json"));
|
||||
assert!(!valid_route(&format!(r#"{{"screen":"app","appId":"{}"}}"#, "a".repeat(500))));
|
||||
// handed out once
|
||||
set_pending(r#"{"screen":"portfolio"}"#);
|
||||
assert_eq!(take_pending().as_deref(), Some(r#"{"screen":"portfolio"}"#));
|
||||
assert_eq!(take_pending(), None);
|
||||
set_pending(r#"{"screen":"evil"}"#);
|
||||
assert_eq!(take_pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attention_dot_changes_only_the_corner() {
|
||||
let (w, h) = (32u32, 32u32);
|
||||
let base: Vec<u8> = std::iter::repeat_n([10u8, 20, 30, 255], (w * h) as usize).flatten().collect();
|
||||
let out = with_attention_dot(&base, w, h);
|
||||
assert_eq!(out.len(), base.len());
|
||||
let at = |v: &[u8], x: u32, y: u32| -> [u8; 4] { let i = ((y * w + x) * 4) as usize; [v[i], v[i + 1], v[i + 2], v[i + 3]] };
|
||||
assert_eq!(at(&out, 2, 30), [10, 20, 30, 255], "the bottom left stays");
|
||||
let dot = at(&out, 25, 6);
|
||||
assert!(dot[0] > 200 && dot[1] < 90 && dot[2] < 90, "red in the middle of the dot: {dot:?}");
|
||||
// transparent icon: the dot is opaque, the rest stays clear
|
||||
let clear = vec![0u8; (w * h * 4) as usize];
|
||||
let out = with_attention_dot(&clear, w, h);
|
||||
assert_eq!(at(&out, 2, 30)[3], 0);
|
||||
assert_eq!(at(&out, 25, 6)[3], 255);
|
||||
// a wrong buffer size is returned unchanged
|
||||
assert_eq!(with_attention_dot(&[1, 2, 3], w, h), vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_settings_are_checked_and_default_off() {
|
||||
let d = Settings::default();
|
||||
assert!(!d.notify.quiet_hours.enabled);
|
||||
assert_eq!((d.notify.quiet_hours.from.as_str(), d.notify.quiet_hours.to.as_str()), ("23:00", "08:00"));
|
||||
assert!(d.notify.rejected && d.notify.approved && d.notify.live);
|
||||
assert!(valid_hhmm("00:00") && valid_hhmm("23:59"));
|
||||
assert!(!valid_hhmm("24:00") && !valid_hhmm("7:00") && !valid_hhmm("12:60") && !valid_hhmm("ab:cd"));
|
||||
let mut s = d.clone();
|
||||
s.notify.quiet_hours.from = "25:00".into();
|
||||
assert!(check_settings(&s).is_err());
|
||||
let old: Settings = serde_json::from_value(serde_json::json!({ "notify": { "crash": false } })).unwrap();
|
||||
assert!(!old.notify.crash && old.notify.rejected && !old.notify.quiet_hours.enabled, "an older file gets the new defaults");
|
||||
}
|
||||
|
|
@ -1,46 +1,36 @@
|
|||
// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line
|
||||
// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh),
|
||||
// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh),
|
||||
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
|
||||
import { DryRunPreview } from './ui/DryRunPreview';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api';
|
||||
import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api';
|
||||
import { devSwitch } from './dev/switches';
|
||||
import { type Key, t } from './i18n';
|
||||
import { ago, num } from './lib/format';
|
||||
import type { UnifiedApp } from './lib/identity';
|
||||
import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { groupByStudio, type UnifiedApp } from './lib/identity';
|
||||
import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { usePrefs } from './prefs-ctx';
|
||||
import { Accounts } from './screens/Accounts';
|
||||
import { AppFolders } from './screens/AppFolders';
|
||||
import { AppView } from './screens/AppView';
|
||||
import { Dependencies } from './screens/Dependencies';
|
||||
import { Ignored } from './screens/Ignored';
|
||||
import { useShowIgnored } from './lib/show-ignored';
|
||||
import { Onboarding } from './screens/Onboarding';
|
||||
import { Portfolio } from './screens/Portfolio';
|
||||
import { Settings } from './screens/Settings';
|
||||
import { useData, useReadState } from './store';
|
||||
import { useData } from './store';
|
||||
import { Icon, type IconName, Mark } from './ui/icons';
|
||||
import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit';
|
||||
import { type Command, Palette } from './ui/Palette';
|
||||
import { ExportMenu, useExportItems, useRunExport } from './ui/Exports';
|
||||
import { ErrorBoundary } from './ui/ErrorBoundary';
|
||||
import { Toasts } from './ui/Toasts';
|
||||
import { useContextMenu } from './ui/ContextMenu';
|
||||
import { appMenu } from './ui/app-menu';
|
||||
import { isEditable, isItem, type MenuInput } from './ui/menu-logic';
|
||||
import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model';
|
||||
|
||||
const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' };
|
||||
export const WS_KEY: Record<WorkspaceScreen, Key> = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' };
|
||||
|
||||
const SIDE_KEY = 'storebound.side.v1';
|
||||
const STUDIO_KEY = 'storebound.side.studio.v1';
|
||||
function loadCollapsed(): string[] {
|
||||
try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; }
|
||||
}
|
||||
function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } }
|
||||
const GROUP_KEY: Record<SideGroup | 'ignored', Key> = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' };
|
||||
|
||||
export function App({ onTheme }: { onTheme: () => void }) {
|
||||
const d = useData();
|
||||
|
|
@ -50,38 +40,11 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const [welcome, setWelcome] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState<string[]>(loadCollapsed);
|
||||
const [studioFilter, setStudioFilterState] = useState<string | null>(loadStudio);
|
||||
const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } };
|
||||
const exports = useExportItems();
|
||||
const runExport = useRunExport();
|
||||
|
||||
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
|
||||
const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
// Refresh all is busy while any read it covers is in flight (its own pass, a card's refresh, Check again, auto-read)
|
||||
const allReads = useReadState({ tags: ['all'] });
|
||||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]);
|
||||
const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]);
|
||||
// the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each
|
||||
const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null;
|
||||
const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]);
|
||||
const sideRows = useMemo(() => visible.filter(inStudio).map(a => {
|
||||
const e = d.statusFor(a), l = d.liveFor(a);
|
||||
return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) };
|
||||
}), [visible, d, inStudio]);
|
||||
const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [
|
||||
...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length),
|
||||
...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []),
|
||||
];
|
||||
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it)
|
||||
const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app)));
|
||||
const stepApp = useCallback((dir: 1 | -1) => {
|
||||
if (!sideOrder.length) return;
|
||||
const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1;
|
||||
const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!;
|
||||
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' });
|
||||
}, [sideOrder, route, go, tabsOf]);
|
||||
const toggleGroup = (id: string) => setCollapsed(c => {
|
||||
const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id];
|
||||
try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ }
|
||||
|
|
@ -104,44 +67,33 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); });
|
||||
// `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder
|
||||
const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); });
|
||||
// the tray menu (an app, Settings, Check for updates) and the first show after a notification
|
||||
const unTray = onTrayRoute(s => { const r = parseRouteJson(s); if (r) go(r); });
|
||||
return () => { un.then(f => f()); unFolder.then(f => f()); unTray.then(f => f()); };
|
||||
return () => { un.then(f => f()); unFolder.then(f => f()); };
|
||||
}, [go, d.reload]);
|
||||
|
||||
const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined;
|
||||
const game = app ? gameOf(app) : false;
|
||||
const tabs = app ? tabsOf(app) : [];
|
||||
const sections = app ? sectionsFor(!!app.project, game) : [];
|
||||
const tabs = app ? tabsFor(!!app.project) : [];
|
||||
|
||||
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections,
|
||||
// Cmd/Ctrl+Shift+[ ] the previous / next section
|
||||
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; }
|
||||
if (palette || welcome) return;
|
||||
if (e.altKey && !mod && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !isEditable(e.target as HTMLInputElement)) { e.preventDefault(); stepApp(e.key === 'ArrowDown' ? 1 : -1); return; }
|
||||
if (mod && !e.shiftKey && !e.altKey && /^[1-6]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
const n = Number(e.key);
|
||||
go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! });
|
||||
return;
|
||||
}
|
||||
if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) {
|
||||
const sec = sections[Number(e.key) - 1];
|
||||
if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); }
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) {
|
||||
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) {
|
||||
e.preventDefault();
|
||||
const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1);
|
||||
go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! });
|
||||
const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1);
|
||||
go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [palette, welcome, route, go, app, sections, game, stepApp]);
|
||||
}, [palette, welcome, route, go, tabs]);
|
||||
|
||||
const closeWelcome = useCallback(() => {
|
||||
setWelcome(false);
|
||||
|
|
@ -155,30 +107,11 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') };
|
||||
const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name;
|
||||
for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) },
|
||||
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) });
|
||||
c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) });
|
||||
c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) });
|
||||
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) });
|
||||
c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) });
|
||||
WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) }));
|
||||
for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) });
|
||||
// every section of the open app (1-5) and every sub view in it
|
||||
if (app) sections.forEach((sec, i) => {
|
||||
const subs = subsOf(sec, !!app.project, game);
|
||||
c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) });
|
||||
if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
|
||||
});
|
||||
// the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is
|
||||
// also one Ctrl+K away; opening a tab is already above
|
||||
if (app) {
|
||||
const skip = new Set([t('cm.open'), t('cm.openTab')]);
|
||||
const add = (items: MenuInput, prefix: string) => items.forEach((e, i) => {
|
||||
if (!e || !isItem(e) || skip.has(e.label) || e.disabled) return;
|
||||
const title = prefix ? `${prefix} · ${e.label}` : e.label;
|
||||
if (e.submenu) { add(e.submenu, title); return; }
|
||||
if (e.onSelect) c.push({ id: `appcmd-${prefix}-${i}-${e.label}`, group: t('palette.gApp', { name: app.name }), title, icon: e.icon, hint: e.shortcut, keywords: app.name, run: e.onSelect });
|
||||
});
|
||||
add(appMenu(app, d, { route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }, { showIgnored }), '');
|
||||
}
|
||||
if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
|
||||
for (const x of exports) c.push({ id: `export-${x.id}`, group: G.exp, title: t('palette.export', { what: x.label }), icon: x.icon ?? 'download', keywords: `export save download ${x.kind}`, hint: x.disabled || `.${x.kind}`, run: () => { runExport(x); } });
|
||||
if (app?.project) {
|
||||
const p = app.project.id;
|
||||
|
|
@ -191,14 +124,13 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
c.push({ id: 'act-check', group: G.act, title: t('action.checkConfigs'), icon: 'shield', run: () => { d.refreshPortfolio(false); } });
|
||||
c.push({ id: 'act-add', group: G.act, title: t('action.addFolder'), icon: 'folderPlus', run: () => go({ screen: 'apps' }) });
|
||||
c.push({ id: 'act-account', group: G.act, title: t('action.addAccount'), icon: 'key', run: () => go({ screen: 'accounts' }) });
|
||||
c.push({ id: 'act-density', group: G.act, title: t('action.toggleDensity'), icon: 'grid', keywords: 'dense compact table density rows', run: () => update({ ...prefs, density: prefs.density === 'compact' ? 'comfortable' : 'compact' }) });
|
||||
c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme });
|
||||
c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) });
|
||||
return c;
|
||||
}, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]);
|
||||
}, [d, visible, app, tabs, route, go, onTheme, exports, runExport]);
|
||||
|
||||
const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }]
|
||||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])]
|
||||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
|
||||
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
|
||||
const lastRead = d.live?.at ?? null;
|
||||
// the app on screen is read first by the background reader
|
||||
|
|
@ -206,22 +138,26 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
|
||||
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
||||
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
|
||||
const groups = d.studios.length ? groupByStudio(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
|
||||
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
|
||||
|
||||
const menu = useContextMenu();
|
||||
const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
|
||||
<div role="listitem" key={a.key}>
|
||||
<button {...menu(() => appMenu(a, d, nav, { showIgnored }), `app:${a.key}`)} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' })}
|
||||
title={`${a.name} · ${st.text}`}>
|
||||
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{(st.tone === 'bad' || st.tone === 'warn') && <span className={`ds-sdot ${st.tone}`} role="img" aria-label={st.tone === 'bad' ? t('pf.blocker') : t('pf.warning')} />}</span>
|
||||
<span className="grow side-t"><span className="side-n">{a.name}</span><span className={`side-s ${st.tone}`} data-testid="side-status">{st.text}</span></span>
|
||||
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
|
||||
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE];
|
||||
const appItem = (a: UnifiedApp) => {
|
||||
const e = d.statusFor(a);
|
||||
const missing = !!a.project?.missing;
|
||||
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
|
||||
return (
|
||||
<div role="listitem" key={a.key}>
|
||||
<button className="nav nav-app" aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' })}>
|
||||
<AppIcon name={a.name} src={d.iconFor(a)} />
|
||||
<span className="grow">{a.name}</span>
|
||||
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
|
||||
{missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span>
|
||||
: bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<NavCtx.Provider value={nav}>
|
||||
|
|
@ -235,41 +171,35 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
</button>
|
||||
</div>
|
||||
<div className="side-scroll">
|
||||
<div className="side-label" id="apps-label"><span>{t('nav.appsLabel')} <span className="count">{visible.length}</span></span>
|
||||
<button className="nav" aria-current={current({ screen: 'portfolio' })} onClick={() => go({ screen: 'portfolio' })}>
|
||||
<Icon name="portfolio" weight={current({ screen: 'portfolio' }) ? 'fill' : 'regular'} /><span className="grow">{t('nav.portfolio')}</span><span className="count">{visible.length}</span>
|
||||
</button>
|
||||
<div className="side-label" id="apps-label"><span>{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')}</span>
|
||||
<button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button>
|
||||
</div>
|
||||
{d.studios.length > 1 && (
|
||||
<div className="side-studios" role="group" aria-label={t('side.studioFilter')}>
|
||||
<button aria-pressed={!studioOn} onClick={() => setStudioFilter(null)}>{t('side.allStudios')}</button>
|
||||
{d.studios.map(s => <button key={s.id} aria-pressed={studioOn === s.id} onClick={() => setStudioFilter(studioOn === s.id ? null : s.id)}><StudioMark studio={s} size="sm" />{s.name}</button>)}
|
||||
</div>
|
||||
)}
|
||||
{groups.map(g => {
|
||||
const gid = `g:${g.id}`;
|
||||
const s = d.studios.find(x => x.id === g.studio) ?? null;
|
||||
const gid = g.studio ?? 'none';
|
||||
const open = !collapsed.includes(gid);
|
||||
if (!d.studios.length) return <div role="list" aria-labelledby="apps-label" key={gid}>{g.rows.map(r => appItem(r.app))}</div>;
|
||||
return (
|
||||
<div className="side-group" key={gid} data-group={g.id}>
|
||||
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${g.id}`} onClick={() => toggleGroup(gid)}>
|
||||
<Icon name="chevronRight" className="chev" /><span className="grow">{t(GROUP_KEY[g.id])}</span><span className="count">{g.rows.length}</span>
|
||||
<div className="side-group" key={gid}>
|
||||
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${gid}`} onClick={() => toggleGroup(gid)}>
|
||||
<Icon name="chevronRight" className="chev" /><StudioMark studio={s} size="sm" /><span className="grow">{s?.name ?? t('studio.none')}</span><span className="count">{g.rows.length}</span>
|
||||
</button>
|
||||
{open && <div role="list" id={`sg-${g.id}`} aria-label={t(GROUP_KEY[g.id])}>{g.rows.map(r => appItem(r.app, r.st))}</div>}
|
||||
{open && <div role="list" id={`sg-${gid}`} aria-label={s?.name ?? t('studio.none')}>{g.rows.map(r => appItem(r.app))}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!visible.length && !showIgnored && <p className="side-empty">{t('nav.noApps')}</p>}
|
||||
{ignoredApps.length > 0 && <button className="link side-ignored-toggle" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)} data-testid="toggle-ignored">{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>}
|
||||
</div>
|
||||
<div className="side-ws" role="group" aria-label={t('nav.workspace')}>
|
||||
{WS_ROW.map((s, i) => {
|
||||
const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]);
|
||||
const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0;
|
||||
return (
|
||||
<button key={s} className="nav-ws" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${label} (${MOD} ${i + 1})`} aria-label={label} data-screen={s}>
|
||||
<Icon name={s === 'portfolio' ? 'portfolio' : WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} />
|
||||
{n > 0 && <span className="ws-n">{n}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!visible.length && <p className="side-empty">{t('nav.noApps')}</p>}
|
||||
<div className="side-label"><span>{t('nav.workspace')}</span></div>
|
||||
{WORKSPACE.map((s, i) => (
|
||||
<button key={s} className="nav" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${t(WS_KEY[s])} (${MOD} ${i + 2})`}>
|
||||
<Icon name={WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} /><span className="grow">{t(WS_KEY[s])}</span>
|
||||
{s === 'accounts' && <span className="count">{d.accounts.length}</span>}
|
||||
{s === 'ignored' && ignoredCount > 0 && <span className="count">{ignoredCount}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="side-foot" aria-live="polite">
|
||||
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
|
||||
|
|
@ -291,8 +221,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
<span className="spacer" />
|
||||
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
|
||||
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length || allReads.busy} aria-busy={allReads.busy} title={allReads.busy ? t('read.busyShared') : t('action.refreshAllHint')} data-testid="refresh-all">
|
||||
<Icon name="refresh" className={allReads.busy ? 'spin' : undefined} />{t('action.refreshAll')}
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all">
|
||||
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')}
|
||||
</button>
|
||||
<ExportMenu />
|
||||
</header>
|
||||
|
|
@ -313,27 +243,21 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
{palette && <ErrorBoundary where="palette"><Palette commands={commands} onClose={() => setPalette(false)} /></ErrorBoundary>}
|
||||
{welcome && <ErrorBoundary where="welcome"><Onboarding onClose={closeWelcome} /></ErrorBoundary>}
|
||||
<ErrorBoundary where="toasts"><Toasts /></ErrorBoundary>
|
||||
<ErrorBoundary where="preview"><DryRunPreview /></ErrorBoundary>
|
||||
</NavCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The background reader in the sidebar foot: progress (count + thin bar) and the App Store hourly limit on their own
|
||||
* short rows, so nothing is cut off; the long wording stays in the tooltip. */
|
||||
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */
|
||||
function ReaderLine() {
|
||||
const d = useData();
|
||||
const r = d.reader;
|
||||
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
|
||||
const busy = r.total > 0 && (r.running || r.queued);
|
||||
const head = busy ? t('reader.progress', { done: r.done, total: r.total }) : !d.reader.autoRead ? t('reader.off') : d.ready && d.apps.length ? t('reader.upToDate') : '';
|
||||
const paused = Object.keys(r.paused).length > 0;
|
||||
if (!head && !q && !paused) return null;
|
||||
return (
|
||||
<div className="reader-line" data-testid="reader-line" aria-live="polite">
|
||||
{head && <div className="row" title={head}><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span>{head}</span></div>}
|
||||
{busy && <div className="reader-bar" role="progressbar" aria-valuemin={0} aria-valuemax={r.total} aria-valuenow={r.done} aria-label={head}><i style={{ width: `${Math.round((r.done / Math.max(1, r.total)) * 100)}%` }} /></div>}
|
||||
{paused && <div className="row" title={t('reader.backoff')}><Icon name="pause" className="sr-icon" /><span>{t('reader.backoffShort')}</span></div>}
|
||||
{q && <div className="row" title={t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) })}><Icon name="gauge" className="sr-icon" /><span>{t('reader.appleQuotaShort', { left: num(q.remaining), limit: num(q.limit) })}</span></div>}
|
||||
</div>
|
||||
);
|
||||
const parts: string[] = [];
|
||||
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total }));
|
||||
else if (!d.reader.autoRead) parts.push(t('reader.off'));
|
||||
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate'));
|
||||
if (Object.keys(r.paused).length) parts.push(t('reader.backoff'));
|
||||
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) }));
|
||||
if (!parts.length) return null;
|
||||
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
|
|||
import type { AccountCheck, AppsListResult, DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol';
|
||||
import type { DeclarationsResult, ListingText, RendersResult } from '../../../cli/lib/desktop-protocol';
|
||||
import type { Overrides } from './lib/identity';
|
||||
import type { TraySummary } from './lib/tray';
|
||||
|
||||
export type {
|
||||
AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult,
|
||||
|
|
@ -25,7 +24,7 @@ export interface SidecarStatus { running: boolean; version: string; protocol: st
|
|||
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
|
||||
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null }
|
||||
export type AccountKind = 'play' | 'apple';
|
||||
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null; /** Play: the developer account id (PLAY_DEVELOPER_ID in the profile) for Play Console deep links. */ developerId?: string | null }
|
||||
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null }
|
||||
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
|
||||
export type StudioColor = (typeof STUDIO_COLORS)[number];
|
||||
/** A user-named group of store accounts (and so of apps). */
|
||||
|
|
@ -34,12 +33,10 @@ export interface Settings {
|
|||
/** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */
|
||||
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 };
|
||||
cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
|
||||
/** reviewState: other state changes (submitted, in review); rejected / approved / live: own toggles; crash also covers a halted rollout. quietHours: no notification between from and to (HH:MM, may cross midnight). */
|
||||
notify: { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: { enabled: boolean; from: string; to: string } };
|
||||
/** Update channel: stable (npm releases) or dev (every push to main); beta is kept for saved states. */
|
||||
channel: 'stable' | 'dev' | 'beta';
|
||||
notify: { reviewState: boolean; lowReviews: boolean; crash: boolean };
|
||||
channel: 'stable' | 'beta';
|
||||
}
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, rejected: true, approved: true, live: true, lowReviews: true, crash: true, quietHours: { enabled: false, from: '23:00', to: '08:00' } }, channel: 'stable' };
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
|
||||
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
|
||||
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
|
||||
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }
|
||||
|
|
@ -48,7 +45,7 @@ export interface CacheEntry<T = unknown> { value: T; at: string }
|
|||
export interface DownloadInfo { tool: 'ffmpeg' | 'chrome'; available: boolean; version: string | null; size: number | null; license: string; licenseUrl: string; source: string; installed: boolean; folder: string }
|
||||
export interface DownloadEvent { job: string; tool: string; stage: 'resolve' | 'download' | 'verify' | 'unpack' | 'install' | 'done' | 'error' | 'cancelled'; received: number; total: number | null; message?: string }
|
||||
export type { IconResult as Icon };
|
||||
export interface UpdateInfo { current: string; channel: 'stable' | 'dev' | 'beta'; available: string | null; notes: string | null; date: string | null }
|
||||
export interface UpdateInfo { current: string; channel: 'stable' | 'beta'; available: string | null; notes: string | null; date: string | null }
|
||||
|
||||
/** What a failed command rejects with (sidecar.rs `Fail`). */
|
||||
export interface Fail { code: number; message: string; data?: unknown }
|
||||
|
|
@ -72,13 +69,6 @@ async function listen<T>(event: string, cb: (payload: T) => void): Promise<Unlis
|
|||
return tauriListen<T>(event, e => cb(e.payload));
|
||||
}
|
||||
|
||||
/** The finding fields the host builds the Fix with AI prompt from. */
|
||||
export interface AiFinding { rule: string; guideline: string; store: 'apple' | 'play'; title: string; evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string }
|
||||
export interface AiChange { path: string; status: string }
|
||||
export interface AiFixInfo { claude: boolean; prompt: string; git: boolean; gitError: string | null; dirty: AiChange[] }
|
||||
export type AiFixRun = { refused: 'dirty'; dirty: AiChange[] }
|
||||
| { refused?: undefined; runId: string; outcome: { code: number | null; timed_out: boolean; cancelled: boolean; summary: string }; changes: AiChange[]; diff: string };
|
||||
|
||||
let jobSeq = 0;
|
||||
/** A fresh job id for one background run (progress events and cancel refer to it). */
|
||||
export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`;
|
||||
|
|
@ -94,10 +84,6 @@ export const api = {
|
|||
invoke<CacheEntry<T> | null>('cache_get', { method, projectId, args }),
|
||||
clearCache: () => invoke<void>('cache_clear'),
|
||||
cancel: (job: string) => invoke<boolean>('job_cancel', { job }),
|
||||
/** Fix with AI (src-tauri/src/ai_fix.rs): claude installed?, the prompt (also for Copy for AI), the dirty files. */
|
||||
aiFixInfo: (projectId: string, finding: AiFinding) => invoke<AiFixInfo>('ai_fix_info', { projectId, finding }),
|
||||
aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke<AiFixRun>('ai_fix_run', { projectId, finding, job }),
|
||||
aiFixUndo: (runId: string) => invoke<string[]>('ai_fix_undo', { runId }),
|
||||
/** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */
|
||||
writeWithConfirm: (method: string, projectId: string, args: Record<string, unknown> = {}, job?: string) =>
|
||||
invoke<WriteOutcome>('write_with_confirm', { method, projectId, args, job: job ?? null }),
|
||||
|
|
@ -111,7 +97,6 @@ export const api = {
|
|||
addAppleAccount: (label: string, keyId: string, issuerId: string, studio: string | null = null) => invoke<Account | null>('account_add_apple', { label, keyId, issuerId, studio }),
|
||||
removeAccount: (id: string) => invoke<void>('account_remove', { id }),
|
||||
updateAccount: (id: string, label: string, studio: string | null) => invoke<Account>('account_update', { id, label, studio }),
|
||||
setPlayDeveloper: (id: string, developerId: string | null) => invoke<Account>('account_set_play_developer', { id, developerId }),
|
||||
/** Does the key work, can it see apps (live, not cached). */
|
||||
checkAccount: (id: string, job?: string) => invoke<AccountCheck>('account_check', { id, job: job ?? null }),
|
||||
/** Reads the apps an account can see from its store and keeps the list. */
|
||||
|
|
@ -125,10 +110,7 @@ export const api = {
|
|||
setSettings: (settings: Settings) => invoke<Settings>('settings_set', { settings }),
|
||||
cacheInfo: () => invoke<CacheInfo>('cache_info'),
|
||||
revealData: () => invoke<void>('data_reveal'),
|
||||
/** target: a route (JSON) the window opens on when it is next shown from the tray (the notification plugin has no click callback on desktop). */
|
||||
notify: (title: string, body: string, target?: string) => invoke<void>('notify', { title, body, target: target ?? null }),
|
||||
/** Pushes the tray's tooltip, attention flag and app list after a read (lib/tray.ts). */
|
||||
trayUpdate: (summary: TraySummary) => invoke<void>('tray_update', { summary }),
|
||||
notify: (title: string, body: string) => invoke<void>('notify', { title, body }),
|
||||
/** Native confirmation first; true when everything was forgotten. */
|
||||
reset: () => invoke<boolean>('app_reset'),
|
||||
/** Offline (folders) or live; live also reads the given store-only apps (store targets). */
|
||||
|
|
@ -173,14 +155,8 @@ export const reads = {
|
|||
export interface ProgressEvent extends ProgressParams { job?: string }
|
||||
export const onProgress = (cb: (p: ProgressEvent) => void): Promise<UnlistenFn> =>
|
||||
listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job }));
|
||||
/** One progress line of a Fix with AI run. */
|
||||
export const onAiFix = (cb: (e: { job: string; line: string }) => void): Promise<UnlistenFn> => listen<{ job: string; line: string }>('ai-fix', cb);
|
||||
export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb);
|
||||
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
|
||||
/** storebound://open?path=... selected or added an app folder (its project id). */
|
||||
export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise<UnlistenFn> => listen<string>('deep-link-folder', cb);
|
||||
/** A tray menu click (or the first window show after a notification) that opens a route (JSON, see parseRouteJson). */
|
||||
export const onTrayRoute = (cb: (route: string) => void): Promise<UnlistenFn> => listen<string>('tray-route', cb);
|
||||
/** A tray menu click that is not navigation: read the stores now, toggle mute. */
|
||||
export const onTrayAction = (cb: (action: 'read' | 'mute-toggle') => void): Promise<UnlistenFn> => listen<'read' | 'mute-toggle'>('tray-action', cb);
|
||||
export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise<UnlistenFn> => listen('update-progress', cb);
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
// The tray card: store status at a glance next to the tray icon. It only draws the main window's snapshot (card_get /
|
||||
// `card-snapshot`) and sends its buttons back (card_action); Refresh is the main window's own Refresh all.
|
||||
import { ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowsClockwise';
|
||||
import { ArrowSquareOutIcon } from '@phosphor-icons/react/dist/csr/ArrowSquareOut';
|
||||
import { BellSlashIcon } from '@phosphor-icons/react/dist/csr/BellSlash';
|
||||
import { BellIcon } from '@phosphor-icons/react/dist/csr/Bell';
|
||||
import { DotsThreeIcon } from '@phosphor-icons/react/dist/csr/DotsThree';
|
||||
import { PowerIcon } from '@phosphor-icons/react/dist/csr/Power';
|
||||
import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
|
||||
import { WarningOctagonIcon } from '@phosphor-icons/react/dist/csr/WarningOctagon';
|
||||
import { PulseIcon } from '@phosphor-icons/react/dist/csr/Pulse';
|
||||
import { AppWindowIcon } from '@phosphor-icons/react/dist/csr/AppWindow';
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import type { CardApp, CardRelease, CardSnapshot, CardStore } from './model';
|
||||
import { ago, cardT, type CardKey, type CardLang } from './text';
|
||||
|
||||
export interface CardHost {
|
||||
get: () => Promise<CardSnapshot | null>;
|
||||
onSnapshot: (fn: (s: CardSnapshot) => void) => () => void;
|
||||
onShown: (fn: () => void) => () => void;
|
||||
action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size', arg?: string) => void;
|
||||
}
|
||||
|
||||
const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab });
|
||||
|
||||
export function Card({ host, lang }: { host: CardHost; lang: CardLang }) {
|
||||
const T = useCallback((k: CardKey, v?: Record<string, string | number>) => cardT(lang, k, v), [lang]);
|
||||
const [s, setS] = useState<CardSnapshot | null>(null);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [shown, setShown] = useState(0);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
host.get().then(x => { if (x) setS(x); }).catch(() => {});
|
||||
const a = host.onSnapshot(setS);
|
||||
const b = host.onShown(() => { setNow(Date.now()); setMenu(false); setShown(n => n + 1); });
|
||||
host.action('ready');
|
||||
const tm = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => { a(); b(); clearInterval(tm); };
|
||||
}, [host]);
|
||||
// the window is as tall as the card: report the height whenever it changes (the host re-anchors it above the icon)
|
||||
useEffect(() => {
|
||||
const el = root.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
let last = 0;
|
||||
const ro = new ResizeObserver(() => {
|
||||
const h = Math.ceil(el.getBoundingClientRect().height);
|
||||
if (h > 0 && h !== last) { last = h; host.action('size', String(h)); }
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [host]);
|
||||
// every show: fade in, focus the first control (keyboard users start at Refresh)
|
||||
useEffect(() => { (root.current?.querySelector<HTMLElement>('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]);
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); if (menu) setMenu(false); else host.action('hide'); return; }
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const all = [...(root.current?.querySelectorAll<HTMLElement>('button:not([disabled])') ?? [])];
|
||||
const i = all.indexOf(document.activeElement as HTMLElement);
|
||||
const n = all[(i + (e.key === 'ArrowDown' ? 1 : -1) + all.length) % all.length];
|
||||
if (n) { e.preventDefault(); n.focus(); }
|
||||
}
|
||||
};
|
||||
|
||||
const store = (st: CardStore) => T(st === 'ios' ? 'appStore' : 'playStore');
|
||||
const phase = (r: CardRelease) => (r.phase === 'rollout' ? (r.fraction != null && r.fraction < 1 ? T('rolloutPct', { pct: Math.round(r.fraction * 100) }) : T('rollout')) : T(r.phase));
|
||||
const link = (key: string): CardApp | undefined => s?.links.find(l => l.key === key);
|
||||
const busy = !!s?.busy;
|
||||
const calm = s && !s.rejections.length && !s.releases.length && !s.health.length && !s.reviews.low.length;
|
||||
|
||||
return (
|
||||
<div className="tc" ref={root} onKeyDown={onKey} data-shown={shown} tabIndex={-1} role="dialog" aria-label={T('title')}>
|
||||
<header className="tc-head">
|
||||
<div className="tc-title">
|
||||
<strong>{T('title')}</strong>
|
||||
<span className="tc-meta">{s?.muted && <span className="tc-muted"><BellSlashIcon size={12} weight="bold" />{T('muted')}</span>}{T('lastRead', { ago: ago(lang, s?.at ?? null, now) })}</span>
|
||||
</div>
|
||||
<button className="tc-btn tc-primary" data-first onClick={() => host.action('refresh')} disabled={busy} aria-busy={busy} data-testid="card-refresh">
|
||||
<ArrowsClockwiseIcon size={14} weight="bold" className={busy ? 'spin' : undefined} />{busy ? T('refreshing') : T('refresh')}
|
||||
</button>
|
||||
<button className="tc-icon" onClick={() => setMenu(m => !m)} aria-haspopup="menu" aria-expanded={menu} aria-label={T('more')} title={T('more')}><DotsThreeIcon size={18} weight="bold" /></button>
|
||||
{menu && (
|
||||
<div className="tc-menu" role="menu">
|
||||
<button role="menuitem" onClick={() => host.action('open')}><AppWindowIcon size={15} />{T('openMain')}</button>
|
||||
<button role="menuitem" onClick={() => { host.action('mute'); setMenu(false); }}>{s?.muted ? <BellIcon size={15} /> : <BellSlashIcon size={15} />}{s?.muted ? T('unmute') : T('mute')}</button>
|
||||
<hr />
|
||||
<button role="menuitem" onClick={() => host.action('quit')}><PowerIcon size={15} />{T('quit')}</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="tc-body">
|
||||
{!s && <p className="tc-empty">{T('noData')}</p>}
|
||||
{s && calm && <p className="tc-empty">{s.at ? T('calm') : T('noData')}</p>}
|
||||
|
||||
{!!s?.rejections.length && (
|
||||
<Section title={T('rejections')} tone="bad">
|
||||
{s.rejections.map(r => (
|
||||
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone="bad" onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
|
||||
line={<><WarningOctagonIcon size={13} weight="fill" />{T('rejected')} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>} sub={r.reason}
|
||||
consoles={consoles(link(r.app), r.store, host, T)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!!s?.releases.length && (
|
||||
<Section title={T('moving')}>
|
||||
{s.releases.map(r => (
|
||||
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone={r.phase === 'halted' ? 'bad' : r.phase === 'approved' ? 'ok' : r.phase === 'review' ? 'info' : 'accent'}
|
||||
onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
|
||||
line={<>{phase(r)} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>}
|
||||
bar={r.phase === 'rollout' && r.fraction != null && r.fraction < 1 ? r.fraction : null}
|
||||
consoles={consoles(link(r.app), r.store, host, T)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!!s?.health.length && (
|
||||
<Section title={T('health')} tone="warn">
|
||||
{s.health.map(h => (
|
||||
<Row key={h.app} hue={h.hue} name={h.name} tone="warn" onOpen={() => host.action('route', route(h.app, 'health'))} openLabel={T('openApp', { name: h.name })}
|
||||
line={<><PulseIcon size={13} weight="bold" />{h.kinds.length > 1 ? T('crashAnr') : T(h.kinds[0]!)}</>} consoles={consoles(link(h.app), 'android', host, T)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{s && s.at && (
|
||||
<Section title={T('reviews')} aside={s.reviews.recent ? T('recent', { n: s.reviews.recent }) : T('recentNone')}>
|
||||
{s.reviews.low.map(r => (
|
||||
<button key={`${r.app}:${r.date}:${r.text}`} className="tc-review" onClick={() => host.action('route', route(r.app, 'reviews'))}>
|
||||
<span className="tc-stars" aria-label={T('stars', { n: r.rating })}>{[1, 2, 3, 4, 5].map(i => <StarIcon key={i} size={11} weight={i <= r.rating ? 'fill' : 'regular'} />)}</span>
|
||||
<span className="tc-rname">{r.name}</span>
|
||||
<span className="tc-rtext">{r.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
{s?.build && <footer className="tc-foot" data-testid="card-build">{T('build', { version: s.build.version, channel: T(s.build.channel === 'dev' ? 'chDev' : s.build.channel === 'beta' ? 'chBeta' : 'chStable') })}</footer>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function consoles(l: CardApp | undefined, st: CardStore, host: CardHost, T: (k: CardKey) => string): ReactNode {
|
||||
const url = st === 'ios' ? l?.asc : l?.play;
|
||||
if (!url) return null;
|
||||
return <button className="tc-ext" onClick={e => { e.stopPropagation(); host.action('external', url); }} title={T(st === 'ios' ? 'asc' : 'play')} aria-label={T(st === 'ios' ? 'asc' : 'play')}><ArrowSquareOutIcon size={14} /></button>;
|
||||
}
|
||||
|
||||
function Section({ title, tone, aside, children }: { title: string; tone?: 'bad' | 'warn'; aside?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={`tc-sec${tone ? ` tc-${tone}` : ''}`}>
|
||||
<h2><span>{title}</span>{aside && <em>{aside}</em>}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ hue, name, line, sub, tone, bar, onOpen, openLabel, consoles }: { hue: number; name: string; line: ReactNode; sub?: string | null; tone: string; bar?: number | null; onOpen: () => void; openLabel: string; consoles: ReactNode }) {
|
||||
return (
|
||||
<div className={`tc-row tone-${tone}`}>
|
||||
<button className="tc-open" onClick={onOpen} aria-label={openLabel}>
|
||||
<span className="tc-ic" style={{ '--tint-h': hue } as React.CSSProperties} aria-hidden>{initials(name)}</span>
|
||||
<span className="tc-txt">
|
||||
<span className="tc-name">{name}</span>
|
||||
<span className="tc-line">{line}</span>
|
||||
{sub && <span className="tc-sub">{sub}</span>}
|
||||
{bar != null && <span className="tc-bar" aria-hidden><i style={{ width: `${Math.max(2, Math.round(bar * 100))}%` }} /></span>}
|
||||
</span>
|
||||
</button>
|
||||
{consoles}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initials = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]!.toUpperCase()).join('') || '?';
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
// Main window side of the tray card: turns what this window already knows (live releases, the last cached reviews and
|
||||
// health per app, the shared read state of Refresh all, mute) into a CardSnapshot and hands it to the host
|
||||
// (card_snapshot), which keeps it for the card. No reads of its own: reviews and health come from the cache that the
|
||||
// window's reads fill, and the card's Refresh arrives as the tray's `tray-action` read (store.tsx), i.e. Refresh all.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { HealthSnapshot, Review } from '../lib/model';
|
||||
import { hueOf } from '../lib/model';
|
||||
import { useData, useReadState } from '../store';
|
||||
import { playUrlFor } from '../ui/app-menu';
|
||||
import { cardApi, inTauri as hasHost } from './card-api';
|
||||
import { cardSnapshot, type CardInput } from './model';
|
||||
|
||||
const inTauri = hasHost();
|
||||
const ascUrl = (id: string | null | undefined) => (id ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(id)}/distribution` : 'https://appstoreconnect.apple.com/apps');
|
||||
|
||||
export function TrayCardBridge() {
|
||||
const d = useData();
|
||||
const all = useReadState({ tags: ['all'] });
|
||||
const [extra, setExtra] = useState<Record<string, { reviews: Review[] | null; health: HealthSnapshot | null }>>({});
|
||||
const [ask, setAsk] = useState(0);
|
||||
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
|
||||
|
||||
// the card opened (or a read pass ended): the last cached reviews and health of every app
|
||||
useEffect(() => {
|
||||
if (!inTauri) return;
|
||||
return cardApi.onRequest(() => setAsk(n => n + 1));
|
||||
}, []);
|
||||
const wasBusy = useRef(all.busy);
|
||||
useEffect(() => { if (wasBusy.current && !all.busy) setAsk(n => n + 1); wasBusy.current = all.busy; }, [all.busy]);
|
||||
useEffect(() => {
|
||||
if (!inTauri || !d.ready) return;
|
||||
let live = true;
|
||||
Promise.all(visible.map(async a => {
|
||||
const [r, h] = await Promise.all([
|
||||
api.cached<{ data?: Review[] }>('reviews.list', a.target, { days: 30 }).catch(() => null),
|
||||
api.cached<{ data?: HealthSnapshot }>('health', a.target, {}).catch(() => null),
|
||||
]);
|
||||
return [a.key, { reviews: Array.isArray(r?.value.data) ? r.value.data : null, health: (h?.value.data as HealthSnapshot | undefined) ?? null }] as const;
|
||||
})).then(rows => { if (live) setExtra(Object.fromEntries(rows)); }).catch(() => {});
|
||||
return () => { live = false; };
|
||||
}, [ask, d.ready, visible]);
|
||||
|
||||
const snap = useMemo(() => {
|
||||
const inputs: CardInput[] = visible.map(a => {
|
||||
const st = d.liveFor(a) ?? d.statusFor(a);
|
||||
const ios = st?.app?.platforms.ios ?? (a.ios ? { ascAppId: a.ios.app.id } : null);
|
||||
return {
|
||||
key: a.key, name: a.name, hue: hueOf(a.name), status: st,
|
||||
reviews: extra[a.key]?.reviews ?? null, health: extra[a.key]?.health ?? null,
|
||||
asc: ios ? ascUrl(ios.ascAppId) : null, play: st?.app?.platforms.android || a.android ? playUrlFor(d, a) : null,
|
||||
};
|
||||
});
|
||||
return cardSnapshot(inputs, { at: d.live?.value.at ?? d.portfolio?.at ?? null, busy: all.busy, muted: d.muteUntil > Date.now(), build: d.info ? { version: d.info.version, channel: d.settings.channel } : null });
|
||||
}, [visible, d, extra, all.busy]);
|
||||
|
||||
const last = useRef('');
|
||||
useEffect(() => {
|
||||
if (!inTauri || !d.ready) return;
|
||||
const json = JSON.stringify(snap);
|
||||
if (json === last.current) return;
|
||||
last.current = json;
|
||||
cardApi.push(snap).catch(() => {});
|
||||
}, [snap, d.ready]);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// The tray card's host calls (the card page's counterpart of src/api.ts, kept apart so the card bundle stays small).
|
||||
// Main window: card_snapshot + the `card-request` event. Card window: card_get, card_action and its two events.
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { CardSnapshot } from './model';
|
||||
|
||||
export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size';
|
||||
|
||||
export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
function sub<T>(ev: string, fn: (p: T) => void): () => void {
|
||||
const un = listen<T>(ev, e => fn(e.payload));
|
||||
return () => { un.then(f => f()).catch(() => {}); };
|
||||
}
|
||||
|
||||
export const cardApi = {
|
||||
/** Main window: hands the host the card's snapshot (the host forwards it as `card-snapshot`). */
|
||||
push: (snapshot: CardSnapshot) => invoke<void>('card_snapshot', { snapshot }),
|
||||
/** Main window: the card opened; refresh what is kept per app. */
|
||||
onRequest: (fn: () => void) => sub<null>('card-request', () => fn()),
|
||||
get: () => invoke<CardSnapshot | null>('card_get'),
|
||||
onSnapshot: (fn: (s: CardSnapshot) => void) => sub<CardSnapshot>('card-snapshot', fn),
|
||||
onShown: (fn: () => void) => sub<null>('card-shown', () => fn()),
|
||||
action: (action: CardAction, arg?: string) => { invoke('card_action', { action, arg: arg ?? null }).catch(() => {}); },
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
/* The tray card (card.html): v2 language, 360 wide and as tall as its content (at most 480: the body scrolls), rounded, its own surface; fades in over 120 ms on every show. */
|
||||
html:has(body.tc-page), body.tc-page { margin: 0; height: 100%; background: transparent; overflow: hidden; }
|
||||
body.tc-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
|
||||
body.tc-page #card { height: auto; }
|
||||
body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; }
|
||||
:root[data-motion="reduce"] body.tc-page.tc-in .tc { animation: none; }
|
||||
@keyframes tc-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.tc { box-sizing: border-box; height: auto; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg);
|
||||
border: 1px solid var(--ds-stroke); background: radial-gradient(120% 60% at 100% 0%, color-mix(in srgb, var(--accent-solid) 10%, transparent), transparent 60%), var(--ds-raised); overflow: hidden; outline: none; }
|
||||
.tc-head { position: relative; display: flex; align-items: center; gap: 8px; padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--ds-stroke); }
|
||||
.tc-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tc-title strong { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); letter-spacing: -.01em; }
|
||||
.tc-meta { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--ds-t-micro); }
|
||||
.tc-muted { display: inline-flex; align-items: center; gap: 3px; color: var(--warn-ink); background: var(--warn-bg); border-radius: var(--ds-r-pill); padding: 0 6px; }
|
||||
.tc button { font: inherit; color: inherit; cursor: pointer; }
|
||||
.tc button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.tc-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: var(--ds-r-pill); border: 0; font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); }
|
||||
.tc .tc-primary { background: var(--ds-grad); color: #fff; box-shadow: var(--ds-e1); }
|
||||
.tc-primary[disabled] { opacity: .7; cursor: default; }
|
||||
.tc-icon { display: grid; place-items: center; width: 30px; height: 30px; border-radius: var(--ds-r-pill); border: 1px solid var(--ds-stroke); background: var(--ds-surface); }
|
||||
.tc-icon:hover, .tc-icon[aria-expanded="true"] { background: var(--hover); }
|
||||
.tc-menu { position: absolute; z-index: 3; right: 12px; top: 50px; min-width: 230px; padding: 5px; border-radius: var(--ds-r); background: var(--ds-raised); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e3); }
|
||||
.tc-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; background: none; border-radius: var(--ds-r-xs); text-align: left; font-size: var(--ds-t-meta); }
|
||||
.tc-menu button:hover, .tc-menu button:focus-visible { background: var(--hover); outline: none; }
|
||||
.tc-menu hr { border: 0; border-top: 1px solid var(--ds-stroke); margin: 4px 2px; }
|
||||
|
||||
.tc-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; }
|
||||
.tc-empty { margin: 28px 16px; text-align: center; color: var(--muted); }
|
||||
.tc-sec { margin-top: 8px; }
|
||||
.tc-sec h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 6px 6px 4px; font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
|
||||
.tc-sec h2 em { font-style: normal; text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--faint); }
|
||||
.tc-sec.tc-bad { background: var(--bad-bg); border: 1px solid color-mix(in srgb, var(--bad-solid) 30%, transparent); border-radius: var(--ds-r); padding: 2px 4px 4px; }
|
||||
.tc-sec.tc-bad h2 { color: var(--bad-ink); }
|
||||
|
||||
.tc-row { display: flex; align-items: center; gap: 2px; border-radius: var(--ds-r-sm); }
|
||||
.tc-row:hover { background: color-mix(in srgb, var(--hover) 80%, transparent); }
|
||||
.tc-open { flex: 1; min-width: 0; display: flex; gap: 10px; align-items: flex-start; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); }
|
||||
.tc-ic { --tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l)); flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; font-size: 10px; font-weight: 700; color: #fff; background: linear-gradient(135deg, var(--tint), hsl(calc(var(--tint-h) + 30) var(--ds-tint-s) 45%)); }
|
||||
.tc-txt { min-width: 0; display: flex; flex-direction: column; gap: 1px; flex: 1; }
|
||||
.tc-name { font-weight: var(--ds-w-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tc-line { display: inline-flex; align-items: center; gap: 4px; font-size: var(--ds-t-meta); color: var(--ink-2); }
|
||||
.tone-bad .tc-line { color: var(--bad-ink); font-weight: var(--ds-w-strong); }
|
||||
.tone-ok .tc-line { color: var(--ok-ink); }
|
||||
.tone-info .tc-line { color: var(--info-ink); }
|
||||
.tone-warn .tc-line { color: var(--warn-ink); }
|
||||
.tone-accent .tc-line { color: var(--accent); }
|
||||
.tc-sub { font-size: var(--ds-t-micro); color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.tc-bar { margin-top: 4px; height: 4px; border-radius: 4px; background: var(--ds-inset); overflow: hidden; }
|
||||
.tc-bar i { display: block; height: 100%; background: var(--ds-grad); border-radius: 4px; }
|
||||
.tc-ext { flex: none; display: grid; place-items: center; width: 28px; height: 28px; margin-right: 4px; border: 0; background: none; color: var(--muted); border-radius: var(--ds-r-xs); }
|
||||
.tc-ext:hover { background: var(--hover); color: var(--ink); }
|
||||
|
||||
.tc-review { display: grid; grid-template-columns: auto 1fr; gap: 1px 8px; width: 100%; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); }
|
||||
.tc-review:hover { background: var(--hover); }
|
||||
.tc-stars { display: inline-flex; gap: 1px; align-items: center; color: var(--warn-solid); }
|
||||
.tc-rname { font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tc-rtext { grid-column: 1 / -1; font-size: var(--ds-t-meta); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tc-foot { padding: 6px 12px 8px; color: var(--muted); font-size: var(--ds-t-micro); border-top: 1px solid var(--ds-stroke); font-variant-numeric: tabular-nums; }
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Dev only: a card snapshot for the plain browser (no Tauri host), so the card can be looked at in `vite` alone.
|
||||
import type { CardSnapshot } from './model';
|
||||
|
||||
const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
|
||||
|
||||
export const CARD_FIXTURE: CardSnapshot = {
|
||||
at: ago(4), busy: false, muted: false, apps: 6,
|
||||
rejections: [{ app: 'a1', name: 'Prism Puddle', hue: 280, store: 'ios', version: '2.4.0', reason: 'Guideline 2.1 - Performance - App Completeness: the app crashed on launch on iPad Air (5th generation).' }],
|
||||
releases: [
|
||||
{ app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'ios', version: '1.8.2', phase: 'review', fraction: null },
|
||||
{ app: 'a3', name: 'Moss Runner', hue: 30, store: 'ios', version: '3.0.0', phase: 'approved', fraction: null },
|
||||
{ app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'android', version: '1.8.1', phase: 'rollout', fraction: 0.2 },
|
||||
],
|
||||
reviews: { recent: 14, low: [
|
||||
{ app: 'a2', name: 'Tidy Tiles', store: 'play', rating: 1, text: 'Lost my progress · After the update every level is locked again.', date: ago(90) },
|
||||
{ app: 'a3', name: 'Moss Runner', store: 'apple', rating: 2, text: 'Too many ads between runs', date: ago(600) },
|
||||
] },
|
||||
health: [{ app: 'a4', name: 'Sample Blocks', hue: 210, kinds: ['crash'] }],
|
||||
links: [
|
||||
{ key: 'a1', name: 'Prism Puddle', hue: 280, asc: 'https://appstoreconnect.apple.com/apps', play: null },
|
||||
{ key: 'a2', name: 'Tidy Tiles', hue: 160, asc: 'https://appstoreconnect.apple.com/apps', play: 'https://play.google.com/console' },
|
||||
{ key: 'a3', name: 'Moss Runner', hue: 30, asc: 'https://appstoreconnect.apple.com/apps', play: null },
|
||||
{ key: 'a4', name: 'Sample Blocks', hue: 210, asc: null, play: 'https://play.google.com/console' },
|
||||
],
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// The tray card window's entry (card.html): theme and language from the same prefs as the main window, the host
|
||||
// through Tauri (or a fixture in a plain dev browser).
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { applyPrefs, loadPrefs, resolveLang } from '../prefs';
|
||||
import { Card, type CardHost } from './Card';
|
||||
import { cardApi, inTauri } from './card-api';
|
||||
import type { CardSnapshot } from './model';
|
||||
import '../styles.css';
|
||||
import '../design.css';
|
||||
import './card.css';
|
||||
|
||||
const sys = () => (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
|
||||
function prefs() {
|
||||
const p = loadPrefs();
|
||||
if (import.meta.env.DEV) {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const th = q.get('theme'), lg = q.get('lang');
|
||||
if (th === 'light' || th === 'dark') p.theme = th;
|
||||
if (lg === 'en' || lg === 'tr') p.lang = lg;
|
||||
}
|
||||
applyPrefs(p, document.documentElement, sys());
|
||||
return resolveLang(p.lang, sys());
|
||||
}
|
||||
|
||||
async function devHost(): Promise<CardHost> {
|
||||
const { CARD_FIXTURE } = await import('./fixture');
|
||||
const q = new URLSearchParams(location.search);
|
||||
const snap: CardSnapshot = q.get('empty')
|
||||
? { ...CARD_FIXTURE, rejections: [], releases: [], health: [], reviews: { recent: 0, low: [] } }
|
||||
: { ...CARD_FIXTURE, busy: !!q.get('busy') };
|
||||
return { get: async () => snap, onSnapshot: () => () => {}, onShown: () => () => {}, action: (a, arg) => console.info('card action', a, arg ?? '') };
|
||||
}
|
||||
|
||||
const tauriHost: CardHost = cardApi;
|
||||
|
||||
void (async () => {
|
||||
let lang = prefs();
|
||||
const host = inTauri() ? tauriHost : await devHost();
|
||||
const root = createRoot(document.getElementById('card')!);
|
||||
const render = () => root.render(<Card key={lang} host={host} lang={lang} />);
|
||||
render();
|
||||
const relang = () => { const l = prefs(); if (l !== lang) { lang = l; render(); } };
|
||||
// theme or language changed in the main window (same origin: shared localStorage)
|
||||
window.addEventListener('storage', relang);
|
||||
host.onShown(() => {
|
||||
relang();
|
||||
document.body.classList.remove('tc-in');
|
||||
void document.body.offsetWidth;
|
||||
document.body.classList.add('tc-in');
|
||||
});
|
||||
document.body.classList.add('tc-in');
|
||||
})();
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
// The tray card's data: what the main window already knows, boiled down to the few rows the card shows. Pure (the
|
||||
// main window's bridge calls it, tests/card-model.test.ts pins it); the card itself only draws a CardSnapshot.
|
||||
import type { AppStatus } from '../api';
|
||||
import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from '../lib/model';
|
||||
|
||||
export type CardStore = 'ios' | 'android';
|
||||
/** A release that moves or waits: in review, rolling out (with its share), approved and waiting for release, halted. */
|
||||
export interface CardRelease { app: string; name: string; hue: number; store: CardStore; version: string | null; phase: 'review' | 'rollout' | 'approved' | 'halted'; fraction: number | null }
|
||||
export interface CardRejection { app: string; name: string; hue: number; store: CardStore; version: string | null; reason: string | null }
|
||||
export interface CardLowReview { app: string; name: string; store: 'apple' | 'play'; rating: number; text: string; date: string }
|
||||
export interface CardHealth { app: string; name: string; hue: number; kinds: ('crash' | 'anr')[] }
|
||||
export interface CardApp { key: string; name: string; hue: number; asc: string | null; play: string | null }
|
||||
export interface CardSnapshot {
|
||||
/** Last store read of the main window (ISO), null before any. */
|
||||
at: string | null;
|
||||
/** The main window's Refresh all is running (its shared read state). */
|
||||
busy: boolean;
|
||||
muted: boolean;
|
||||
apps: number;
|
||||
rejections: CardRejection[];
|
||||
releases: CardRelease[];
|
||||
reviews: { recent: number; low: CardLowReview[] };
|
||||
health: CardHealth[];
|
||||
/** Apps with something on the card, for the quick actions (console links). */
|
||||
links: CardApp[];
|
||||
/** This app's version and update channel (Settings > Updates), shown at the card's foot. */
|
||||
build?: { version: string; channel: string } | null;
|
||||
}
|
||||
|
||||
export interface CardInput {
|
||||
key: string; name: string; hue: number; status: AppStatus | undefined;
|
||||
reviews?: Review[] | null; health?: HealthSnapshot | null;
|
||||
asc?: string | null; play?: string | null;
|
||||
}
|
||||
|
||||
export const RECENT_DAYS = 7;
|
||||
export const LOW_DAYS = 14;
|
||||
export const MAX_ROWS = 6;
|
||||
export const MAX_LOW = 3;
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const one = (s: string, max: number): string => { const x = s.replace(/\s+/g, ' ').trim(); return x.length > max ? `${x.slice(0, max - 1)}…` : x; };
|
||||
|
||||
/** Crash / ANR rates over the Play bad behavior threshold right now. */
|
||||
export function healthAlerts(h: HealthSnapshot | null | undefined): ('crash' | 'anr')[] {
|
||||
if (!h?.play || isError(h.play)) return [];
|
||||
const out: ('crash' | 'anr')[] = [];
|
||||
if (healthSeries(h.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash).tone === 'bad') out.push('crash');
|
||||
if (healthSeries(h.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr).tone === 'bad') out.push('anr');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function cardSnapshot(apps: CardInput[], o: { at: string | null; busy: boolean; muted: boolean; now?: number; build?: { version: string; channel: string } | null }): CardSnapshot {
|
||||
const now = o.now ?? Date.now();
|
||||
const rejections: CardRejection[] = [], releases: CardRelease[] = [], low: CardLowReview[] = [], health: CardHealth[] = [];
|
||||
const touched = new Set<string>();
|
||||
let recent = 0;
|
||||
for (const a of apps) {
|
||||
for (const r of a.status?.release.all ?? []) {
|
||||
if (r.phase === 'rejected') { rejections.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, reason: r.reason ? one(r.reason, 140) : null }); touched.add(a.key); }
|
||||
else if (r.phase === 'review' || r.phase === 'sent' || r.phase === 'rollout' || r.phase === 'approved' || r.phase === 'halted') {
|
||||
releases.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, phase: r.phase === 'sent' ? 'review' : r.phase, fraction: r.phase === 'rollout' ? r.fraction : null });
|
||||
touched.add(a.key);
|
||||
}
|
||||
}
|
||||
for (const rv of a.reviews ?? []) {
|
||||
const age = now - Date.parse(rv.date);
|
||||
if (!(age > -DAY)) continue; // unreadable date, or far in the future
|
||||
if (age <= RECENT_DAYS * DAY) recent++;
|
||||
if (rv.rating <= 2 && age <= LOW_DAYS * DAY) {
|
||||
low.push({ app: a.key, name: a.name, store: rv.store, rating: rv.rating, text: one([rv.title, rv.body].filter(Boolean).join(' · '), 110), date: rv.date });
|
||||
touched.add(a.key);
|
||||
}
|
||||
}
|
||||
const kinds = healthAlerts(a.health);
|
||||
if (kinds.length) { health.push({ app: a.key, name: a.name, hue: a.hue, kinds }); touched.add(a.key); }
|
||||
}
|
||||
// halted first (it needs the owner), then review, waiting for release, rolling out
|
||||
const order = { halted: 0, review: 1, approved: 2, rollout: 3 } as const;
|
||||
releases.sort((x, y) => order[x.phase] - order[y.phase] || x.name.localeCompare(y.name));
|
||||
low.sort((x, y) => y.date.localeCompare(x.date));
|
||||
return {
|
||||
at: o.at, busy: o.busy, muted: o.muted, apps: apps.length,
|
||||
rejections: rejections.slice(0, MAX_ROWS), releases: releases.slice(0, MAX_ROWS),
|
||||
reviews: { recent, low: low.slice(0, MAX_LOW) }, health: health.slice(0, MAX_ROWS),
|
||||
links: apps.filter(a => touched.has(a.key)).map(a => ({ key: a.key, name: a.name, hue: a.hue, asc: a.asc ?? null, play: a.play ?? null })),
|
||||
build: o.build ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
// The tray card's words in every language the app has (en, tr; same keys, tr typed against en). Kept apart from the
|
||||
// main catalogs so the card bundle stays small.
|
||||
export const en = {
|
||||
title: 'Store status',
|
||||
rejected: 'Rejected',
|
||||
rejections: 'Needs you',
|
||||
moving: 'Releases on the move',
|
||||
review: 'In review',
|
||||
rollout: 'Rolling out',
|
||||
rolloutPct: 'Rolling out {pct}%',
|
||||
approved: 'Waiting for release',
|
||||
halted: 'Rollout halted',
|
||||
reviews: 'Reviews',
|
||||
recent: '{n} new in the last 7 days',
|
||||
recentNone: 'No new reviews in the last 7 days',
|
||||
health: 'Crash and ANR alerts',
|
||||
crash: 'Crash rate over the bad behavior threshold',
|
||||
anr: 'ANR rate over the bad behavior threshold',
|
||||
crashAnr: 'Crash and ANR rates over the bad behavior threshold',
|
||||
calm: 'All quiet: nothing in review, rolling out or waiting on you.',
|
||||
noData: 'No store read yet. Refresh reads every app.',
|
||||
lastRead: 'Read {ago}',
|
||||
never: 'never',
|
||||
justNow: 'just now',
|
||||
min: '{n} min ago',
|
||||
hour: '{n} h ago',
|
||||
day: '{n} d ago',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Reading…',
|
||||
openMain: 'Open storebound',
|
||||
build: 'storebound {version} · {channel} channel',
|
||||
chStable: 'stable',
|
||||
chDev: 'dev',
|
||||
chBeta: 'beta',
|
||||
more: 'More',
|
||||
mute: 'Mute notifications for 1 hour',
|
||||
unmute: 'Unmute notifications',
|
||||
muted: 'Muted',
|
||||
quit: 'Quit storebound',
|
||||
openApp: 'Open {name}',
|
||||
asc: 'App Store Connect',
|
||||
play: 'Play Console',
|
||||
appStore: 'App Store',
|
||||
playStore: 'Play',
|
||||
stars: '{n} of 5 stars',
|
||||
};
|
||||
export type CardKey = keyof typeof en;
|
||||
|
||||
export const tr: Record<CardKey, string> = {
|
||||
title: 'Mağaza durumu',
|
||||
rejected: 'Reddedildi',
|
||||
rejections: 'Seni bekliyor',
|
||||
moving: 'İlerleyen sürümler',
|
||||
review: 'İncelemede',
|
||||
rollout: 'Yayılıyor',
|
||||
rolloutPct: '%{pct} yayılıyor',
|
||||
approved: 'Yayın bekliyor',
|
||||
halted: 'Yayılım durduruldu',
|
||||
reviews: 'Yorumlar',
|
||||
recent: 'Son 7 günde {n} yeni',
|
||||
recentNone: 'Son 7 günde yeni yorum yok',
|
||||
health: 'Çökme ve ANR uyarıları',
|
||||
crash: 'Çökme oranı kötü davranış eşiğinin üstünde',
|
||||
anr: 'ANR oranı kötü davranış eşiğinin üstünde',
|
||||
crashAnr: 'Çökme ve ANR oranları kötü davranış eşiğinin üstünde',
|
||||
calm: 'Her şey sakin: incelemede, yayılan ya da seni bekleyen bir şey yok.',
|
||||
noData: 'Henüz mağaza okunmadı. Yenile her uygulamayı okur.',
|
||||
lastRead: '{ago} okundu',
|
||||
never: 'hiç',
|
||||
justNow: 'az önce',
|
||||
min: '{n} dk önce',
|
||||
hour: '{n} sa önce',
|
||||
day: '{n} gün önce',
|
||||
refresh: 'Yenile',
|
||||
refreshing: 'Okunuyor…',
|
||||
openMain: 'storebound\'u aç',
|
||||
build: 'storebound {version} · {channel} kanalı',
|
||||
chStable: 'kararlı',
|
||||
chDev: 'geliştirme',
|
||||
chBeta: 'beta',
|
||||
more: 'Diğer',
|
||||
mute: 'Bildirimleri 1 saat sustur',
|
||||
unmute: 'Bildirimleri aç',
|
||||
muted: 'Sessiz',
|
||||
quit: 'storebound\'dan çık',
|
||||
openApp: '{name} uygulamasını aç',
|
||||
asc: 'App Store Connect',
|
||||
play: 'Play Console',
|
||||
appStore: 'App Store',
|
||||
playStore: 'Play',
|
||||
stars: '5 üzerinden {n} yıldız',
|
||||
};
|
||||
|
||||
export const CARD_TEXT = { en, tr } as const;
|
||||
export type CardLang = keyof typeof CARD_TEXT;
|
||||
|
||||
export function cardT(lang: CardLang, key: CardKey, vars?: Record<string, string | number>): string {
|
||||
const s = CARD_TEXT[lang][key] ?? en[key];
|
||||
return vars ? s.replace(/\{(\w+)\}/g, (m, k: string) => (vars[k] === undefined ? m : String(vars[k]))) : s;
|
||||
}
|
||||
|
||||
/** "3 min ago" style age of the last read. */
|
||||
export function ago(lang: CardLang, at: string | null, now: number): string {
|
||||
if (!at) return cardT(lang, 'never');
|
||||
const m = Math.floor((now - Date.parse(at)) / 60_000);
|
||||
if (!Number.isFinite(m)) return cardT(lang, 'never');
|
||||
if (m < 1) return cardT(lang, 'justNow');
|
||||
if (m < 60) return cardT(lang, 'min', { n: m });
|
||||
if (m < 48 * 60) return cardT(lang, 'hour', { n: Math.floor(m / 60) });
|
||||
return cardT(lang, 'day', { n: Math.floor(m / 1440) });
|
||||
}
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
/* Design system v2 (components in src/ui/ds.tsx, guide in docs/drafts/desktop-v2/SYSTEM.md).
|
||||
The store-like language of the portfolio home for every screen: tinted surfaces, big type, pill tabs, calm tables.
|
||||
Loaded once after styles.css (main.tsx). Screens keep their own CSS in src/styles/<screen>.css. */
|
||||
|
||||
/* ---------- tokens ---------- */
|
||||
:root {
|
||||
/* surfaces and elevation */
|
||||
--ds-page: var(--bg);
|
||||
--ds-surface: var(--surface);
|
||||
--ds-raised: #ffffff;
|
||||
--ds-inset: var(--bg-sunken);
|
||||
--ds-stroke: var(--line);
|
||||
--ds-stroke-strong: var(--line-2);
|
||||
--ds-e1: var(--shadow-1);
|
||||
--ds-e2: var(--shadow-2);
|
||||
--ds-e3: var(--shadow-3);
|
||||
/* radii */
|
||||
--ds-r-xs: 6px; --ds-r-sm: 10px; --ds-r: 14px; --ds-r-lg: 18px; --ds-r-xl: 24px; --ds-r-pill: 999px;
|
||||
/* accent gradient (buttons, active pills, focus glow) */
|
||||
--ds-grad: linear-gradient(135deg, #5b55e8, #8a5cf6 55%, #c264e8);
|
||||
--ds-grad-soft: linear-gradient(135deg, color-mix(in srgb, #5b55e8 14%, transparent), color-mix(in srgb, #c264e8 10%, transparent));
|
||||
/* per-app tint: set --tint-h (0-360) on an ancestor (tintStyle(hue) in ui/ds.tsx) */
|
||||
--tint-h: 245;
|
||||
--ds-tint-s: 78%; --ds-tint-l: 62%; --ds-tint-a: .16; --ds-tint-a2: .07;
|
||||
/* typography scale */
|
||||
--ds-t-display: 34px; --ds-t-h1: 28px; --ds-t-h2: 18px; --ds-t-h3: 15px; --ds-t-body: 13.5px; --ds-t-meta: 12px; --ds-t-micro: 11px;
|
||||
--ds-t-stat: 32px;
|
||||
--ds-w-display: 780; --ds-w-title: 700; --ds-w-strong: 650;
|
||||
}
|
||||
:root[data-theme="dark"] { --ds-raised: #151d2e; --ds-tint-l: 58%; --ds-tint-a: .26; --ds-tint-a2: .10; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ds-raised: #151d2e; --ds-tint-l: 58%; --ds-tint-a: .26; --ds-tint-a2: .10; } }
|
||||
|
||||
/* Anything below a tint: --tint is the app colour, .ds-tinted paints the soft gradient surface. */
|
||||
[style*="--tint-h"] { --tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l)); }
|
||||
.ds-tinted {
|
||||
background:
|
||||
radial-gradient(120% 80% at 100% 0%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a)), transparent 60%),
|
||||
linear-gradient(180deg, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent 55%), var(--ds-surface);
|
||||
border: 1px solid color-mix(in srgb, var(--tint, var(--accent-solid)) 22%, var(--ds-stroke));
|
||||
}
|
||||
|
||||
/* ---------- shell: sidebar ---------- */
|
||||
.side { background: linear-gradient(180deg, color-mix(in srgb, var(--bg-sunken) 92%, var(--accent-solid)) 0%, var(--bg-sunken) 260px); }
|
||||
.side .nav { height: 34px; border-radius: 10px; }
|
||||
.side .nav[aria-current="page"] { background: var(--ds-raised); box-shadow: var(--ds-e1), inset 0 0 0 1px var(--ds-stroke); }
|
||||
.side .nav[aria-current="page"]::before { content: ""; position: absolute; left: 0; width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--ds-grad); }
|
||||
.side .nav { position: relative; }
|
||||
.side .nav-app { height: 36px; gap: 11px; }
|
||||
.ds-side-ic { position: relative; display: inline-grid; flex: none; }
|
||||
.side .nav-app .app-icon { width: 24px; height: 24px; border-radius: 7px; font-size: 9px; box-shadow: 0 2px 6px -2px rgba(0, 0, 0, .35), inset 0 0 0 1px rgba(255, 255, 255, .08); }
|
||||
.ds-sdot { position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--bg-sunken); background: var(--neutral-solid); }
|
||||
.ds-sdot.ok { background: var(--ok-solid); } .ds-sdot.warn { background: var(--warn-solid); } .ds-sdot.bad { background: var(--bad-solid); } .ds-sdot.info { background: var(--info-solid); }
|
||||
.side-group-head { border-radius: 10px; }
|
||||
.side-label { letter-spacing: .09em; }
|
||||
|
||||
/* ---------- shell: top bar ---------- */
|
||||
.topbar { height: 56px; background: color-mix(in srgb, var(--bg) 70%, transparent); backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); position: relative; z-index: 5; }
|
||||
.topbar .btn { border-radius: 10px; }
|
||||
.crumbs .here { font-weight: 650; }
|
||||
|
||||
/* ---------- PageHeader ---------- */
|
||||
.ds-ph { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||
.ds-ph-t { min-width: 0; display: grid; gap: 4px; }
|
||||
.ds-eyebrow { font-size: var(--t-section); font-weight: 600; color: var(--accent); }
|
||||
.ds-h1 { font-size: var(--ds-t-h1); line-height: 1.15; letter-spacing: -.025em; font-weight: var(--ds-w-display); display: flex; align-items: center; gap: 12px; }
|
||||
.ds-ph-ic { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--ds-grad); color: #fff; box-shadow: 0 8px 20px -10px #5b55e8; }
|
||||
.ds-ph-ic svg { width: 22px; height: 22px; }
|
||||
.ds-sub { color: var(--muted); max-width: 80ch; }
|
||||
|
||||
/* ---------- Hero (app page) ---------- */
|
||||
.ds-hero {
|
||||
--tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l));
|
||||
position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; border-radius: var(--ds-r-xl); overflow: hidden; min-height: 240px;
|
||||
background:
|
||||
radial-gradient(90% 120% at 100% 0%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / calc(var(--ds-tint-a) * 1.6)), transparent 62%),
|
||||
radial-gradient(60% 90% at 0% 100%, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / var(--ds-tint-a2)), transparent 70%), var(--ds-surface);
|
||||
border: 1px solid color-mix(in srgb, var(--tint) 26%, var(--ds-stroke)); box-shadow: var(--ds-e2);
|
||||
}
|
||||
.ds-hero-main { display: grid; gap: 22px; padding: 26px 28px; align-content: space-between; min-width: 0; }
|
||||
.ds-hero-id { display: flex; gap: 20px; align-items: center; min-width: 0; }
|
||||
.ds-hero .ds-hero-icon { width: 88px; height: 88px; border-radius: 22px; font-size: 28px; box-shadow: 0 16px 36px -14px hsl(var(--tint-h) 70% 30% / .75), inset 0 0 0 1px rgba(255, 255, 255, .12); }
|
||||
.ds-hero-t { display: grid; gap: 8px; min-width: 0; }
|
||||
.ds-hero-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.ds-hero-name { font-size: var(--ds-t-h1); font-weight: var(--ds-w-display); letter-spacing: -.025em; line-height: 1.1; }
|
||||
.ds-hero-ids { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.ds-hero-ids .store-badge { height: 30px; padding-right: 4px; background: color-mix(in srgb, var(--ds-surface) 70%, transparent); }
|
||||
.ds-hero-ids .store-badge .chip { height: 22px; }
|
||||
.ds-hero-links { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.ds-hero-links .btn { height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--ds-surface) 60%, transparent); }
|
||||
.ds-hero-pipe { display: grid; grid-template-columns: minmax(0, 560px) auto; gap: 28px; align-items: end; padding: 18px 20px; border-radius: var(--ds-r); background: color-mix(in srgb, var(--ds-surface) 55%, transparent); border: 1px solid color-mix(in srgb, var(--tint) 16%, var(--ds-stroke)); }
|
||||
.ds-pipe-lg { gap: 9px; }
|
||||
.ds-pipe-lg .pf-segs b { height: 10px; }
|
||||
.ds-hero-foot { display: grid; gap: 10px; justify-items: end; }
|
||||
.ds-hero-next { display: grid; justify-items: end; line-height: 1.1; }
|
||||
.ds-hero-next-v { font-size: 26px; font-weight: var(--ds-w-display); letter-spacing: -.02em; }
|
||||
.ds-hero-shots { position: relative; display: flex; gap: 12px; height: 100%; max-height: 300px; padding: 22px 22px 0; align-items: flex-start; overflow: hidden; width: min(44vw, 520px);
|
||||
background: linear-gradient(160deg, hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l) / .30), transparent 80%);
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 78%, transparent), linear-gradient(90deg, transparent, #000 10%); -webkit-mask-composite: source-in; mask-image: linear-gradient(180deg, #000 78%, transparent); }
|
||||
.ds-hero-shots img { height: 280px; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 16px; box-shadow: 0 18px 36px -14px rgba(0, 0, 0, .55); flex: none; transition: transform var(--speed) var(--ease); }
|
||||
.ds-hero-shots img:nth-child(even) { margin-top: 26px; }
|
||||
.ds-hero:hover .ds-hero-shots img { transform: translateY(-3px); }
|
||||
/* compact hero: every app tab but Releases (one slim row, the tab content starts above the fold) */
|
||||
.ds-hero.compact { grid-template-columns: minmax(0, 1fr); min-height: 0; border-radius: var(--ds-r-lg); }
|
||||
.ds-hero.compact .ds-hero-main { padding: 14px 20px; gap: 0; }
|
||||
.ds-hero.compact .ds-hero-icon { width: 52px; height: 52px; border-radius: 14px; font-size: 18px; }
|
||||
.ds-hero.compact .ds-hero-id { gap: 14px; }
|
||||
.ds-hero.compact .ds-hero-t { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
|
||||
.ds-hero.compact .ds-hero-row { flex-basis: 100%; }
|
||||
.ds-hero.compact .ds-hero-name { font-size: 20px; }
|
||||
.ds-hero.compact .ds-hero-ids .store-badge { height: 26px; }
|
||||
.ds-hero-cta { margin-left: auto; flex: none; }
|
||||
@media (max-width: 1500px) { .ds-hero { grid-template-columns: minmax(0, 1fr); } .ds-hero-shots { display: none; } }
|
||||
|
||||
@media (max-width: 900px) { .ds-hero-pipe { grid-template-columns: minmax(0, 1fr); } .ds-hero-foot { justify-items: start; } }
|
||||
|
||||
/* ---------- app page shell ---------- */
|
||||
.ds-app { gap: 18px; }
|
||||
.ds-app .app-controls { padding: 0 4px; }
|
||||
.ds-app-sticky { position: sticky; top: -1px; z-index: 4; margin: 0 -28px; padding: 8px 28px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||
|
||||
/* ---------- Tabs v2 (pill) ---------- */
|
||||
.ds-tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); flex-wrap: nowrap; overflow: hidden; width: max-content; max-width: 100%; box-sizing: border-box; }
|
||||
.ds-tabs::-webkit-scrollbar { display: none; }
|
||||
.ds-tab { all: unset; flex: none; display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; transition: background var(--speed) var(--ease), color var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
|
||||
.ds-tab svg { width: 16px; height: 16px; }
|
||||
.ds-tab:hover { color: var(--ink); background: color-mix(in srgb, var(--ds-raised) 60%, transparent); }
|
||||
.ds-tab[aria-selected="true"] { color: var(--ink); background: var(--ds-raised); box-shadow: var(--ds-e1), inset 0 0 0 1px var(--ds-stroke); }
|
||||
.ds-tab[aria-selected="true"] svg { color: var(--tint, var(--accent-solid)); }
|
||||
.ds-tab:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
.ds-tab-n { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 99px; display: inline-grid; place-items: center; font-size: 10.5px; font-weight: 700; background: var(--bad-solid); color: #fff; }
|
||||
|
||||
/* ---------- Section / Card v2 ---------- */
|
||||
.ds-sec { background: var(--ds-surface); border: 1px solid var(--ds-stroke); border-radius: var(--ds-r-lg); box-shadow: var(--ds-e1); min-width: 0; }
|
||||
.ds-sec.plain { background: none; border: 0; box-shadow: none; }
|
||||
.ds-sec-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px 0; }
|
||||
.ds-sec.plain .ds-sec-h { padding: 0 2px; }
|
||||
.ds-sec-h h2 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); letter-spacing: -.015em; display: flex; align-items: center; gap: 10px; }
|
||||
.ds-sec-ic { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 16%, transparent); color: var(--tint, var(--accent-solid)); }
|
||||
.ds-sec-ic svg { width: 16px; height: 16px; }
|
||||
.ds-sec-sub { color: var(--muted); font-size: var(--ds-t-meta); margin-top: 3px; }
|
||||
.ds-sec-b { padding: 16px 22px 20px; }
|
||||
.ds-sec.plain .ds-sec-b { padding: 12px 0 0; }
|
||||
.ds-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(var(--ds-min, 220px), 1fr)); }
|
||||
|
||||
/* ---------- StatTile ---------- */
|
||||
.ds-stat { all: unset; box-sizing: border-box; display: grid; gap: 6px; align-content: start; padding: 18px 20px; border-radius: 16px; background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e1); position: relative; overflow: hidden; min-width: 0; }
|
||||
.ds-stat::after { content: ""; position: absolute; inset: 0 0 auto; height: 3px; background: var(--tint, transparent); opacity: .8; }
|
||||
.ds-stat.clickable { cursor: pointer; transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
|
||||
.ds-stat.clickable:hover { transform: translateY(-1px); box-shadow: var(--ds-e2); }
|
||||
.ds-stat:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.ds-stat-l { display: flex; align-items: center; gap: 7px; font-size: var(--ds-t-meta); font-weight: 600; color: var(--muted); }
|
||||
.ds-stat-l svg { width: 15px; height: 15px; }
|
||||
.ds-stat-v { font-size: var(--ds-t-stat); font-weight: var(--ds-w-display); letter-spacing: -.03em; line-height: 1.05; color: var(--ink); }
|
||||
.ds-stat-s { display: flex; gap: 8px; align-items: center; font-size: var(--ds-t-meta); color: var(--muted); min-width: 0; }
|
||||
.ds-stat-s > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ds-stat.tone-bad { border-left: 3px solid var(--bad-solid); } .ds-stat.tone-warn { border-left: 3px solid var(--warn-solid); } .ds-stat.tone-ok { border-left: 3px solid var(--ok-solid); }
|
||||
.ds-delta { font-weight: 700; font-variant-numeric: tabular-nums; padding: 1px 6px; border-radius: 6px; background: var(--neutral-bg); color: var(--neutral-ink); }
|
||||
.ds-delta.up { background: var(--ok-bg); color: var(--ok-ink); } .ds-delta.down { background: var(--bad-bg); color: var(--bad-ink); }
|
||||
|
||||
/* ---------- StatusPill ---------- */
|
||||
.ds-pill { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; border-radius: var(--ds-r-pill); font-size: var(--ds-t-micro); font-weight: 650; white-space: nowrap; background: var(--neutral-bg); color: var(--neutral-ink); }
|
||||
.ds-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--neutral-solid); }
|
||||
.ds-pill svg { width: 13px; height: 13px; }
|
||||
.ds-pill.ok { background: var(--ok-bg); color: var(--ok-ink); } .ds-pill.ok i { background: var(--ok-solid); }
|
||||
.ds-pill.warn { background: var(--warn-bg); color: var(--warn-ink); } .ds-pill.warn i { background: var(--warn-solid); }
|
||||
.ds-pill.bad { background: var(--bad-bg); color: var(--bad-ink); } .ds-pill.bad i { background: var(--bad-solid); }
|
||||
.ds-pill.info { background: var(--info-bg); color: var(--info-ink); } .ds-pill.info i { background: var(--info-solid); }
|
||||
.ds-pill.accent { background: var(--accent-soft); color: var(--accent); } .ds-pill.accent i { background: var(--accent-solid); }
|
||||
.ds-pill.solid { color: #fff; } .ds-pill.solid i { background: #fff; }
|
||||
.ds-pill.solid.ok { background: var(--ok-solid); } .ds-pill.solid.warn { background: var(--warn-solid); color: #1b1203; } .ds-pill.solid.bad { background: var(--bad-solid); } .ds-pill.solid.info { background: var(--info-solid); } .ds-pill.solid.accent { background: var(--ds-grad); }
|
||||
|
||||
/* ---------- EmptyState ---------- */
|
||||
.ds-empty { display: grid; justify-items: center; gap: 12px; padding: 36px 24px; text-align: center; color: var(--muted); }
|
||||
.ds-empty-ic { width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--tint, var(--accent-solid)) 14%, transparent); color: var(--tint, var(--accent-solid)); }
|
||||
.ds-empty-ic svg { width: 26px; height: 26px; }
|
||||
.ds-empty.tone-ok .ds-empty-ic { background: var(--ok-bg); color: var(--ok-solid); }
|
||||
.ds-empty.tone-bad .ds-empty-ic { background: var(--bad-bg); color: var(--bad-solid); }
|
||||
.ds-empty p { font-size: var(--t-em); color: var(--ink-2); max-width: 52ch; }
|
||||
|
||||
/* ---------- Toolbar ---------- */
|
||||
.ds-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px; border-radius: var(--ds-r); background: var(--ds-surface); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e1); }
|
||||
.ds-toolbar .grow { flex: 1; }
|
||||
.ds-toolbar .input, .ds-toolbar .btn { border-radius: 10px; }
|
||||
|
||||
/* ---------- Table v2 (add .v2 to .table, wrap in .ds-table-wrap) ---------- */
|
||||
.ds-table-wrap { overflow: auto; max-height: var(--ds-table-max, none); border-radius: var(--ds-r-lg); border: 1px solid var(--ds-stroke); background: var(--ds-surface); box-shadow: var(--ds-e1); }
|
||||
.table.v2 th { position: sticky; top: 0; z-index: 1; background: color-mix(in srgb, var(--ds-surface) 92%, var(--ds-inset)); backdrop-filter: blur(8px); text-transform: none; letter-spacing: 0; font-size: var(--ds-t-meta); font-weight: 600; padding-top: 12px; padding-bottom: 12px; }
|
||||
.table.v2 th:first-child, .table.v2 th:last-child { border-radius: 0; }
|
||||
.table.v2 td { padding-top: 14px; padding-bottom: 14px; border-bottom-color: color-mix(in srgb, var(--ds-stroke) 65%, transparent); }
|
||||
.table.v2 tbody tr { transition: background-color var(--speed) var(--ease); }
|
||||
.table.v2 tbody tr:hover td { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--hover)); }
|
||||
.table.v2 tbody tr:focus-within td { background: var(--hover); }
|
||||
.table.v2 .num, .table.v2 td.right { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---------- Dialog v2 ---------- */
|
||||
.ds-scrim { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 24px; background: var(--scrim); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ds-fade var(--speed) var(--ease); }
|
||||
.ds-dialog { width: min(560px, 100%); max-height: calc(100vh - 48px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border-radius: var(--ds-r-xl); background: var(--ds-raised); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e3); animation: ds-pop var(--speed) var(--ease); }
|
||||
.ds-dialog.wide { width: min(880px, 100%); }
|
||||
.ds-dialog:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.ds-dialog-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 22px 8px; }
|
||||
.ds-dialog-h h2 { font-size: var(--ds-t-h2); font-weight: var(--ds-w-title); display: flex; align-items: center; gap: 10px; }
|
||||
.ds-dialog-b { padding: 8px 22px 18px; overflow: auto; }
|
||||
.ds-dialog-f { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--ds-stroke); background: color-mix(in srgb, var(--ds-inset) 50%, transparent); border-radius: 0 0 var(--ds-r-xl) var(--ds-r-xl); }
|
||||
@keyframes ds-fade { from { opacity: 0; } }
|
||||
@keyframes ds-pop { from { opacity: 0; transform: translateY(6px) scale(.985); } }
|
||||
|
||||
/* ---------- reduced motion ---------- */
|
||||
@media (prefers-reduced-motion: reduce) { .ds-hero-shots img, .ds-stat.clickable, .ds-scrim, .ds-dialog { transform: none !important; transition: none; animation: none; } }
|
||||
:root[data-motion="reduce"] .ds-hero-shots img, :root[data-motion="reduce"] .ds-stat.clickable, :root[data-motion="reduce"] .ds-scrim, :root[data-motion="reduce"] .ds-dialog { transform: none !important; transition: none; animation: none; }
|
||||
|
||||
/* ---------- context menu (ui/ContextMenu.tsx) ---------- */
|
||||
.ds-menu { position: fixed; z-index: 1000; min-width: 200px; max-width: 320px; max-height: calc(100vh - 16px); overflow: auto; padding: 5px;
|
||||
border-radius: 12px; background: color-mix(in srgb, var(--ds-raised) 94%, transparent); border: 1px solid var(--ds-stroke-strong);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, .05) inset, 0 14px 38px -10px rgba(10, 14, 30, .38), 0 3px 10px -4px rgba(10, 14, 30, .22);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.4); backdrop-filter: blur(18px) saturate(1.4);
|
||||
font-size: var(--ds-t-body); color: var(--ink); outline: none; user-select: none;
|
||||
opacity: 0; transform: scale(.97); transform-origin: top left; transition: opacity 120ms var(--ease), transform 120ms var(--ease); }
|
||||
.ds-menu.in { opacity: 1; transform: none; }
|
||||
.ds-menu-item { display: flex; align-items: center; gap: 9px; height: 30px; padding: 0 10px 0 8px; border-radius: 7px; cursor: default; white-space: nowrap; }
|
||||
.ds-menu-item.active { background: var(--accent-soft); color: var(--ink); }
|
||||
.ds-menu-item.active .ds-menu-ic { color: var(--accent); }
|
||||
.ds-menu-item.danger { color: var(--bad-solid); }
|
||||
.ds-menu-item.danger.active { background: var(--bad-bg); color: var(--bad-ink); }
|
||||
.ds-menu-item.danger .ds-menu-ic, .ds-menu-item.danger.active .ds-menu-ic { color: inherit; }
|
||||
.ds-menu-item.disabled { opacity: .45; }
|
||||
.ds-menu-ic { width: 16px; height: 16px; flex: none; display: grid; place-items: center; color: var(--muted); }
|
||||
.ds-menu-ic svg { width: 15px; height: 15px; }
|
||||
.ds-menu-l { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ds-menu-k { max-width: 150px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--faint); font-size: var(--ds-t-micro); letter-spacing: .02em; font-variant-numeric: tabular-nums; }
|
||||
.ds-menu-sub { width: 12px; height: 12px; color: var(--faint); margin-left: 10px; }
|
||||
.ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); }
|
||||
/* menu header: the pin button (a normal menu or submenu), a pinned panel's title, unpin, close and drag handle */
|
||||
.ds-menu-head { display: flex; align-items: center; gap: 2px; height: 24px; padding: 0 0 0 9px; margin-bottom: 1px; }
|
||||
.ds-menu-head.pinned { cursor: grab; touch-action: none; }
|
||||
.ds-menu-head.pinned:active { cursor: grabbing; }
|
||||
.ds-menu-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ds-t-micro);
|
||||
font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--faint); }
|
||||
.ds-menu-btn { flex: none; width: 22px; height: 22px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px;
|
||||
background: transparent; color: var(--faint); cursor: pointer; }
|
||||
.ds-menu-btn svg { width: 13px; height: 13px; }
|
||||
.ds-menu-btn:hover, .ds-menu-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; }
|
||||
.ds-menu-btn.on { color: var(--accent); }
|
||||
.ds-menu.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); transition: opacity 120ms var(--ease); }
|
||||
.ds-menu-gone { max-width: 240px; padding: 6px 10px 8px; color: var(--muted); font-size: var(--ds-t-micro); white-space: normal; }
|
||||
@media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } }
|
||||
:root[data-motion="reduce"] .ds-menu { transition: none; transform: none; }
|
||||
/* headings focused by code (dialog / wizard step titles) for screen readers: no ring */
|
||||
[tabindex="-1"]:focus:not(:focus-visible), h1[tabindex="-1"]:focus, h2[tabindex="-1"]:focus { outline: none; }
|
||||
|
||||
.ds-tab[hidden] { display: none; }
|
||||
.ds-tab-more svg:last-child { width: 12px; height: 12px; }
|
||||
@media (max-width: 1560px) { .ds-tabs .ds-tab { padding: 0 10px; gap: 6px; } .ds-tabs .ds-tab svg { width: 15px; height: 15px; } }
|
||||
.dr-cmd { display: block; margin: 10px 0 8px; padding: 6px 10px; border-radius: var(--ds-r-xs); background: var(--ds-inset); overflow-wrap: anywhere; }
|
||||
.dr-out { max-height: 320px; overflow: auto; margin: 0; padding: 10px 12px; border-radius: var(--ds-r-sm); background: var(--ds-inset); border: 1px solid var(--ds-stroke); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre-wrap; }
|
||||
/* wide tables: the first column (the row's name) stays in view while the rest scrolls sideways */
|
||||
.table-wrap .table tr > :first-child, .ds-table-wrap .table tr > :first-child { position: sticky; left: 0; z-index: 1; background: var(--ds-surface, var(--surface)); }
|
||||
.table-wrap .table thead tr > :first-child, .ds-table-wrap .table thead tr > :first-child { z-index: 3; }
|
||||
.table-wrap .table tbody tr:hover > :first-child, .ds-table-wrap .table tbody tr:hover > :first-child { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--ds-surface, var(--surface))); }
|
||||
/* Settings > Appearance > Tables: compact */
|
||||
:root[data-density="compact"] .table th, :root[data-density="compact"] .table td { padding-top: 5px; padding-bottom: 5px; padding-left: 8px; padding-right: 8px; font-size: 12.5px; }
|
||||
|
||||
/* ---------- App sections (docs IA): section header, next step, sub views ---------- */
|
||||
.ds-tab-k { font: 600 10px/1 var(--mono, ui-monospace, monospace); min-width: 16px; height: 16px; display: inline-grid; place-items: center; border-radius: 4px; color: var(--muted); border: 1px solid var(--ds-stroke); background: var(--ds-surface); margin-left: 2px; opacity: .75; }
|
||||
.ds-tab[aria-selected="true"] .ds-tab-k { opacity: 1; }
|
||||
.sec-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "t next" "subs subs"; gap: 10px 16px; align-items: center; padding: 2px 4px 4px; }
|
||||
.sec-head-t { grid-area: t; min-width: 0; }
|
||||
.sec-head-name { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--ds-t-h2, 18px); font-weight: 700; letter-spacing: -.01em; }
|
||||
.sec-head-name svg { width: 18px; height: 18px; color: var(--tint, var(--accent-solid)); }
|
||||
.sec-head-for { margin: 2px 0 0; color: var(--muted); font-size: var(--t-meta); }
|
||||
.sec-next { grid-area: next; display: inline-flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; min-height: 32px; box-sizing: border-box; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-surface); font-weight: 600; font-size: var(--t-meta); max-width: 560px; }
|
||||
.sec-next > svg { width: 15px; height: 15px; flex: none; }
|
||||
.sec-next-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sec-next:not(:has(.btn)) { padding-right: 12px; }
|
||||
.sec-next.tone-bad { background: var(--bad-bg); color: var(--bad-ink); border-color: transparent; }
|
||||
.sec-next.tone-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; }
|
||||
.sec-next.tone-ok > svg { color: var(--ok-solid); }
|
||||
.sec-next.tone-info > svg { color: var(--accent-solid); }
|
||||
.sec-next .btn { flex: none; }
|
||||
.sec-subs { grid-area: subs; justify-self: start; }
|
||||
.sec-subs .ed-dot { margin-left: 2px; }
|
||||
@media (max-width: 1100px) { .sec-head { grid-template-columns: 1fr; grid-template-areas: "t" "next" "subs"; } .sec-next { justify-self: start; } }
|
||||
|
||||
/* ---------- Sidebar: attention groups, one status line per app, workspace footer ---------- */
|
||||
.nav-app.has-line { height: auto; min-height: 40px; padding-top: 4px; padding-bottom: 4px; align-items: center; }
|
||||
.nav-app .side-t { display: grid; min-width: 0; line-height: 1.2; }
|
||||
.nav-app .side-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-app .side-s { font-size: 11px; font-weight: 500; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-app .side-s.bad { color: var(--bad-ink); } .nav-app .side-s.warn { color: var(--warn-ink); } .nav-app .side-s.ok { color: var(--ok-ink); }
|
||||
:root[data-theme="dark"] .nav-app .side-s.bad { color: color-mix(in srgb, var(--bad-solid) 70%, white); }
|
||||
:root[data-theme="dark"] .nav-app .side-s.warn { color: color-mix(in srgb, var(--warn-solid) 70%, white); }
|
||||
:root[data-theme="dark"] .nav-app .side-s.ok { color: color-mix(in srgb, var(--ok-solid) 70%, white); }
|
||||
.side-studios { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 6px 2px; }
|
||||
.side-studios button { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 99px; font-size: 11px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); }
|
||||
.side-studios button[aria-pressed="true"] { color: var(--ink); background: var(--surface); box-shadow: var(--shadow-1); }
|
||||
.side-studios button:focus-visible { outline: 2px solid var(--focus); }
|
||||
.side-ws { display: flex; gap: 2px; padding: 6px 8px; border-top: 1px solid var(--line); }
|
||||
.side-ws button { all: unset; cursor: pointer; position: relative; flex: 1; height: 30px; display: grid; place-items: center; border-radius: 7px; color: var(--muted); }
|
||||
.side-ws button:hover { background: var(--hover); color: var(--ink); }
|
||||
.side-ws button[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1); }
|
||||
.side-ws button:focus-visible { outline: 2px solid var(--focus); }
|
||||
.side-ws svg { width: 17px; height: 17px; }
|
||||
.side-ws .ws-n { position: absolute; top: 2px; right: 4px; font-size: 9.5px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
|
|
@ -83,7 +83,7 @@ export const DECLARATIONS: Record<string, DeclarationsResult> = {
|
|||
review: { signInRequired: false, accounts: false, contact: 's***@example.com', demoAccount: null, notes: 'Offline puzzle game, no sign-in. Remove Ads is restored from Settings > Restore purchases. The daily drop unlocks at midnight local time.', notesBytes: 148, notesMax: 4000 },
|
||||
ask: [
|
||||
{ area: 'appInfo', id: 'ai-age', text: 'No age rating answers: storebound would answer every content question "none".', fix: 'Confirm that is true (ads, user-generated content, chat, loot boxes, web access count), or set apple.info.ageRating overrides.' },
|
||||
{ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy: https://appstoreconnect.apple.com/apps/1000000001/distribution/privacy for each type below (tracking also needs App Tracking Transparency in the app).', url: 'https://appstoreconnect.apple.com/apps/1000000001/distribution/privacy' },
|
||||
{ area: 'appPrivacy', id: 'ap-linked', text: 'App Privacy asks per data type whether it is linked to the user\'s identity and whether it is used for tracking; the config cannot prove either.', fix: 'Answer both in App Store Connect > App Privacy for each type below (tracking also needs App Tracking Transparency in the app).' },
|
||||
],
|
||||
},
|
||||
'p-tidy': {
|
||||
|
|
|
|||
|
|
@ -5,21 +5,11 @@
|
|||
// not contain it (tests/webview.test.ts checks the built files for FIXTURE_MARKER).
|
||||
import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleForm, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, type DownloadEvent, type DraftsResult, type ListingField, type ListingsResult,
|
||||
type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api';
|
||||
import { buildReport } from '../../../../cli/lib/report';
|
||||
import { buildApps, type Overrides } from '../lib/identity';
|
||||
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
|
||||
import { editableFixture, liveFixture } from './editor-fixtures';
|
||||
import { serviceDisabledMessage } from './error-fixtures';
|
||||
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
|
||||
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
|
||||
import { TOOLBOX } from '../../../../cli/lib/desktop-coverage';
|
||||
|
||||
/** system.methods for the More tools tab: the toolbox methods with a small schema each (reads end in a read verb). */
|
||||
const toolMethods = () => TOOLBOX.flatMap(g => g.methods).map(m => {
|
||||
const read = /status|list|devices|check|diff|next_|app_id|info|wait|plan|verify|rank|competitors|suggest|scan|manifest|triage|originality|aso/.test(m);
|
||||
return { method: m, title: m.replace('.', ' ').replace(/_/g, ' '), description: `Fixture descriptor of ${m}.`, kind: read ? 'read' : 'write', network: m.startsWith('apple.') || m.startsWith('play.'), destructive: false, json: false,
|
||||
args: { type: 'object', additionalProperties: false, properties: m === 'play.upload' ? { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, name: { type: 'string', description: 'Release name' } } : m === 'agent.install' ? { agent: { type: 'string', enum: ['claude', 'codex'] }, no_mcp: { type: 'boolean' } } : {}, ...(m === 'agent.install' ? { required: ['agent'] } : {}) } };
|
||||
});
|
||||
|
||||
export const FIXTURE_MARKER = 'storebound-dev-fixtures';
|
||||
|
||||
|
|
@ -102,7 +92,7 @@ let overrides: Overrides = {
|
|||
};
|
||||
if (import.meta.env.VITE_FIXTURES === 'empty') overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] };
|
||||
let settings: Settings = { ...DEFAULT_SETTINGS, refresh: { ...DEFAULT_SETTINGS.refresh, autoMinutes: 60 } };
|
||||
const PLAY_INVITE = (who: string) => `Play Console > Users and permissions: https://play.google.com/console > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`;
|
||||
const PLAY_INVITE = (who: string) => `Play Console > Users and permissions > Invite new users: add ${who}. Account permissions: "View app information and download bulk reports (read-only)"; per app, as needed: "Manage store presence", "Manage testing tracks", "Release to production, exclude devices and use Play App Signing" (rollouts), "Reply to reviews", "Manage orders and subscriptions". One service account can be invited to several developer accounts.`;
|
||||
const CHECKS: Record<string, AccountCheck> = {
|
||||
[P1]: { store: 'android', status: 'ok', principal: 'play-bot@example.com', apps: 8, message: 'The service account can see 8 apps.', fix: null, docs: null },
|
||||
[P2]: { store: 'android', status: 'permission', principal: 'sample-labs-bot@example.com', apps: 0, message: 'The key works, but no developer account has invited this service account (it sees no apps).', fix: PLAY_INVITE('sample-labs-bot@example.com'), docs: 'https://play.google.com/console/developers/users-and-permissions' },
|
||||
|
|
@ -349,12 +339,9 @@ const CATALOG: Record<string, CatalogDiff> = {
|
|||
const VALIDATE: Record<string, ValidateResult> = {
|
||||
'p-prism': {
|
||||
blockers: 1, findings: [
|
||||
{ store: 'play', level: 'blocker', code: 'listing', msg: 'ja-JP: missing shortDescription', fix: 'storebound play listing', action: { method: 'play.listing', args: {} } },
|
||||
{ store: 'play', level: 'blocker', code: 'product-missing', msg: 'starter_pack is in the config but not on Google Play', fix: 'storebound play products --only starter_pack (dry run first)' },
|
||||
{ store: 'apple', level: 'warn', code: 'screenshots-ipad', msg: 'ar-SA has no 13" iPad screenshots', fix: 'storebound shots render --lang ar, then storebound apple screenshots --only ar-SA' },
|
||||
{ store: 'apple', level: 'warn', code: 'whats-new', msg: '2.4.0 has no "What\'s New" for ja', fix: 'storebound whats-new --since v2.3.0' },
|
||||
// a long unbreakable path (a merged manifest of a local build) must wrap inside the card
|
||||
{ store: 'play', level: 'warn', code: 'scan', msg: 'Play: Foreground service types: FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (apps/native/android/app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml:74)', fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill Play Console > App content > Foreground service permissions: https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services', url: 'https://play.google.com/console/u/0/developers/5550000000000000001/app/4970000000000000002/app-content/foreground-services' },
|
||||
],
|
||||
},
|
||||
'p-tidy': { blockers: 0, findings: [] },
|
||||
|
|
@ -369,19 +356,12 @@ const SCAN_PRISM: ScanResult['findings'] = [
|
|||
{ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'Tracking-capable code without App Tracking Transparency (AppLovin MAX, Unity Ads)',
|
||||
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Add NSUserTrackingUsageDescription and request ATT before the SDKs start, or configure non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
|
||||
{ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: 'targetSdk 35 is below 36, which new apps and updates need since 2026-08-31',
|
||||
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk', aiFix: true },
|
||||
evidence: [{ file: 'ProjectSettings/ProjectSettings.asset', line: 212, text: 'targetSdk 35' }], fix: 'Raise targetSdk to 36, test, upload a new AAB.', skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' },
|
||||
{ rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
|
||||
evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
|
||||
{ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review',
|
||||
evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' },
|
||||
// code changes: Fix with AI (the owner's Claude Code) or Copy for AI
|
||||
{ rule: 'ats', guideline: '2.5.1 / ATS', store: 'apple', severity: 'medium', title: 'NSAllowsArbitraryLoads is true: App Review asks for a reason',
|
||||
evidence: [{ file: 'ios/Unity-iPhone/Info.plist', line: 48, text: '<key>NSAllowsArbitraryLoads</key><true/>' }], fix: 'Remove NSAllowsArbitraryLoads (or limit it with NSExceptionDomains) unless the app must load arbitrary http content.', skill: 'review-rejections', ref: 'https://developer.apple.com/documentation/bundleresources/information-property-list/nsapptransportsecurity', aiFix: true },
|
||||
// a deterministic fix (scan.write: the detected answers into the config)
|
||||
{ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: 'Data safety likely misses PSL_DEVICE_ID: an SDK collects it, dataSafety does not declare it',
|
||||
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', action: { method: 'scan.write', args: {} } },
|
||||
];
|
||||
const aiRuns = new Map<string, ScanResult['findings'][number]>();
|
||||
const SCAN: Record<string, ScanResult> = {
|
||||
'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }],
|
||||
findings: SCAN_PRISM, counts: scanCounts(SCAN_PRISM),
|
||||
|
|
@ -424,23 +404,6 @@ const TOOLS = [
|
|||
];
|
||||
let chromeInstalled = false;
|
||||
|
||||
// games status (cli/games-status.ts) of the game demo: Game Center on, one leaderboard to create; PGS project set
|
||||
const GAMES_PRISM = {
|
||||
apple: { capability: true, enabled: true, version: '1.2.0', versionEnabled: true, leaderboards: 1, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: ['leaderboard com.example.prismpuddle.daily: create'] },
|
||||
play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] },
|
||||
findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }],
|
||||
links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }],
|
||||
items: [
|
||||
{ kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true,
|
||||
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } },
|
||||
{ kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true,
|
||||
apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } },
|
||||
...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true,
|
||||
apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })),
|
||||
],
|
||||
localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] },
|
||||
playApiDisabled: null,
|
||||
};
|
||||
const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data });
|
||||
|
||||
const STORE_REVIEWS: Review[] = [
|
||||
|
|
@ -465,17 +428,7 @@ export function fixtureRead(method: string, id: string, args: Record<string, unk
|
|||
return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r;
|
||||
}
|
||||
|
||||
/** ?blocked: every Play health read fails with Google's SERVICE_DISABLED (the portfolio's "Check again" notice). */
|
||||
const blockedSwitch = (): boolean => typeof location !== 'undefined' && new URLSearchParams(location.search).has('blocked');
|
||||
|
||||
function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
const r = read0(method, id, args);
|
||||
const h = r.data as HealthSnapshot | undefined;
|
||||
if (method !== 'health' || !blockedSwitch() || !h?.play) return r;
|
||||
return { ...r, data: { ...h, play: { error: serviceDisabledMessage('com.example.app') } } };
|
||||
}
|
||||
|
||||
function read0(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args));
|
||||
if (id.startsWith('store:')) {
|
||||
const play = /android:/.test(id);
|
||||
|
|
@ -498,12 +451,6 @@ function read0(method: string, id: string, args: Record<string, unknown> = {}):
|
|||
case 'apple.testflight_status': return ok(method, TF[id]);
|
||||
case 'catalog.diff': return ok(method, CATALOG[id]);
|
||||
case 'validate': return ok(method, VALIDATE[id]);
|
||||
case 'report': {
|
||||
const sum = SUMMARY[id];
|
||||
const r = buildReport({ app: { name: sum?.name ?? id }, stores: (['apple', 'play'] as const).filter(x => !!sum?.[x]), storeRead: { apple: true, play: 'no Play service account' },
|
||||
generatedAt: new Date().toISOString(), storebound: 'fixture', validate: VALIDATE[id] as never, scan: (SCAN[id] ?? SCAN_EMPTY) as never });
|
||||
return ok(method, { file: `/fixture/${id}/.storebound/reports/readiness.html`, path: '.storebound/reports/readiness.html', ...r });
|
||||
}
|
||||
case 'scan': return ok(method, SCAN[id] ?? SCAN_EMPTY);
|
||||
case 'project.declarations': return ok(method, DECLARATIONS[id]);
|
||||
case 'project.listing_text': return ok(method, LISTING_TEXT[id]);
|
||||
|
|
@ -519,7 +466,6 @@ function read0(method: string, id: string, args: Record<string, unknown> = {}):
|
|||
// send for review: Prism Puddle is ready to submit, Tidy Lists is back from App Review (UNRESOLVED_ISSUES)
|
||||
case 'apple.status': return ok(method, id === 'p-tidy' ? APPLE_STATUS_UNRESOLVED : APPLE_STATUS_READY);
|
||||
case 'play.status': return ok(method, PLAY_STATUS);
|
||||
case 'games.status': return ok(method, id === 'p-prism' ? GAMES_PRISM : { apple: null, play: null, findings: [], links: [] });
|
||||
case 'console.plan': return ok(method, consolePlan(String(args.form)));
|
||||
case 'console.verify': return ok(method, consoleVerify(String(args.form)));
|
||||
default:
|
||||
|
|
@ -549,18 +495,13 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
|
|||
};
|
||||
|
||||
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
|
||||
const NETWORK = new Set(['games.status', 'health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'report', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
|
||||
async function progressLines(job: string | null, lines: string[]) {
|
||||
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
|
||||
}
|
||||
|
||||
/** Every fake-host call, for checks that count them (window.__fakeCalls: "cmd method target args"). */
|
||||
const fakeCalls: string[] = [];
|
||||
if (typeof window !== 'undefined') (window as unknown as { __fakeCalls: string[] }).__fakeCalls = fakeCalls;
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, a: Record<string, unknown>): Promise<T> {
|
||||
fakeCalls.push([cmd, a.method ?? '', a.projectId ?? a.id ?? '', JSON.stringify((a.params as { args?: unknown } | null)?.args ?? a.live ?? '')].join(' '));
|
||||
const r = await answer(cmd, a);
|
||||
return r as T;
|
||||
}
|
||||
|
|
@ -591,12 +532,11 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
}
|
||||
case 'studio_remove': studios = studios.filter(s => s.id !== a.id); accounts = accounts.map(x => (x.studio === a.id ? { ...x, studio: null } : x)); return null;
|
||||
case 'studio_logo': return null;
|
||||
case 'account_set_play_developer': { const x = accounts.find(y => y.id === a.id)!; const v = (a.developerId as string | null) ?? null; if (v && !/^\d{1,25}$/.test(v)) throw new Error('the Play developer id is digits'); (x as { developerId?: string | null }).developerId = v; return x; }
|
||||
case 'account_update': { const x = accounts.find(y => y.id === a.id)!; x.label = a.label as string; x.studio = (a.studio as string | null) ?? null; return x; }
|
||||
case 'overrides_set': overrides = a.overrides as Overrides; return overrides;
|
||||
case 'settings_set': settings = a.settings as Settings; return settings;
|
||||
case 'cache_info': return { rows: 214, bytes: 3_412_000, oldest: iso(60 * 24 * 26), file_bytes: 4_718_592 };
|
||||
case 'data_reveal': case 'notify': case 'tray_update': return null;
|
||||
case 'data_reveal': case 'notify': return null;
|
||||
case 'app_reset': return false;
|
||||
case 'update_check': await sleep(300); return { current: '0.1.0', channel: settings.channel, available: null, notes: null, date: null };
|
||||
case 'update_install': return false;
|
||||
|
|
@ -624,7 +564,6 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
case 'cache_clear': return null;
|
||||
case 'sidecar_call': {
|
||||
const method = a.method as string;
|
||||
if (method === 'system.methods') return { methods: toolMethods() };
|
||||
if (method === 'system.tools') return { tools: (await answer('sidecar_status', {}) as { tools: { tools: unknown[] } }).tools.tools, androidSdk: { path: `${HOME}/Library/Android/sdk`, source: 'default', checked: ['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', `Android Studio default folder: ${HOME}/Library/Android/sdk`] } };
|
||||
const id = a.projectId as string;
|
||||
if (NETWORK.has(method)) await progressLines(job, [`[${method}] reading App Store Connect`, `[${method}] reading Google Play`, `[${method}] done`]);
|
||||
|
|
@ -633,29 +572,6 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
return fixtureRead(method, id, ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {});
|
||||
}
|
||||
case 'job_cancel': cancelled.add(a.job as string); return true;
|
||||
case 'ai_fix_info': {
|
||||
const f = a.finding as { rule: string; evidence: { file: string; line: number }[] };
|
||||
return { claude: !new URLSearchParams(location.search).has('noclaude'), git: true, gitError: null, dirty: new URLSearchParams(location.search).has('dirty') ? [{ path: 'ios/Unity-iPhone/Info.plist', status: 'M' }] : [],
|
||||
prompt: `Fix one App Store rejection risk that \`storebound scan\` found in this app folder.\n\nFinding (${f.rule}): ...\nEvidence:\n${f.evidence.map(e => `- ${e.file}:${e.line}`).join('\n')}\n` };
|
||||
}
|
||||
case 'ai_fix_run': {
|
||||
const job = a.job as string, f = a.finding as { rule: string; title: string; evidence: { file: string }[] };
|
||||
for (const line of ['Read ios/Unity-iPhone/Info.plist', 'Grep NSAllowsArbitraryLoads', 'Edit ios/Unity-iPhone/Info.plist', 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.']) {
|
||||
await sleep(400); if (cancelled.has(job)) break; emit('ai-fix', { job, line });
|
||||
}
|
||||
if (cancelled.has(job)) return { runId: job, outcome: { code: null, timed_out: false, cancelled: true, summary: '' }, changes: [], diff: '' };
|
||||
const file = f.evidence[0]?.file ?? 'ios/Unity-iPhone/Info.plist';
|
||||
const i = SCAN_PRISM.findIndex(x => x.rule === f.rule && x.title === f.title);
|
||||
const removed = i >= 0 ? SCAN_PRISM.splice(i, 1)[0] : undefined;
|
||||
if (removed) { aiRuns.set(job, removed); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
|
||||
return { runId: job, outcome: { code: 0, timed_out: false, cancelled: false, summary: 'Removed NSAllowsArbitraryLoads; no http URLs are loaded.' }, changes: [{ path: file, status: 'M' }],
|
||||
diff: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -45,8 +45,6 @@\n <key>NSAppTransportSecurity</key>\n <dict>\n- <key>NSAllowsArbitraryLoads</key>\n- <true/>\n </dict>\n` };
|
||||
}
|
||||
case 'ai_fix_undo': {
|
||||
const r = aiRuns.get(a.runId as string);
|
||||
if (r) { SCAN_PRISM.push(r); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); aiRuns.delete(a.runId as string); }
|
||||
return ['ios/Unity-iPhone/Info.plist'];
|
||||
}
|
||||
case 'write_with_confirm': {
|
||||
const method = a.method as string, args = (a.args ?? {}) as Record<string, unknown>;
|
||||
if (method === 'console.fill') {
|
||||
|
|
@ -675,9 +591,6 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
if (method === 'reviews.approve' && d) d.approved = !args.undo;
|
||||
if (method === 'reviews.reply' && d) { d.reply = String(args.text); d.approved = false; d.issues = []; }
|
||||
if (method === 'owner.reply_send' && d) d.sent = iso(0);
|
||||
// a Fix button's write: the finding it fixed is gone on the next read
|
||||
if (method === 'scan.write') { const i = SCAN_PRISM.findIndex(f => f.rule === 'privacy-labels'); if (i >= 0) SCAN_PRISM.splice(i, 1); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
|
||||
if (method === 'play.listing') { const v = VALIDATE[a.projectId as string]; if (v) v.findings = v.findings.filter(f => f.code !== 'listing'); }
|
||||
const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')}`, output: `[${method}] done (fixture)` };
|
||||
return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true }, result: res };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,36 +59,6 @@ export const en = {
|
|||
'palette.gApps': 'Apps',
|
||||
'palette.gGo': 'Go to',
|
||||
'palette.gTabs': 'App tabs',
|
||||
'palette.gApp': 'This app: {name}',
|
||||
'palette.nextApp': 'Next app',
|
||||
'palette.prevApp': 'Previous app',
|
||||
'settings.scApps': 'Previous / next app',
|
||||
'settings.scMenu': 'Context menu of the focused item',
|
||||
'reader.backoffShort': "Slowed down by the store",
|
||||
'reader.appleQuotaShort': "App Store {left} / {limit} per hour",
|
||||
'kpi.more': "+{count} more",
|
||||
'pf.noShots': "No screenshots in the app folder yet",
|
||||
'pf.noShotsStore': "Store only: no app folder with screenshots",
|
||||
'tabs.more': "{count} more tabs",
|
||||
'tabs.moreShort': "More",
|
||||
'reviews.keysHint': "Keyboard: J / K next and previous, R reply, C copy, O open in the console",
|
||||
'tools.filter': "Find a command",
|
||||
'tools.search': "Search {count} commands (name, method, what it does)",
|
||||
'tools.searchAria': "Search commands",
|
||||
'tools.density': "Layout",
|
||||
'tools.list': "List",
|
||||
'tools.results': "{count} matching commands",
|
||||
'tools.noHits': "No command matches. Try a store word (listing, prices, testflight...).",
|
||||
'set.search': "Search settings",
|
||||
'set.noHits': "No setting matches",
|
||||
'preview.continue': "Continue to confirm",
|
||||
'preview.lead': "Dry run: nothing is changed yet. Continue opens the confirmation, which runs the dry run once more and then applies it.",
|
||||
'preview.more': "… {count} more lines",
|
||||
'preview.nothing': "The dry run reports no change.",
|
||||
'settings.density': "Tables",
|
||||
'settings.comfortable': "Comfortable",
|
||||
'settings.compact': "Compact",
|
||||
'action.toggleDensity': "Compact / comfortable tables",
|
||||
'palette.gActions': 'Actions',
|
||||
'palette.openApp': 'Open',
|
||||
|
||||
|
|
@ -153,10 +123,7 @@ export const en = {
|
|||
'phase.releasedUnknown': "Couldn't check the store page",
|
||||
'phase.pageUnknown': "Couldn't check the store page",
|
||||
'phase.pageStale': "couldn't check the store page, as of {when}",
|
||||
'phase.reviewLive': 'Not live yet (live: {version}): in review or blocked before review',
|
||||
'phase.playPending': 'Not live yet: in review or blocked before review',
|
||||
'roll.publishReady': 'If the release is approved, publish it in Publishing overview ("Publish N changes"). The agent prepares it with `{cmd}`; the final press is for the owner.',
|
||||
'roll.publishOpen': 'Open Publishing overview',
|
||||
'phase.reviewLive': 'In review (live: {version})',
|
||||
'phase.reviewManaged': 'In review / ready to publish (managed publishing)',
|
||||
'phase.rejected': 'Rejected',
|
||||
'phase.testing': 'Testing',
|
||||
|
|
@ -399,7 +366,6 @@ export const en = {
|
|||
'reviews.shown': '{count} shown',
|
||||
'reviews.noMatch': 'No review matches the filter',
|
||||
'reviews.pick': 'Pick a review',
|
||||
'reviews.lagNote': "Read from the stores' APIs. Apple's API can trail the App Store Connect page; Google Play returns only the last 7 days.",
|
||||
'reviews.answered': 'Answered',
|
||||
'reviews.drafted': 'Draft',
|
||||
'reviews.approved': 'Approved',
|
||||
|
|
@ -456,14 +422,6 @@ export const en = {
|
|||
'checks.problem': 'Problem',
|
||||
'checks.warning': 'Warning',
|
||||
'checks.validate': 'Store readiness',
|
||||
'checks.report': 'Shareable report',
|
||||
'checks.reportSub': 'One page for both stores with every finding, fix and console link, and the questions only you can answer. Opens in your browser; nothing is sent to a store.',
|
||||
'checks.reportOpen': 'Report',
|
||||
'checks.reportFailed': 'The report was not written',
|
||||
'checks.reportBlockers': '{count} blocker(s)',
|
||||
'checks.reportNotChecked': 'not checked (no store key)',
|
||||
'checks.reportNotSetUp': 'not set up',
|
||||
'checks.reportReady': 'ready to submit',
|
||||
'checks.validateSub': 'Read-only report from both stores, blockers in order',
|
||||
'checks.run': 'Run the report',
|
||||
'checks.validateEmpty': 'Readiness not checked yet',
|
||||
|
|
@ -479,33 +437,6 @@ export const en = {
|
|||
'checks.scanAsk': '{count} declaration answers only you can give',
|
||||
'checks.scanAsk_one': '1 declaration answer only you can give',
|
||||
'checks.scanWrite': 'Detected Data safety / App Privacy answers differ from the config: storebound scan --write adds them',
|
||||
'checks.fix': 'Fix',
|
||||
'checks.fixing': 'Fix: {what}',
|
||||
'checks.fixed': 'Fixed. Checking again',
|
||||
'checks.fixHint.scan.write': 'Writes the detected Data safety / App Privacy answers into the config. You see the diff before anything is written.',
|
||||
'checks.fixHint.play.listing': 'Pushes the listing file to Google Play (text only, nothing is published). You see the dry run first.',
|
||||
'checks.fixHint.apple.listing': 'Pushes the listing file and copyright to the App Store version (text only, nothing is submitted). You see the dry run first.',
|
||||
'checks.fixHint.apple.review_details': 'Sends the App Review contact and notes from the config (nothing is submitted). You see the dry run first.',
|
||||
'ai.run': 'Fix with AI',
|
||||
'ai.runHint': 'Runs Claude Code on this machine in the app folder with file tools only (read and edit; no commands, no network, no git push). You see the diff and can undo it.',
|
||||
'ai.copy': 'Copy for AI',
|
||||
'ai.copyHint': 'Claude Code (claude) is not installed: copies a prompt for this finding to paste into your AI agent.',
|
||||
'ai.starting': 'Starting Claude Code...',
|
||||
'ai.running': 'Claude Code is fixing this finding (at most 10 minutes).',
|
||||
'ai.dirty': 'Uncommitted files in the app folder ({count}): commit or stash them first, so Undo can restore exactly what the AI changes.',
|
||||
'ai.noGit': 'The app folder is not in a git repository: Fix with AI needs git for the diff and Undo.',
|
||||
'ai.cancelled': 'Cancelled',
|
||||
'ai.signedOut': 'Claude Code is not signed in: run claude once in a terminal and sign in with /login, or copy the prompt for your AI agent',
|
||||
'ai.timedOut': 'Stopped after 10 minutes',
|
||||
'ai.noChanges': 'No files changed',
|
||||
'ai.changed': 'Changed files: {count}',
|
||||
'ai.stillThere': 'the scan still reports it',
|
||||
'ai.gone': 'the scan no longer reports it',
|
||||
'ai.diff': 'Changes',
|
||||
'ai.keep': 'Keep',
|
||||
'ai.undo': 'Undo',
|
||||
'ai.undone': 'Undone. Restored files: {count}',
|
||||
'ai.failed': 'Fix with AI failed: {msg}',
|
||||
'checks.accepted': 'Accepted: {reason}',
|
||||
'checks.sev.blocker': 'Blocker',
|
||||
'checks.sev.high': 'High',
|
||||
|
|
@ -577,7 +508,6 @@ export const en = {
|
|||
'deps.tools': 'Tools on this computer',
|
||||
'deps.found': '{found} of {total} found',
|
||||
'deps.missing': 'Missing',
|
||||
'deps.outdated': 'Too old (needs {version})',
|
||||
'deps.appCopy': 'App copy',
|
||||
'deps.installed': 'Installed',
|
||||
'deps.download': 'Download',
|
||||
|
|
@ -770,16 +700,6 @@ export const en = {
|
|||
'app.unpair': 'Unpair',
|
||||
'app.ignore': 'Ignore',
|
||||
'app.ignoreHint': 'Leave this app out of the portfolio and store reads (Ignored lists it to restore)',
|
||||
'app.unignore': 'Stop ignoring',
|
||||
'app.unignoreHint': 'Bring this app back into the portfolio and store reads',
|
||||
'app.ignoreSide': 'Ignore {store}',
|
||||
'app.unignoreSide': 'Stop ignoring {store}',
|
||||
'app.ignoreSideHint': 'Leave only the {store} side of this app out of reads and notifications. Storebound only: the store itself is untouched',
|
||||
'app.unignoreSideHint': 'Read the {store} side of this app again',
|
||||
'ignored.show': '{count} ignored · show',
|
||||
'ignored.hide': '{count} ignored · hide',
|
||||
'ignored.rowHint': 'Ignored: not read, not watched',
|
||||
'ignored.sideHint': 'Ignored on {stores}',
|
||||
'app.studioHint': 'Open the studio',
|
||||
'app.storeOnlyTitle': 'Store only.',
|
||||
'app.storeOnlyText': 'This app is on your store accounts but has no app folder: releases, reviews, health and TestFlight are shown read-only. Link its folder, or start one, to manage listings, products and checks.',
|
||||
|
|
@ -826,54 +746,6 @@ export const en = {
|
|||
'notify.lowTitle': '{name}: {count} new low ratings',
|
||||
'notify.lowTitle_one': '{name}: a new low rating',
|
||||
'notify.crashTitle': '{name}: over the Play threshold',
|
||||
'notify.rejectedTitle': '{name}: rejected by {store}',
|
||||
'notify.rejectedBody': 'Version {version} was rejected.',
|
||||
'notify.approvedTitle': '{name}: approved',
|
||||
'notify.approvedBody': '{store} {version} passed review and is ready for release.',
|
||||
'notify.liveTitle': '{name}: live',
|
||||
'notify.liveBody': '{store} {version} is live.',
|
||||
'notify.haltedTitle': '{name}: rollout halted',
|
||||
'notify.haltedBody': '{store} {version} was halted.',
|
||||
'tray.open': 'Open storebound',
|
||||
'tray.readNow': 'Read the stores now',
|
||||
'tray.apps': 'Apps',
|
||||
'tray.noApps': 'No apps yet',
|
||||
'tray.noState': 'no release read yet',
|
||||
'tray.mute': 'Mute notifications for 1 hour',
|
||||
'tray.unmute': 'Unmute notifications',
|
||||
'tray.update': 'Check for updates',
|
||||
'tray.settings': 'Settings',
|
||||
'tray.quit': 'Quit storebound',
|
||||
'tray.tipReview': '{count} in review',
|
||||
'tray.tipAttention': '{count} needs you',
|
||||
'tray.mutedTip': 'muted',
|
||||
'set.nRejected': 'Rejected',
|
||||
'set.nRejectedHint': 'A version the store rejected, with its reason when the store gives one',
|
||||
'set.nApproved': 'Approved and ready for release',
|
||||
'set.nApprovedHint': 'A version passed review',
|
||||
'set.nLive': 'Live',
|
||||
'set.nLiveHint': 'A version reached the store page or a rollout',
|
||||
'set.quiet': 'Quiet hours',
|
||||
'set.quietHint': 'No notifications in this window (they are dropped, not saved for later)',
|
||||
'set.quietFrom': 'From',
|
||||
'set.quietTo': 'To',
|
||||
'set.muteHour': 'Mute for 1 hour',
|
||||
'set.unmute': 'Unmute ({until})',
|
||||
'set.muted': 'Muted until {until}',
|
||||
'set.clickNote': "Clicking a notification does not open the app on desktop; the next time you open the window from the tray, it goes to that app's Releases tab.",
|
||||
'v2cfg.workspace': 'Workspace',
|
||||
'v2cfg.studios': 'Studios',
|
||||
'v2cfg.accounts': 'Store accounts',
|
||||
'v2cfg.perStore': '{play} Google Play · {apple} App Store',
|
||||
'v2cfg.keysWork': 'Keys working',
|
||||
'v2cfg.keysFailing': 'Keys failing',
|
||||
'v2cfg.depsFound': 'Tools found',
|
||||
'v2cfg.depsNeeds': 'Need attention',
|
||||
'v2cfg.depsInApp': 'In app data',
|
||||
'v2cfg.depsInAppSub': 'downloaded by storebound',
|
||||
'v2cfg.needs': 'needs {version}',
|
||||
'v2cfg.stepOf': 'Step {n} of {total}',
|
||||
'v2cfg.progress': 'Setup progress',
|
||||
'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.',
|
||||
'set.sections': 'Settings sections',
|
||||
'set.studios': 'Studios & accounts',
|
||||
|
|
@ -920,9 +792,6 @@ export const en = {
|
|||
'read.couldNotRefresh': "couldn't refresh: {reason}",
|
||||
'read.refresh': "Refresh",
|
||||
'read.waiting': "Reading…",
|
||||
'read.justNow': "read just now",
|
||||
'read.paused': "store limit: waits until {time}",
|
||||
'read.busyShared': "Already reading: this joins the read in progress",
|
||||
'reader.progress': "Reading {done} of {total}",
|
||||
'reader.upToDate': "Everything read",
|
||||
'reader.off': "Auto-read is off",
|
||||
|
|
@ -949,11 +818,11 @@ export const en = {
|
|||
'set.always': 'Always',
|
||||
'set.notifications': 'Notifications',
|
||||
'set.notificationsSub': 'After a read, when something changed since the previous one',
|
||||
'set.nReview': 'Other release state changes',
|
||||
'set.nReviewHint': 'Submitted, in review',
|
||||
'set.nReview': 'Review and release state changes',
|
||||
'set.nReviewHint': 'In review, approved, rejected, live, halted',
|
||||
'set.nLow': 'New 1 or 2 star reviews',
|
||||
'set.nLowHint': 'When the Reviews tab reads new ones',
|
||||
'set.nCrash': 'Rollout halted, or crash / ANR over the threshold',
|
||||
'set.nCrash': 'Crash or ANR rate over the threshold',
|
||||
'set.nCrashHint': 'Google Play bad behavior thresholds',
|
||||
'set.testNotify': 'Send a test notification',
|
||||
'set.testTitle': 'storebound',
|
||||
|
|
@ -977,9 +846,7 @@ export const en = {
|
|||
'set.channel': 'Channel',
|
||||
'set.stable': 'Stable',
|
||||
'set.beta': 'Beta',
|
||||
'set.dev': 'Dev (every change)',
|
||||
'set.devToStable': 'This is the dev build {version}: it stays until a stable release is newer (no downgrade).',
|
||||
'set.updatesText': 'Updates come from storebound.dev and are signed; the app checks the signature before it replaces anything. It looks for a newer version of the chosen channel at start, every 15 minutes and after sleep, and installs it in the background (not while you work in this window). Stable follows the npm releases; Dev gets every change on main.',
|
||||
'set.updatesText': 'Updates come from storebound.dev and are signed; the app checks the signature before it replaces anything, and installs only when you press Install. Nothing is checked in the background.',
|
||||
'set.updateCheck': 'Check for updates',
|
||||
'set.updateChecking': 'Checking…',
|
||||
'set.updateNone': 'storebound {version} is the newest version on the {channel} channel.',
|
||||
|
|
@ -1225,14 +1092,6 @@ export const en = {
|
|||
'art.render': 'Render {lang}',
|
||||
'art.notThere': 'Not there yet',
|
||||
'art.count': '{count} files',
|
||||
'v2lst.preview': 'Store preview',
|
||||
'v2lst.noSub': 'No subtitle yet',
|
||||
'v2lst.noDesc': 'No description yet',
|
||||
'v2lst.more': 'more',
|
||||
'v2lst.ready': 'Ready',
|
||||
'v2lst.toFix': '{count} to fix',
|
||||
'v2lst.issues': '{count} issues',
|
||||
'v2lst.issues_one': '1 issue',
|
||||
'art.count_one': '1 file',
|
||||
'art.open': 'Open {name}',
|
||||
'art.tooBig': 'too big to preview',
|
||||
|
|
@ -1584,8 +1443,6 @@ export const en = {
|
|||
'cp.failed': 'Could not make the plan for {form}',
|
||||
'cp.agentFirst': 'Agent-first: your AI agent drives the page with this plan; storebound never clicks the final button',
|
||||
'cp.openPage': 'Open the page',
|
||||
'link.open': 'Open',
|
||||
'link.openFailed': 'Could not open the page',
|
||||
'cp.openFailed': 'Could not open the page',
|
||||
'cp.steps': '{count} steps',
|
||||
'cp.steps_one': '1 step',
|
||||
|
|
@ -1911,325 +1768,4 @@ export const en = {
|
|||
'ed.targetAudience': 'Target audience and ads (Play)',
|
||||
'ed.appContent': 'App content declarations (Play)',
|
||||
'ed.appContentHint': 'Government, financial and health features, advertising ID. No API: the console plan step uses them.',
|
||||
'tab.tools': 'More tools',
|
||||
'tools.lead': 'Every CLI command without a tab of its own, as a form with the command\'s own options. Reads run here; writes show the dry run and ask in a native dialog first. Owner steps stay yours: copy the terminal command.',
|
||||
'tools.g.build': 'Build and upload',
|
||||
'tools.g.adhoc': 'Ad-hoc devices',
|
||||
'tools.g.store': 'Store features',
|
||||
'tools.g.text': 'Text and ASO',
|
||||
'tools.g.capture': 'Captures',
|
||||
'tools.g.quality': 'Quality',
|
||||
'tools.g.ads': 'Apple Ads',
|
||||
'tools.g.flows': 'Workflows and AI tools',
|
||||
'tools.count': '{count} commands',
|
||||
'v2ins.hStartup': 'Launch time',
|
||||
'v2ins.hStartupSub': 'App Store diagnostics · version {version}',
|
||||
'v2ins.rvOpen': 'Unanswered',
|
||||
'v2ins.gFunnel': 'Store funnel',
|
||||
'v2ins.gVsPrev': '% vs the {count} days before',
|
||||
'v2ins.gPts': 'points vs the {count} days before',
|
||||
'v2ins.cScore': 'Store readiness',
|
||||
'v2ins.cScoreSub': '{ok} of {total} checks pass',
|
||||
'v2ins.cReady': 'Ready to submit',
|
||||
'v2ins.cBlocked': '{count} blockers to fix',
|
||||
'v2ins.cNotRun': 'Not checked yet',
|
||||
'v2ins.cBlockers': 'Blockers',
|
||||
'v2ins.cWarnings': 'Warnings',
|
||||
'v2ins.cPassedN': 'Passed',
|
||||
'v2ins.cFixFirst': 'Fix first',
|
||||
'v2ins.cPassed': 'Passed checks ({count})',
|
||||
'v2ins.cAccepted': 'Accepted risks ({count})',
|
||||
'v2ins.aiSheet': 'Store info',
|
||||
'v2ins.aiOpen': 'Open questions',
|
||||
'v2ins.aiNone': 'None',
|
||||
'tools.count_one': '1 command',
|
||||
'tools.show': 'Show',
|
||||
'tools.hide': 'Hide',
|
||||
'tools.read': 'read-only',
|
||||
'tools.write': 'write (dry run first)',
|
||||
'tools.network': 'store',
|
||||
'tools.run': 'Run',
|
||||
'tools.dryRun': 'Dry run, then apply',
|
||||
'tools.done': '{title}: done',
|
||||
'tools.default': 'default: {value}',
|
||||
'tools.exit': 'exit {code}',
|
||||
'tools.copy': 'Copy',
|
||||
'tools.copied': 'Copied',
|
||||
'tools.owner': 'Owner steps (terminal)',
|
||||
'tools.ownerSub': 'Public or price changes only you decide; run them in a terminal in the app folder',
|
||||
'tools.o.offer_codes': 'App Store offer codes for one-time purchases',
|
||||
'tools.o.win_back': 'Win-back offers for lapsed subscribers',
|
||||
'tools.o.promoted': 'Turn on promoted in-app purchases',
|
||||
'tools.o.preorder': 'Enable or end an App Store pre-order',
|
||||
'tools.o.cpp_submit': 'Submit a custom product page (then make it visible)',
|
||||
'tools.o.events_submit': 'Submit an in-app event',
|
||||
'tools.o.tf_expire': 'Expire old TestFlight builds',
|
||||
'tools.o.ads_change': 'Apple Ads bid, budget or pause',
|
||||
'tools.o.rc_sync': 'Sync the catalog to RevenueCat',
|
||||
'tools.o.shots_login': 'Sign in to the demo account for screenshots (by hand, Enter saves)',
|
||||
'cp.playGames': 'Play Games Services setup',
|
||||
'cp.playGamesText': 'Games project, Android credential with the app signing SHA-1, web OAuth client; you press Publish.',
|
||||
'gs.title': 'Game services',
|
||||
'gs.sub': 'Game Center and Play Games Services: switches, ids, leaderboards and achievements from the config.',
|
||||
'gs.gameCenter': 'Game Center',
|
||||
'gs.playGames': 'Play Games Services',
|
||||
'gs.enabled': 'On for the app',
|
||||
'gs.capability': 'App ID capability',
|
||||
'gs.version': 'Version {v}',
|
||||
'gs.items': 'Leaderboards / achievements',
|
||||
'gs.counts': '{lb} of {lbWant} leaderboards · {ach} of {achWant} achievements',
|
||||
'gs.pending': '{n} change(s) to write',
|
||||
'gs.projectId': 'Project id',
|
||||
'gs.resource': 'games_APP_ID resource',
|
||||
'gs.notSet': 'Not set',
|
||||
'gs.yes': 'Yes',
|
||||
'gs.no': 'No',
|
||||
'gs.unknown': 'Unknown',
|
||||
'gs.syncApple': 'Sync Game Center',
|
||||
'gs.syncPlay': 'Sync Play Games',
|
||||
'gs.syncHint': 'Dry run first, then you confirm; nothing is submitted or published.',
|
||||
'gs.synced': 'Game services in step',
|
||||
'gs.setup': 'Setup plan',
|
||||
'pf.morning': "Good morning.",
|
||||
'pf.afternoon': "Good afternoon.",
|
||||
'pf.evening': "Good evening.",
|
||||
'pf.moving': "{count} releases are moving",
|
||||
'pf.moving_one': "1 release is moving",
|
||||
'pf.movingNone': "Nothing is moving right now",
|
||||
'pf.notMovingUnknown': "Read the stores to see what is moving",
|
||||
'pf.readAgo': "stores read {when}",
|
||||
'pf.layout': "Layout",
|
||||
'pf.cards': "Cards",
|
||||
'pf.table': "Table",
|
||||
'pf.next': "next",
|
||||
'pf.crash': "crash",
|
||||
'pf.ctaFix': "Fix {count} blockers",
|
||||
'pf.ctaFix_one': "Fix 1 blocker",
|
||||
'pf.ctaRollout': "Roll out (now {share})",
|
||||
'pf.ctaRelease': "Release {version}",
|
||||
'pf.ctaSubmit': "Submit {version}",
|
||||
'pf.ctaReview': "Follow review of {version}",
|
||||
'pf.ctaWarnings': "Look at {count} warnings",
|
||||
'pf.ctaWarnings_one': "Look at 1 warning",
|
||||
'pf.ctaUpload': "Upload a build",
|
||||
'pf.ctaOpen': "Open releases",
|
||||
'pf.feed': "What changed",
|
||||
'pf.feedSub': "Releases, health and folders, newest first",
|
||||
'pf.feedEmpty': "Nothing changed yet",
|
||||
'pf.feedEmptyText': "Read the stores to see releases and health here.",
|
||||
'pf.evLive': "{version} is live on {store}",
|
||||
'pf.evRollout': "{version} is rolling out to {share} on {store}",
|
||||
'pf.evReview': "{version} went into review on {store}",
|
||||
'pf.evApproved': "{version} was approved on {store}",
|
||||
'pf.evRejected': "{version} was rejected on {store}",
|
||||
'pf.evHalted': "{version} rollout halted on {store}",
|
||||
'pf.evTesting': "{version} is in testing on {store}",
|
||||
'pf.evDraft': "{version} draft on {store}",
|
||||
'pf.evCrash': "crash rate {rate} on Google Play",
|
||||
'pf.evAdded': "folder added to the workspace",
|
||||
'pf.more': "Show {count} more",
|
||||
'pf.less': "Show less",
|
||||
'pf.fixFirst': "Fix first",
|
||||
'pf.fixFirstSub': "{count} items, blockers first",
|
||||
'pf.fixFirstSub_one': "1 item",
|
||||
'pf.blocker': "Blocker",
|
||||
'pf.warning': "Warning",
|
||||
'pf.note': "Note",
|
||||
'pf.fixIn': "Fix in {tab}",
|
||||
'pf.openTab': "Open {tab}",
|
||||
'v2rel.version': "Version",
|
||||
'v2rel.stages': "{store} release stages",
|
||||
'v2rel.ofUsers': "of users get this version",
|
||||
'v2rel.versions': "{count} versions",
|
||||
'v2rel.ready': "Ready",
|
||||
'v2rel.blocked': "Blocked",
|
||||
'v2rel.todo': "To do",
|
||||
'v2rel.attention': "Check",
|
||||
'v2rel.readyCount': "{done} of {total} steps ready",
|
||||
'v2rel.steps': "Steps to send for review",
|
||||
'v2rel.stepRead': "Store state",
|
||||
'v2rel.stepNoOpen': "Nothing else in review",
|
||||
'v2rel.stepBuild': "Pick the build",
|
||||
'v2rel.stepVersion': "Version to send",
|
||||
'v2rel.stepSend': "Submit for review",
|
||||
'v2rel.stepCode': "Pick the versionCode",
|
||||
'v2rel.tfGroups': "Beta groups",
|
||||
'v2rel.tfInGroups': "in {count} groups",
|
||||
'v2rel.tfSplit': "{internal} internal · {external} external",
|
||||
'v2rel.tfUsable': "Ready to test",
|
||||
|
||||
// context menus (ui/ContextMenu.tsx)
|
||||
'cm.cut': "Cut",
|
||||
'cm.pin': "Pin: keeps this menu open",
|
||||
'cm.unpin': "Unpin",
|
||||
'cm.closePanel': "Close",
|
||||
'cm.gone': "Gone: what this menu was for is not on screen now.",
|
||||
'cm.menu': "Menu",
|
||||
'cm.tearOff': "Ctrl+click or middle-click: pin this submenu",
|
||||
'cm.copy': "Copy",
|
||||
'cm.paste': "Paste",
|
||||
'cm.selectAll': "Select all",
|
||||
'cm.copied': "Copied",
|
||||
'cm.open': "Open",
|
||||
'cm.openTab': "Open tab",
|
||||
'cm.openConsole': "Open in console",
|
||||
'cm.openIn': "Open in {place}",
|
||||
'cm.openFolder': "Open folder",
|
||||
'cm.folderFailed': "Could not open the folder",
|
||||
'cm.copyName': "Name",
|
||||
'cm.copyBundleId': "Bundle id",
|
||||
'cm.copyPackage': "Package name",
|
||||
'cm.copyAscId': "App Store Connect app id",
|
||||
'cm.copyPath': "Copy path",
|
||||
'cm.refreshReads': "Read again from the stores",
|
||||
'cm.refreshing': "Reading {name} again",
|
||||
'cm.ignoredNoReads': "Ignored apps are not read",
|
||||
'cm.ignore': "Ignore",
|
||||
'acct.devId': 'Play developer id {id}',
|
||||
'acct.viaBroker': 'tokens from the credential broker',
|
||||
'acct.viaFile': 'local key file',
|
||||
'acct.grantsTitle': 'Play permissions on the credential broker',
|
||||
'acct.grantsOk': 'everything storebound needs',
|
||||
'acct.grantsMissing': 'missing: {list}',
|
||||
'acct.grantsNone': 'no permission data',
|
||||
'acct.notRegistered': 'not registered on the broker',
|
||||
'acct.noDevId': 'no Play developer id',
|
||||
'acct.devIdLabel': 'Play developer account id',
|
||||
'acct.devIdHint': "The digits after /developers/ in the Play Console address. With it, Play Console links open each app's own page; the CLI uses it too.",
|
||||
'checks.openGuide': 'Open guide',
|
||||
'checks.openGuideHint': 'Open the {skill} guide (SKILL.md)',
|
||||
'cm.more': "More actions",
|
||||
'cm.showIgnored': "Show ignored apps",
|
||||
'cm.copyVersion': "Copy version",
|
||||
'cm.copyBuild': "Copy build",
|
||||
'cm.copyVersionCode': "Copy version code",
|
||||
'cm.preview': "Preview",
|
||||
'cm.openFile': "Open file",
|
||||
'cm.reveal': "Show in folder",
|
||||
'cm.readAgain': "Read again",
|
||||
'cm.reading': "Reading…",
|
||||
'cm.openDraft': "Show reply draft",
|
||||
'cm.copyReview': "Copy review",
|
||||
'cm.copyReply': "Copy the published reply",
|
||||
'cm.copyDraft': "Copy the draft",
|
||||
'cm.revealEvidence': "Show the file",
|
||||
'cm.copyTitle': "Copy title",
|
||||
'cm.copyFix': "Copy the fix",
|
||||
'cm.copySkill': "Copy skill name",
|
||||
'cm.copyEvidence': "Copy evidence lines",
|
||||
'cm.copyCode': "Copy check code",
|
||||
'cm.copyValue': "Copy value",
|
||||
'cm.notChanged': "Not changed",
|
||||
'cm.copyProductId': "Copy product id",
|
||||
'cm.showPrices': "Show prices",
|
||||
'cm.copyRow': "Copy row",
|
||||
'cm.showPerRegion': "Show per region",
|
||||
'cm.copyPriceOf': "Copy {store} price",
|
||||
'cm.copyRegion': "Copy region code",
|
||||
'cm.busy': "Another action is running",
|
||||
'cm.copyPrincipal': "Service account / key",
|
||||
'cm.copyProfile': "Profile name",
|
||||
'cm.openAccounts': "Open Studios & accounts",
|
||||
"sec.release": "Release",
|
||||
"sec.store": "Store page",
|
||||
"sec.revenue": "Revenue",
|
||||
"sec.players": "Players",
|
||||
"sec.health": "Health",
|
||||
"sec.release.for": "Get a version to players on both stores: builds, TestFlight, review and rollout.",
|
||||
"sec.store.for": "What people see on the store: texts per language, screenshots and app details.",
|
||||
"sec.revenue.for": "What the app sells and for how much: in-app products, subscriptions and prices.",
|
||||
"sec.players.for": "What players say, and for games what they can earn: reviews, Game Center, Play Games.",
|
||||
"sec.health.for": "Is the app well and ready: crashes and ANRs, growth, checks to fix first, more tools.",
|
||||
"sec.views": "{section} views",
|
||||
"tab.games": "Game services",
|
||||
"next.open": "Open {where}",
|
||||
"next.rejected": "{version} was rejected on {store}",
|
||||
"next.halted": "{version} is halted on {store}",
|
||||
"next.blockers": "{count} blockers before submit",
|
||||
"next.blockers_one": "1 blocker before submit",
|
||||
"next.rollout": "{version} is rolling out at {share}",
|
||||
"next.approved": "{version} is approved: release it",
|
||||
"next.submit": "Submit {version} for review",
|
||||
"next.review": "{version} is in review",
|
||||
"next.managed": "{version} waits in Publishing overview (managed publishing)",
|
||||
"next.upload": "Upload a first build",
|
||||
"next.live": "Live: {version}",
|
||||
"next.notRead": "Release state shows once the stores are read",
|
||||
"next.storeLive": "The live store page, read from the stores",
|
||||
"next.listingProblems": "{count} listing problems",
|
||||
"next.listingProblems_one": "1 listing problem",
|
||||
"next.unsaved": "{count} unsaved edits",
|
||||
"next.unsaved_one": "1 unsaved edit",
|
||||
"next.listingsUnread": "Listings not checked yet",
|
||||
"next.storeOk": "Store page in step with the config",
|
||||
"next.catalogDiff": "{count} products differ between the stores",
|
||||
"next.catalogDiff_one": "1 product differs between the stores",
|
||||
"next.noProducts": "No products yet: add one",
|
||||
"next.products": "{products} products · {subs} subscriptions",
|
||||
"next.reviews": "{count} reviews without a reply",
|
||||
"next.reviews_one": "1 review without a reply",
|
||||
"next.reviewsUnread": "See what players say",
|
||||
"next.reviewsOk": "All reviews answered",
|
||||
"next.gamesApiOff": "Play Games Services API is off",
|
||||
"next.gamesBlockers": "{count} game services blockers",
|
||||
"next.gamesBlockers_one": "1 game services blocker",
|
||||
"next.gamesSync": "Game services: {count} changes to sync",
|
||||
"next.gamesSync_one": "Game services: 1 change to sync",
|
||||
"next.fixFirst": "{count} problems to fix first",
|
||||
"next.fixFirst_one": "1 problem to fix first",
|
||||
"next.warnings": "{count} warnings to look at",
|
||||
"next.warnings_one": "1 warning to look at",
|
||||
"next.healthOk": "No problems found",
|
||||
"side.gAttention": "Needs attention",
|
||||
"side.gProgress": "In progress",
|
||||
"side.gLive": "Live",
|
||||
"side.gDraft": "Not on a store yet",
|
||||
"side.missing": "Folder missing",
|
||||
"side.rejected": "Rejected · {what}",
|
||||
"side.halted": "Halted · {what}",
|
||||
"side.blockers": "{count} blockers",
|
||||
"side.blockers_one": "1 blocker",
|
||||
"side.rollout": "Rollout {share} · {what}",
|
||||
"side.review": "In review · {what}",
|
||||
"side.approved": "Approved · {what}",
|
||||
"side.draft": "Draft · {what}",
|
||||
"side.live": "Live · {version}",
|
||||
"side.liveWarn": "Live · {version} · {count} warnings",
|
||||
"side.liveWarn_one": "Live · {version} · 1 warning",
|
||||
"side.storeOnly": "Store only · not read yet",
|
||||
"side.notRead": "Not read yet",
|
||||
"side.warnings": "{count} warnings",
|
||||
"side.warnings_one": "1 warning",
|
||||
"side.notOnStore": "No store version yet",
|
||||
"side.ignored": "Ignored",
|
||||
"side.studioFilter": "Show apps of a studio",
|
||||
"side.allStudios": "All",
|
||||
"games.keep": "In step",
|
||||
"games.update": "Differs",
|
||||
"games.create": "Missing",
|
||||
"games.notOnStore": "Not on this store",
|
||||
"games.notListed": "Not planned",
|
||||
"games.notRead": "Not read",
|
||||
"games.item": "Item",
|
||||
"games.name": "Name",
|
||||
"games.points": "Points",
|
||||
"games.languages": "Languages",
|
||||
"games.leaderboard": "Leaderboard",
|
||||
"games.achievement": "Achievement",
|
||||
"games.hidden": "hidden until earned",
|
||||
"games.localeChanges": "Game Center languages: {list}",
|
||||
"games.checkAgain": "Check again",
|
||||
"games.apiOffTitle": "Play Games Services API is off.",
|
||||
"games.apiOffText": "Turn on {api} in the service account's Google Cloud project, wait a minute, then check again.",
|
||||
"games.itemsTitle": "Leaderboards and achievements",
|
||||
"games.itemsSub": "One list in the config, mapped to both stores: each store's state against the config.",
|
||||
"games.refresh": "Read again",
|
||||
"games.noItems": "No leaderboards or achievements in the config yet (games.leaderboards, games.achievements).",
|
||||
"games.notReadYet": "Reading game services…",
|
||||
"games.images": "Images of leaderboards and achievements are added in the consoles; on Play the existing icon is kept on every sync.",
|
||||
"games.localeMap": "Game Center locale map",
|
||||
"games.localeMapSub": "Config language to the locale the live Game Center uses (games.localeMap).",
|
||||
"games.mapSet": "Set",
|
||||
"games.mapSuggested": "Suggested",
|
||||
"games.mapHint": "Add to games.localeMap: {entries}",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -61,36 +61,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'palette.gApps': 'Uygulamalar',
|
||||
'palette.gGo': 'Git',
|
||||
'palette.gTabs': 'Uygulama sekmeleri',
|
||||
'palette.gApp': 'Bu uygulama: {name}',
|
||||
'palette.nextApp': 'Sonraki uygulama',
|
||||
'palette.prevApp': 'Önceki uygulama',
|
||||
'settings.scApps': 'Önceki / sonraki uygulama',
|
||||
'settings.scMenu': 'Odaktaki öğenin bağlam menüsü',
|
||||
'reader.backoffShort': "Mağaza yavaşlattı",
|
||||
'reader.appleQuotaShort': "App Store saatte {left} / {limit}",
|
||||
'kpi.more': "+{count} tane daha",
|
||||
'pf.noShots': "Uygulama klasöründe henüz ekran görüntüsü yok",
|
||||
'pf.noShotsStore': "Yalnız mağazada: ekran görüntülü uygulama klasörü yok",
|
||||
'tabs.more': "{count} sekme daha",
|
||||
'tabs.moreShort': "Daha fazla",
|
||||
'reviews.keysHint': "Klavye: J / K sonraki ve önceki, R yanıtla, C kopyala, O konsolda aç",
|
||||
'tools.filter': "Komut bul",
|
||||
'tools.search': "{count} komutta ara (ad, yöntem, ne yaptığı)",
|
||||
'tools.searchAria': "Komut ara",
|
||||
'tools.density': "Görünüm",
|
||||
'tools.list': "Liste",
|
||||
'tools.results': "{count} eşleşen komut",
|
||||
'tools.noHits': "Eşleşen komut yok. Bir mağaza kelimesi deneyin (listing, prices, testflight...).",
|
||||
'set.search': "Ayarlarda ara",
|
||||
'set.noHits': "Eşleşen ayar yok",
|
||||
'preview.continue': "Onaya geç",
|
||||
'preview.lead': "Deneme çalıştırması: henüz hiçbir şey değişmedi. Devam onay penceresini açar; deneme bir kez daha çalışır, sonra uygulanır.",
|
||||
'preview.more': "… {count} satır daha",
|
||||
'preview.nothing': "Deneme çalıştırması bir değişiklik bildirmiyor.",
|
||||
'settings.density': "Tablolar",
|
||||
'settings.comfortable': "Rahat",
|
||||
'settings.compact': "Sıkı",
|
||||
'action.toggleDensity': "Sıkı / rahat tablolar",
|
||||
'palette.gActions': 'İşlemler',
|
||||
'palette.openApp': 'Aç',
|
||||
|
||||
|
|
@ -155,10 +125,7 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi',
|
||||
'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi',
|
||||
'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla',
|
||||
'phase.reviewLive': 'Henüz yayında değil (yayında: {version}): incelemede ya da incelemeden önce takıldı',
|
||||
'phase.playPending': 'Henüz yayında değil: incelemede ya da incelemeden önce takıldı',
|
||||
'roll.publishReady': 'Sürüm onaylandıysa Yayınlama özetinde yayınlayın ("Publish N changes"). Ajan `{cmd}` ile hazırlar; son basış sahibine aittir.',
|
||||
'roll.publishOpen': 'Yayınlama özetini aç',
|
||||
'phase.reviewLive': 'İncelemede (yayında: {version})',
|
||||
'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)',
|
||||
'phase.rejected': 'Reddedildi',
|
||||
'phase.testing': 'Testte',
|
||||
|
|
@ -401,7 +368,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'reviews.shown': '{count} gösteriliyor',
|
||||
'reviews.noMatch': 'Süzgece uyan yorum yok',
|
||||
'reviews.pick': 'Bir yorum seçin',
|
||||
'reviews.lagNote': "Mağaza API'lerinden okunur. Apple'ın API'si App Store Connect sayfasından geride kalabilir; Google Play yalnızca son 7 günü verir.",
|
||||
'reviews.answered': 'Yanıtlandı',
|
||||
'reviews.drafted': 'Taslak',
|
||||
'reviews.approved': 'Onaylı',
|
||||
|
|
@ -458,14 +424,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'checks.problem': 'Sorun',
|
||||
'checks.warning': 'Uyarı',
|
||||
'checks.validate': 'Mağaza hazırlığı',
|
||||
'checks.report': 'Paylaşılabilir rapor',
|
||||
'checks.reportSub': 'İki mağaza için tek sayfa: her bulgu, düzeltme ve konsol bağlantısı, yalnızca senin yanıtlayabileceğin sorular. Tarayıcında açılır; mağazaya hiçbir şey gönderilmez.',
|
||||
'checks.reportOpen': 'Rapor',
|
||||
'checks.reportFailed': 'Rapor yazılamadı',
|
||||
'checks.reportBlockers': '{count} engel',
|
||||
'checks.reportNotChecked': 'kontrol edilmedi (mağaza anahtarı yok)',
|
||||
'checks.reportNotSetUp': 'kurulmamış',
|
||||
'checks.reportReady': 'gönderime hazır',
|
||||
'checks.validateSub': 'İki mağazadan salt okunur rapor, engeller sırayla',
|
||||
'checks.run': 'Raporu çalıştır',
|
||||
'checks.validateEmpty': 'Hazırlık henüz kontrol edilmedi',
|
||||
|
|
@ -481,33 +439,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'checks.scanAsk': 'Yalnızca senin cevaplayabileceğin {count} beyan sorusu',
|
||||
'checks.scanAsk_one': 'Yalnızca senin cevaplayabileceğin 1 beyan sorusu',
|
||||
'checks.scanWrite': 'Bulunan Data safety / App Privacy cevapları ayardan farklı: storebound scan --write ekler',
|
||||
'checks.fix': 'Düzelt',
|
||||
'checks.fixing': 'Düzelt: {what}',
|
||||
'checks.fixed': 'Düzeltildi. Yeniden kontrol ediliyor',
|
||||
'checks.fixHint.scan.write': 'Bulunan Data safety / App Privacy cevaplarını ayara yazar. Bir şey yazılmadan önce farkı görürsün.',
|
||||
'checks.fixHint.play.listing': "Mağaza metni dosyasını Google Play'e gönderir (yalnız metin, yayınlanmaz). Önce deneme çalıştırmasını görürsün.",
|
||||
'checks.fixHint.apple.listing': 'Mağaza metnini ve telif hakkını App Store sürümüne gönderir (yalnız metin, incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
|
||||
'checks.fixHint.apple.review_details': 'App Review iletişim bilgisini ve notları ayardan gönderir (incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
|
||||
'ai.run': 'Yapay zekâyla düzelt',
|
||||
'ai.runHint': "Claude Code'u bu bilgisayarda, uygulama klasöründe yalnız dosya araçlarıyla çalıştırır (okuma ve düzenleme; komut, ağ, git push yok). Farkı görür, geri alabilirsin.",
|
||||
'ai.copy': 'Yapay zekâ için kopyala',
|
||||
'ai.copyHint': 'Claude Code (claude) kurulu değil: bu bulgu için bir istemi yapay zekâ ajanına yapıştırmak üzere kopyalar.',
|
||||
'ai.starting': 'Claude Code başlıyor...',
|
||||
'ai.running': 'Claude Code bu bulguyu düzeltiyor (en çok 10 dakika).',
|
||||
'ai.dirty': 'Uygulama klasöründe commit edilmemiş {count} dosya var: önce commit et ya da stash yap ki Geri al tam olarak yapay zekânın değiştirdiğini geri yüklesin.',
|
||||
'ai.noGit': 'Uygulama klasörü bir git deposunda değil: Yapay zekâyla düzelt fark ve Geri al için git ister.',
|
||||
'ai.cancelled': 'İptal edildi',
|
||||
'ai.signedOut': 'Claude Code oturum açmamış: bir terminalde claude çalıştırıp /login ile giriş yapın ya da istemi yapay zekâ ajanınız için kopyalayın',
|
||||
'ai.timedOut': '10 dakika sonra durduruldu',
|
||||
'ai.noChanges': 'Hiçbir dosya değişmedi',
|
||||
'ai.changed': 'Değişen dosya: {count}',
|
||||
'ai.stillThere': 'tarama hâlâ bildiriyor',
|
||||
'ai.gone': 'tarama artık bildirmiyor',
|
||||
'ai.diff': 'Değişiklikler',
|
||||
'ai.keep': 'Tut',
|
||||
'ai.undo': 'Geri al',
|
||||
'ai.undone': 'Geri alındı. Geri yüklenen dosya: {count}',
|
||||
'ai.failed': 'Yapay zekâyla düzeltme başarısız: {msg}',
|
||||
'checks.accepted': 'Kabul edildi: {reason}',
|
||||
'checks.sev.blocker': 'Engel',
|
||||
'checks.sev.high': 'Yüksek',
|
||||
|
|
@ -579,7 +510,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'deps.tools': 'Bu bilgisayardaki araçlar',
|
||||
'deps.found': '{total} araçtan {found} tanesi var',
|
||||
'deps.missing': 'Eksik',
|
||||
'deps.outdated': 'Eski (gereken {version})',
|
||||
'deps.appCopy': 'Uygulamanın kopyası',
|
||||
'deps.installed': 'Kurulu',
|
||||
'deps.download': 'İndir',
|
||||
|
|
@ -771,16 +701,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'app.pick': 'Bir uygulama seçin',
|
||||
'app.unpair': 'Eşleşmeyi ayır',
|
||||
'app.ignore': 'Yok say',
|
||||
'app.unignore': 'Yok saymayı bırak',
|
||||
'app.unignoreHint': 'Bu uygulamayı portföye ve mağaza okumalarına geri al',
|
||||
'app.ignoreSide': '{store} tarafını yok say',
|
||||
'app.unignoreSide': '{store} tarafını geri al',
|
||||
'app.ignoreSideHint': 'Uygulamanın yalnızca {store} tarafını okumalardan ve bildirimlerden çıkarır. Yalnızca storebound: mağazaya dokunulmaz',
|
||||
'app.unignoreSideHint': 'Uygulamanın {store} tarafını yeniden oku',
|
||||
'ignored.show': '{count} yok sayılan · göster',
|
||||
'ignored.hide': '{count} yok sayılan · gizle',
|
||||
'ignored.rowHint': 'Yok sayılıyor: okunmaz, izlenmez',
|
||||
'ignored.sideHint': '{stores} tarafı yok sayılıyor',
|
||||
'app.ignoreHint': 'Bu uygulamayı portföy ve mağaza okumalarının dışında bırak (Yok sayılanlar ekranından geri getirilir)',
|
||||
'app.studioHint': 'Stüdyoyu aç',
|
||||
'app.storeOnlyTitle': 'Yalnızca mağaza.',
|
||||
|
|
@ -828,54 +748,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'notify.lowTitle': '{name}: {count} yeni düşük puan',
|
||||
'notify.lowTitle_one': '{name}: yeni bir düşük puan',
|
||||
'notify.crashTitle': '{name}: Play eşiğinin üstünde',
|
||||
'notify.rejectedTitle': '{name}: {store} reddetti',
|
||||
'notify.rejectedBody': '{version} sürümü reddedildi.',
|
||||
'notify.approvedTitle': '{name}: onaylandı',
|
||||
'notify.approvedBody': '{store} {version} incelemeyi geçti, yayına hazır.',
|
||||
'notify.liveTitle': '{name}: yayında',
|
||||
'notify.liveBody': '{store} {version} yayında.',
|
||||
'notify.haltedTitle': '{name}: kademeli yayın durduruldu',
|
||||
'notify.haltedBody': '{store} {version} durduruldu.',
|
||||
'tray.open': "storebound'u aç",
|
||||
'tray.readNow': 'Mağazaları şimdi oku',
|
||||
'tray.apps': 'Uygulamalar',
|
||||
'tray.noApps': 'Henüz uygulama yok',
|
||||
'tray.noState': 'henüz okunmadı',
|
||||
'tray.mute': 'Bildirimleri 1 saat sustur',
|
||||
'tray.unmute': 'Bildirimlerin sesini aç',
|
||||
'tray.update': 'Güncellemeleri denetle',
|
||||
'tray.settings': 'Ayarlar',
|
||||
'tray.quit': "storebound'dan çık",
|
||||
'tray.tipReview': '{count} incelemede',
|
||||
'tray.tipAttention': '{count} sizi bekliyor',
|
||||
'tray.mutedTip': 'sessiz',
|
||||
'set.nRejected': 'Reddedildi',
|
||||
'set.nRejectedHint': 'Mağazanın reddettiği sürüm; mağaza verirse gerekçesiyle',
|
||||
'set.nApproved': 'Onaylandı, yayına hazır',
|
||||
'set.nApprovedHint': 'Bir sürüm incelemeyi geçti',
|
||||
'set.nLive': 'Yayında',
|
||||
'set.nLiveHint': 'Bir sürüm mağaza sayfasına ya da kademeli yayına ulaştı',
|
||||
'set.quiet': 'Sessiz saatler',
|
||||
'set.quietHint': 'Bu aralıkta bildirim gelmez (sonraya saklanmaz, atılır)',
|
||||
'set.quietFrom': 'Başlangıç',
|
||||
'set.quietTo': 'Bitiş',
|
||||
'set.muteHour': '1 saat sustur',
|
||||
'set.unmute': 'Sesi aç ({until})',
|
||||
'set.muted': '{until} saatine kadar sessiz',
|
||||
'set.clickNote': 'Masaüstünde bildirime tıklamak uygulamayı açmaz; pencereyi bir sonraki sefer tepsiden açtığınızda o uygulamanın Yayınlar sekmesine gider.',
|
||||
'v2cfg.workspace': 'Çalışma alanı',
|
||||
'v2cfg.studios': 'Stüdyolar',
|
||||
'v2cfg.accounts': 'Mağaza hesapları',
|
||||
'v2cfg.perStore': '{play} Google Play · {apple} App Store',
|
||||
'v2cfg.keysWork': 'Çalışan anahtarlar',
|
||||
'v2cfg.keysFailing': 'Sorunlu anahtarlar',
|
||||
'v2cfg.depsFound': 'Bulunan araçlar',
|
||||
'v2cfg.depsNeeds': 'İlgi bekleyen',
|
||||
'v2cfg.depsInApp': 'Uygulama verisinde',
|
||||
'v2cfg.depsInAppSub': 'storebound indirdi',
|
||||
'v2cfg.needs': '{version} gerekli',
|
||||
'v2cfg.stepOf': 'Adım {n} / {total}',
|
||||
'v2cfg.progress': 'Kurulum ilerlemesi',
|
||||
'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.',
|
||||
'set.sections': 'Ayar bölümleri',
|
||||
'set.studios': 'Stüdyolar ve hesaplar',
|
||||
|
|
@ -922,9 +794,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'read.couldNotRefresh': "yenilenemedi: {reason}",
|
||||
'read.refresh': "Yenile",
|
||||
'read.waiting': "Okunuyor…",
|
||||
'read.justNow': "az önce okundu",
|
||||
'read.paused': "mağaza sınırı: {time} saatine kadar bekliyor",
|
||||
'read.busyShared': "Zaten okunuyor: bu, süren okumaya katılır",
|
||||
'reader.progress': "{total} okumadan {done} tamam",
|
||||
'reader.upToDate': "Her şey okundu",
|
||||
'reader.off': "Otomatik okuma kapalı",
|
||||
|
|
@ -951,11 +820,11 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'set.always': 'Her zaman',
|
||||
'set.notifications': 'Bildirimler',
|
||||
'set.notificationsSub': 'Bir okumadan sonra, öncekinden beri bir şey değiştiyse',
|
||||
'set.nReview': 'Diğer yayın durumu değişiklikleri',
|
||||
'set.nReviewHint': 'Gönderildi, incelemede',
|
||||
'set.nReview': 'İnceleme ve yayın durumu değişiklikleri',
|
||||
'set.nReviewHint': 'İncelemede, onaylandı, reddedildi, yayında, durduruldu',
|
||||
'set.nLow': 'Yeni 1 ya da 2 yıldızlı yorumlar',
|
||||
'set.nLowHint': 'Yorumlar sekmesi yenilerini okuduğunda',
|
||||
'set.nCrash': 'Kademeli yayın durdu, ya da çökme / ANR eşiğin üstünde',
|
||||
'set.nCrash': 'Çökme ya da ANR oranı eşiğin üstünde',
|
||||
'set.nCrashHint': 'Google Play kötü davranış eşikleri',
|
||||
'set.testNotify': 'Deneme bildirimi gönder',
|
||||
'set.testTitle': 'storebound',
|
||||
|
|
@ -979,9 +848,7 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'set.channel': 'Kanal',
|
||||
'set.stable': 'Kararlı',
|
||||
'set.beta': 'Beta',
|
||||
'set.dev': 'Geliştirme (her değişiklik)',
|
||||
'set.devToStable': 'Bu, geliştirme sürümü {version}: daha yeni bir kararlı sürüm çıkana kadar kalır (eski sürüme dönülmez).',
|
||||
'set.updatesText': 'Güncellemeler storebound.dev\'den imzalı gelir; uygulama bir şeyi değiştirmeden önce imzayı denetler. Seçili kanalda daha yeni bir sürümü açılışta, 15 dakikada bir ve uykudan sonra arar, arka planda yükler (siz bu pencerede çalışırken değil). Kararlı, npm sürümlerini izler; Geliştirme, main\'deki her değişikliği alır.',
|
||||
'set.updatesText': 'Güncellemeler storebound.dev\'den imzalı gelir; uygulama bir şeyi değiştirmeden önce imzayı denetler ve yalnızca siz Yükle\'ye bastığınızda yükler. Arka planda denetim yapılmaz.',
|
||||
'set.updateCheck': 'Güncellemeleri denetle',
|
||||
'set.updateChecking': 'Denetleniyor…',
|
||||
'set.updateNone': 'storebound {version}, {channel} kanalındaki en yeni sürüm.',
|
||||
|
|
@ -1227,14 +1094,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'art.render': '{lang} oluştur',
|
||||
'art.notThere': 'Henüz yok',
|
||||
'art.count': '{count} dosya',
|
||||
'v2lst.preview': 'Mağaza önizlemesi',
|
||||
'v2lst.noSub': 'Henüz alt başlık yok',
|
||||
'v2lst.noDesc': 'Henüz açıklama yok',
|
||||
'v2lst.more': 'devamı',
|
||||
'v2lst.ready': 'Hazır',
|
||||
'v2lst.toFix': '{count} düzeltilecek',
|
||||
'v2lst.issues': '{count} sorun',
|
||||
'v2lst.issues_one': '1 sorun',
|
||||
'art.count_one': '1 dosya',
|
||||
'art.open': '{name} aç',
|
||||
'art.tooBig': 'önizleme için çok büyük',
|
||||
|
|
@ -1586,8 +1445,6 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'cp.failed': '{form} için plan oluşturulamadı',
|
||||
'cp.agentFirst': 'Önce ajan: yapay zekâ aracınız sayfayı bu planla yönetir; storebound son düğmeye asla basmaz',
|
||||
'cp.openPage': 'Sayfayı aç',
|
||||
'link.open': 'Aç',
|
||||
'link.openFailed': 'Sayfa açılamadı',
|
||||
'cp.openFailed': 'Sayfa açılamadı',
|
||||
'cp.steps': '{count} adım',
|
||||
'cp.steps_one': '1 adım',
|
||||
|
|
@ -1913,325 +1770,4 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'ed.targetAudience': 'Hedef kitle ve reklamlar (Play)',
|
||||
'ed.appContent': 'Uygulama içeriği beyanları (Play)',
|
||||
'ed.appContentHint': 'Devlet, finans ve sağlık özellikleri, reklam kimliği. API yok: konsol planı adımı bunları kullanır.',
|
||||
'tab.tools': 'Diğer araçlar',
|
||||
'tools.lead': 'Kendi sekmesi olmayan her CLI komutu, komutun kendi seçenekleriyle bir form olarak. Okumalar burada çalışır; yazmalar önce prova sonucunu gösterir ve yerel bir pencerede onay ister. Sahip adımları sizindir: terminal komutunu kopyalayın.',
|
||||
'tools.g.build': 'Derleme ve yükleme',
|
||||
'tools.g.adhoc': 'Ad-hoc cihazlar',
|
||||
'tools.g.store': 'Mağaza özellikleri',
|
||||
'tools.g.text': 'Metin ve ASO',
|
||||
'tools.g.capture': 'Çekimler',
|
||||
'tools.g.quality': 'Kalite',
|
||||
'tools.g.ads': 'Apple Ads',
|
||||
'tools.g.flows': 'İş akışları ve yapay zekâ araçları',
|
||||
'tools.count': '{count} komut',
|
||||
'v2ins.hStartup': 'Açılış süresi',
|
||||
'v2ins.hStartupSub': 'App Store tanılamaları · sürüm {version}',
|
||||
'v2ins.rvOpen': 'Yanıtsız',
|
||||
'v2ins.gFunnel': 'Mağaza hunisi',
|
||||
'v2ins.gVsPrev': 'önceki {count} güne göre %',
|
||||
'v2ins.gPts': 'önceki {count} güne göre puan',
|
||||
'v2ins.cScore': 'Mağaza hazırlığı',
|
||||
'v2ins.cScoreSub': '{total} kontrolden {ok} tanesi geçiyor',
|
||||
'v2ins.cReady': 'Göndermeye hazır',
|
||||
'v2ins.cBlocked': 'Düzeltilecek {count} engel',
|
||||
'v2ins.cNotRun': 'Henüz kontrol edilmedi',
|
||||
'v2ins.cBlockers': 'Engeller',
|
||||
'v2ins.cWarnings': 'Uyarılar',
|
||||
'v2ins.cPassedN': 'Geçti',
|
||||
'v2ins.cFixFirst': 'Önce bunları düzelt',
|
||||
'v2ins.cPassed': 'Geçen kontroller ({count})',
|
||||
'v2ins.cAccepted': 'Kabul edilen riskler ({count})',
|
||||
'v2ins.aiSheet': 'Mağaza bilgileri',
|
||||
'v2ins.aiOpen': 'Açık sorular',
|
||||
'v2ins.aiNone': 'Yok',
|
||||
'tools.count_one': '1 komut',
|
||||
'tools.show': 'Göster',
|
||||
'tools.hide': 'Gizle',
|
||||
'tools.read': 'salt okunur',
|
||||
'tools.write': 'yazma (önce prova)',
|
||||
'tools.network': 'mağaza',
|
||||
'tools.run': 'Çalıştır',
|
||||
'tools.dryRun': 'Prova, sonra uygula',
|
||||
'tools.done': '{title}: tamam',
|
||||
'tools.default': 'varsayılan: {value}',
|
||||
'tools.exit': 'çıkış {code}',
|
||||
'tools.copy': 'Kopyala',
|
||||
'tools.copied': 'Kopyalandı',
|
||||
'tools.owner': 'Sahip adımları (terminal)',
|
||||
'tools.ownerSub': 'Yalnızca sizin karar verdiğiniz herkese açık veya fiyat değişiklikleri; uygulama klasöründe bir terminalde çalıştırın',
|
||||
'tools.o.offer_codes': 'Tek seferlik satın almalar için App Store teklif kodları',
|
||||
'tools.o.win_back': 'Ayrılan aboneler için geri kazanma teklifleri',
|
||||
'tools.o.promoted': 'Öne çıkan uygulama içi satın almaları aç',
|
||||
'tools.o.preorder': 'App Store ön siparişini aç veya bitir',
|
||||
'tools.o.cpp_submit': 'Özel ürün sayfasını gönder (sonra görünür yap)',
|
||||
'tools.o.events_submit': 'Uygulama içi etkinliği gönder',
|
||||
'tools.o.tf_expire': 'Eski TestFlight derlemelerinin süresini bitir',
|
||||
'tools.o.ads_change': 'Apple Ads teklif, bütçe veya duraklatma',
|
||||
'tools.o.rc_sync': 'Kataloğu RevenueCat ile eşitle',
|
||||
'tools.o.shots_login': 'Ekran görüntüleri için demo hesaba giriş (elle, Enter kaydeder)',
|
||||
'cp.playGames': 'Play Games Hizmetleri kurulumu',
|
||||
'cp.playGamesText': 'Oyun projesi, uygulama imzalama SHA-1 ile Android kimliği, web OAuth istemcisi; Yayınla düğmesine sen basarsın.',
|
||||
'gs.title': 'Oyun hizmetleri',
|
||||
'gs.sub': 'Game Center ve Play Games Hizmetleri: anahtarlar, kimlikler, yapılandırmadaki skor tabloları ve başarımlar.',
|
||||
'gs.gameCenter': 'Game Center',
|
||||
'gs.playGames': 'Play Games Hizmetleri',
|
||||
'gs.enabled': 'Uygulamada açık',
|
||||
'gs.capability': 'App ID yeteneği',
|
||||
'gs.version': 'Sürüm {v}',
|
||||
'gs.items': 'Skor tabloları / başarımlar',
|
||||
'gs.counts': '{lbWant} skor tablosundan {lb} · {achWant} başarımdan {ach}',
|
||||
'gs.pending': 'Yazılacak {n} değişiklik',
|
||||
'gs.projectId': 'Proje kimliği',
|
||||
'gs.resource': 'games_APP_ID kaynağı',
|
||||
'gs.notSet': 'Ayarlı değil',
|
||||
'gs.yes': 'Evet',
|
||||
'gs.no': 'Hayır',
|
||||
'gs.unknown': 'Bilinmiyor',
|
||||
'gs.syncApple': 'Game Center eşitle',
|
||||
'gs.syncPlay': 'Play Games eşitle',
|
||||
'gs.syncHint': 'Önce deneme çalıştırması, sonra sen onaylarsın; hiçbir şey gönderilmez ya da yayınlanmaz.',
|
||||
'gs.synced': 'Oyun hizmetleri eşit',
|
||||
'gs.setup': 'Kurulum planı',
|
||||
'pf.morning': "Günaydın.",
|
||||
'pf.afternoon': "İyi günler.",
|
||||
'pf.evening': "İyi akşamlar.",
|
||||
'pf.moving': "{count} sürüm ilerliyor",
|
||||
'pf.moving_one': "1 sürüm ilerliyor",
|
||||
'pf.movingNone': "Şu an ilerleyen sürüm yok",
|
||||
'pf.notMovingUnknown': "Neyin ilerlediğini görmek için mağazaları oku",
|
||||
'pf.readAgo': "mağazalar {when} okundu",
|
||||
'pf.layout': "Görünüm",
|
||||
'pf.cards': "Kartlar",
|
||||
'pf.table': "Tablo",
|
||||
'pf.next': "sıradaki",
|
||||
'pf.crash': "çökme",
|
||||
'pf.ctaFix': "{count} engeli düzelt",
|
||||
'pf.ctaFix_one': "1 engeli düzelt",
|
||||
'pf.ctaRollout': "Dağıtımı sürdür (şu an {share})",
|
||||
'pf.ctaRelease': "{version} sürümünü yayınla",
|
||||
'pf.ctaSubmit': "{version} sürümünü gönder",
|
||||
'pf.ctaReview': "{version} incelemesini izle",
|
||||
'pf.ctaWarnings': "{count} uyarıya bak",
|
||||
'pf.ctaWarnings_one': "1 uyarıya bak",
|
||||
'pf.ctaUpload': "Derleme yükle",
|
||||
'pf.ctaOpen': "Sürümleri aç",
|
||||
'pf.feed': "Neler değişti",
|
||||
'pf.feedSub': "Sürümler, sağlık ve klasörler, en yenisi üstte",
|
||||
'pf.feedEmpty': "Henüz değişiklik yok",
|
||||
'pf.feedEmptyText': "Sürümleri ve sağlığı burada görmek için mağazaları oku.",
|
||||
'pf.evLive': "{version}, {store} üzerinde yayında",
|
||||
'pf.evRollout': "{version}, {store} üzerinde {share} kullanıcıya dağıtılıyor",
|
||||
'pf.evReview': "{version}, {store} incelemesine girdi",
|
||||
'pf.evApproved': "{version}, {store} tarafından onaylandı",
|
||||
'pf.evRejected': "{version}, {store} tarafından reddedildi",
|
||||
'pf.evHalted': "{version} dağıtımı {store} üzerinde durduruldu",
|
||||
'pf.evTesting': "{version}, {store} üzerinde testte",
|
||||
'pf.evDraft': "{version}, {store} üzerinde taslak",
|
||||
'pf.evCrash': "Google Play çökme oranı {rate}",
|
||||
'pf.evAdded': "klasör çalışma alanına eklendi",
|
||||
'pf.more': "{count} tane daha göster",
|
||||
'pf.less': "Daha az göster",
|
||||
'pf.fixFirst': "Önce bunları düzelt",
|
||||
'pf.fixFirstSub': "{count} madde, önce engeller",
|
||||
'pf.fixFirstSub_one': "1 madde",
|
||||
'pf.blocker': "Engel",
|
||||
'pf.warning': "Uyarı",
|
||||
'pf.note': "Not",
|
||||
'pf.fixIn': "{tab} sekmesinde düzelt",
|
||||
'pf.openTab': "{tab} sekmesini aç",
|
||||
'v2rel.version': "Sürüm",
|
||||
'v2rel.stages': "{store} yayın aşamaları",
|
||||
'v2rel.ofUsers': "kullanıcı bu sürümü alıyor",
|
||||
'v2rel.versions': "{count} sürüm",
|
||||
'v2rel.ready': "Hazır",
|
||||
'v2rel.blocked': "Engelli",
|
||||
'v2rel.todo': "Yapılacak",
|
||||
'v2rel.attention': "Kontrol et",
|
||||
'v2rel.readyCount': "{total} adımın {done} tanesi hazır",
|
||||
'v2rel.steps': "İncelemeye gönderme adımları",
|
||||
'v2rel.stepRead': "Mağaza durumu",
|
||||
'v2rel.stepNoOpen': "İncelemede başka gönderim yok",
|
||||
'v2rel.stepBuild': "Build'i seç",
|
||||
'v2rel.stepVersion': "Gönderilecek sürüm",
|
||||
'v2rel.stepSend': "İncelemeye gönder",
|
||||
'v2rel.stepCode': "versionCode'u seç",
|
||||
'v2rel.tfGroups': "Beta grupları",
|
||||
'v2rel.tfInGroups': "{count} grupta",
|
||||
'v2rel.tfSplit': "{internal} dahili · {external} harici",
|
||||
'v2rel.tfUsable': "Teste hazır",
|
||||
|
||||
// context menus (ui/ContextMenu.tsx)
|
||||
'cm.cut': "Kes",
|
||||
'cm.pin': "Sabitle: menü açık kalır",
|
||||
'cm.unpin': "Sabitlemeyi kaldır",
|
||||
'cm.closePanel': "Kapat",
|
||||
'cm.gone': "Yok: bu menünün ait olduğu şey şu an ekranda değil.",
|
||||
'cm.menu': "Menü",
|
||||
'cm.tearOff': "Ctrl+tık veya orta tık: bu alt menüyü sabitle",
|
||||
'cm.copy': "Kopyala",
|
||||
'cm.paste': "Yapıştır",
|
||||
'cm.selectAll': "Tümünü seç",
|
||||
'cm.copied': "Kopyalandı",
|
||||
'cm.open': "Aç",
|
||||
'cm.openTab': "Sekme aç",
|
||||
'cm.openConsole': "Konsolda aç",
|
||||
'cm.openIn': "{place} içinde aç",
|
||||
'cm.openFolder': "Klasörü aç",
|
||||
'cm.folderFailed': "Klasör açılamadı",
|
||||
'cm.copyName': "Ad",
|
||||
'cm.copyBundleId': "Bundle id",
|
||||
'cm.copyPackage': "Paket adı",
|
||||
'cm.copyAscId': "App Store Connect uygulama kimliği",
|
||||
'cm.copyPath': "Yolu kopyala",
|
||||
'cm.refreshReads': "Mağazalardan yeniden oku",
|
||||
'cm.refreshing': "{name} yeniden okunuyor",
|
||||
'cm.ignoredNoReads': "Yok sayılan uygulamalar okunmaz",
|
||||
'cm.ignore': "Yok say",
|
||||
'acct.devId': 'Play geliştirici kimliği {id}',
|
||||
'acct.viaBroker': 'jetonlar kimlik aracısından',
|
||||
'acct.viaFile': 'yerel anahtar dosyası',
|
||||
'acct.grantsTitle': 'Kimlik aracısındaki Play izinleri',
|
||||
'acct.grantsOk': 'storebound için gereken her şey var',
|
||||
'acct.grantsMissing': 'eksik: {list}',
|
||||
'acct.grantsNone': 'izin bilgisi yok',
|
||||
'acct.notRegistered': 'aracıda kayıtlı değil',
|
||||
'acct.noDevId': 'Play geliştirici kimliği yok',
|
||||
'acct.devIdLabel': 'Play geliştirici hesabı kimliği',
|
||||
'acct.devIdHint': 'Play Console adresinde /developers/ sonrasındaki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.',
|
||||
'checks.openGuide': 'Kılavuzu aç',
|
||||
'checks.openGuideHint': '{skill} kılavuzunu aç (SKILL.md)',
|
||||
'cm.more': "Diğer işlemler",
|
||||
'cm.showIgnored': "Yok sayılanları göster",
|
||||
'cm.copyVersion': "Sürümü kopyala",
|
||||
'cm.copyBuild': "Build'i kopyala",
|
||||
'cm.copyVersionCode': "Sürüm kodunu kopyala",
|
||||
'cm.preview': "Önizle",
|
||||
'cm.openFile': "Dosyayı aç",
|
||||
'cm.reveal': "Klasörde göster",
|
||||
'cm.readAgain': "Yeniden oku",
|
||||
'cm.reading': "Okunuyor…",
|
||||
'cm.openDraft': "Yanıt taslağını göster",
|
||||
'cm.copyReview': "Yorumu kopyala",
|
||||
'cm.copyReply': "Yayındaki yanıtı kopyala",
|
||||
'cm.copyDraft': "Taslağı kopyala",
|
||||
'cm.revealEvidence': "Dosyayı göster",
|
||||
'cm.copyTitle': "Başlığı kopyala",
|
||||
'cm.copyFix': "Düzeltmeyi kopyala",
|
||||
'cm.copySkill': "Skill adını kopyala",
|
||||
'cm.copyEvidence': "Kanıt satırlarını kopyala",
|
||||
'cm.copyCode': "Kontrol kodunu kopyala",
|
||||
'cm.copyValue': "Değeri kopyala",
|
||||
'cm.notChanged': "Değişmedi",
|
||||
'cm.copyProductId': "Ürün kimliğini kopyala",
|
||||
'cm.showPrices': "Fiyatları göster",
|
||||
'cm.copyRow': "Satırı kopyala",
|
||||
'cm.showPerRegion': "Bölge bölge göster",
|
||||
'cm.copyPriceOf': "{store} fiyatını kopyala",
|
||||
'cm.copyRegion': "Bölge kodunu kopyala",
|
||||
'cm.busy': "Başka bir işlem sürüyor",
|
||||
'cm.copyPrincipal': "Hizmet hesabı / anahtar",
|
||||
'cm.copyProfile': "Profil adı",
|
||||
'cm.openAccounts': "Stüdyolar ve hesapları aç",
|
||||
"sec.release": "Yayın",
|
||||
"sec.store": "Mağaza sayfası",
|
||||
"sec.revenue": "Gelir",
|
||||
"sec.players": "Oyuncular",
|
||||
"sec.health": "Sağlık",
|
||||
"sec.release.for": "Bir sürümü iki mağazada oyunculara ulaştır: build, TestFlight, inceleme ve dağıtım.",
|
||||
"sec.store.for": "Mağazada insanların gördüğü: dile göre metinler, ekran görüntüleri ve uygulama bilgileri.",
|
||||
"sec.revenue.for": "Uygulamanın sattıkları ve fiyatları: uygulama içi ürünler, abonelikler ve fiyatlar.",
|
||||
"sec.players.for": "Oyuncuların söyledikleri ve oyunlarda kazanabilecekleri: yorumlar, Game Center, Play Games.",
|
||||
"sec.health.for": "Uygulama sağlıklı ve hazır mı: çökmeler ve ANR, büyüme, önce düzeltilecek kontroller, diğer araçlar.",
|
||||
"sec.views": "{section} görünümleri",
|
||||
"tab.games": "Oyun servisleri",
|
||||
"next.open": "{where} aç",
|
||||
"next.rejected": "{version}, {store} tarafından reddedildi",
|
||||
"next.halted": "{version}, {store} üzerinde durduruldu",
|
||||
"next.blockers": "Göndermeden önce {count} engel",
|
||||
"next.blockers_one": "Göndermeden önce 1 engel",
|
||||
"next.rollout": "{version} dağıtımı {share} seviyesinde",
|
||||
"next.approved": "{version} onaylandı: yayınla",
|
||||
"next.submit": "{version} sürümünü incelemeye gönder",
|
||||
"next.review": "{version} incelemede",
|
||||
"next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)",
|
||||
"next.upload": "İlk build'i yükle",
|
||||
"next.live": "Yayında: {version}",
|
||||
"next.notRead": "Yayın durumu mağazalar okununca görünür",
|
||||
"next.storeLive": "Mağazalardan okunan canlı mağaza sayfası",
|
||||
"next.listingProblems": "{count} mağaza metni sorunu",
|
||||
"next.listingProblems_one": "1 mağaza metni sorunu",
|
||||
"next.unsaved": "{count} kaydedilmemiş düzenleme",
|
||||
"next.unsaved_one": "1 kaydedilmemiş düzenleme",
|
||||
"next.listingsUnread": "Mağaza metinleri henüz kontrol edilmedi",
|
||||
"next.storeOk": "Mağaza sayfası yapılandırmayla uyumlu",
|
||||
"next.catalogDiff": "{count} ürün mağazalar arasında farklı",
|
||||
"next.catalogDiff_one": "1 ürün mağazalar arasında farklı",
|
||||
"next.noProducts": "Henüz ürün yok: bir tane ekle",
|
||||
"next.products": "{products} ürün · {subs} abonelik",
|
||||
"next.reviews": "{count} yorum yanıtsız",
|
||||
"next.reviews_one": "1 yorum yanıtsız",
|
||||
"next.reviewsUnread": "Oyuncuların söylediklerine bak",
|
||||
"next.reviewsOk": "Tüm yorumlar yanıtlandı",
|
||||
"next.gamesApiOff": "Play Games Services API kapalı",
|
||||
"next.gamesBlockers": "{count} oyun servisi engeli",
|
||||
"next.gamesBlockers_one": "1 oyun servisi engeli",
|
||||
"next.gamesSync": "Oyun servisleri: eşitlenecek {count} değişiklik",
|
||||
"next.gamesSync_one": "Oyun servisleri: eşitlenecek 1 değişiklik",
|
||||
"next.fixFirst": "Önce düzeltilecek {count} sorun",
|
||||
"next.fixFirst_one": "Önce düzeltilecek 1 sorun",
|
||||
"next.warnings": "Bakılacak {count} uyarı",
|
||||
"next.warnings_one": "Bakılacak 1 uyarı",
|
||||
"next.healthOk": "Sorun bulunmadı",
|
||||
"side.gAttention": "İlgi bekliyor",
|
||||
"side.gProgress": "Sürüyor",
|
||||
"side.gLive": "Yayında",
|
||||
"side.gDraft": "Henüz mağazada değil",
|
||||
"side.missing": "Klasör bulunamadı",
|
||||
"side.rejected": "Reddedildi · {what}",
|
||||
"side.halted": "Durduruldu · {what}",
|
||||
"side.blockers": "{count} engel",
|
||||
"side.blockers_one": "1 engel",
|
||||
"side.rollout": "Dağıtım {share} · {what}",
|
||||
"side.review": "İncelemede · {what}",
|
||||
"side.approved": "Onaylandı · {what}",
|
||||
"side.draft": "Taslak · {what}",
|
||||
"side.live": "Yayında · {version}",
|
||||
"side.liveWarn": "Yayında · {version} · {count} uyarı",
|
||||
"side.liveWarn_one": "Yayında · {version} · 1 uyarı",
|
||||
"side.storeOnly": "Yalnız mağaza · henüz okunmadı",
|
||||
"side.notRead": "Henüz okunmadı",
|
||||
"side.warnings": "{count} uyarı",
|
||||
"side.warnings_one": "1 uyarı",
|
||||
"side.notOnStore": "Henüz mağaza sürümü yok",
|
||||
"side.ignored": "Yok sayıldı",
|
||||
"side.studioFilter": "Bir stüdyonun uygulamalarını göster",
|
||||
"side.allStudios": "Tümü",
|
||||
"games.keep": "Uyumlu",
|
||||
"games.update": "Farklı",
|
||||
"games.create": "Eksik",
|
||||
"games.notOnStore": "Bu mağazada değil",
|
||||
"games.notListed": "Planlanmadı",
|
||||
"games.notRead": "Okunmadı",
|
||||
"games.item": "Öğe",
|
||||
"games.name": "Ad",
|
||||
"games.points": "Puan",
|
||||
"games.languages": "Diller",
|
||||
"games.leaderboard": "Skor tablosu",
|
||||
"games.achievement": "Başarım",
|
||||
"games.hidden": "kazanılana kadar gizli",
|
||||
"games.localeChanges": "Game Center dilleri: {list}",
|
||||
"games.checkAgain": "Yeniden kontrol et",
|
||||
"games.apiOffTitle": "Play Games Services API kapalı.",
|
||||
"games.apiOffText": "Hizmet hesabının Google Cloud projesinde {api} açın, bir dakika bekleyin, sonra yeniden kontrol edin.",
|
||||
"games.itemsTitle": "Skor tabloları ve başarımlar",
|
||||
"games.itemsSub": "Yapılandırmada tek liste, iki mağazaya eşlenir: her mağazanın yapılandırmaya göre durumu.",
|
||||
"games.refresh": "Yeniden oku",
|
||||
"games.noItems": "Yapılandırmada henüz skor tablosu veya başarım yok (games.leaderboards, games.achievements).",
|
||||
"games.notReadYet": "Oyun servisleri okunuyor…",
|
||||
"games.images": "Skor tablosu ve başarım görselleri konsollarda eklenir; Play'de mevcut simge her eşitlemede korunur.",
|
||||
"games.localeMap": "Game Center dil eşlemesi",
|
||||
"games.localeMapSub": "Yapılandırma dilinden canlı Game Center'ın kullandığı dile (games.localeMap).",
|
||||
"games.mapSet": "Ayarlı",
|
||||
"games.mapSuggested": "Önerilen",
|
||||
"games.mapHint": "games.localeMap'e ekleyin: {entries}",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import type { AnalyticsCompare, AppStatus, DeclarationsResult, ListingText, PricesCurrent } from '../api';
|
||||
import type { CatalogDiff, HealthSnapshot, Review, ValidateResult } from './model';
|
||||
import { isError } from './model';
|
||||
import { buildReport, renderMarkdown } from '../../../../cli/lib/report';
|
||||
|
||||
type Cell = string | number | boolean | null | undefined;
|
||||
|
||||
|
|
@ -63,12 +62,11 @@ export function healthMd(app: string, h: HealthSnapshot): string {
|
|||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** The store readiness export: the same report and Markdown renderer as `storebound report --md` (cli/lib/report.ts),
|
||||
* from what the Checks tab read (validate, the offline check, the scan when it ran). */
|
||||
export function validateMd(app: string, v: ValidateResult, at: string | null, more: { check?: { problems: string[]; warnings: string[] } | null; scan?: unknown; version?: string; stores?: ('apple' | 'play')[] } = {}): string {
|
||||
const r = buildReport({ app: { name: app }, ...(more.stores ? { stores: more.stores } : {}), generatedAt: at ?? new Date().toISOString(), storebound: more.version ?? 'desktop',
|
||||
validate: v as never, check: more.check ?? null, scan: (more.scan ?? null) as never, storeRead: { apple: true, play: true } });
|
||||
return renderMarkdown(r);
|
||||
export function validateMd(app: string, v: ValidateResult, at: string | null): string {
|
||||
const lvl = (l: string) => (l === 'blocker' ? 'Blocker' : l === 'warn' ? 'Warning' : 'OK');
|
||||
return [`# ${app}: store readiness`, '', `Read ${at ?? 'now'} · ${v.blockers} blocker(s), ${v.findings.filter(f => f.level === 'warn').length} warning(s)`, '',
|
||||
mdTable(['store', 'level', 'check', 'finding', 'fix'], v.findings.map(f => [f.store === 'apple' ? 'App Store' : 'Google Play', lvl(f.level), f.code, f.msg, f.fix ?? ''])),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function catalogMd(app: string, c: CatalogDiff): string {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { UnifiedApp as CoreApp } from '../../../../cli/lib/identity';
|
|||
|
||||
export {
|
||||
buildApps, type BuildInput, NO_OVERRIDES, normId, normName, type Overrides, type Pair, pairCandidates, type Pairing, refKey, type StoreRef, storeTarget,
|
||||
withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair,
|
||||
withIgnored, withLink, withPair, withStudio, withUnpair,
|
||||
} from '../../../../cli/lib/identity';
|
||||
|
||||
export type UnifiedApp = CoreApp<Project>;
|
||||
|
|
|
|||