diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b8dfe47..fcc4c0d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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.24", + "version": "0.8.77", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 39866c9..4b980c5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.24", + "version": "0.8.77", "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" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 841c6ac..397705c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.24", + "version": "0.8.77", "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" diff --git a/.cursor/rules/storebound.mdc b/.cursor/rules/storebound.mdc index 8ba9940..2b9d056 100644 --- a/.cursor/rules/storebound.mdc +++ b/.cursor/rules/storebound.mdc @@ -20,6 +20,16 @@ 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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, 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 diff --git a/.forgejo/actions/linux-setup/action.yml b/.forgejo/actions/linux-setup/action.yml index a16be63..2f0d06c 100644 --- a/.forgejo/actions/linux-setup/action.yml +++ b/.forgejo/actions/linux-setup/action.yml @@ -26,6 +26,7 @@ runs: with: path: | ~/.bun/install/cache + ~/.cache/tauri ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db @@ -35,7 +36,7 @@ runs: shell: bash run: | P="${FORGEJO_PATH:-$GITHUB_PATH}" - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh 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" diff --git a/.forgejo/workflows/acceptance.yml b/.forgejo/workflows/acceptance.yml index cc5cbb2..e6a4b22 100644 --- a/.forgejo/workflows/acceptance.yml +++ b/.forgejo/workflows/acceptance.yml @@ -33,7 +33,7 @@ jobs: if: ${{ matrix.platform == 'Linux' }} shell: bash run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" - name: Acceptance matrix (Linux) if: ${{ matrix.platform == 'Linux' }} @@ -97,7 +97,7 @@ jobs: path: acceptance-runs/Windows-bun - name: Matrix (markdown), red cells fail the job run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh export PATH="$HOME/.bun/bin:$PATH" V=$(bun tools/desktop-version.ts --print) mkdir -p out diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 286c25a..656ac95 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: if: ${{ matrix.platform == 'Linux' }} shell: bash run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" - name: Acceptance matrix (Linux) if: ${{ matrix.platform == 'Linux' }} @@ -173,7 +173,9 @@ jobs: working-directory: apps/desktop run: | rm -rf "$CARGO_TARGET_DIR/release/bundle" - bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci + # 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) @@ -223,6 +225,34 @@ jobs: name: storebound-desktop-linux path: out/ retention-days: 7 + # dev channel (tools/desktop-dev.ts): the same commit again with the version -dev., 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 @@ -288,6 +318,78 @@ jobs: 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- (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- (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] @@ -307,7 +409,7 @@ jobs: # no apt: the installers were scanned with the private terms in their own jobs; this job only records - name: Tools run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + 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 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ee88ef0..e77a9fd 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -61,7 +61,7 @@ jobs: - name: Tag, package.json version, every version copy and the CHANGELOG agree id: check run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" export PATH="$HOME/.bun/bin:$PATH" bun tools/desktop-version.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5515c62..2082191 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,6 +20,16 @@ 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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, 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 diff --git a/AGENTS.md b/AGENTS.md index abbc502..e1b9812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,16 @@ 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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bed5bc..a0bcd5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,365 @@ + # 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 -- 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 `: 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..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 `/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 --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 [--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 ` (`apple.build.derivedData`) is xcodebuild `-derivedDataPath`; with `--out` (or `apple.build.out`) it defaults to `/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 --production completed|inProgress --yes`, `play upload --track production --yes`, `apple submit --build --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/`, 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 ] [--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//` 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/`) 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 ` (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 `), 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 ""` (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 ` (`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 ""`), 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 [--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 --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 `-dev.` (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 `///.png` (feature: `/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 ` 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 ] [--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 `, 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 `. `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 --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 `, 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 --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 `/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@ mcp` (or diff --git a/GEMINI.md b/GEMINI.md index 05becf9..144e9e7 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -19,6 +19,16 @@ 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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, 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 diff --git a/apps/desktop/card.html b/apps/desktop/card.html new file mode 100644 index 0000000..effa772 --- /dev/null +++ b/apps/desktop/card.html @@ -0,0 +1,14 @@ + + + + + + + + storebound + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d1be11a..395fdfd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.24", + "version": "0.8.77", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index ec08b77..be06ab9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -227,6 +227,20 @@ 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" @@ -855,6 +869,15 @@ 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" @@ -2446,6 +2469,18 @@ 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" @@ -2724,6 +2759,30 @@ 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" @@ -2818,6 +2877,21 @@ 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" @@ -3926,6 +4000,18 @@ 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" @@ -4010,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.24" +version = "0.8.77" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", @@ -4025,6 +4111,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-notification", @@ -4037,6 +4124,9 @@ dependencies = [ "tempfile", "tokio", "url", + "webview2-com", + "windows-core", + "winreg", "zip", ] @@ -4250,7 +4340,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4300,7 +4390,7 @@ checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4370,6 +4460,20 @@ 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" @@ -4532,7 +4636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 7.0.0", "flate2", "futures-util", "http", @@ -5018,7 +5122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 7.0.0", "libappindicator", "muda", "objc2", @@ -5841,7 +5945,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -5955,7 +6059,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.8", "serde", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index f81a7e5..95dca43 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.24" +version = "0.8.77" 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 = [] } +tauri = { version = "2.12", features = ["tray-icon"] } # 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,6 +31,7 @@ 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"] } @@ -48,6 +49,13 @@ 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" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index a76dc9d..02a67cc 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -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(COMMANDS)), + .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS, MENU_COMMANDS].concat().into_boxed_slice()))), ) .expect("tauri build script failed"); } diff --git a/apps/desktop/src-tauri/capabilities/card.json b/apps/desktop/src-tauri/capabilities/card.json new file mode 100644 index 0000000..aaae544 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/card.json @@ -0,0 +1,13 @@ +{ + "$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" + ] +} diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index 89a4039..159856d 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -35,6 +35,7 @@ "allow-studio-remove", "allow-studio-logo", "allow-account-update", + "allow-account-set-play-developer", "allow-account-check", "allow-store-apps-refresh", "allow-store-apps-cached", @@ -43,6 +44,8 @@ "allow-cache-info", "allow-data-reveal", "allow-notify", + "allow-tray-update", + "allow-card-snapshot", "allow-app-reset", "allow-update-check", "allow-update-install", diff --git a/apps/desktop/src-tauri/capabilities/menu.json b/apps/desktop/src-tauri/capabilities/menu.json new file mode 100644 index 0000000..2f5783e --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/menu.json @@ -0,0 +1,21 @@ +{ + "$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" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index 701fe9a..bfff39e 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index 9b3b288..875659f 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/256x256.png b/apps/desktop/src-tauri/icons/256x256.png index 9b3b288..875659f 100644 Binary files a/apps/desktop/src-tauri/icons/256x256.png and b/apps/desktop/src-tauri/icons/256x256.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index 2d4c28a..37cd4c8 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png index b6d21d6..f3e642c 100644 Binary files a/apps/desktop/src-tauri/icons/64x64.png and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png index a4da06d..4e83a2b 100644 Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png index a4cd877..183a972 100644 Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png index bf34596..486b26b 100644 Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png index ba1b7f7..742469c 100644 Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png index 8778d57..23cc203 100644 Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png index 331c002..92efd64 100644 Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png index 835e26b..2deae72 100644 Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png index 19f2b33..def91e6 100644 Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png index c1641b8..0f29e78 100644 Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png index aa60d96..0a6324a 100644 Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index 24989c3..1236e01 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index 390285c..a0e6f09 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/permissions/autogenerated/account_set_play_developer.toml b/apps/desktop/src-tauri/permissions/autogenerated/account_set_play_developer.toml new file mode 100644 index 0000000..b80223a --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/account_set_play_developer.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-account-set-play-developer" +description = "Enables the account_set_play_developer command without any pre-configured scope." +commands.allow = ["account_set_play_developer"] + +[[permission]] +identifier = "deny-account-set-play-developer" +description = "Denies the account_set_play_developer command without any pre-configured scope." +commands.deny = ["account_set_play_developer"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/card_action.toml b/apps/desktop/src-tauri/permissions/autogenerated/card_action.toml new file mode 100644 index 0000000..1d41f1c --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/card_action.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/card_get.toml b/apps/desktop/src-tauri/permissions/autogenerated/card_get.toml new file mode 100644 index 0000000..3be151c --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/card_get.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/card_snapshot.toml b/apps/desktop/src-tauri/permissions/autogenerated/card_snapshot.toml new file mode 100644 index 0000000..4118e9c --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/card_snapshot.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_close.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_close.toml new file mode 100644 index 0000000..f113d1c --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_close.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_close_subs.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_close_subs.toml new file mode 100644 index 0000000..dab5968 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_close_subs.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_dismiss.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_dismiss.toml new file mode 100644 index 0000000..5c7a0ef --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_dismiss.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_open_sub.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_open_sub.toml new file mode 100644 index 0000000..ee4175b --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_open_sub.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_pin.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin.toml new file mode 100644 index 0000000..6e1dabc --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_pin_sub.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin_sub.toml new file mode 100644 index 0000000..b339f12 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin_sub.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_ready.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_ready.toml new file mode 100644 index 0000000..c839ac0 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_ready.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_select.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_select.toml new file mode 100644 index 0000000..e546f01 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_select.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_state.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_state.toml new file mode 100644 index 0000000..b4844a3 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_state.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml b/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml new file mode 100644 index 0000000..1e61a83 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/tray_update.toml @@ -0,0 +1,11 @@ +# 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"] diff --git a/apps/desktop/src-tauri/src/accounts.rs b/apps/desktop/src-tauri/src/accounts.rs index f842af7..faf7b9c 100644 --- a/apps/desktop/src-tauri/src/accounts.rs +++ b/apps/desktop/src-tauri/src/accounts.rs @@ -31,6 +31,10 @@ pub struct Account { /// None only for an account of an earlier version whose keychain entry has not moved yet. #[serde(default)] pub profile: Option, + /// Play only: the developer account id (PLAY_DEVELOPER_ID in its credential profile, shared with the CLI), for + /// Play Console deep links. Read from the profile on every list; never a secret. + #[serde(default, rename = "developerId", skip_serializing_if = "Option::is_none")] + pub developer_id: Option, } /// The keychain entry of one account (JSON). diff --git a/apps/desktop/src-tauri/src/card.rs b/apps/desktop/src-tauri/src/card.rs new file mode 100644 index 0000000..e7c85ff --- /dev/null +++ b/apps/desktop/src-tauri/src/card.rs @@ -0,0 +1,188 @@ +//! 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> = 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 { + SNAPSHOT.lock().ok().and_then(|s| s.clone()) +} + +// ---- show / hide ---- + +static HIDDEN_AT: Mutex> = 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(app: &AppHandle) -> 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> = 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(app: &AppHandle, w: &tauri::WebviewWindow, 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(app: &AppHandle, 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 = 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(app: &AppHandle, 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(app: &AppHandle) { + if let Some(w) = app.get_webview_window(LABEL) { + let _ = w.hide(); + note_hidden(); + } + } +} + +#[cfg(desktop)] +pub use window::{create, hide, resize, toggle}; diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index d22ad97..5bfa27e 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -44,6 +44,16 @@ 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> { s.saved.lock().map_err(|_| Fail::host("state poisoned")) } @@ -73,7 +83,8 @@ pub fn sync_machine(s: &AppState) -> Res<()> { false } }; - if report.changed() || folders { + let ignored = registry::pull_ignored(&mut st, &s.registry); + if report.changed() || folders || ignored { persist(s, &st)?; } Ok(()) @@ -802,7 +813,11 @@ pub fn project_set_accounts(window: WebviewWindow, state: State<'_, AppState>, i pub fn accounts_list(window: WebviewWindow, state: State<'_, AppState>) -> Res> { guard(&window)?; sync_machine(&state)?; - Ok(saved(&state)?.accounts.clone()) + let mut out = saved(&state)?.accounts.clone(); + for a in out.iter_mut().filter(|a| a.kind == accounts::AccountKind::Play) { + a.developer_id = a.profile.as_deref().and_then(|p| state.profiles.play_developer_id(p)); + } + Ok(out) } fn check_studio(state: &AppState, studio: &Option) -> Res<()> { @@ -833,7 +848,7 @@ fn add_account(state: &AppState, label: &str, secret: Secret, studio: Option, id: Str Ok(out) } +/// Sets or clears the Play developer account id of a Play account (PLAY_DEVELOPER_ID in its credential profile, the +/// same field `storebound accounts play-developer` writes): Play Console links then open each app's own page. +#[tauri::command] +pub fn account_set_play_developer(window: WebviewWindow, state: State<'_, AppState>, id: String, developer_id: Option) -> Res { + guard(&window)?; + let st = saved(&state)?; + let mut a = st.accounts.iter().find(|a| a.id == id).cloned().ok_or_else(|| Fail::host("unknown account"))?; + if a.kind != accounts::AccountKind::Play { + return Err(Fail::host("a Play account only")); + } + let profile = a.profile.clone().ok_or_else(|| Fail::host("this account has no credential profile yet"))?; + let v = developer_id.as_deref().map(str::trim).filter(|v| !v.is_empty()); + state.profiles.set_play_developer_id(&profile, v).map_err(Fail::host)?; + a.developer_id = state.profiles.play_developer_id(&profile); + Ok(a) +} + /// Does the account's key work, and can it see apps (sidecar `account.check`). Not cached: it is a live answer. #[tauri::command] pub async fn account_check(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, id: String, job: Option) -> Res { @@ -1206,6 +1238,10 @@ 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) } @@ -1215,6 +1251,10 @@ 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); @@ -1259,11 +1299,90 @@ 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) -> Res<()> { +pub fn notify(window: WebviewWindow, app: AppHandle, title: String, body: String, target: Option) -> Res<()> { guard(&window)?; use tauri_plugin_notification::NotificationExt; let cut = |s: &str, n: usize| s.chars().filter(|c| !c.is_control()).take(n).collect::(); - app.notification().builder().title(cut(&title, 80)).body(cut(&body, 240)).show().map_err(|e| Fail::host(e.to_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> { + 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) -> 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(()) } /// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders @@ -1374,7 +1493,7 @@ pub struct UpdateInfo { pub date: Option, } -async fn find_update(app: &AppHandle, channel: &str) -> Res> { +pub async fn find_update(app: &AppHandle, channel: &str) -> Res> { 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) { @@ -1385,6 +1504,8 @@ async fn find_update(app: &AppHandle, channel: &str) -> Res Res Res> { + 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(()) +} diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 3846067..a076389 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -27,6 +27,7 @@ pub const COMMANDS: &[&str] = &[ "studio_remove", "studio_logo", "account_update", + "account_set_play_developer", "account_check", "store_apps_refresh", "store_apps_cached", @@ -35,6 +36,8 @@ pub const COMMANDS: &[&str] = &[ "cache_info", "data_reveal", "notify", + "tray_update", + "card_snapshot", "app_reset", "update_check", "update_install", @@ -50,3 +53,9 @@ pub const COMMANDS: &[&str] = &[ "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"]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c16ea24..17627f4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,10 +3,12 @@ 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; @@ -14,6 +16,7 @@ pub mod selftest; pub mod updates; pub mod sidecar; pub mod store; +pub mod tray; pub mod workspace; include!("commands_list.rs"); @@ -21,7 +24,9 @@ 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 @@ -34,11 +39,178 @@ 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::(); + 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::(scale); + let s = rect.size.to_physical::(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(app: &AppHandle, 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::(); + 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(app: &AppHandle) { 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); + } } } @@ -129,13 +301,34 @@ 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 = 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 - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) + // 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) })) .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_window_state::Builder::new().build()) + // 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_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -143,6 +336,10 @@ 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(); @@ -178,7 +375,29 @@ 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"))] @@ -202,9 +421,16 @@ pub fn run() { Ok(()) }) .on_window_event(|w, e| { - // macOS: the close button hides the window (the app stays in the Dock and the Window menu); - // Cmd+Q and the app menu's Quit still quit. Elsewhere closing the window quits. - if cfg!(target_os = "macos") && w.label() == policy::MAIN { + // 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 { if let WindowEvent::CloseRequested { api, .. } = e { api.prevent_close(); let _ = w.hide(); @@ -238,6 +464,7 @@ pub fn run() { commands::studio_remove, commands::studio_logo, commands::account_update, + commands::account_set_play_developer, commands::account_check, commands::store_apps_refresh, commands::store_apps_cached, @@ -246,6 +473,8 @@ 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, @@ -260,6 +489,17 @@ pub fn run() { 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"); @@ -267,6 +507,8 @@ 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::() { s.sidecar.shutdown_now(); @@ -276,3 +518,22 @@ pub fn run() { _ => {} }); } + +/// 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::() { + let _ = s3.SetAreBrowserAcceleratorKeysEnabled(false); + } + }); + if let Err(e) = r { + logs::host("warn", &format!("webview menus not turned off: {e}")); + } +} diff --git a/apps/desktop/src-tauri/src/menu_panel.rs b/apps/desktop/src-tauri/src/menu_panel.rs new file mode 100644 index 0000000..935ac09 --- /dev/null +++ b/apps/desktop/src-tauri/src/menu_panel.rs @@ -0,0 +1,749 @@ +//! 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 }, +} + +/// 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) -> Vec { + let mut seen: BTreeMap = 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 { + 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, + pub pinned: bool, + pub parent: Option, + 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, + 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, + next: u32, +} + +impl Book { + fn add(&mut self, path: Vec, pinned: bool, parent: Option, 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 { + 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 { + 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) { + 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)> { + 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 = 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 { + 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 { + 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 { + 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 = 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 { + let gone: Vec = 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 { + 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 { + 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, + pub pinned: bool, + pub has_parent: bool, + pub focus_first: bool, + pub nodes: Vec, +} + +/// The panels' shared state (managed by the app). +#[derive(Default)] +pub struct MenuPanels { + pub book: Mutex, + pub nodes: Mutex>, + /// 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::() + } + + fn book(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 { + 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::() 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) { + 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::>())); + 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 { + 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::().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 { + 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 { + 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 { + let labels: Vec = 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 { + 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::*; diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index a03dc88..3381956 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -43,6 +43,30 @@ 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 { @@ -190,6 +214,8 @@ pub const EXTERNAL_HOSTS: &[&str] = &[ "appstoreconnect.apple.com", "developer.apple.com", "storebound.dev", + // the guides (skills//SKILL.md) of check findings, in storebound's public repo + "git.crt.fyi", // Dependencies screen link-outs "developer.android.com", "ffmpeg.org", diff --git a/apps/desktop/src-tauri/src/profiles.rs b/apps/desktop/src-tauri/src/profiles.rs index 350b66f..072484f 100644 --- a/apps/desktop/src-tauri/src/profiles.rs +++ b/apps/desktop/src-tauri/src/profiles.rs @@ -56,6 +56,11 @@ pub fn slug(label: &str) -> String { /// The CLI workspace's account id for a profile's store: sha256(":"), 16 hex (cli/lib/workspace.ts /// accountId), so the same account has the same id in `storebound.workspace.json` and in the app. +/// A Play developer account id: 1-25 digits. +pub fn valid_developer_id(v: &str) -> bool { + !v.is_empty() && v.len() <= 25 && v.bytes().all(|b| b.is_ascii_digit()) +} + pub fn account_id(kind: AccountKind, profile: &str) -> String { let store = match kind { AccountKind::Apple => "apple", @@ -139,6 +144,24 @@ impl ProfileStore { out } + /// The Play developer account id a profile keeps (PLAY_DEVELOPER_ID, digits), if any. + pub fn play_developer_id(&self, name: &str) -> Option { + self.read(name).ok().and_then(|m| str_of(&m, "PLAY_DEVELOPER_ID").map(str::trim).filter(|v| valid_developer_id(v)).map(str::to_string)) + } + + /// Sets or clears (None) PLAY_DEVELOPER_ID in an existing profile; every other field stays. + pub fn set_play_developer_id(&self, name: &str, id: Option<&str>) -> Result<(), String> { + let mut m = self.read(name)?; + match id { + Some(v) if valid_developer_id(v) => { m.insert("PLAY_DEVELOPER_ID".into(), Value::String(v.to_string())); } + Some(_) => return Err("the Play developer id is the digits in play.google.com/console/u/0/developers/".into()), + None => { m.remove("PLAY_DEVELOPER_ID"); } + } + let text = serde_json::to_string_pretty(&Value::Object(m)).map_err(|e| e.to_string())? + " +"; + write_private(&self.dir.join(format!("{name}.json")), text.as_bytes()) + } + /// The label the profile carries, if any (written by the app; the CLI ignores it). pub fn label(&self, name: &str) -> Option { self.read(name).ok().and_then(|m| str_of(&m, "label").map(str::to_string)) @@ -367,7 +390,7 @@ pub fn sync(accounts: &mut Vec, store: &ProfileStore, legacy: &dyn Secr if accounts.iter().any(|a| a.id == id) { continue; } - accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()) }); + accounts.push(Account { id: id.clone(), label: store.label(&name).unwrap_or_else(|| name.clone()), kind, created: now.to_string(), studio: None, profile: Some(name.clone()), developer_id: None }); r.added.push(format!("{id} ({name})")); } } diff --git a/apps/desktop/src-tauri/src/registry.rs b/apps/desktop/src-tauri/src/registry.rs index 2ae732b..a67d261 100644 --- a/apps/desktop/src-tauri/src/registry.rs +++ b/apps/desktop/src-tauri/src/registry.rs @@ -76,6 +76,16 @@ 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::(&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::(&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; } @@ -217,3 +227,139 @@ 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:" (both) / "project::ios" / "project::android" +// storeApps: "ignored": ["apple"] <-> "app:ios::" (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 { + 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 { + 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 { + 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:::" 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 { + 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 { + serde_json::from_str::(&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 { + 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| { + 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) +} diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 5d2c567..f999653 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -45,11 +45,14 @@ 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, } impl Default for State { fn default() -> State { - State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() } + State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] } } } @@ -121,6 +124,7 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec) { 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(); diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs new file mode 100644 index 0000000..0b23b9f --- /dev/null +++ b/apps/desktop/src-tauri/src/tray.rs @@ -0,0 +1,286 @@ +//! 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, + 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 }, +} + +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::>().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 { + let item = |id: &str, label: &str| Entry::Item { id: id.into(), label: clean(label, 90), enabled: true }; + let l = &s.labels; + let apps: Vec = 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::()), 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 { + 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::(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 { + 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> = 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 { + let (route, at) = PENDING.lock().ok()?.take()?; + (at.elapsed() < PENDING_FOR).then_some(route) +} + +// ---- drawing ---- + +static LAST: Mutex> = Mutex::new(None); + +/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth. +fn native_items(app: &AppHandle, nodes: &[Node]) -> tauri::Result>>> { + let mut out: Vec>> = 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> = 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(app: &AppHandle, nodes: &[Node]) -> tauri::Result> { + let items = native_items(app, nodes)?; + let refs: Vec<&dyn IsMenuItem> = 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 { + 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(()) +} diff --git a/apps/desktop/src-tauri/src/updates.rs b/apps/desktop/src-tauri/src/updates.rs index 40c0ef5..2baaf87 100644 --- a/apps/desktop/src-tauri/src/updates.rs +++ b/apps/desktop/src-tauri/src/updates.rs @@ -1,22 +1,111 @@ //! 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). Pure parts here so tests can pin them. +//! 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}; + 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): `-dev.`. +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 { - if channel == "beta" { - BETA - } else { - STABLE + match channel { + "beta" => BETA, + "dev" => DEV, + _ => 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)> { + 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::>()), + None => (v, Vec::new()), + }; + let mut n = core.split('.').map(|x| x.parse::().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::(), y.parse::()) { + (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/") diff --git a/apps/desktop/src-tauri/src/workspace.rs b/apps/desktop/src-tauri/src/workspace.rs index e8e7ab3..02f170b 100644 --- a/apps/desktop/src-tauri/src/workspace.rs +++ b/apps/desktop/src-tauri/src/workspace.rs @@ -269,9 +269,39 @@ 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::().is_ok_and(|h| h < 24) && s[3..].parse::().is_ok_and(|m| m < 60) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -280,7 +310,7 @@ pub struct Settings { pub refresh: RefreshSettings, pub cache: CacheSettings, pub notify: NotifySettings, - /// Update channel (placeholder until the updater ships): stable | beta. + /// Update channel (Settings > Updates): stable | dev (beta kept for saved states). crate::updates::endpoint. pub channel: String, } @@ -300,7 +330,7 @@ impl Default for CacheSettings { impl Default for NotifySettings { fn default() -> NotifySettings { - NotifySettings { review_state: true, low_reviews: true, crash: true } + NotifySettings { review_state: true, rejected: true, approved: true, live: true, low_reviews: true, crash: true, quiet_hours: QuietHours::default() } } } @@ -327,8 +357,11 @@ 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 !["stable", "beta"].contains(&s.channel.as_str()) { - return Err("channel: stable or beta".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()); } Ok(()) } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 122f7ad..ab2239f 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.24", + "version": "0.8.77", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", @@ -20,7 +20,8 @@ "minWidth": 880, "minHeight": 560, "resizable": true, - "center": true + "center": true, + "visible": false } ], "security": { @@ -49,7 +50,7 @@ "form-action": "'none'" }, "freezePrototype": true, - "capabilities": ["main-window"] + "capabilities": ["main-window", "card-window", "menu-window"] } }, "bundle": { @@ -87,6 +88,7 @@ }, "updater": { "endpoints": ["https://storebound.dev/desktop/latest.json"], + "windows": { "installMode": "quiet" }, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK" } } diff --git a/apps/desktop/src-tauri/tests/accounts.rs b/apps/desktop/src-tauri/tests/accounts.rs index de742b5..7bcdca6 100644 --- a/apps/desktop/src-tauri/tests/accounts.rs +++ b/apps/desktop/src-tauri/tests/accounts.rs @@ -9,7 +9,7 @@ const FAKE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PR #[test] fn shown_metadata_carries_no_secret() { - let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()) }; + let a = Account { id: "abc".into(), label: "Studio Play".into(), kind: AccountKind::Play, created: "2026-10-10T00:00:00Z".into(), studio: Some("s1".into()), profile: Some("studio".into()), developer_id: None }; let v = serde_json::to_value(&a).unwrap(); assert_eq!(v.as_object().unwrap().keys().cloned().collect::>(), vec!["created", "id", "kind", "label", "profile", "studio"]); assert_eq!(v["kind"], "play"); diff --git a/apps/desktop/src-tauri/tests/ai_fix.rs b/apps/desktop/src-tauri/tests/ai_fix.rs index 2231869..179bf1d 100644 --- a/apps/desktop/src-tauri/tests/ai_fix.rs +++ b/apps/desktop/src-tauri/tests/ai_fix.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(unix)] use std::time::{Duration, Instant}; use storebound_desktop::ai_fix::*; diff --git a/apps/desktop/src-tauri/tests/card.rs b/apps/desktop/src-tauri/tests/card.rs new file mode 100644 index 0000000..c1ac947 --- /dev/null +++ b/apps/desktop/src-tauri/tests/card.rs @@ -0,0 +1,77 @@ +//! 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); +} diff --git a/apps/desktop/src-tauri/tests/d5.rs b/apps/desktop/src-tauri/tests/d5.rs index 27fd060..9f2f382 100644 --- a/apps/desktop/src-tauri/tests/d5.rs +++ b/apps/desktop/src-tauri/tests/d5.rs @@ -41,10 +41,13 @@ 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); - for ch in ["stable", "beta", "nightly", ""] { + assert_eq!(c["plugins"]["updater"]["windows"]["installMode"], "quiet"); + for ch in ["stable", "beta", "dev", "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}"); @@ -103,3 +106,49 @@ 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(&[])); +} diff --git a/apps/desktop/src-tauri/tests/machine.rs b/apps/desktop/src-tauri/tests/machine.rs index 3dde508..8fc1076 100644 --- a/apps/desktop/src-tauri/tests/machine.rs +++ b/apps/desktop/src-tauri/tests/machine.rs @@ -38,7 +38,7 @@ impl SecretStore for MapStore { } fn account(id: &str, label: &str, kind: AccountKind, profile: Option<&str>) -> Account { - Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string) } + Account { id: id.into(), label: label.into(), kind, created: "2026-10-01T00:00:00Z".into(), studio: Some("s1".into()), profile: profile.map(str::to_string), developer_id: None } } #[test] @@ -207,3 +207,56 @@ 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); +} diff --git a/apps/desktop/src-tauri/tests/menu_panel.rs b/apps/desktop/src-tauri/tests/menu_panel.rs new file mode 100644 index 0000000..d46cd18 --- /dev/null +++ b/apps/desktop/src-tauri/tests/menu_panel.rs @@ -0,0 +1,214 @@ +//! 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 { + 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 = (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}"); + } +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 9b780bd..6c6c4c6 100644 --- a/apps/desktop/src-tauri/tests/security.rs +++ b/apps/desktop/src-tauri/tests/security.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; use storebound_desktop::policy::*; -use storebound_desktop::COMMANDS; +use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS}; use url::Url; fn root() -> PathBuf { @@ -24,9 +24,10 @@ fn u(s: &str) -> Url { #[test] fn one_capability_for_the_main_window_with_exactly_the_app_commands() { let dir = root().join("capabilities"); - let files: Vec = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect(); - assert_eq!(files.len(), 1, "one capability file expected, found {files:?}"); - let cap = read_json(&files[0]); + let mut files: Vec = 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")); assert_eq!(cap["identifier"], "main-window"); assert_eq!(cap["windows"], json!(["main"])); assert!(cap.get("webviews").is_none(), "no webview-scoped grants"); @@ -46,6 +47,69 @@ 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 = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + let mut want: BTreeSet = 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 = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + let mut want: BTreeSet = 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(); @@ -56,7 +120,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().map(|s| s.to_string()).collect::>()); + assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::>()); // every command checks the caller first let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); for c in COMMANDS { @@ -84,7 +148,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"])); + assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-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(); diff --git a/apps/desktop/src-tauri/tests/tray.rs b/apps/desktop/src-tauri/tests/tray.rs new file mode 100644 index 0000000..47c03e8 --- /dev/null +++ b/apps/desktop/src-tauri/tests/tray.rs @@ -0,0 +1,122 @@ +//! 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 { + 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 = 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"); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4c77cad..02a809d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,36 +1,46 @@ -// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), +// 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), // 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 } from './api'; +import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api'; import { devSwitch } from './dev/switches'; import { type Key, t } from './i18n'; import { ago, num } from './lib/format'; -import { groupByStudio, type UnifiedApp } from './lib/identity'; -import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; +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 { 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 } from './store'; +import { useData, useReadState } 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 = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; export const WS_KEY: Record = { 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 = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' }; export function App({ onTheme }: { onTheme: () => void }) { const d = useData(); @@ -40,11 +50,38 @@ export function App({ onTheme }: { onTheme: () => void }) { const [welcome, setWelcome] = useState(false); const [started, setStarted] = useState(false); const [collapsed, setCollapsed] = useState(loadCollapsed); + const [studioFilter, setStudioFilterState] = useState(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 */ } @@ -67,33 +104,44 @@ 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' })); }); - return () => { un.then(f => f()); unFolder.then(f => f()); }; + // 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()); }; }, [go, d.reload]); const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; - const tabs = app ? tabsFor(!!app.project) : []; + const game = app ? gameOf(app) : false; + const tabs = app ? tabsOf(app) : []; + const sections = app ? sectionsFor(!!app.project, game) : []; - // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs + // 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 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.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) { + 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) { e.preventDefault(); - const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1); - go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! }); + 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]! }); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [palette, welcome, route, go, tabs]); + }, [palette, welcome, route, go, app, sections, game, stepApp]); const closeWelcome = useCallback(() => { setWelcome(false); @@ -107,11 +155,30 @@ 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' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) }); + 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) }); 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 }) }); - 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 }) }); + // 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 }), ''); + } 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; @@ -124,13 +191,14 @@ 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, tabs, route, go, onTheme, exports, runExport]); + }, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]); 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(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(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]) }] : [])] : [{ 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 @@ -138,26 +206,22 @@ 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 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 ( -
- -
- ); - }; + const menu = useContextMenu(); + const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => ( +
+ +
+ ); + const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE]; return ( @@ -171,35 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
- -
{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')} +
{t('nav.appsLabel')} {visible.length}
+ {d.studios.length > 1 && ( +
+ + {d.studios.map(s => )} +
+ )} {groups.map(g => { - const s = d.studios.find(x => x.id === g.studio) ?? null; - const gid = g.studio ?? 'none'; + const gid = `g:${g.id}`; const open = !collapsed.includes(gid); - if (!d.studios.length) return
{g.rows.map(r => appItem(r.app))}
; return ( -
- - {open &&
{g.rows.map(r => appItem(r.app))}
} + {open &&
{g.rows.map(r => appItem(r.app, r.st))}
}
); })} - {!visible.length &&

{t('nav.noApps')}

} -
{t('nav.workspace')}
- {WORKSPACE.map((s, i) => ( - - ))} + {!visible.length && !showIgnored &&

{t('nav.noApps')}

} + {ignoredApps.length > 0 && } +
+
+ {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 ( + + ); + })}
@@ -221,8 +291,8 @@ export function App({ onTheme }: { onTheme: () => void }) { {FIXTURES && {t('dev.fixtures')}} {lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')} - @@ -243,21 +313,27 @@ export function App({ onTheme }: { onTheme: () => void }) { {palette && setPalette(false)} />} {welcome && } + ); } -/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */ +/** 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. */ function ReaderLine() { const d = useData(); const r = d.reader; const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0]; - 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
{parts.join(' · ')}
; + 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 ( +
+ {head &&
{head}
} + {busy &&
} + {paused &&
{t('reader.backoffShort')}
} + {q &&
{t('reader.appleQuotaShort', { left: num(q.remaining), limit: num(q.limit) })}
} +
+ ); } diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 68cce68..58c06e8 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -6,6 +6,7 @@ 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, @@ -24,7 +25,7 @@ export interface SidecarStatus { running: boolean; version: string; protocol: st /** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */ export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null } export type AccountKind = 'play' | 'apple'; -export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null } +export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null; /** Play: the developer account id (PLAY_DEVELOPER_ID in the profile) for Play Console deep links. */ developerId?: string | null } export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const; export type StudioColor = (typeof STUDIO_COLORS)[number]; /** A user-named group of store accounts (and so of apps). */ @@ -33,10 +34,12 @@ 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 }; - notify: { reviewState: boolean; lowReviews: boolean; crash: boolean }; - channel: 'stable' | 'beta'; + /** 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'; } -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 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 interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings } export interface AccountApps { account: string; entry: CacheEntry | null } export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number } @@ -45,7 +48,7 @@ export interface CacheEntry { 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' | 'beta'; available: string | null; notes: string | null; date: string | null } +export interface UpdateInfo { current: string; channel: 'stable' | 'dev' | '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 } @@ -108,6 +111,7 @@ export const api = { addAppleAccount: (label: string, keyId: string, issuerId: string, studio: string | null = null) => invoke('account_add_apple', { label, keyId, issuerId, studio }), removeAccount: (id: string) => invoke('account_remove', { id }), updateAccount: (id: string, label: string, studio: string | null) => invoke('account_update', { id, label, studio }), + setPlayDeveloper: (id: string, developerId: string | null) => invoke('account_set_play_developer', { id, developerId }), /** Does the key work, can it see apps (live, not cached). */ checkAccount: (id: string, job?: string) => invoke('account_check', { id, job: job ?? null }), /** Reads the apps an account can see from its store and keeps the list. */ @@ -121,7 +125,10 @@ export const api = { setSettings: (settings: Settings) => invoke('settings_set', { settings }), cacheInfo: () => invoke('cache_info'), revealData: () => invoke('data_reveal'), - notify: (title: string, body: string) => invoke('notify', { title, body }), + /** 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('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('tray_update', { summary }), /** Native confirmation first; true when everything was forgotten. */ reset: () => invoke('app_reset'), /** Offline (folders) or live; live also reads the given store-only apps (store targets). */ @@ -172,4 +179,8 @@ export const onDownload = (cb: (e: DownloadEvent) => void): Promise export const onDeepLink = (cb: (screen: string) => void): Promise => listen('deep-link', cb); /** storebound://open?path=... selected or added an app folder (its project id). */ export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise => listen('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 => listen('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 => listen<'read' | 'mute-toggle'>('tray-action', cb); export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise => listen('update-progress', cb); diff --git a/apps/desktop/src/card/Card.tsx b/apps/desktop/src/card/Card.tsx new file mode 100644 index 0000000..4140e6a --- /dev/null +++ b/apps/desktop/src/card/Card.tsx @@ -0,0 +1,178 @@ +// 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; + 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) => cardT(lang, k, v), [lang]); + const [s, setS] = useState(null); + const [menu, setMenu] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const [shown, setShown] = useState(0); + const root = useRef(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('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('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 ( +
+
+
+ {T('title')} + {s?.muted && {T('muted')}}{T('lastRead', { ago: ago(lang, s?.at ?? null, now) })} +
+ + + {menu && ( +
+ + +
+ +
+ )} +
+ +
+ {!s &&

{T('noData')}

} + {s && calm &&

{s.at ? T('calm') : T('noData')}

} + + {!!s?.rejections.length && ( +
+ {s.rejections.map(r => ( + host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })} + line={<>{T('rejected')} · {store(r.store)}{r.version ? ` ${r.version}` : ''}} sub={r.reason} + consoles={consoles(link(r.app), r.store, host, T)} /> + ))} +
+ )} + + {!!s?.releases.length && ( +
+ {s.releases.map(r => ( + 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)} /> + ))} +
+ )} + + {!!s?.health.length && ( +
+ {s.health.map(h => ( + host.action('route', route(h.app, 'health'))} openLabel={T('openApp', { name: h.name })} + line={<>{h.kinds.length > 1 ? T('crashAnr') : T(h.kinds[0]!)}} consoles={consoles(link(h.app), 'android', host, T)} /> + ))} +
+ )} + + {s && s.at && ( +
+ {s.reviews.low.map(r => ( + + ))} +
+ )} +
+ {s?.build &&
{T('build', { version: s.build.version, channel: T(s.build.channel === 'dev' ? 'chDev' : s.build.channel === 'beta' ? 'chBeta' : 'chStable') })}
} +
+ ); +} + +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 ; +} + +function Section({ title, tone, aside, children }: { title: string; tone?: 'bad' | 'warn'; aside?: string; children: ReactNode }) { + return ( +
+

{title}{aside && {aside}}

+ {children} +
+ ); +} + +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 ( +
+ + {consoles} +
+ ); +} + +const initials = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]!.toUpperCase()).join('') || '?'; diff --git a/apps/desktop/src/card/bridge.tsx b/apps/desktop/src/card/bridge.tsx new file mode 100644 index 0000000..f34d644 --- /dev/null +++ b/apps/desktop/src/card/bridge.tsx @@ -0,0 +1,66 @@ +// 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>({}); + 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; +} diff --git a/apps/desktop/src/card/card-api.ts b/apps/desktop/src/card/card-api.ts new file mode 100644 index 0000000..86b466c --- /dev/null +++ b/apps/desktop/src/card/card-api.ts @@ -0,0 +1,25 @@ +// 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(ev: string, fn: (p: T) => void): () => void { + const un = listen(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('card_snapshot', { snapshot }), + /** Main window: the card opened; refresh what is kept per app. */ + onRequest: (fn: () => void) => sub('card-request', () => fn()), + get: () => invoke('card_get'), + onSnapshot: (fn: (s: CardSnapshot) => void) => sub('card-snapshot', fn), + onShown: (fn: () => void) => sub('card-shown', () => fn()), + action: (action: CardAction, arg?: string) => { invoke('card_action', { action, arg: arg ?? null }).catch(() => {}); }, +}; diff --git a/apps/desktop/src/card/card.css b/apps/desktop/src/card/card.css new file mode 100644 index 0000000..68ca2e6 --- /dev/null +++ b/apps/desktop/src/card/card.css @@ -0,0 +1,59 @@ +/* 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; } diff --git a/apps/desktop/src/card/fixture.ts b/apps/desktop/src/card/fixture.ts new file mode 100644 index 0000000..937ac3e --- /dev/null +++ b/apps/desktop/src/card/fixture.ts @@ -0,0 +1,25 @@ +// 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' }, + ], +}; diff --git a/apps/desktop/src/card/main.tsx b/apps/desktop/src/card/main.tsx new file mode 100644 index 0000000..c0f3790 --- /dev/null +++ b/apps/desktop/src/card/main.tsx @@ -0,0 +1,53 @@ +// 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 { + 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(); + 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'); +})(); diff --git a/apps/desktop/src/card/model.ts b/apps/desktop/src/card/model.ts new file mode 100644 index 0000000..79ca0ac --- /dev/null +++ b/apps/desktop/src/card/model.ts @@ -0,0 +1,89 @@ +// 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(); + 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, + }; +} diff --git a/apps/desktop/src/card/text.ts b/apps/desktop/src/card/text.ts new file mode 100644 index 0000000..3a700fc --- /dev/null +++ b/apps/desktop/src/card/text.ts @@ -0,0 +1,111 @@ +// 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 = { + 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 { + 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) }); +} diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css new file mode 100644 index 0000000..2ac3a50 --- /dev/null +++ b/apps/desktop/src/design.css @@ -0,0 +1,302 @@ +/* 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/.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; } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 9acb44a..7e7be9e 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -9,6 +9,7 @@ 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'; @@ -429,6 +430,16 @@ const GAMES_PRISM = { 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 }); @@ -454,7 +465,17 @@ export function fixtureRead(method: string, id: string, args: Record typeof location !== 'undefined' && new URLSearchParams(location.search).has('blocked'); + function read(method: string, id: string, args: Record = {}): 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 = {}): ToolResult { if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args)); if (id.startsWith('store:')) { const play = /android:/.test(id); @@ -534,7 +555,12 @@ 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(cmd: string, a: Record): Promise { + 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; } @@ -565,11 +591,12 @@ async function answer(cmd: string, a: Record): Promise } case 'studio_remove': studios = studios.filter(s => s.id !== a.id); accounts = accounts.map(x => (x.studio === a.id ? { ...x, studio: null } : x)); return null; case 'studio_logo': return null; + case 'account_set_play_developer': { const x = accounts.find(y => y.id === a.id)!; const v = (a.developerId as string | null) ?? null; if (v && !/^\d{1,25}$/.test(v)) throw new Error('the Play developer id is digits'); (x as { developerId?: string | null }).developerId = v; return x; } case 'account_update': { const x = accounts.find(y => y.id === a.id)!; x.label = a.label as string; x.studio = (a.studio as string | null) ?? null; return x; } case 'overrides_set': overrides = a.overrides as Overrides; return overrides; case 'settings_set': settings = a.settings as Settings; return settings; 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': return null; + case 'data_reveal': case 'notify': case 'tray_update': 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; diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3585c39..181d7c2 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -59,6 +59,36 @@ 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', @@ -123,7 +153,10 @@ 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': 'In review (live: {version})', + '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.reviewManaged': 'In review / ready to publish (managed publishing)', 'phase.rejected': 'Rejected', 'phase.testing': 'Testing', @@ -366,6 +399,7 @@ 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', @@ -543,6 +577,7 @@ 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', @@ -735,6 +770,16 @@ 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.', @@ -781,6 +826,54 @@ 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', @@ -827,6 +920,9 @@ 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", @@ -853,11 +949,11 @@ export const en = { 'set.always': 'Always', 'set.notifications': 'Notifications', 'set.notificationsSub': 'After a read, when something changed since the previous one', - 'set.nReview': 'Review and release state changes', - 'set.nReviewHint': 'In review, approved, rejected, live, halted', + 'set.nReview': 'Other release state changes', + 'set.nReviewHint': 'Submitted, in review', 'set.nLow': 'New 1 or 2 star reviews', 'set.nLowHint': 'When the Reviews tab reads new ones', - 'set.nCrash': 'Crash or ANR rate over the threshold', + 'set.nCrash': 'Rollout halted, or crash / ANR over the threshold', 'set.nCrashHint': 'Google Play bad behavior thresholds', 'set.testNotify': 'Send a test notification', 'set.testTitle': 'storebound', @@ -881,7 +977,9 @@ export const en = { 'set.channel': 'Channel', 'set.stable': 'Stable', 'set.beta': 'Beta', - '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.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.updateCheck': 'Check for updates', 'set.updateChecking': 'Checking…', 'set.updateNone': 'storebound {version} is the newest version on the {channel} channel.', @@ -1127,6 +1225,14 @@ 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', @@ -1816,6 +1922,26 @@ export const en = { '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', @@ -1864,4 +1990,246 @@ export const en = { '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; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index e4847bc..417f0dd 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -61,6 +61,36 @@ export const tr: Record = { '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ç', @@ -125,7 +155,10 @@ export const tr: Record = { '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': 'İncelemede (yayında: {version})', + '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.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)', 'phase.rejected': 'Reddedildi', 'phase.testing': 'Testte', @@ -368,6 +401,7 @@ export const tr: Record = { '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ı', @@ -545,6 +579,7 @@ export const tr: Record = { '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', @@ -736,6 +771,16 @@ export const tr: Record = { '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.', @@ -783,6 +828,54 @@ export const tr: Record = { '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', @@ -829,6 +922,9 @@ export const tr: Record = { '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ı", @@ -855,11 +951,11 @@ export const tr: Record = { 'set.always': 'Her zaman', 'set.notifications': 'Bildirimler', 'set.notificationsSub': 'Bir okumadan sonra, öncekinden beri bir şey değiştiyse', - 'set.nReview': 'İnceleme ve yayın durumu değişiklikleri', - 'set.nReviewHint': 'İncelemede, onaylandı, reddedildi, yayında, durduruldu', + 'set.nReview': 'Diğer yayın durumu değişiklikleri', + 'set.nReviewHint': 'Gönderildi, incelemede', 'set.nLow': 'Yeni 1 ya da 2 yıldızlı yorumlar', 'set.nLowHint': 'Yorumlar sekmesi yenilerini okuduğunda', - 'set.nCrash': 'Çökme ya da ANR oranı eşiğin üstünde', + 'set.nCrash': 'Kademeli yayın durdu, ya da çökme / ANR eşiğin üstünde', 'set.nCrashHint': 'Google Play kötü davranış eşikleri', 'set.testNotify': 'Deneme bildirimi gönder', 'set.testTitle': 'storebound', @@ -883,7 +979,9 @@ export const tr: Record = { 'set.channel': 'Kanal', 'set.stable': 'Kararlı', 'set.beta': 'Beta', - '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.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.updateCheck': 'Güncellemeleri denetle', 'set.updateChecking': 'Denetleniyor…', 'set.updateNone': 'storebound {version}, {channel} kanalındaki en yeni sürüm.', @@ -1129,6 +1227,14 @@ export const tr: Record = { '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', @@ -1818,6 +1924,26 @@ export const tr: Record = { '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', @@ -1866,4 +1992,246 @@ export const tr: Record = { '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}", }; diff --git a/apps/desktop/src/lib/identity.ts b/apps/desktop/src/lib/identity.ts index 7dff543..1e2d2d4 100644 --- a/apps/desktop/src/lib/identity.ts +++ b/apps/desktop/src/lib/identity.ts @@ -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, withStudio, withUnpair, + withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair, } from '../../../../cli/lib/identity'; export type UnifiedApp = CoreApp; diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index e1979c0..a104d02 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -2,7 +2,7 @@ // inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher. // "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok". import { plainError } from '../../../../cli/lib/google-errors'; -import type { AppStatus, ProjectSummary } from '../api'; +import type { AppStatus, ProjectSummary, StoreRelease } from '../api'; import { t } from '../i18n'; import type { Tone } from '../../../../cli/lib/train'; @@ -138,7 +138,7 @@ export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | null) // ---- Portfolio: needs attention ---- -export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' } +export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' | 'games' } export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] { const out: Attention[] = []; @@ -203,3 +203,46 @@ export function hueOf(name: string): number { for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 360; } + +// ---- App kind and the sidebar ---- + +const PLAY_GAME_CATS = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word']; +/** A game: profile game, a games section in the config, or a Play game category. */ +export function isGameSummary(s: ProjectSummary | null | undefined): boolean { + if (!s) return false; + return s.profile === 'game' || !!s.games || PLAY_GAME_CATS.includes(s.playRelease?.category ?? ''); +} + +export type SideGroup = 'attention' | 'progress' | 'live' | 'draft'; +export interface SideStatus { group: SideGroup; text: string; tone: Tone } + +/** One sidebar row's group and status line: the one thing worth knowing about the app (docs IA). */ +export function sideStatus(o: { att: Attention[]; all: StoreRelease[]; read: boolean; missing: boolean; storeOnly: boolean }): SideStatus { + const sn = (r: StoreRelease) => `${r.store === 'ios' ? 'iOS' : 'Play'} ${r.version ?? ''}`.trim(); + if (o.missing) return { group: 'attention', text: t('side.missing'), tone: 'bad' }; + const rej = o.all.find(r => r.phase === 'rejected'); + if (rej) return { group: 'attention', text: t('side.rejected', { what: sn(rej) }), tone: 'bad' }; + const halted = o.all.find(r => r.phase === 'halted'); + if (halted) return { group: 'attention', text: t('side.halted', { what: sn(halted) }), tone: 'bad' }; + const bad = o.att.filter(a => a.tone === 'bad').length; + if (bad) return { group: 'attention', text: t('side.blockers', { count: bad }), tone: 'bad' }; + const crash = o.att.find(a => a.tab === 'health'); + if (crash) return { group: 'attention', text: crash.text, tone: crash.tone }; + const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { group: 'progress', text: t('side.rollout', { share: `${Math.round((rolling.fraction ?? 0) * 100)} %`, what: sn(rolling) }), tone: 'info' }; + const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { group: 'progress', text: t('side.review', { what: sn(review) }), tone: 'info' }; + const approved = o.all.find(r => r.phase === 'approved'); + if (approved) return { group: 'progress', text: t('side.approved', { what: sn(approved) }), tone: 'info' }; + const live = o.all.filter(r => r.phase === 'live' || r.phase === 'released' || r.phase === 'rollout'); + const draft = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (draft && !live.length) return { group: 'progress', text: t('side.draft', { what: sn(draft) }), tone: 'neutral' }; + const warn = o.att.filter(a => a.tone === 'warn').length; + if (live.length) { + const vs = [...new Set(live.map(r => r.version).filter(Boolean))]; + return { group: 'live', text: warn ? t('side.liveWarn', { version: vs.join(' / '), count: warn }) : t('side.live', { version: vs.join(' / ') }), tone: warn ? 'warn' : 'ok' }; + } + if (!o.read) return { group: o.storeOnly ? 'live' : 'draft', text: o.storeOnly ? t('side.storeOnly') : t('side.notRead'), tone: 'neutral' }; + return { group: 'draft', text: warn ? t('side.warnings', { count: warn }) : t('side.notOnStore'), tone: warn ? 'warn' : 'neutral' }; +} +export const SIDE_GROUPS: SideGroup[] = ['attention', 'progress', 'live', 'draft']; diff --git a/apps/desktop/src/lib/normalize.ts b/apps/desktop/src/lib/normalize.ts index 36e7844..bab3173 100644 --- a/apps/desktop/src/lib/normalize.ts +++ b/apps/desktop/src/lib/normalize.ts @@ -49,17 +49,23 @@ function overrides(v: unknown): Overrides { function pick(v: unknown, d: T, ok: (x: unknown) => boolean): T { return ok(v) ? (v as T) : d; } const isNum = (x: unknown) => typeof x === 'number' && Number.isFinite(x); const isBool = (x: unknown) => typeof x === 'boolean'; +const isHm = (x: unknown) => typeof x === 'string' && /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(x); export function normalizeSettings(v: unknown): Settings { const d = DEFAULT_SETTINGS; if (!isRec(v)) return d; const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {}; + const q = isRec(n.quietHours) ? n.quietHours : {}; return { refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool), autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) }, cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) }, - notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) }, - channel: v.channel === 'beta' ? 'beta' : 'stable', + notify: { + reviewState: pick(n.reviewState, d.notify.reviewState, isBool), rejected: pick(n.rejected, d.notify.rejected, isBool), approved: pick(n.approved, d.notify.approved, isBool), live: pick(n.live, d.notify.live, isBool), + lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool), + quietHours: { enabled: pick(q.enabled, d.notify.quietHours.enabled, isBool), from: pick(q.from, d.notify.quietHours.from, isHm), to: pick(q.to, d.notify.quietHours.to, isHm) }, + }, + channel: v.channel === 'dev' || v.channel === 'beta' ? v.channel : 'stable', }; } diff --git a/apps/desktop/src/lib/notify.ts b/apps/desktop/src/lib/notify.ts index 247f8ba..e6b48f0 100644 --- a/apps/desktop/src/lib/notify.ts +++ b/apps/desktop/src/lib/notify.ts @@ -4,7 +4,18 @@ import type { PortfolioResult } from '../api'; import { sameVersion } from '../../../../cli/lib/portfolio'; import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model'; -export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string } +/** Which Settings > Notifications toggle a release change belongs to. */ +export type ReleaseKind = 'rejected' | 'approved' | 'live' | 'halted' | 'state'; +export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string; kind: ReleaseKind; /** The store's reason text, when the status carries one. */ reason?: string } + +/** The notification kind of a transition into phase `to`.*/ +export function releaseKind(to: string): ReleaseKind { + if (to === 'rejected') return 'rejected'; + if (to === 'approved') return 'approved'; + if (to === 'live' || to === 'rollout') return 'live'; + if (to === 'halted') return 'halted'; + return 'state'; +} /** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */ export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] { @@ -18,7 +29,12 @@ export function releaseChanges(prev: PortfolioResult | null | undefined, next: P if (!r.version) continue; const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version))); if (!old || old.phase === r.phase) continue; - if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase }); + // a version the developer withdrew on their own is not a store decision + if (r.state === 'DEVELOPER_REJECTED') continue; + if (WATCH.has(r.phase) || WATCH.has(old.phase)) { + const reason = typeof r.reason === 'string' && r.reason.trim() ? r.reason.trim().slice(0, 160) : undefined; + out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase, kind: releaseKind(r.phase), ...(reason ? { reason } : {}) }); + } } } return out; @@ -40,3 +56,55 @@ export function crashCrossed(prev: HealthSnapshot | null | undefined, next: Heal for (const k of ['crash', 'anr'] as const) if (now[k].tone === 'bad' && was[k].tone !== 'bad') out.push(k); return out; } + +// ---- when a notification may show: kind toggle, quiet hours, mute ---- + +export interface QuietHours { enabled: boolean; from: string; to: string } +export interface NotifyPrefs { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: QuietHours } +export type NoticeKind = ReleaseKind | 'lowReviews' | 'crash' | 'test'; + +/** The toggle of a notification kind ("halted" shares the crash toggle: both are "something is going wrong"). */ +export function kindEnabled(n: NotifyPrefs, kind: NoticeKind): boolean { + switch (kind) { + case 'rejected': return n.rejected; + case 'approved': return n.approved; + case 'live': return n.live; + case 'halted': case 'crash': return n.crash; + case 'state': return n.reviewState; + case 'lowReviews': return n.lowReviews; + case 'test': return true; + } +} + +const minutesOf = (hhmm: string): number | null => { + const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm); + return m ? Number(m[1]) * 60 + Number(m[2]) : null; +}; + +/** True inside the quiet window (it may cross midnight: 23:00-08:00). Off, equal ends or a bad time = never quiet. */ +export function inQuietHours(q: QuietHours, now: Date): boolean { + if (!q.enabled) return false; + const from = minutesOf(q.from), to = minutesOf(q.to); + if (from === null || to === null || from === to) return false; + const cur = now.getHours() * 60 + now.getMinutes(); + return from < to ? cur >= from && cur < to : cur >= from || cur < to; +} + +/** "Mute for 1 hour": muted while `muteUntil` (epoch ms) is in the future. */ +export const isMuted = (muteUntil: number, now = Date.now()): boolean => muteUntil > now; + +/** Whether a notification of `kind` may show now. A test notification ignores quiet hours and mute (it checks delivery). */ +export function mayNotify(n: NotifyPrefs, kind: NoticeKind, o: { now?: Date; muteUntil?: number } = {}): boolean { + if (kind === 'test') return true; + const now = o.now ?? new Date(); + return kindEnabled(n, kind) && !inQuietHours(n.quietHours, now) && !isMuted(o.muteUntil ?? 0, now.getTime()); +} + +/** + * Delay before the next background pass: the interval with +-10% jitter, doubled for every failure in a row (at most + * x16, capped at 2 hours before the jitter). `rand` is in [0, 1). + */ +export function nextReadDelay(everyMinutes: number, failures: number, rand = Math.random()): number { + const base = everyMinutes * 60_000 * 2 ** Math.min(Math.max(failures, 0), 4); + return Math.round(Math.min(base, 2 * 3_600_000) * (0.9 + rand * 0.2)); +} diff --git a/apps/desktop/src/lib/read-registry.ts b/apps/desktop/src/lib/read-registry.ts new file mode 100644 index 0000000..bd28135 --- /dev/null +++ b/apps/desktop/src/lib/read-registry.ts @@ -0,0 +1,283 @@ +// The one read registry of the window: every read button, notice, card, the background reader, the tray and Ctrl+K +// ask it, never the host directly. Pure logic over lib/scheduler.ts (tests/read-registry.test.ts). +// +// - Canonical key: target + method + args (lib/freshness.ts readKey), or a named key for reads without a target +// (the portfolio, an account's app list, an account check). +// - Single-flight: a read whose key is in flight (queued or running) is joined: the caller gets the same promise, its +// result or its error. No second host call, ever. +// - Scope: a read carries tags ('all', 'app:', 'import', 'account:' ...). A query asks by keys and / or +// tags; it is busy when any in-flight read has one of its keys, one of its tags, or COVERS one of its keys. +// - Covering: a broad read (Refresh all, Import all) is a group: it lists the narrow keys it stands for. While it runs +// those keys show busy everywhere and a click on one of them waits for the group, then reads only if the group did +// not (it usually did, so the click costs nothing). A narrow read in flight makes every broad button whose tag it +// carries busy too (decided with the owner: overlapping buttons are all busy and disabled; one click never starts a +// duplicate, it joins). +// - Just now: a read that finished less than `justNowMs` ago is not run again by a second click: the caller gets the +// last result ("read just now"). A write's invalidation drops that memo for the keys it touched. +// - Rate limits stay the scheduler's (lanes per store, spacing and the App Store Connect quota per account, shared +// 429 backoff); the registry reports a paused account on every query that uses it. +import { type Clock, type Lane, type Priority, type ReadScheduler, realClock } from './scheduler'; + +export interface ReadSpec { + key: string; + /** Scopes the read belongs to: a query by any of these tags sees it. */ + tags?: string[]; + lanes: Lane[]; + /** Credentials it uses ("apple:", "play:"): their quota pause shows on its buttons. */ + accounts: string[]; + priority: Priority; + run: () => Promise; + /** Stops a running read (the host job). */ + cancel?: () => void; +} + +export interface GroupSpec { + key: string; + tags?: string[]; + /** Narrow keys this read stands for until it releases them (release()) or ends. */ + covers: string[]; + run: (g: GroupHandle) => Promise; +} + +export interface GroupHandle { + /** This key is no longer the group's (read by its own entry now, or skipped as fresh). */ + release(key: string): void; +} + +export type ReadQuery = string | { keys?: string[]; tags?: string[] }; + +export interface ReadStatus { + /** Something this query overlaps is queued or running. */ + busy: boolean; + /** How many in-flight reads overlap it. */ + inFlight: number; + /** Every key of the query finished successfully less than justNowMs ago. */ + justNow: boolean; + /** Oldest last-finished time over the query's keys (epoch ms), null when one never finished here. */ + lastAt: number | null; + /** The last error of a single-key query (cleared by its next success). */ + error: string | null; + /** An account the query uses waits on a quota pause or a 429 backoff until this time (epoch ms). */ + pausedUntil: number | null; +} + +export type ReadEvent = { kind: 'start' } | { kind: 'done'; result: unknown } | { kind: 'error'; error: unknown }; + +interface Entry { + key: string; + tags: Set; + covers: Set; + accounts: string[]; + promise: Promise; + cancel?: () => void; + group: boolean; +} + +interface Done { at: number; ok: boolean; result?: unknown; error?: string } + +export interface RegistryOptions { + justNowMs?: number; + clock?: Clock; + /** Per-key start / done / error, for the cards' read bus (only real reads: never a join or a just-now answer). */ + onEvent?: (key: string, e: ReadEvent) => void; +} + +const errText = (e: unknown): string => (e instanceof Error ? e.message : typeof e === 'string' ? e : (e as { message?: string })?.message ?? String(e)); + +export class ReadRegistry { + private entries = new Map(); + private done = new Map(); + private accountsOf = new Map(); + private listeners = new Set<() => void>(); + private ver = 0; + private justNowMs: number; + private clock: Clock; + private onEvent?: RegistryOptions['onEvent']; + private expiry: unknown = null; + + constructor(private sched: ReadScheduler, o: RegistryOptions = {}) { + this.justNowMs = o.justNowMs ?? 20_000; + this.clock = o.clock ?? realClock; + this.onEvent = o.onEvent; + // a quota pause or backoff of the scheduler changes what the buttons say + sched.subscribe(() => this.emit()); + } + + subscribe = (fn: () => void): (() => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }; + /** Changes on every start, finish, cover release, invalidation and scheduler change (useSyncExternalStore). */ + version = (): number => this.ver; + + /** True when this exact key is in flight (its own read, or a group that covers it). */ + active(key: string): boolean { + if (this.entries.has(key)) return true; + for (const e of this.entries.values()) if (e.covers.has(key)) return true; + return false; + } + + /** The last result of a key that finished less than justNowMs ago (undefined when there is none). */ + recent(key: string): unknown { + const d = this.done.get(key); + return d?.ok && this.clock.now() - d.at < this.justNowMs ? d.result : undefined; + } + + /** + * Reads one key: joins it when it is in flight, waits for a group that covers it, answers a just-finished read + * from its memo (unless `force`), else runs it through the scheduler. + */ + read(spec: ReadSpec, o: { force?: boolean } = {}): Promise { + this.accountsOf.set(spec.key, spec.accounts); + const own = this.entries.get(spec.key); + if (own) { + // a higher priority asker raises the queued read + if (!own.group) this.sched.enqueue({ key: spec.key, lanes: spec.lanes, accounts: spec.accounts, priority: spec.priority, run: spec.run as () => Promise }).catch(() => {}); + return own.promise as Promise; + } + for (const e of this.entries.values()) { + if (e.covers.has(spec.key)) { + // the broad read stands for this key: wait for it, then read only what it did not + return e.promise.then(() => undefined, () => undefined).then(() => this.read(spec, { force: false })); + } + } + const d = this.done.get(spec.key); + if (!o.force && d?.ok && this.clock.now() - d.at < this.justNowMs) return Promise.resolve(d.result as R); + const p = this.sched.enqueue({ key: spec.key, lanes: spec.lanes, accounts: spec.accounts, priority: spec.priority, run: spec.run }); + const entry: Entry = { key: spec.key, tags: new Set(spec.tags ?? []), covers: new Set(), accounts: spec.accounts, promise: p, cancel: spec.cancel, group: false }; + this.entries.set(spec.key, entry); + this.onEvent?.(spec.key, { kind: 'start' }); + this.emit(); + p.then(r => this.settle(entry, true, r), e => this.settle(entry, false, undefined, e)); + return p; + } + + /** A broad read made of narrow ones (Refresh all, Import all): single-flight on its own key too. */ + group(spec: GroupSpec): Promise { + const own = this.entries.get(spec.key); + if (own) return own.promise as Promise; + const covers = new Set(spec.covers); + let resolve!: (r: R) => void, reject!: (e: unknown) => void; + const promise = new Promise((a, b) => { resolve = a; reject = b; }); + const entry: Entry = { key: spec.key, tags: new Set(spec.tags ?? []), covers, accounts: [], promise, group: true }; + this.entries.set(spec.key, entry); + this.emit(); + const handle: GroupHandle = { release: k => { if (covers.delete(k)) this.emit(); } }; + // run after the entry exists, so a read started inside joins nothing by mistake (release first, then read) + Promise.resolve().then(() => spec.run(handle)).then(r => { this.settle(entry, true, r); resolve(r); }, e => { this.settle(entry, false, undefined, e); reject(e); }); + return promise; + } + + /** Stops what overlaps the query: queued reads are dropped (their waiters get "cancelled"), running ones cancel their job. */ + cancel(q: ReadQuery): void { + const { keys, tags } = norm(q); + for (const e of [...this.entries.values()]) { + if (!this.overlaps(e, keys, tags) || e.group) continue; + if (this.sched.isRunning(e.key)) e.cancel?.(); + else this.sched.clear(k => k === e.key); + } + } + + /** A write changed what these keys read: their "just now" memo goes, so the next read really reads. */ + invalidate(match: (key: string) => boolean): string[] { + const hit: string[] = []; + for (const k of [...this.done.keys()]) if (match(k)) { this.done.delete(k); hit.push(k); } + if (hit.length) this.emit(); + return hit; + } + + state(q: ReadQuery): ReadStatus { + const { keys, tags } = norm(q); + const now = this.clock.now(); + let inFlight = 0; + const accts = new Set(); + for (const e of this.entries.values()) { + if (!this.overlaps(e, keys, tags)) continue; + inFlight++; + for (const a of e.accounts) accts.add(a); + } + for (const k of keys) for (const a of this.accountsOf.get(k) ?? []) accts.add(a); + let lastAt: number | null = keys.length ? Infinity : null; + let justNow = keys.length > 0; + for (const k of keys) { + const d = this.done.get(k); + if (!d) { lastAt = null; justNow = false; break; } + lastAt = Math.min(lastAt as number, d.at); + if (!d.ok || now - d.at >= this.justNowMs) justNow = false; + } + const paused = this.sched.stats().paused; + let pausedUntil: number | null = null; + for (const a of accts) if (paused[a] && paused[a] > now) pausedUntil = Math.max(pausedUntil ?? 0, paused[a]); + const one = keys.length === 1 ? this.done.get(keys[0]!) : undefined; + return { busy: inFlight > 0, inFlight, justNow: justNow && inFlight === 0, lastAt: lastAt === Infinity ? null : lastAt, error: one && !one.ok ? one.error ?? null : null, pausedUntil }; + } + + private overlaps(e: Entry, keys: string[], tags: string[]): boolean { + for (const k of keys) if (e.key === k || e.covers.has(k)) return true; + for (const t of tags) if (e.tags.has(t) || e.key === t) return true; + return false; + } + + private settle(e: Entry, ok: boolean, result: unknown, error?: unknown): void { + if (this.entries.get(e.key) === e) this.entries.delete(e.key); + const at = this.clock.now(); + if (ok) this.done.set(e.key, { at, ok, result }); + else if (!/cancel/i.test(errText(error))) this.done.set(e.key, { at, ok, error: errText(error) }); + if (!e.group) this.onEvent?.(e.key, ok ? { kind: 'done', result } : { kind: 'error', error }); + this.armExpiry(); + this.emit(); + } + + /** "Read just now" ends by itself: one timer re-renders the buttons when the oldest memo runs out. */ + private armExpiry(): void { + if (this.expiry !== null) return; + this.expiry = this.clock.setTimeout(() => { this.expiry = null; this.emit(); }, this.justNowMs + 50); + } + + private emit(): void { + this.ver++; + for (const f of this.listeners) { try { f(); } catch { /* a listener never breaks the registry */ } } + } +} + +function norm(q: ReadQuery): { keys: string[]; tags: string[] } { + return typeof q === 'string' ? { keys: [q], tags: [] } : { keys: q.keys ?? [], tags: q.tags ?? [] }; +} + +// ---- canonical keys of reads without an app target ---- +export const PORTFOLIO_KEY = '*\u001fportfolio.status\u001f{"live":true}'; +export const PORTFOLIO_CHECK_KEY = '*\u001fportfolio.status\u001f{}'; +export const READ_ALL_KEY = '*\u001freadAll'; +export const IMPORT_ALL_KEY = '*\u001fimport'; +export const importKey = (account: string): string => `account:${account}\u001fstore_apps.list`; +export const accountCheckKey = (account: string): string => `account:${account}\u001faccount.check`; + +/** The scope tags of a read of one app (Refresh all sees every app read; an app's own buttons see `app:`). */ +export const appTags = (target: string): string[] => ['all', `app:${target}`]; + +// ---- which reads a write changes ---- +const ALL = null; +const LISTING = ['project.listings', 'project.editable', 'listing.live', 'validate', 'scan', 'aso.audit']; +const CATALOG = ['prices.current', 'prices.diff', 'catalog.diff', 'validate', 'scan']; +const ART = ['art.live', 'project.renders', 'project.icon', 'validate', 'scan']; + +/** + * The read methods of the same app a write makes outdated (null: all of them). `portfolio` true when the write + * changes release state the portfolio read shows. Unknown writes invalidate everything of the app (safe default). + */ +export function affectedReads(writeMethod: string): { methods: string[] | null; portfolio: boolean } { + const m = writeMethod; + const r = (methods: string[] | null, portfolio = false) => ({ methods, portfolio }); + if (m === 'owner.play_rollout' || m === 'owner.ios_phased') return r(['apple.status', 'play.status'], true); + if (m.startsWith('reviews.') || m === 'owner.reply_send') return r(['reviews.list', 'reviews.drafts']); + if (m.startsWith('prices.') || m === 'owner.prices_import' || m === 'apple.iap' || m === 'apple.subscriptions' || m === 'play.products' || m === 'play.subscriptions') return r(CATALOG); + if (m === 'apple.listing_pull' || m === 'play.listing_pull' || m === 'play.listing' || m === 'apple.appinfo' || m === 'apple.review_details') return r(LISTING); + if (m.startsWith('art.') || m.startsWith('shots.') || m === 'apple.screenshots' || m === 'apple.previews' || m === 'play.images' || m === 'icons') return r(ART); + if (m.startsWith('apple.testflight_') || m === 'owner.testflight_testers') return r(['apple.testflight_status']); + if (m.startsWith('owner.ppo_') || m.startsWith('apple.ppo_')) return r(['apple.ppo_status']); + if (m === 'apple.game_center' || m === 'play.games') return r(['games.status']); + if (m === 'apple.encryption' || m === 'play.data_safety') return r(['project.declarations', 'validate', 'scan']); + if (m.startsWith('analytics.')) return r(['analytics.compare']); + return r(ALL, true); +} + +/** The method part of a canonical key (readKey: target \u001f method \u001f args). */ +export const methodOfKey = (key: string): string => key.split('\u001f')[1] ?? ''; +export const targetOfKey = (key: string): string => key.split('\u001f')[0] ?? ''; diff --git a/apps/desktop/src/lib/scheduler.ts b/apps/desktop/src/lib/scheduler.ts index b2e79e3..a2b94c0 100644 --- a/apps/desktop/src/lib/scheduler.ts +++ b/apps/desktop/src/lib/scheduler.ts @@ -127,6 +127,9 @@ export class ReadScheduler { subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; } + /** True while the read of this key is running (not just queued). */ + isRunning(key: string): boolean { return this.running.has(key); } + has(key: string): boolean { return this.running.has(key) || this.queue.some(i => i.task.key === key); } enqueue(task: ReadTask): Promise { diff --git a/apps/desktop/src/lib/sections.ts b/apps/desktop/src/lib/sections.ts new file mode 100644 index 0000000..5cad08e --- /dev/null +++ b/apps/desktop/src/lib/sections.ts @@ -0,0 +1,94 @@ +// The "what's next" line of each app section (docs IA): one sentence and the sub view it opens, from state that is +// already read (status, live releases, attention, cached reads). Pure, so tests pin the rules; a section never starts +// a read to fill it. +import type { CatalogParity, ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { AppSection, AppTab } from '../nav'; +import type { IconName } from '../ui/icons'; +import { type Attention, isGameSummary } from './model'; +import { share } from './format'; +import type { ProjectSummary } from '../api'; + +export type NextTone = 'bad' | 'warn' | 'ok' | 'info' | 'neutral'; +export interface SectionNext { text: string; tone: NextTone; tab: AppTab | null; icon: IconName } + +export interface GamesNext { changes: number; blockers: number; apiOff: boolean } +export interface SectionInput { + project: boolean; + att: Attention[]; + all: StoreRelease[]; + next: string | null; + summary: ProjectSummary | null; + catalog: CatalogParity | null; + /** Unsaved edits of the section's editors. */ + unsaved: number; + /** Cached reads (null: not read yet). */ + listings: ListingsResult | null; + reviews: { reply?: unknown }[] | null; + games: GamesNext | null; + /** Blockers of the last store readiness read (validate), null when never read. */ + readiness?: number | null; +} + +const storeName = (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Google Play'); + +/** Problems of a listings read: file problems plus required fields missing or over the limit. */ +export function listingProblems(l: ListingsResult | null): number | null { + if (!l) return null; + let n = l.problems.length; + for (const loc of l.locales) for (const s of [loc.play, loc.apple]) for (const f of s?.fields ?? []) if (f.state === 'missing' || f.state === 'over') n++; + return n; +} + +export function sectionNext(section: AppSection, i: SectionInput): SectionNext { + const bad = i.att.filter(a => a.tone === 'bad'); + if (section === 'release') { + const rej = i.all.find(r => r.phase === 'rejected' || r.phase === 'halted'); + if (rej) return { text: t(rej.phase === 'rejected' ? 'next.rejected' : 'next.halted', { version: rej.version ?? '', store: storeName(rej.store) }), tone: 'bad', tab: 'releases', icon: 'alertCircle' }; + const checks = bad.filter(a => a.tab === 'checks').length + (i.readiness ?? 0); + if (checks) return { text: t('next.blockers', { count: checks }), tone: 'bad', tab: 'checks', icon: 'alertCircle' }; + const rolling = i.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { text: t('next.rollout', { share: share(rolling.fraction ?? 0), version: rolling.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const approved = i.all.find(r => r.phase === 'approved'); + if (approved) return { text: t('next.approved', { version: approved.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const ready = i.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (ready) return { text: t('next.submit', { version: ready.version ?? '' }), tone: 'info', tab: 'releases', icon: 'send' }; + const review = i.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { text: review.managed ? t('next.managed', { version: review.version ?? '' }) : t('next.review', { version: review.version ?? '' }), tone: 'neutral', tab: 'releases', icon: 'clock' }; + if (i.project && !i.next && !i.all.length) return { text: t('next.upload'), tone: 'neutral', tab: 'releases', icon: 'upload' }; + const live = i.all.find(r => r.phase === 'live' || r.phase === 'released'); + return live ? { text: t('next.live', { version: live.version ?? '' }), tone: 'ok', tab: null, icon: 'check' } : { text: t('next.notRead'), tone: 'neutral', tab: null, icon: 'clock' }; + } + if (section === 'store') { + if (!i.project) return { text: t('next.storeLive'), tone: 'neutral', tab: null, icon: 'cloud' }; + const n = listingProblems(i.listings); + if (n) return { text: t('next.listingProblems', { count: n }), tone: 'warn', tab: 'listings', icon: 'alert' }; + if (i.unsaved) return { text: t('next.unsaved', { count: i.unsaved }), tone: 'warn', tab: null, icon: 'edit' }; + return n === null ? { text: t('next.listingsUnread'), tone: 'neutral', tab: 'listings', icon: 'text' } : { text: t('next.storeOk'), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'revenue') { + const c = i.catalog; + if (c && c.checked === 'live' && !c.ok) return { text: t('next.catalogDiff', { count: c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length }), tone: 'warn', tab: 'catalog', icon: 'alert' }; + const p = i.summary?.products ?? 0, s = i.summary?.subscriptions ?? 0; + if (!p && !s) return { text: t('next.noProducts'), tone: 'neutral', tab: 'catalog', icon: 'plus' }; + return { text: t('next.products', { products: p, subs: s }), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'players') { + const open = i.reviews ? i.reviews.filter(r => !r.reply).length : null; + if (open) return { text: t('next.reviews', { count: open }), tone: 'warn', tab: 'reviews', icon: 'message' }; + if (i.games?.apiOff) return { text: t('next.gamesApiOff'), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.blockers) return { text: t('next.gamesBlockers', { count: i.games.blockers }), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.changes) return { text: t('next.gamesSync', { count: i.games.changes }), tone: 'warn', tab: 'games', icon: 'upload' }; + return open === null ? { text: t('next.reviewsUnread'), tone: 'neutral', tab: 'reviews', icon: 'message' } : { text: t('next.reviewsOk'), tone: 'ok', tab: null, icon: 'check' }; + } + const crash = i.att.find(a => a.tab === 'health'); + if (crash) return { text: crash.text, tone: crash.tone === 'bad' ? 'bad' : 'warn', tab: 'health', icon: 'activity' }; + const problems = i.att.filter(a => a.tab === 'checks'); + const fix = problems.filter(a => a.tone === 'bad').length + (i.readiness ?? 0); + if (fix) return { text: t('next.fixFirst', { count: fix }), tone: 'bad', tab: 'checks', icon: 'shield' }; + if (problems.length) return { text: t('next.warnings', { count: problems.length }), tone: 'warn', tab: 'checks', icon: 'shield' }; + return { text: t('next.healthOk'), tone: 'ok', tab: null, icon: 'check' }; +} + +/** A game: profile game, a games section in the config, or a Play game category. */ +export const isGame = (s: ProjectSummary | null | undefined): boolean => isGameSummary(s); diff --git a/apps/desktop/src/lib/show-ignored.ts b/apps/desktop/src/lib/show-ignored.ts new file mode 100644 index 0000000..21a3d1c --- /dev/null +++ b/apps/desktop/src/lib/show-ignored.ts @@ -0,0 +1,25 @@ +// "N ignored · show": whether the sidebar and the portfolio list the ignored apps (dimmed, with Restore). A per-viewer +// convenience kept in localStorage (a private window or blocked storage just means hidden), shared by every list. +import { useEffect, useState } from 'react'; + +const KEY = 'storebound.showIgnored'; +const EVT = 'storebound:show-ignored'; + +export function readShowIgnored(): boolean { + try { return localStorage.getItem(KEY) === '1'; } catch { return false; } +} + +export function setShowIgnored(on: boolean): void { + try { localStorage.setItem(KEY, on ? '1' : '0'); } catch { /* private mode */ } + window.dispatchEvent(new Event(EVT)); +} + +export function useShowIgnored(): [boolean, (on: boolean) => void] { + const [on, set] = useState(readShowIgnored); + useEffect(() => { + const f = () => set(readShowIgnored()); + window.addEventListener(EVT, f); + return () => window.removeEventListener(EVT, f); + }, []); + return [on, setShowIgnored]; +} diff --git a/apps/desktop/src/lib/tray.ts b/apps/desktop/src/lib/tray.ts new file mode 100644 index 0000000..7211e2b --- /dev/null +++ b/apps/desktop/src/lib/tray.ts @@ -0,0 +1,48 @@ +// What the tray shows: one short state per app and store, the tooltip counts and the attention flag. Pure; the host +// (src-tauri/src/tray.rs) only draws it. +import type { AppStatus } from '../api'; +import type { ReleasePhase } from '../../../../cli/lib/desktop-protocol'; + +export interface TrayApp { key: string; name: string; state: string } +export interface TrayLabels { open: string; readNow: string; apps: string; noApps: string; mute: string; unmute: string; update: string; settings: string; quit: string } +export interface TraySummary { tooltip: string; attention: number; muted: boolean; apps: TrayApp[]; labels: TrayLabels } + +type Phase = ReleasePhase; + +/** What matters most first: a rejection, a halt, a review, an approval, a rollout, live. */ +const RANK: Record = { rejected: 0, halted: 1, review: 2, sent: 2, approved: 3, rollout: 4, live: 5, released: 6 }; + +/** The release of each store that the tray reports: the one with the most pressing phase (testing / draft only when nothing else). */ +export function trayPhases(s: AppStatus | undefined): { ios?: Phase; android?: Phase } { + const out: { ios?: Phase; android?: Phase } = {}; + for (const r of s?.release.all ?? []) { + const cur = out[r.store]; + if (!cur || (RANK[r.phase] ?? 9) < (RANK[cur] ?? 9)) out[r.store] = r.phase; + } + return out; +} + +/** True for a phase that needs the owner (rejected, halted). */ +export const needsYou = (p: string | undefined): boolean => p === 'rejected' || p === 'halted'; +export const inReview = (p: string | undefined): boolean => p === 'review' || p === 'sent'; + +/** + * Builds the summary the host shows. `phaseText` gives the words of a phase, `text` the tray strings (all i18n by the + * caller). The attention count is the number of apps with a rejected or halted release; the tooltip counts apps. + */ +export function traySummary( + apps: { key: string; name: string; status: AppStatus | undefined }[], muted: boolean, + tr: { phaseText: (p: string) => string; store: (s: 'ios' | 'android') => string; tipReview: (n: number) => string; tipAttention: (n: number) => string; muted: string; none: string; labels: TrayLabels }, +): TraySummary { + let review = 0, attention = 0; + const list: TrayApp[] = []; + for (const a of apps) { + const ph = trayPhases(a.status); + if (inReview(ph.ios) || inReview(ph.android)) review++; + if (needsYou(ph.ios) || needsYou(ph.android)) attention++; + const parts = (['ios', 'android'] as const).filter(s => ph[s]).map(s => `${tr.store(s)}: ${tr.phaseText(ph[s]!)}`); + list.push({ key: a.key, name: a.name, state: parts.length ? parts.join(' · ') : tr.none }); + } + const bits = [review ? tr.tipReview(review) : '', attention ? tr.tipAttention(attention) : '', muted ? tr.muted : ''].filter(Boolean); + return { tooltip: ['storebound', ...bits].join(' · '), attention, muted, apps: list, labels: tr.labels }; +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 0487a0a..a78cda8 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -7,9 +7,12 @@ import { applyPrefs, loadPrefs, type Prefs, resolveLang, savePrefs } from './pre import { PrefsContext } from './prefs-ctx'; import { installGlobalHandlers, report } from './lib/crash'; import { DataProvider } from './store'; +import { TrayCardBridge } from './card/bridge'; import { ErrorBoundary } from './ui/ErrorBoundary'; +import { ContextMenuProvider } from './ui/ContextMenu'; import { ExportProvider } from './ui/Exports'; import './styles.css'; +import './design.css'; const systemLang = (): string => (typeof navigator !== 'undefined' ? navigator.language : 'en'); @@ -35,7 +38,7 @@ function Root() { update({ ...prefs, theme: dark ? 'light' : 'dark' }); }, [prefs, update]); // a language change re-renders the whole tree in the new language - return ; + return ; } installGlobalHandlers(); diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index b438256..b442265 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -4,36 +4,75 @@ import { createContext, useContext } from 'react'; import type { Key } from './i18n'; import type { IconName } from './ui/icons'; -export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const; +export const APP_TABS = ['releases', 'testflight', 'listings', 'art', 'appinfo', 'catalog', 'prices', 'reviews', 'games', 'health', 'growth', 'checks', 'tools'] as const; export type AppTab = (typeof APP_TABS)[number]; -/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and - * store art are the live ones; Growth shows the Product Page Optimization tests only). */ -export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth']; -export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); + +/** The five sections of an app, by job (docs IA): each holds sub views (the old tabs). The route keeps the sub id. */ +export const SECTIONS = ['release', 'store', 'revenue', 'players', 'health'] as const; +export type AppSection = (typeof SECTIONS)[number]; +export const SECTION_TABS: Record = { + release: ['releases', 'testflight'], store: ['listings', 'art', 'appinfo'], revenue: ['catalog', 'prices'], players: ['reviews', 'games'], health: ['health', 'growth', 'checks', 'tools'], +}; +export const sectionOf = (tab: AppTab): AppSection => SECTIONS.find(s => SECTION_TABS[s].includes(tab)) ?? 'release'; +export const SECTION_KEY: Record = { release: 'sec.release', store: 'sec.store', revenue: 'sec.revenue', players: 'sec.players', health: 'sec.health' }; +export const SECTION_ICON: Record = { release: 'rocket', store: 'image', revenue: 'coins', players: 'users', health: 'activity' }; + +/** Sub views of a store-only app: store reads only (catalog, prices, app info, checks and tools need the app folder; + * listings and store art are the live ones; Growth shows the Product Page Optimization tests only). */ +export const STORE_TABS: readonly AppTab[] = ['releases', 'testflight', 'listings', 'art', 'reviews', 'health', 'growth']; +/** The sub views an app has: the folder decides most; Game services only for games. */ +export const tabsFor = (hasFolder: boolean, game = false): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS).filter(x => x !== 'games' || (game && hasFolder)); +/** The sections an app has (a section with no sub view is left out: Revenue for a store-only app). */ +export const sectionsFor = (hasFolder: boolean, game = false): AppSection[] => { const tabs = tabsFor(hasFolder, game); return SECTIONS.filter(s => SECTION_TABS[s].some(x => tabs.includes(x))); }; +export const subsOf = (s: AppSection, hasFolder: boolean, game = false): AppTab[] => { const tabs = tabsFor(hasFolder, game); return SECTION_TABS[s].filter(x => tabs.includes(x)); }; export const TAB_KEY: Record = { releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews', - testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', + testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', games: 'tab.games', }; export const TAB_ICON: Record = { releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart', - appinfo: 'clipboard', checks: 'shield', tools: 'sliders', + appinfo: 'clipboard', checks: 'shield', tools: 'sliders', games: 'sparkle', }; + +/** A place in an app from a link: an old tab id ("prices"), a section ("revenue", its first sub view) or + * "section/sub" ("store/art"). Unknown: Release. */ +export function placeOf(v: string | undefined | null): AppTab { + if (!v) return 'releases'; + const [a, b] = v.split('/'); + if (b && (SECTIONS as readonly string[]).includes(a!) && SECTION_TABS[a as AppSection].includes(b as AppTab)) return b as AppTab; + if (APP_TABS.includes(a as AppTab)) return a as AppTab; + if ((SECTIONS as readonly string[]).includes(a!)) return SECTION_TABS[a as AppSection][0]!; + return 'releases'; +} + export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const; export type WorkspaceScreen = (typeof WORKSPACE)[number]; export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen; section?: string }; -/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health tab; - * "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ +/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health section; + * "app:store/art" = a sub view; "app:prices" = an old tab id; "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ export function parseStart(v: string, apps: string[]): Route | null { const [screen, tab, n] = v.split(':'); const app = apps[Math.max(0, (Number(n) || 1) - 1)]; - if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' }; + if (screen === 'app' && app) return { screen: 'app', appId: app, tab: placeOf(tab) }; if (screen === 'portfolio') return { screen: 'portfolio' }; if (!(WORKSPACE as readonly string[]).includes(screen ?? '')) return null; return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen }; } +/** A route sent as JSON by the host (tray menu, notification target); anything that is not a known route is null. */ +export function parseRouteJson(v: string): Route | null { + let o: unknown; + try { o = JSON.parse(v); } catch { return null; } + if (!o || typeof o !== 'object') return null; + const r = o as Record; + if (r.screen === 'portfolio') return { screen: 'portfolio' }; + if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: placeOf(typeof r.tab === 'string' ? r.tab : null) } : null; + if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen }; + return null; +} + export const sameRoute = (a: Route, b: Route): boolean => JSON.stringify(a) === JSON.stringify(b); export interface Nav { route: Route; go: (r: Route) => void; openPalette: () => void; openWelcome: () => void } diff --git a/apps/desktop/src/prefs.ts b/apps/desktop/src/prefs.ts index 7c69eae..7919bbb 100644 --- a/apps/desktop/src/prefs.ts +++ b/apps/desktop/src/prefs.ts @@ -3,7 +3,9 @@ export type Theme = 'system' | 'light' | 'dark'; export type Motion = 'system' | 'reduce'; export type Lang = 'system' | 'en' | 'tr'; -export interface Prefs { theme: Theme; motion: Motion; lang: Lang; onboarded: boolean } +/** Tables: comfortable rows, or compact (smaller padding) for wide data on small windows. */ +export type Density = 'comfortable' | 'compact'; +export interface Prefs { theme: Theme; motion: Motion; lang: Lang; onboarded: boolean; density?: Density } export const DEFAULT_PREFS: Prefs = { theme: 'system', motion: 'system', lang: 'system', onboarded: false }; const KEY = 'storebound.prefs.v1'; @@ -16,6 +18,7 @@ export function parsePrefs(raw: string | null): Prefs { motion: v.motion === 'reduce' ? 'reduce' : 'system', lang: v.lang === 'en' || v.lang === 'tr' ? v.lang : 'system', onboarded: v.onboarded === true, + ...(v.density === 'compact' ? { density: 'compact' as const } : {}), }; } catch { return { ...DEFAULT_PREFS }; } } @@ -32,6 +35,7 @@ export function savePrefs(p: Prefs): void { export function applyPrefs(p: Prefs, root: { dataset: DOMStringMap; lang?: string } = document.documentElement, systemLang = 'en'): void { if (p.theme === 'system') delete root.dataset.theme; else root.dataset.theme = p.theme; if (p.motion === 'reduce') root.dataset.motion = 'reduce'; else delete root.dataset.motion; + if (p.density === 'compact') root.dataset.density = 'compact'; else delete root.dataset.density; root.lang = resolveLang(p.lang, systemLang); } diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx index f9412b7..9a897b0 100644 --- a/apps/desktop/src/screens/Accounts.tsx +++ b/apps/desktop/src/screens/Accounts.tsx @@ -3,14 +3,20 @@ // issuer id, .p8). Keys live in the machine's credential profiles (~/.storebound, shared with the CLI); // the app keeps a label, the studio and the profile name only. Per account: a health // check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see). +import { type MenuInput, useContextMenu, useMenuButton } from '../ui/ContextMenu'; +import { copyToast } from '../ui/app-menu'; import { type FormEvent, useEffect, useState } from 'react'; import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api'; import { type Key, t } from '../i18n'; import { ago } from '../lib/format'; import { withIgnored } from '../lib/identity'; -import { type AccountHealth, useData } from '../store'; +import { type AccountHealth, useData, useReadState } from '../store'; +import { accountCheckKey, importKey } from '../lib/read-registry'; +import { ImportAppsButton, TestAccountButton } from '../ui/ReadButtons'; import { Icon, StoreGlyph } from '../ui/icons'; -import { Card, Chip, Empty, Notice, StudioMark } from '../ui/kit'; +import { Chip, Notice, StudioMark } from '../ui/kit'; +import { EmptyState, PageHeader, Section, StatTile } from '../ui/ds'; +import '../styles/accounts.css'; import type { Tone } from '../lib/model'; const STATUS: Record = { @@ -42,6 +48,8 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | const [edit, setEdit] = useState(false); const [label, setLabel] = useState(a.label); const [studio, setStudio] = useState(a.studio ?? ''); + const [dev, setDev] = useState(a.developerId ?? ''); + const devOk = !dev.trim() || /^\d{1,25}$/.test(dev.trim()); const h = d.accountHealth[a.id]; const list = d.storeApps[a.id]; const ignored = d.overrides.ignored.includes(`account:${a.id}`); @@ -50,10 +58,29 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | setBusy(key); onError(null); try { await fn(); } catch (e) { onError(errorText(e)); } finally { setBusy(null); } }; - const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); await d.reload(); setEdit(false); }); }; + const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); if (a.kind === 'play' && a.profile && dev.trim() !== (a.developerId ?? '')) await api.setPlayDeveloper(a.id, dev.trim() || null); await d.reload(); setEdit(false); }); }; const check = h?.check; + // Test and Import of this account: busy from the read registry (Test all / Import apps make them busy too) + const ck = useReadState(accountCheckKey(a.id)); + const im = useReadState(importKey(a.id)); + const cm = useContextMenu(); + const more = useMenuButton(); + const menu = (): MenuInput => [ + { label: t('acct.test'), icon: 'shield', disabled: ck.busy ? t('cm.busy') : false, onSelect: () => { d.checkAccount(a.id).catch(() => {}); } }, + { label: t('acct.import'), icon: 'download', disabled: im.busy ? t('cm.busy') : false, onSelect: () => { d.importApps(a.id).catch(() => {}); } }, + { label: t('acct.edit'), icon: 'edit', onSelect: () => setEdit(true) }, + 'sep', + { label: t('cm.copy'), icon: 'copy', submenu: [ + { label: t('cm.copyName'), icon: 'text', onSelect: () => copyToast(d, a.label) }, + check?.principal && { label: t('cm.copyPrincipal'), icon: 'key', shortcut: check.principal, onSelect: () => copyToast(d, check.principal!) }, + a.profile && { label: t('cm.copyProfile'), icon: 'folder', shortcut: a.profile, onSelect: () => copyToast(d, a.profile!) }, + ] }, + 'sep', + { label: ignored ? t('acct.unignore') : t('acct.ignore'), icon: ignored ? 'eye' : 'eyeOff', onSelect: () => { void d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], !ignored)); } }, + { label: t('accounts.remove'), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => run(`rm-${a.id}`, async () => { await api.removeAccount(a.id); await d.reload(); }) }, + ]; return ( -
+
@@ -63,15 +90,16 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | {' · '}{a.profile ? {t('acct.profile', { name: a.profile })} : t('acct.noProfile')} {' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')} {used ? ` · ${t('acct.folders', { count: used })}` : ''} + {a.kind === 'play' && (a.developerId ? <> · {t('acct.devId', { id: a.developerId })} : <> · {t('acct.noDevId')})} {check?.principal ? <> · {check.principal} : null} + {a.kind === 'play' && check?.source ? ` · ${check.source === 'broker' ? t('acct.viaBroker') : t('acct.viaFile')}` : ''}
- - + + - - +
{edit && ( @@ -82,7 +110,10 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | {d.studios.map(s => )}
-
+ {a.kind === 'play' && a.profile &&
+ setDev(e.target.value)} placeholder="5550000000000000001" aria-invalid={!devOk} aria-describedby={`adh-${a.id}`} /> + {t('acct.devIdHint')}
} +
)} {check && check.status !== 'ok' && check.fix && ( @@ -92,6 +123,22 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
)} + {check?.grants && check.grants.length > 0 && ( +
+
{t('acct.grantsTitle')}
+
    + {check.grants.map(g => ( +
  • + {g.packageName}{': '} + {!g.registered ? {t('acct.notRegistered')} + : g.missing.length ? {t('acct.grantsMissing', { list: g.missing.join(', ') })} + : g.grants ? {t('acct.grantsOk')} : {t('acct.grantsNone')}} + {g.grants && g.grants.length > 0 && {` ${g.grants.join(', ')}`}} +
  • + ))} +
+
+ )} {h?.error &&
{h.error}
}
); @@ -108,9 +155,10 @@ function StudioCard({ s, accounts, focus }: { s: Studio | null; accounts: Accoun const storeOnly = apps.filter(a => !a.project).length; const act = async (key: string, fn: () => Promise) => { setBusy(key); setError(null); try { await fn(); await d.reload(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); } }; const id = s ? `studio-${s.id}` : 'studio-none'; + const more = useMenuButton(); useEffect(() => { if (focus) document.getElementById(id)?.scrollIntoView({ block: 'start' }); }, [focus, id]); return ( -
+
@@ -122,9 +170,12 @@ function StudioCard({ s, accounts, focus }: { s: Studio | null; accounts: Accoun {s && (
- - {s.logo && } - +
)}
@@ -167,7 +218,6 @@ export function Accounts({ focus }: { focus?: string }) { const addStudio = () => act('studio', async () => { const s = await api.saveStudio(null, studioName, studioColor); setStudioName(''); return s; }, r => t('studio.added', { name: (r as Studio).name })); const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel, playStudio || null); if (a) setPlayLabel(''); return a; }, r => t('accounts.saved', { label: (r as Account).label })); const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId, appleStudio || null); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; }, r => t('accounts.saved', { label: (r as Account).label })); - const testAll = async () => { setBusy('all'); await Promise.all(d.accounts.map(a => d.checkAccount(a.id))); setBusy(null); }; const unassigned = d.accounts.filter(a => !a.studio || !d.studios.some(s => s.id === a.studio)); const studioSelect = (id: string, value: string, set: (v: string) => void) => (
@@ -178,28 +228,31 @@ export function Accounts({ focus }: { focus?: string }) { ); return ( -
-
-

{t('nav.accounts')}

{t('accounts.leadStudios')}

-
- - -
-
+
+ + + + } /> + {d.accounts.length > 0 &&
+ + a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length })} /> + d.accountHealth[a.id]?.check?.status === 'ok').length} /> + { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }) ? 'bad' : undefined} label={t('v2cfg.keysFailing')} value={d.accounts.filter(a => { const h = d.accountHealth[a.id]; return !!h && (!!h.error || (!!h.check && h.check.status !== 'ok')); }).length} /> +
} {error && {error}} - {!d.accounts.length && !d.studios.length && {t('accounts.emptyTextStudios')}} + {!d.accounts.length && !d.studios.length &&
{t('accounts.emptyTitle')} {t('accounts.emptyTextStudios')}
} {d.studios.map(s => a.studio === s.id)} focus={focus === s.id} />)} {(unassigned.length > 0 || !d.studios.length) && d.accounts.length > 0 && } -
- +
+
{ e.preventDefault(); addStudio(); }}>
setStudioName(e.target.value)} maxLength={48} placeholder={t('studio.namePlaceholder')} autoComplete="off" />
{t('studio.color')}
- - +
+
{ e.preventDefault(); addPlay(); }}>
setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" />
@@ -207,8 +260,8 @@ export function Accounts({ focus }: { focus?: string }) {

{t('accounts.playHow')}

- - +
+
{ e.preventDefault(); addApple(); }}>
setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" />
@@ -221,7 +274,7 @@ export function Accounts({ focus }: { focus?: string }) {
-
+
{t('accounts.safetyStudios')} diff --git a/apps/desktop/src/screens/AppFolders.tsx b/apps/desktop/src/screens/AppFolders.tsx index 1162a6f..4a59ae2 100644 --- a/apps/desktop/src/screens/AppFolders.tsx +++ b/apps/desktop/src/screens/AppFolders.tsx @@ -1,7 +1,9 @@ // Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new // folder from the templates (`storebound init` with a profile, confirmed in a native dialog). +import { type MenuInput, useContextMenu } from '../ui/ContextMenu'; +import { copyToast } from '../ui/app-menu'; import { failureText } from '../lib/results'; -import { useEffect, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api'; import { devSwitch } from '../dev/switches'; import { t } from '../i18n'; @@ -10,12 +12,21 @@ import { useNav } from '../nav'; import { useData } from '../store'; import { Icon, StoreGlyph } from '../ui/icons'; import { useFolderActions } from '../ui/FolderMissing'; -import { AppIcon, Card, Chip, Empty, Notice, Seg, StoreBadge } from '../ui/kit'; +import { AppIcon, Empty, Notice, Seg, StoreBadge } from '../ui/kit'; +import { PageHeader, Section, StatusPill, tintStyle, useTint } from '../ui/ds'; +import '../styles/folders.css'; import { checkState } from './Portfolio'; type Profile = 'app' | 'game' | 'subscription-app'; interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string } +/** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */ +function Tinted({ src, name, children, menu }: { src: string | null | undefined; name: string; children: ReactNode; menu: () => MenuInput }) { + const hue = useTint(src, name); + const cm = useContextMenu(); + return
{children}
; +} + function LocateButton({ project }: { project: Project }) { const { busy, locate } = useFolderActions(project); return ; @@ -73,41 +84,48 @@ export function AppFolders() { : run.result.ok ? t('run.done') : failureText(run.result); return ( -
-
-

{t('nav.apps')}

{t('folders.lead')}

-
- - - -
-
+
+ {t('action.addFolder')}} /> {error && {error}} {!d.projects.length ? ( - +
- }>{t('folders.emptyText')} + }>{t('folders.emptyText')}
) : ( - +
{d.projects.map(p => { const e = d.statusOf(p); const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e); return ( -
- + [ + !p.missing && { label: t('folders.open'), icon: 'arrowRight', onSelect: () => nav.go({ screen: 'app', appId: p.id, tab: 'releases' }) }, + !p.missing && { label: t('folders.reveal'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(x => setError(errorText(x))); } }, + 'sep', + !p.missing && { label: t('folders.check'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => check(p) }, + !p.missing && { label: t('folders.gallery'), icon: 'image', disabled: busy ? t('cm.busy') : false, onSelect: () => gallery(p) }, + 'sep', + { label: t('cm.copy'), icon: 'copy', submenu: [ + { label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) }, + e?.app?.platforms.ios?.bundleId && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: e.app.platforms.ios.bundleId, onSelect: () => copyToast(d, e.app!.platforms.ios!.bundleId) }, + e?.app?.platforms.android?.packageName && { label: t('cm.copyPackage'), icon: 'tag', shortcut: e.app.platforms.android.packageName, onSelect: () => copyToast(d, e.app!.platforms.android!.packageName) }, + ] }, + 'sep', + { label: t('folders.remove', { name: p.name }), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => remove(p) }, + ]}> +
- {e?.app?.name ?? p.name}{st.text} + {e?.app?.name ?? p.name}{st.text} {shortPath(p.path, d.info?.home)}
-
+
-
+
{p.missing ? : <> @@ -128,14 +146,14 @@ export function AppFolders() { }
-
+ ); })}
- +
)} - +
@@ -147,16 +165,16 @@ export function AppFolders() {

{t('folders.newNote')}

- +
{run && ( - {status}{run.result && <> · {run.result.command}}} +
{status}{run.result && <> · {run.result.command}}} actions={}>
{run.error && {run.error}}
{tildeText(run.result?.output ?? run.lines.join('\n'), d.info?.home) || ' '}
- +
)}
); diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 319e105..150c29b 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -1,17 +1,25 @@ -// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no +// One app across both stores: header (icon, studio, ids, store state, pairing), then five sections by job (Release, +// Store page, Revenue, Players, Health; docs IA), each with a header (what it is for, the one next step) and its sub views. A store-only app (no // app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and // "Start an app folder here" to make it a full app. -import { type KeyboardEvent, useRef, useState } from 'react'; +import { useContextMenu, useMenuButton } from '../ui/ContextMenu'; +import { appMenu, playUrlFor } from '../ui/app-menu'; +import { useShowIgnored } from '../lib/show-ignored'; +import { useState } from 'react'; +import { GamesTab } from './tabs/Games'; +import { SectionHead } from './SectionHead'; import { api, errorText } from '../api'; import { type Key, t } from '../i18n'; -import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withStudio, withUnpair } from '../lib/identity'; -import { storeNow } from '../lib/model'; -import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav'; +import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity'; +import { attention, storeNow } from '../lib/model'; +import { Hero, nextStep, Tabs } from '../ui/ds'; +import { isGameSummary } from '../lib/model'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, SECTION_TABS, sectionOf, sectionsFor, subsOf, useNav } from '../nav'; import { useData } from '../store'; import { Icon } from '../ui/icons'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { FolderMissing } from '../ui/FolderMissing'; -import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit'; +import { Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit'; import { AppInfoTab } from './tabs/AppInfo'; import { ArtTab } from './tabs/Art'; import { LiveArt } from './tabs/LiveArt'; @@ -30,7 +38,6 @@ import { StoreListingEditor } from './editors/StoreListingEditor'; import { useEdits } from '../ui/Editor'; export const ascUrl = (appId: string | null | undefined): string => (appId ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(appId)}/distribution` : 'https://appstoreconnect.apple.com/apps'); -export const PLAY_CONSOLE = 'https://play.google.com/console/'; const PAIRING: Record = { folder: 'pair.folder', manual: 'pair.manual', id: 'pair.id', close: 'pair.close', name: 'pair.name', single: 'pair.single' }; @@ -39,7 +46,8 @@ function AppControls({ app }: { app: UnifiedApp }) { const d = useData(); const nav = useNav(); const cands = pairCandidates(app, d.apps); - const canUnpair = !app.project && !!app.ios && !!app.android; + const more = useMenuButton(); + const [showIgnored] = useShowIgnored(); return (
{t(PAIRING[app.pairing])} @@ -60,8 +68,8 @@ function AppControls({ app }: { app: UnifiedApp }) { {cands.slice(0, 30).map(c => )} )} - {canUnpair && } - + +
); } @@ -103,61 +111,54 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { const ios = e?.app?.platforms.ios ?? (app.ios ? { bundleId: app.ios.app.bundleId, ascAppId: app.ios.app.id } : null); const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null); const name = (app.project && e?.app?.name) || app.name; - const tabs = tabsFor(!!app.project); + const game = isGameSummary(e?.summary); + const sections = sectionsFor(!!app.project, game); + const section = sectionOf(tab); const ed = useEdits(); useUnsavedGuard(); - const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const studio = d.studios.find(s => s.id === app.studio) ?? null; const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x }); - const onKey = (ev: KeyboardEvent) => { - const i = tabs.indexOf(tab); - const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? tabs.length - 1 : null; - if (j === null) return; - ev.preventDefault(); - const n = (j + tabs.length) % tabs.length; - setTab(tabs[n]!); - tabRefs.current[n]?.focus(); - }; + const setSection = (x: AppSection) => setTab(subsOf(x, !!app.project, game)[0] ?? SECTION_TABS[x][0]!); + const editCount = (x: AppTab) => (EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0); const all = l?.release.all ?? []; const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null; + const att = attention(app.key, app.name, e, l); const open = (url: string) => { api.openExternal(url).catch(() => {}); }; + const cm = useContextMenu(); + const [showIgnored] = useShowIgnored(); return ( -
-
-
- -
-
-

{name}

- {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} - {!app.project && } - -
-
- {iosNow && <>{iosNow.version}} - {androidNow && <>{androidNow.version}} -
-
-
-
- {ios && } - {android && } -
-
+
+ appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} + badges={<> + {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} + {!app.project && } + + } + ids={<> + {iosNow && <>{iosNow.version}} + {androidNow && <>{androidNow.version}} + } + links={(ios || android) && <> + {ios && } + {android && } + } + all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} + compact={section !== 'release' || tab !== 'releases'} + cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> {!app.project && } {app.project?.storeboundPin && {t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}} {app.project?.missing ? : <> -
- {tabs.map((x, i) => ( - - ))} +
+ { + const n = subsOf(x, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0); + return { id: x, label: t(SECTION_KEY[x]), icon: SECTION_ICON[x], hint: String(i + 1), count: att.filter(a => a.tab && sectionOf(a.tab) === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; + })} />
-
+ a + editCount(s2), 0)} editCount={editCount} onTab={setTab} /> +
{tab === 'releases' && } {tab === 'catalog' && app.project && } @@ -168,6 +169,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'art' && !app.project && } {tab === 'health' && } {tab === 'reviews' && } + {tab === 'games' && app.project && } {tab === 'testflight' && } {tab === 'growth' && } {tab === 'appinfo' && app.project && } diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx index 3d7cc20..9320e90 100644 --- a/apps/desktop/src/screens/Dependencies.tsx +++ b/apps/desktop/src/screens/Dependencies.tsx @@ -6,15 +6,21 @@ import { type AndroidSdkLookup, api, type DownloadInfo, errorText, type LocalToo import { t } from '../i18n'; import { bytes } from '../lib/format'; import { shortPath } from '../paths'; -import { useData } from '../store'; +import { useData, useReadState } from '../store'; import { Icon, type IconName } from '../ui/icons'; -import { Card, Chip, Notice, Skeleton } from '../ui/kit'; +import { Chip, Notice, Skeleton } from '../ui/kit'; +import { PageHeader, Section, StatTile, StatusPill } from '../ui/ds'; +import '../styles/deps.css'; + +/** The local tools check (system.tools): one registry key. */ +const TOOLS_KEY = '*\u001fsystem.tools'; const TOOL_ICON: Record = { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' }; /** Status chip of one tool. */ export function toolState(x: ToolStatus): { text: string; tone: 'ok' | 'warn' | 'accent' } { if (!x.found) return { text: t('deps.missing'), tone: 'warn' }; + if (x.status === 'outdated') return { text: t('deps.outdated', { version: x.required ?? '' }), tone: 'warn' }; return x.source === 'app' ? { text: t('deps.appCopy'), tone: 'accent' } : { text: t('deps.installed'), tone: 'ok' }; } @@ -26,16 +32,18 @@ export function Dependencies() { const [sdk, setSdk] = useState(d.sidecar?.tools?.androidSdk ?? null); const [info, setInfo] = useState([]); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); + // local tools check: one registry key, so a second click (or a remount) joins the check in flight + const busy = useReadState(TOOLS_KEY).busy; const [dl, setDl] = useState>({}); + const reg = d.registry; const refresh = useCallback(async () => { - setBusy(true); setError(null); + setError(null); try { - const [s, i] = await Promise.all([api.call('system.tools'), api.toolsInfo()]); + const [s, i] = await reg.read({ key: TOOLS_KEY, lanes: ['local'], accounts: [], priority: 0, run: () => Promise.all([api.call('system.tools'), api.toolsInfo()]) }, { force: true }); setTools(s.tools); setInfo(i); setSdk(s.androidSdk ?? null); - } catch (e) { setError(errorText(e)); } finally { setBusy(false); } - }, []); + } catch (e) { setError(errorText(e)); } + }, [reg]); useEffect(() => { refresh(); }, [refresh]); useEffect(() => { const un = onDownload(e => setDl(m => { @@ -71,29 +79,32 @@ export function Dependencies() { const managed = (id: string): DownloadInfo | undefined => info.find(i => i.tool === (id === 'ffprobe' ? 'ffmpeg' : id)); return ( -
-
-

{t('nav.dependencies')}

{t('deps.lead')}

-
-
+
+ } /> {error && {error}} - + {tools &&
+ + !x.found || x.status === 'outdated') ? 'warn' : undefined} label={t('v2cfg.depsNeeds')} value={tools.filter(x => !x.found || x.status === 'outdated').length} /> + i.installed).length} sub={t('v2cfg.depsInAppSub')} /> +
} +
{!tools ?
: ( -
+
{tools.map(x => { const st = toolState(x); const m = x.install.kind === 'download' ? managed(x.id) : undefined; const running = m ? dl[m.tool] : undefined; const pct = running?.total ? running.received / running.total : null; return ( -
+
- {x.name}{st.text}{x.version && {x.version}} + {x.name} + {st.text}{x.version && {x.version}}{x.required && {t('v2cfg.needs', { version: x.required })}} {x.usedFor} {x.path && {shortPath(x.path, d.info?.home)}}
-
+
{running ? (
@@ -113,9 +124,9 @@ export function Dependencies() { })}
)} - +
{sdk && ( - {t('deps.installed')} : <>{t('deps.missing')}}> {sdk.path ? (
@@ -128,27 +139,27 @@ export function Dependencies() {
    {sdk.checked.map((c, i) =>
  • {d.info?.home ? c.split(d.info.home).join("~") : c}
  • )}
)} - + )} -
+
{info.map(m => ( - +
{t('deps.source')}
{m.source}
{t('deps.license')}
{m.license}
{t('deps.verify')}
{m.tool === 'chrome' ? t('deps.verifyChrome') : t('deps.verifyFfmpeg')}
- +
))}
- +
{['storebound doctor', 'bun add -d playwright', 'brew install ffmpeg'].map(c => (
{c}
))}
- +
); } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 6095165..fdc3167 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -1,34 +1,36 @@ // Ignored: apps and whole store accounts left out of the portfolio, the sidebar and store reads. Restore brings them // back (nothing on the stores changes either way). +import { useContextMenu } from '../ui/ContextMenu'; +import { appMenu } from '../ui/app-menu'; import { t } from '../i18n'; -import { withIgnored } from '../lib/identity'; +import { withIgnored, withSideIgnored } from '../lib/identity'; import { useNav } from '../nav'; import { useData } from '../store'; import { Icon, StoreGlyph } from '../ui/icons'; -import { AppIcon, Card, Empty, StoreOnlyTag, StudioMark } from '../ui/kit'; +import { AppIcon, StoreOnlyTag, StudioMark } from '../ui/kit'; +import { EmptyState, PageHeader, Section } from '../ui/ds'; +import '../styles/folders.css'; export function Ignored() { const d = useData(); const nav = useNav(); - const apps = d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)); + const apps = d.apps.filter(a => a.ignored || a.ignoredStores.length > 0).sort((a, b) => a.name.localeCompare(b.name)); const accounts = d.accounts.filter(a => d.overrides.ignored.includes(`account:${a.id}`)); const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] })); const empty = !apps.length && !accounts.length; + const cm = useContextMenu(); return ( -
-
-

{t('nav.ignored')}

{t('ignored.lead')}

- {!empty &&
} -
- {empty ? {t('ignored.emptyText')} : ( +
+ : undefined} /> + {empty ?
{t('ignored.emptyTitle')} {t('ignored.emptyText')}
: ( <> - +
{apps.length ? (
{apps.map(a => { const s = d.studios.find(x => x.id === a.studio) ?? null; return ( -
+
appMenu(a, d, nav, { showIgnored: true }), `app:${a.key}`)}>
{a.name}{!a.project && } @@ -38,18 +40,19 @@ export function Ignored() { {a.android && {a.android.app.id}}
- + {a.ignoredStores.map(k => )} + {a.ignored && }
); })}
) :

{t('ignored.noApps')}

} - - +
+
{accounts.length ? (
{accounts.map(a => ( -
+
[{ label: t('ignored.restore'), icon: 'eye', onSelect: () => { void d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], false)); } }, { label: t('cm.openAccounts'), icon: 'key', onSelect: () => nav.go({ screen: 'accounts' }) }])}>
{a.label}{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')} · {t('ignored.accountApps', { count: d.storeApps[a.id]?.value.apps.length ?? 0 })}
@@ -57,7 +60,7 @@ export function Ignored() { ))}
) :

{t('ignored.noAccounts')}

} - +
)}

{t('ignored.howTo')}

diff --git a/apps/desktop/src/screens/Onboarding.tsx b/apps/desktop/src/screens/Onboarding.tsx index ca535fb..9fa6026 100644 --- a/apps/desktop/src/screens/Onboarding.tsx +++ b/apps/desktop/src/screens/Onboarding.tsx @@ -11,6 +11,7 @@ import { Icon, type IconName, Mark } from '../ui/icons'; import { Illo } from '../ui/illustrations'; import { AppIcon, Chip, Notice, Seg, StudioMark } from '../ui/kit'; import { Swatches } from './Accounts'; +import '../styles/onboarding.css'; const STEPS: [Key, IconName][] = [['wz.s1', 'sparkle'], ['wz.s0', 'building'], ['wz.s2', 'folder'], ['wz.s3', 'key'], ['wz.s4', 'package'], ['wz.s5', 'refresh']]; const LAST = STEPS.length - 1; @@ -72,7 +73,7 @@ export function Onboarding({ onClose }: { onClose: () => void }) { const liveApps = d.live?.value.apps.length ?? 0; return ( -
+
+
+ {t('v2cfg.stepOf', { n: step + 1, total: STEPS.length })} · {t(STEPS[step]![0])} +
+ {STEPS.map(([k], i) => )} +
+
{step === 0 && ( <> diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 91dc8d1..2a0b7c0 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -1,20 +1,30 @@ -// Portfolio: every app of every studio on both stores. KPIs, a toolbar (search, studio chips with counts, sort, -// grouping), one row per app (live version and state per store with its place on the release track, next version, -// crash rate, catalog parity, config check), grouped by studio; then what needs attention and the workspace state. +// Portfolio: every app of every studio on both stores. A greeting that says what is moving, KPIs, a toolbar (search, +// studio chips with counts, sort, grouping, cards / table), one store-listing card per app (rendered screenshots, the +// release track per store, crash rate, one next step) or the dense table (live version and state per store, next +// version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first. import { plainError } from '../../../../cli/lib/google-errors'; import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError'; -import { useEffect, useMemo, useState } from 'react'; -import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api'; +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; -import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity'; +import { useShowIgnored } from '../lib/show-ignored'; +import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp, withIgnored } from '../lib/identity'; import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model'; -import { useNav } from '../nav'; -import { useData } from '../store'; +import { type AppTab, useNav } from '../nav'; +import { useContextMenu } from '../ui/ContextMenu'; +import { appMenu } from '../ui/app-menu'; +import type { IconName } from '../ui/icons'; +import { useData, useReadState } from '../store'; +import { ImportAppsButton } from '../ui/ReadButtons'; +import { readKey } from '../lib/freshness'; +import { PORTFOLIO_KEY } from '../lib/read-registry'; import { exportName, portfolioCsv } from '../lib/exports'; import { useExports } from '../ui/Exports'; import { Icon, StoreGlyph } from '../ui/icons'; -import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit'; +import { nextStep, Pipeline, tintStyle, useShots, useTint } from '../ui/ds'; +export { nextStep } from '../ui/ds'; +import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, phaseLabel, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit'; /** Tag of one app's offline config check. */ export function checkState(e: Pick | undefined): { text: string; tone: Tone } { @@ -62,7 +72,7 @@ function NextCell({ e }: { e: AppStatus | undefined }) { {n.version}
{n.ios && {t(`phase.${n.ios.phase}`)}} - {n.android && {t(`phase.${n.android.phase}`)}} + {n.android && {phaseLabel(n.android).text}}
); @@ -95,35 +105,193 @@ function HealthCell({ c, android }: { c: { h: HealthSnapshot; at: string } | nul ); } -function AttentionList({ items }: { items: Attention[] }) { - const nav = useNav(); - if (!items.length) return {t('portfolio.allClearText')}; - return ( -
- {items.slice(0, 8).map((a, i) => ( - - ))} -
- ); -} - /** Newest store activity of an app (release record date), else when its folder was added. */ function updatedAt(a: UnifiedApp, l: AppStatus | undefined): string | null { const dates = (l?.release.all ?? []).map(r => r.date).filter((x): x is string => !!x); return dates.sort().at(-1) ?? a.project?.added ?? null; } +// ---- App cards ---- + +/** Every release record of an app (live read first). */ +function releasesOf(l: AppStatus | undefined): StoreRelease[] { + if (!l) return []; + return l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x); +} + +function CardHealth({ c, android }: { c: { h: HealthSnapshot; at: string } | null | undefined; android: boolean }) { + const play = c?.h.play; + if (!android || !c || !play || isError(play)) { + return {!android || (c && !play) ? t('portfolio.healthNoPlay') : c && play && isError(play) ? t('portfolio.unreadable') : t('portfolio.healthNotRead')}; + } + const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); + return ( + + + {t('pf.crash')}{pct(s.current)} + + ); +} + +interface CardRow { app: UnifiedApp; e: AppStatus | undefined; l: AppStatus | undefined; att: Attention[] } + +function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; health: { h: HealthSnapshot; at: string } | null | undefined; studio?: ReactNode }) { + const nav = useNav(); + const { app, e, l, att } = r; + const projectId = app.project && !app.project.missing ? app.project.id : null; + const shots = useShots(projectId); + const hue = useTint(icon, app.name); + const all = releasesOf(l); + const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android); + const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? ''; + const next = l?.release.next?.version ?? null; + const cta = nextStep({ project: !!app.project, att, all, next }); + const name = e?.app?.name && app.project ? e.app.name : app.name; + const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab }); + const style = tintStyle(hue); + const d = useData(); + const menu = useContextMenu(); + const [showIgnored] = useShowIgnored(); + return ( +
appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}> + +
+
+ +
+ + {sub}{!app.project && {t('apps.storeOnly')}} +
+
+ {t('pf.next')} + {next ?? '–'} +
+
+ {studio} + +
+ + +
+
+
+ ); +} + +// ---- What changed ---- + +interface Ev { key: string; app: UnifiedApp; text: string; at: string; icon: IconName; tone: Tone } + +function events(rows: CardRow[], health: Record): Ev[] { + const out: Ev[] = []; + for (const r of rows) { + for (const rel of releasesOf(r.l)) { + if (!rel.date) continue; + const store = rel.store === 'ios' ? 'App Store' : 'Google Play', version = rel.version ?? ''; + const ev = (key: Key, icon: IconName, tone: Tone, vars: Record = {}) => + out.push({ key: `${r.app.key}|${rel.store}|${version}|${rel.phase}`, app: r.app, text: t(key, { version, store, ...vars }), at: rel.date!, icon, tone }); + switch (rel.phase) { + case 'live': ev('pf.evLive', 'check', 'ok'); break; + case 'rollout': ev('pf.evRollout', 'rocket', 'accent', { share: share(rel.fraction ?? 1) }); break; + case 'review': case 'sent': ev('pf.evReview', 'clock', 'warn'); break; + case 'approved': ev('pf.evApproved', 'check', 'ok'); break; + case 'rejected': ev('pf.evRejected', 'alertCircle', 'bad'); break; + case 'halted': ev('pf.evHalted', 'pause', 'bad'); break; + case 'testing': ev('pf.evTesting', 'flask', 'info'); break; + case 'draft': ev('pf.evDraft', 'edit', 'neutral'); break; + default: break; + } + } + const c = health[r.app.target]; + const play = c?.h.play; + if (c && play && !isError(play)) { + const s = healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); + if (s.current !== null) out.push({ key: `${r.app.key}|health`, app: r.app, text: t('pf.evCrash', { rate: pct(s.current) }), at: c.at, icon: 'activity', tone: s.tone }); + } + if (r.app.project?.added) out.push({ key: `${r.app.key}|added`, app: r.app, text: t('pf.evAdded'), at: r.app.project.added, icon: 'folderPlus', tone: 'neutral' }); + } + return out.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); +} + +function Activity({ items, iconFor }: { items: Ev[]; iconFor: (a: UnifiedApp) => string | null }) { + const nav = useNav(); + const [more, setMore] = useState(false); + if (!items.length) return {t('pf.feedEmptyText')}; + const shown = more ? items : items.slice(0, 8); + return ( + <> +
    + {shown.map(ev => ( +
  1. + +
  2. + ))} +
+ {items.length > 8 && } + + ); +} + +function FixFirst({ items, appOf, iconFor }: { items: Attention[]; appOf: (key: string) => UnifiedApp | undefined; iconFor: (a: UnifiedApp) => string | null }) { + const nav = useNav(); + const [more, setMore] = useState(false); + if (!items.length) return {t('portfolio.allClearText')}; + const shown = more ? items : items.slice(0, 5); + return ( +
+ {shown.map((a, i) => { + const app = appOf(a.projectId), tab = a.tab ?? 'checks'; + return ( +
+
+ {app ? : } + {a.app} + {a.tone === 'bad' ? t('pf.blocker') : a.tone === 'warn' ? t('pf.warning') : t('pf.note')} +
+

{a.text}

+ {a.hint &&

{a.hint}

} +
+ +
+
+ ); + })} + {items.length > 5 && } +
+ ); +} + +function greeting(now = new Date()): string { + const h = now.getHours(); + return h < 5 ? t('pf.evening') : h < 12 ? t('pf.morning') : h < 18 ? t('pf.afternoon') : t('pf.evening'); +} + const VIEW_KEY = 'storebound.portfolio.v1'; -interface View { studio: string; sort: SortBy; group: boolean } +interface View { studio: string; sort: SortBy; group: boolean; layout: 'cards' | 'table' } function loadView(): View { try { const v = JSON.parse(localStorage.getItem(VIEW_KEY) ?? '{}') as Partial; - return { studio: typeof v.studio === 'string' ? v.studio : 'all', sort: v.sort === 'recent' || v.sort === 'name' ? v.sort : 'attention', group: v.group !== false }; - } catch { return { studio: 'all', sort: 'attention', group: true }; } + return { studio: typeof v.studio === 'string' ? v.studio : 'all', sort: v.sort === 'recent' || v.sort === 'name' ? v.sort : 'attention', group: v.group !== false, layout: v.layout === 'table' ? 'table' : 'cards' }; + } catch { return { studio: 'all', sort: 'attention', group: true, layout: 'cards' }; } } export function Portfolio() { @@ -133,21 +301,29 @@ export function Portfolio() { const [query, setQuery] = useState(''); const setView = (v: Partial) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; }); const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]); + const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored), [d.apps]); + const [showIgnored, setShowIgnored] = useShowIgnored(); + const menu = useContextMenu(); const [round, setRound] = useState(0); - const [rechecking, setRechecking] = useState(false); const health = useCachedHealth(visible.map(a => a.target), round); const blocked = useMemo(() => blockedProjects(visible.map(a => { const h = health[a.target]?.h, l = d.liveFor(a); return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] }; })), [visible, health, d]); - // "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache - const recheck = async () => { - setRechecking(true); - const names = new Set(blocked.flatMap(b => b.apps)); - for (const a of visible.filter(x => names.has(x.name))) await api.call('health', a.target, { args: {} }, newJob('health')).catch(() => null); - setRechecking(false); - setRound(n => n + 1); + // "Check again": the health reads of the blocked apps (the same keys as their Health tab's refresh and Refresh all), + // plus the portfolio read when a blocked store came from it. Busy (and disabled) whenever any of these keys is in + // flight, whoever started it; a click joins what runs and the scheduler spaces the rest (one Cloud project's quota). + const blockedApps = useMemo(() => { const names = new Set(blocked.flatMap(b => b.apps)); return visible.filter(x => names.has(x.name)); }, [blocked, visible]); + const fromLive = blockedApps.some(a => !!d.liveFor(a)?.storeErrors.android); + const checkKeys = useMemo(() => [...blockedApps.map(a => readKey(a.target, 'health', {})), ...(fromLive ? [PORTFOLIO_KEY] : [])], [blockedApps, fromLive]); + const checkReads = useReadState({ keys: checkKeys }); + const recheck = () => { + Promise.allSettled([...blockedApps.map(a => d.schedule(a.target, 'health', {}, 0)), ...(fromLive ? [d.refreshPortfolio(true)] : [])]).catch(() => {}); }; + // whoever read them (this button, Refresh all, a Health tab), the notice looks at the saved health again after + const wasBusy = useRef(false); + useEffect(() => { if (wasBusy.current && !checkReads.busy) setRound(n => n + 1); wasBusy.current = checkReads.busy; }, [checkReads.busy]); + const allReads = useReadState({ tags: ['all'] }); const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' }); const all = useMemo(() => visible.map(app => { @@ -197,7 +373,7 @@ export function Portfolio() { const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android); const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? ''; return ( - open(app)}> + open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>
@@ -226,27 +402,36 @@ export function Portfolio() { }; const notRead = d.portfolioBusy === 'live' ? t('read.waiting') : t('kpi.notRead'); + const moving = rolling.length + review.length; + const headline = !known ? t('pf.notMovingUnknown') : moving ? t('pf.moving', { count: moving }) : t('pf.movingNone'); + const sortedAtt = [...att].sort((a, b) => (a.tone === 'bad' ? 0 : a.tone === 'warn' ? 1 : 2) - (b.tone === 'bad' ? 0 : b.tone === 'warn' ? 1 : 2)); + const feed = events(rows, health); + const appOf = (key: string) => visible.find(a => a.key === key); + const cards = (list: typeof all) => ( +
{list.map(r => )}
+ ); return ( -
-
-

{t('nav.portfolio')}

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}

+
+
+
+

{greeting()}

+

{headline}

+

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}

+
- - + {neverImported === 0 && }
{d.portfolioError && {d.portfolioError}} - - {neverImported > 0 && d.importApps()} disabled={d.importBusy}>{t('action.importApps')}}>{t('portfolio.notImported', { count: neverImported })}} - {!known && d.ready && d.portfolioBusy !== 'live' && d.refreshPortfolio(true)}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}} + + {neverImported > 0 && }>{t('portfolio.notImported', { count: neverImported })}} + {!known && d.ready && d.portfolioBusy !== 'live' && { d.readAll(true).catch(() => {}); }} disabled={allReads.busy} aria-busy={allReads.busy}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}}
- r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} /> + : t('kpi.none')} /> `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
@@ -263,9 +448,34 @@ export function Portfolio() { setView({ sort })} label={t('portfolio.sort')} options={[['attention', t('portfolio.sortAttention'), 'alert'], ['recent', t('portfolio.sortRecent'), 'clock'], ['name', t('portfolio.sortName')]]} /> {d.studios.length > 0 && } + setView({ layout })} label={t('pf.layout')} options={[['cards', t('pf.cards'), 'grid'], ['table', t('pf.table'), 'layers']]} />
- 0 && ( +
+ + {showIgnored && ignoredApps.map(a => ( + + + + + ))} +
+ )} + + {view.layout === 'cards' && (rows.length ? grouped.map(g => { + const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null; + return ( +
+ {s !== undefined && ( +

{s?.name ?? t('studio.none')}{t('portfolio.groupCount', { count: g.rows.length })} · {t('portfolio.groupAccounts', { count: d.accounts.filter(a => (a.studio ?? null) === (g.studio ?? null)).length })}

+ )} + {cards(g.rows)} +
+ ); + }) : {t('portfolio.noMatchText')})} + + {view.layout === 'table' && {t('legend.done')}{t('legend.stages')}
}> {rows.length ? (
@@ -289,11 +499,13 @@ export function Portfolio() {
) : {t('portfolio.noMatchText')}} - + } -
- (a.tone === 'bad' ? 0 : 1) - (b.tone === 'bad' ? 0 : 1))} /> - +
+ +
+ +
{t('nav.studios')}
{d.studios.length ? t('portfolio.studiosN', { count: d.studios.length }) : }
{t('nav.accounts')}
{d.accounts.length ? t('portfolio.accounts', { play: d.accounts.filter(a => a.kind === 'play').length, apple: d.accounts.filter(a => a.kind === 'apple').length }) : }
@@ -302,8 +514,23 @@ export function Portfolio() {
{t('portfolio.storesRead')}
{d.live ? ago(d.live.at) : t('time.never')}
CLI
{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? '–'}
-
+ +
); } + +/** The rolling-out tile: the first two versions on their own lines ("2.3.0 iOS 20% · Android 20%"), then "+N more"; all of + them in the tooltip. Never one long ellipsized line. */ +function RollingLines({ rolling }: { rolling: StoreRelease[] }) { + const versions = [...new Set(rolling.map(r => r.version ?? '?'))]; + const line = (v: string) => rolling.filter(r => (r.version ?? '?') === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(' · '); + const all = versions.map(v => `${v} ${line(v)}`).join(' · '); + return ( + + {versions.slice(0, 2).map(v => {v} {line(v)})} + {versions.length > 2 && {t('kpi.more', { count: versions.length - 2 })}} + + ); +} diff --git a/apps/desktop/src/screens/SectionHead.tsx b/apps/desktop/src/screens/SectionHead.tsx new file mode 100644 index 0000000..75f04c1 --- /dev/null +++ b/apps/desktop/src/screens/SectionHead.tsx @@ -0,0 +1,60 @@ +// The header of one app section (docs IA): the section's name, one line on what it is for, the single next step +// derived from state that is already read (lib/sections.ts), and the sub views as a segmented control (only when the +// section has more than one). +import type { ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { UnifiedApp } from '../lib/identity'; +import type { Attention } from '../lib/model'; +import { sectionNext } from '../lib/sections'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, TAB_ICON, TAB_KEY } from '../nav'; +import { useCachedRead, useData } from '../store'; +import type { GameServicesStatus } from '../ui/GameServicesCard'; +import { Icon } from '../ui/icons'; + +const PURPOSE: Record = { + release: 'sec.release.for', store: 'sec.store.for', revenue: 'sec.revenue.for', players: 'sec.players.for', health: 'sec.health.for', +}; + +export function SectionHead({ app, section, tab, subs, att, all, next, unsaved, editCount, onTab }: { + app: UnifiedApp; section: AppSection; tab: AppTab; subs: AppTab[]; att: Attention[]; all: StoreRelease[]; next: string | null; unsaved: number; + editCount: (t: AppTab) => number; onTab: (t: AppTab) => void; +}) { + const d = useData(); + const e = d.statusFor(app); + const pid = app.project && !app.project.missing ? app.project.id : null; + // cached reads only: the header never starts a store call + const listings = useCachedRead(section === 'store' ? pid : null, 'project.listings'); + const reviews = useCachedRead<{ reply?: unknown }[]>(section === 'players' ? app.target : null, 'reviews.list', { days: 30 }); + const games = useCachedRead(section === 'players' && subs.includes('games') ? pid : null, 'games.status'); + const validate = useCachedRead<{ findings?: { level: string }[] }>(section === 'health' || section === 'release' ? pid : null, 'validate'); + const n = sectionNext(section, { + project: !!app.project, att, all, next, summary: e?.summary ?? null, catalog: (d.liveFor(app) ?? e)?.catalog ?? null, unsaved, listings, + reviews: Array.isArray(reviews) ? reviews : null, readiness: validate?.findings ? validate.findings.filter(f => f.level === 'blocker').length : null, + games: games ? { changes: (games.apple?.changes.length ?? 0) + (games.play?.changes.length ?? 0), blockers: games.findings.filter(f => f.level === 'error').length, apiOff: !!games.playApiDisabled } : null, + }); + return ( +
+
+

{t(SECTION_KEY[section])}

+

{t(PURPOSE[section])}

+
+
+ + {n.text} + {n.tab && n.tab !== tab && } +
+ {subs.length > 1 && ( +
+ {subs.map(x => { + const c = editCount(x); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 96ded11..e5ca1ab 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -3,6 +3,8 @@ // local tools, the CLI sidecar, privacy, keyboard shortcuts, updates and reset. A section list on the left jumps. import { useEffect, useState } from 'react'; import { useScrollSpy } from '../ui/useScrollSpy'; +import { PageHeader, Section } from '../ui/ds'; +import '../styles/settings.css'; import { api, type CacheInfo, errorText, onUpdateProgress, type Settings as S, type UpdateInfo } from '../api'; import { type Key, t } from '../i18n'; import { ago, bytes, date, num } from '../lib/format'; @@ -10,8 +12,9 @@ import { pairCandidates, type UnifiedApp, withPair, withUnpair } from '../lib/id import { useNav } from '../nav'; import { usePrefs } from '../prefs-ctx'; import { useData } from '../store'; +import { ImportAppsButton, RefreshAllButton } from '../ui/ReadButtons'; import { Icon, type IconName, StoreGlyph } from '../ui/icons'; -import { AppIcon, Card, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit'; +import { AppIcon, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit'; export const SECTIONS: [string, IconName, Key][] = [ ['studios', 'building', 'set.studios'], ['pairing', 'link', 'set.pairing'], ['refresh', 'refresh', 'set.refresh'], ['cache', 'database', 'set.cache'], @@ -78,22 +81,40 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; } } catch (e) { setError(errorText(e)); } }; + // search: sections whose title or text contains the words stay; the rest hide (nav and body) + const [q, setQ] = useState(''); + const [hits, setHits] = useState(null); + useEffect(() => { + const words = q.trim().toLowerCase().split(/\s+/).filter(Boolean); + const out: string[] = []; + for (const [id, , key] of SECTIONS) { + const el = document.getElementById(`set-${id}`); + const text = `${t(key)} ${el?.textContent ?? ''}`.toLowerCase(); + const ok = !words.length || words.every(w => text.includes(w)); + if (el) el.hidden = !ok; + if (ok) out.push(id); + } + setHits(words.length ? out : null); + }, [q]); const storeOnly = d.apps.filter(a => !a.project && !a.ignored); const paired = storeOnly.filter(a => a.ios && a.android); const singles = storeOnly.filter(a => !(a.ios && a.android)); return ( -
-

{t('nav.settings')}

{t('set.lead')}

+
+ {error && {error}}
- nav.go({ screen: 'accounts' })}>{t('set.manage')}}> +
nav.go({ screen: 'accounts' })}>{t('set.manage')}}> {d.studios.length ? (
{d.studios.map(st => { @@ -107,20 +128,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; })}
) :

{t('set.noStudios')}

} - +
- +
{storeOnly.length ? ( - +
{[...paired, ...singles].map(a => )}
{t('col.app')}{t('set.pairedHow')}{t('col.actions')}
) :

{t('set.noStoreOnly')}

}
{t('set.ignoredLine', { count: d.apps.filter(a => a.ignored).length, accounts: d.overrides.ignored.filter(i => i.startsWith('account:')).length })}
- +
- +
set(x => ({ ...x, refresh: { ...x.refresh, autoRead: v } }))} />
{t('set.everyMinutes')} @@ -133,14 +154,14 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; {[1, 2, 3, 4, 6, 8].map(n => )} {t('set.parallelHint')}
- - + + {d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}
- +
- +
{t('set.cacheSize')}
{cache ? `${bytes(cache.file_bytes)} · ${t('set.cacheRows', { count: cache.rows })}` : '–'}
@@ -152,9 +173,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;

{t('settings.dataText')}

- +
- +
{t('settings.language')} update({ ...prefs, lang })} label={t('settings.language')} options={[['system', t('settings.system')], ['en', 'English'], ['tr', 'Türkçe']]} />
@@ -162,28 +183,41 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; update({ ...prefs, theme })} label={t('settings.theme')} options={[['system', t('settings.system'), 'settings'], ['light', t('settings.light'), 'sun'], ['dark', t('settings.dark'), 'moon']]} />
{t('settings.motion')} update({ ...prefs, motion })} label={t('settings.motion')} options={[['system', t('settings.followSystem')], ['reduce', t('settings.reduce')]]} />
+
{t('settings.density')} + update({ ...prefs, density })} label={t('settings.density')} options={[['comfortable', t('settings.comfortable'), 'layers'], ['compact', t('settings.compact'), 'grid']]} />
- + - +
+ set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, live: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, reviewState: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, lowReviews: v } }))} /> set(x => ({ ...x, notify: { ...x.notify, crash: v } }))} /> + set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, enabled: v } } }))} /> + {s.notify.quietHours.enabled &&
+ + +
} + {d.muteUntil > Date.now() &&

{t('set.muted', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })}

} +

{t('set.clickNote')}

+
- +
- nav.go({ screen: 'dependencies' })}>{t('set.manage')}}> +
nav.go({ screen: 'dependencies' })}>{t('set.manage')}}> {tools ? (
{tools.tools.map(x => {x.name}{x.version ? {x.version} : null}{x.source === 'app' && {t('set.appCopy')}})}
) :

–

}

{t('set.depsText')}

- +
- +
{t('settings.app')}
{d.info ? `storebound desktop ${d.info.version}${d.info.debug ? ` (${t('settings.debug')})` : ''} · ${d.info.platform}` : '–'}
CLI
{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? t('foot.cliStarting')}
@@ -191,9 +225,9 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('set.state')}
{d.sidecar?.running ? t('set.running') : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}
{t('set.writes')}
{t('set.writesText')}
- +
- +
  • {t('set.p1')}
  • {t('set.p2')}
  • @@ -204,30 +238,33 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- + - +
{t('settings.scPalette')}
{t('nav.portfolio')}
{t('settings.scWorkspace')}
–
{t('settings.scTabs')}
+
{t('settings.scApps')}
+
{t('settings.scMenu')}
{t('settings.scTabArrows')}
{t('settings.scClose')}
{t('set.scQuit')}
- +
- +
{t('set.channel')} - set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['beta', t('set.beta')]]} />
+ set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['dev', t('set.dev')]]} />
+ {s.channel === 'stable' && /-dev\./.test(d.info?.version ?? '') &&

{t('set.devToStable', { version: d.info!.version })}

}

{t('set.updatesText')}

- + - +

{t('set.resetText', { accounts: d.accounts.length, studios: d.studios.length, folders: d.projects.length })}

@@ -235,7 +272,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
- +

{t('set.footer', { n: num(d.apps.length) })}

@@ -243,7 +280,7 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; ); } -/** Settings > Updates: checks on click only; installs after the host's native dialog (the signature is checked there). */ +/** Settings > Updates: Check asks the channel now; Install runs after the host's native dialog (the signature is checked there). The host also checks and installs in the background (lib.rs auto_update). */ function Updates() { const [info, setInfo] = useState(null); const [busy, setBusy] = useState<'check' | 'install' | null>(null); @@ -258,7 +295,7 @@ function Updates() { setBusy('install'); setError(null); try { await api.updateInstall(version); } catch (e) { setError(errorText(e)); } finally { setBusy(null); setGot(null); } }; - const channel = info ? t(info.channel === 'beta' ? 'set.beta' : 'set.stable') : ''; + const channel = info ? t(info.channel === 'beta' ? 'set.beta' : info.channel === 'dev' ? 'set.dev' : 'set.stable') : ''; return (
{error && {error}} diff --git a/apps/desktop/src/screens/editors/AppInfoEditor.tsx b/apps/desktop/src/screens/editors/AppInfoEditor.tsx index 053f4ea..c0685af 100644 --- a/apps/desktop/src/screens/editors/AppInfoEditor.tsx +++ b/apps/desktop/src/screens/editors/AppInfoEditor.tsx @@ -8,7 +8,8 @@ import { type Key, t } from '../../i18n'; import { AGE_FREQUENCY, AGE_RATING_NONE, APPLE_CATEGORIES, APPLE_GAME_SUBCATEGORIES, KIDS_AGE_BANDS, PLAY_AGE_GROUPS, PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES } from '../../../../../cli/lib/store-catalogs'; import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, TextField, useEdits } from '../../ui/Editor'; import { StoreGlyph } from '../../ui/icons'; -import { Card } from '../../ui/kit'; +import { Section } from '../../ui/ds'; +import '../../styles/appinfo.css'; type O = Record; const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {}); @@ -30,7 +31,7 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult } const contact = data.contact; return ( <> - {t('ed.appleInfoSub')}}> +
{t('ed.appleInfoSub')}}>
{games && } @@ -43,8 +44,8 @@ function AppleInfo({ project, data }: { project: Project; data: EditableResult } options={[['false', t('ai.encNo')], ['true', t('ai.encYes')]]} hint={t('ed.encHint')} />
- - +
+
{Object.entries(AGE_RATING_NONE).map(([q, none]) => ( ))}
- - +
+
{data.demoAccount ? `${data.demoAccount} · ••••••••` : t('ed.notSet')}
{t('ed.demoHint')}
- + ); } @@ -75,7 +76,7 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul const acc = obj(data.config.accounts); const cats: [string, string][] = [...PLAY_GAME_CATEGORIES.map(c => [c, `${t('ed.game')}: ${c}`] as [string, string]), ...PLAY_APP_CATEGORIES.map(c => [c, `${t('ed.app')}: ${c}`] as [string, string])]; return ( - {t('ed.playSettingsSub')}}> +
{t('ed.playSettingsSub')}}>
@@ -83,14 +84,14 @@ function PlaySettings({ project, data }: { project: Project; data: EditableResul
- +
); } function Declarations({ project, data }: { project: Project; data: EditableResult }) { const ds = obj(data.config.dataSafety); return ( - +
{data.play && } {data.play && } @@ -101,14 +102,14 @@ function Declarations({ project, data }: { project: Project; data: EditableResul {data.play && } {data.play && } {data.play && } - +
); } function Promotion({ project, data }: { project: Project; data: EditableResult }) { const apple = obj(data.config.apple); return ( - {t('ed.promoSub')}}> +
{t('ed.promoSub')}}> @@ -116,7 +117,7 @@ function Promotion({ project, data }: { project: Project; data: EditableResult }
- + ); } @@ -124,13 +125,13 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da const e = useEdits(); const unsaved = e.count(project.id, 'appinfo') ? t('ed.saveFirst') : null; return ( -
+
{data.apple && } {data.play && } {data.apple && } - +
{data.apple && (
@@ -149,7 +150,7 @@ export function AppInfoEditor({ project, data, onSaved }: { project: Project; da
)}
- +
); } diff --git a/apps/desktop/src/screens/editors/ArtEditor.tsx b/apps/desktop/src/screens/editors/ArtEditor.tsx index 4b40d05..77f3e25 100644 --- a/apps/desktop/src/screens/editors/ArtEditor.tsx +++ b/apps/desktop/src/screens/editors/ArtEditor.tsx @@ -1,13 +1,15 @@ // Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the // raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config; // then render, and upload to each store with the dry run in the window (uploads replace the store's sets). +import '../../styles/art.css'; import { useState } from 'react'; import { api, errorText, type EditableResult, type Project } from '../../api'; import { useData, useWrite } from '../../store'; import { t } from '../../i18n'; import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Empty } from '../../ui/kit'; +import { Empty } from '../../ui/kit'; +import { Section } from '../../ui/ds'; import { keyOf } from '../../lib/edits'; type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record; @@ -33,7 +35,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: const p = e.get(project.id, keyOf(op)); const scenes = (p && p.op.kind === 'config' ? p.op.value : saved) as Scene[]; const unsaved = e.count(project.id, 'art') ? t('ed.saveFirst') : null; - if (!saved.length) return {t('ed.artEmptyText')}; + if (!saved.length) return
{t('ed.artEmptyText')}
; const move = (i: number, d: -1 | 1) => { const next = [...scenes]; const j = i + d; @@ -42,8 +44,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: e.set(project.id, { ...op, value: next }, 'art', saved); }; return ( -
- +
1 ? : undefined}>
    {scenes.map((s, i) => ( @@ -61,16 +63,16 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data: ))}
- +
{data.shotsCopy && s.id)} />} - +
{t('act.render')}
{data.play &&
Google Play
} {data.apple &&
App Store
}
- +
); } @@ -82,7 +84,7 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR const [lang, setLang] = useState(langs[0] ?? ''); const L = langs.includes(lang) ? lang : langs[0] ?? ''; return ( - +
{langs.map(x => )}
@@ -103,6 +105,6 @@ function Captions({ project, data, scenes }: { project: Project; data: EditableR ); })}
- + ); } diff --git a/apps/desktop/src/screens/editors/CatalogEditor.tsx b/apps/desktop/src/screens/editors/CatalogEditor.tsx index 27380c4..2a3be47 100644 --- a/apps/desktop/src/screens/editors/CatalogEditor.tsx +++ b/apps/desktop/src/screens/editors/CatalogEditor.tsx @@ -3,12 +3,14 @@ // offer blocks (introductory and promotional offers, offer codes, win-back) as checked JSON. Saved into the config, // then applied per store with the dry run in the window. A price change of a product that is already live goes // through the owner's price decision (the Catalog tab's "Apply the price changes" owner actions). +import '../../styles/catalog.css'; import { useState } from 'react'; import type { EditableResult, Project } from '../../api'; import { t } from '../../i18n'; import { ApplyPanel, ChoiceField, EditBar, JsonField, ListField, NumberField, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Empty } from '../../ui/kit'; +import { Chip, Empty } from '../../ui/kit'; +import { Section } from '../../ui/ds'; type O = Record; const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {}); @@ -44,8 +46,8 @@ function ProductCard({ project, data, p }: { project: Project; data: EditableRes return (
{open && (
@@ -71,7 +73,7 @@ function SubCard({ project, data, group, s }: { project: Project; data: Editable return (
{open && (
@@ -99,17 +101,17 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da const groups = data.config.subscriptions as O[]; const unsaved = e.count(project.id, 'catalog') ? t('ed.saveFirst') : null; if (!products.length && !groups.length) { - return {t('ed.catalogEmptyText')}; + return
{t('ed.catalogEmptyText')}
; } return ( -
+
{products.length > 0 && ( - +
{products.map(p => )}
- +
)} {groups.map(g => ( - +
{data.languages.map(l => ( @@ -120,10 +122,10 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da
{(g.subscriptions as O[]).map(s => )}
- +
))} - +
{data.play && (
@@ -142,7 +144,7 @@ export function CatalogEditor({ project, data, onSaved }: { project: Project; da )}

{t('ed.priceGate')}

- +
); } diff --git a/apps/desktop/src/screens/editors/ListingEditor.tsx b/apps/desktop/src/screens/editors/ListingEditor.tsx index a09d1f5..69270fa 100644 --- a/apps/desktop/src/screens/editors/ListingEditor.tsx +++ b/apps/desktop/src/screens/editors/ListingEditor.tsx @@ -2,6 +2,7 @@ // counters against the store limits and the checks of `check --aso` / `scan` as the owner types; the shared store // details (URLs, contact, copyright, promo video); Save into store/listing.md, store/appstore.md and the config // (project.edit), Pull the live texts into the files (diff first), and Apply to each store (dry run in the window). +import '../../styles/listings.css'; import { useState } from 'react'; import type { EditableListing, EditableResult, Project } from '../../api'; import { type Key, t } from '../../i18n'; @@ -10,7 +11,8 @@ import { keyOf } from '../../lib/edits'; import { useData, useWrite } from '../../store'; import { ApplyPanel, EditBar, type FieldIssueView, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Notice } from '../../ui/kit'; +import { Chip, Notice } from '../../ui/kit'; +import { Section } from '../../ui/ds'; import { WriteButton } from '../../ui/WriteButton'; const PLAY: { f: string; label: Key; multiline?: boolean; rows?: number; mono?: boolean }[] = [ @@ -30,6 +32,30 @@ function current(e: ReturnType, project: Project, store: 'play' } return out; } +/** The texts of one language as the store page shows them: icon, title, subtitle / short description, promo text and + * the first lines of the description (live, with the unsaved edits). */ +export function StorePreview({ store, vals, name }: { store: 'play' | 'apple'; vals: Record; name: string }) { + const title = (store === 'play' ? vals.title : vals.name) || name; + const sub = store === 'play' ? vals.short : vals.subtitle; + const promo = store === 'apple' ? vals.promotionalText : ''; + const desc = store === 'play' ? vals.full : vals.description; + return ( +
+ {t('v2lst.preview')} +
+ +
+
{title || '–'}
+ {sub ?
{sub}
:
{t('v2lst.noSub')}
} +
+
+ {promo &&

{promo}

} + {desc ?

{desc}

:

{t('v2lst.noDesc')}

} + {desc && desc.length > 180 && } +
+ ); +} + const byField = (issues: FieldIssue[], f: string): FieldIssueView[] => issues.filter(i => i.field === f).map(i => ({ level: i.level, msg: i.msg })); function StoreColumn({ project, data, store, l }: { project: Project; data: EditableResult; store: 'play' | 'apple'; l: EditableListing }) { @@ -48,6 +74,7 @@ function StoreColumn({ project, data, store, l }: { project: Project; data: Edit {l.status && {l.status === 'draft' ? t('ed.statusDraft') : t('ed.statusTranslate')}} {(l.codes?.length ?? 0) > 1 && {t('ed.alsoServes', { codes: l.codes!.filter(c => c !== l.lang).join(', ') })}}
+ {fresh && {t('ed.newSection', { lang: l.lang })}} {defs.map(d => ( - +
{data.play && {t('ed.pullPlay')}} {data.apple && {t('ed.pullApple')}} }>
{unsaved > 0 && {t('ed.pullNote')}} -
+
{data.languages.map(x => { const n = e.list(project.id, 'listings').filter(p => p.op.kind === 'listing' && (p.op.lang === x.apple || x.play.includes(p.op.lang))).length; return ( @@ -132,12 +159,12 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da {!pl && !al && {t('ed.noStoreForLang', { lang: L?.id ?? '' })}}
- - +
+
- +
- +
{data.play && (
@@ -157,7 +184,7 @@ export function ListingEditor({ project, data, onSaved }: { project: Project; da )}

{t('ed.applyNote')}

- - +
+
); } diff --git a/apps/desktop/src/screens/editors/StoreListingEditor.tsx b/apps/desktop/src/screens/editors/StoreListingEditor.tsx index a1640cc..0d2374b 100644 --- a/apps/desktop/src/screens/editors/StoreListingEditor.tsx +++ b/apps/desktop/src/screens/editors/StoreListingEditor.tsx @@ -2,6 +2,7 @@ // (listing.live), edited in place with the same counters and checks, and applied straight to the store per language // (listing.set: the store's dry run in the window, the native dialog, then the real run, which changes only the edited // fields). URLs, contact and app details stay with an app folder (they come from its config). +import '../../styles/listings.css'; import { useState } from 'react'; import type { Project } from '../../api'; import { type Key, t } from '../../i18n'; @@ -12,7 +13,9 @@ import { keyOf } from '../../lib/edits'; import { useRead } from '../../store'; import { ApplyPanel, type FieldIssueView, TextField, useEdits } from '../../ui/Editor'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Section } from '../../ui/ds'; +import { StorePreview } from './ListingEditor'; const PLAY: { f: 'title' | 'short' | 'full'; label: Key; limit: number; rows?: number }[] = [ { f: 'title', label: 'lst.title', limit: 30 }, { f: 'short', label: 'lst.short', limit: 80 }, { f: 'full', label: 'lst.full', limit: 4000, rows: 10 }, @@ -41,6 +44,7 @@ function Column({ project, store, lang, saved, appName, editable }: { project: P return (
{store === 'play' ? 'Google Play' : 'App Store'}{lang}
+ {store === 'apple' && !editable && {t('ed.noEditableVersion')}} {defs.map(d => ( ; - return {t('tf.read')}}>{r.error ?? t('ed.storeOnlyNote')}; + if (r.loading && !r.error) return
; + return
{t('tf.read')}}>{r.error ?? t('ed.storeOnlyNote')}
; } const L = r.data; const langs = [...new Set([...(L.play ?? []).map(x => x.language), ...(L.apple?.locales ?? []).map(x => x.locale)])].sort(); @@ -69,12 +73,12 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) { const pl = L.play?.find(x => x.language === cur); const al = L.apple?.locales.find(x => x.locale === cur); return ( - <> +
{Object.entries(L.errors).map(([s, e]) => )} - } id="store-listing-editor"> +
} id="store-listing-editor">
{t('ed.storeOnlyNote')} -
+
{langs.map(x => )}
@@ -83,7 +87,7 @@ export function StoreListingEditor({ app }: { app: UnifiedApp }) { {!pl && !al && {t('ed.noStoreForLang', { lang: cur })}}
- - +
+
); } diff --git a/apps/desktop/src/screens/editors/TabEditor.tsx b/apps/desktop/src/screens/editors/TabEditor.tsx index 462fae0..b38ded1 100644 --- a/apps/desktop/src/screens/editors/TabEditor.tsx +++ b/apps/desktop/src/screens/editors/TabEditor.tsx @@ -6,7 +6,8 @@ import { t } from '../../i18n'; import { edits } from '../../lib/edits'; import type { AppTab } from '../../nav'; import { useEditable } from '../../ui/Editor'; -import { Card, ErrorNotice, Skeleton } from '../../ui/kit'; +import { ErrorNotice, Skeleton } from '../../ui/kit'; +import { Section } from '../../ui/ds'; import { AppInfoEditor } from './AppInfoEditor'; import { ArtEditor } from './ArtEditor'; import { CatalogEditor } from './CatalogEditor'; @@ -21,8 +22,8 @@ export const EDIT_TABS: Partial : ; - const p = { project, data: r.data, onSaved: () => { r.refresh(); } }; + if (!r.data) return r.error ? :
; + const p = { project, data: r.data, onSaved: () => { r.refresh(true); } }; switch (tab) { case 'listings': return ; case 'appinfo': return ; diff --git a/apps/desktop/src/screens/editors/TestFlightEditor.tsx b/apps/desktop/src/screens/editors/TestFlightEditor.tsx index 9507aab..07a3dd0 100644 --- a/apps/desktop/src/screens/editors/TestFlightEditor.tsx +++ b/apps/desktop/src/screens/editors/TestFlightEditor.tsx @@ -5,7 +5,8 @@ import type { EditableResult, Project } from '../../api'; import { t } from '../../i18n'; import { ApplyPanel, EditBar, JsonField, TextField, useEdits } from '../../ui/Editor'; -import { Card } from '../../ui/kit'; +import { Section } from '../../ui/ds'; +import '../../styles/testflight.css'; type O = Record; const obj = (v: unknown): O => (v && typeof v === 'object' && !Array.isArray(v) ? (v as O) : {}); @@ -17,8 +18,8 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project; const unsaved = e.count(project.id, 'testflight') ? t('ed.saveFirst') : null; if (!data.apple) return null; return ( -
- +
+
{data.apple.map(l => (
@@ -35,14 +36,14 @@ export function TestFlightEditor({ project, data, onSaved }: { project: Project; {typeof desc === 'string' && } - +
- -
+
+
- +
); } diff --git a/apps/desktop/src/screens/tabs/AppInfo.tsx b/apps/desktop/src/screens/tabs/AppInfo.tsx index 7b7e5bf..8666d46 100644 --- a/apps/desktop/src/screens/tabs/AppInfo.tsx +++ b/apps/desktop/src/screens/tabs/AppInfo.tsx @@ -12,12 +12,13 @@ import { declarationsMd, exportName } from '../../lib/exports'; import { ago, num } from '../../lib/format'; import { useData, useRead, useWrite } from '../../store'; import { useExports, useRunExport } from '../../ui/Exports'; -import { Icon, StoreGlyph } from '../../ui/icons'; +import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; import { CONSOLE_FORM_NAME, ConsoleFillButton } from '../../ui/ConsoleFill'; import { ConsolePlanButton, PLAN_FORMS } from '../../ui/ConsolePlan'; -import { Bar, Card, Chip, Empty, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; +import { Bar, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; +import { EmptyState, Section } from '../../ui/ds'; +import '../../styles/appinfo.css'; import { WriteButton } from '../../ui/WriteButton'; -import { GameServicesCard } from '../../ui/GameServicesCard'; import { ascUrl } from '../AppView'; const AREA: Record = { dataSafety: 'ai.aDataSafety', appPrivacy: 'ai.aAppPrivacy', appInfo: 'ai.aAppInfo', encryption: 'ai.aEncryption', review: 'ai.aReview' }; @@ -34,7 +35,7 @@ const yes = (b: boolean | null | undefined): string => (b === null || b === unde function AskCard({ ask }: { ask: DeclAsk[] }) { if (!ask.length) return {t('ai.allAnswered')}; return ( - +
{ask.map(a => (
@@ -45,7 +46,7 @@ function AskCard({ ask }: { ask: DeclAsk[] }) {
))}
- +
); } @@ -76,12 +77,12 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR const up = ds?.uploaded ?? null; const asked = d.ask.filter(a => a.area === 'dataSafety').length; return ( - {t('ai.dsSub')}} pad={false} +
{t('ai.dsSub')}} pad={false} actions={ds ? <> {t('ai.dsUpload')} : undefined}> - {!ds ? {t('ai.dsNoneText')} : ( + {!ds ? {t('ai.dsNoneTitle')}
{t('ai.dsNoneText')}
: ( <>
@@ -99,23 +100,23 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR {asked > 0 && {t('ai.askedHere', { count: asked })}}
{ds.types.length > 0 && ( - +
{ds.types.map(x => ( - - + + ))} -
{t('ai.colType')}{t('ai.colCollected')}{t('ai.colShared')}
{typeLabel(x.id)}
{x.category}{x.optional ? ` · ${t('ai.optional')}` : ''}{x.ephemeral ? ` · ${t('ai.ephemeral')}` : ''}
{x.collect.map(p => {t(PURPOSE[p] ?? 'ds.functionality')})}
{x.shared ?
{x.share.map(p => {t(PURPOSE[p] ?? 'ds.functionality')})}
: {t('ai.notShared')}}
{x.collect.map((p, i) => {t(PURPOSE[p] ?? 'ds.functionality')})}
{x.shared ?
{x.share.map((p, i) => {t(PURPOSE[p] ?? 'ds.functionality')})}
: {t('ai.notShared')}}
+
)}
{t('ai.dsFoot', { rows: ds.rows })}
)} -
+ ); } @@ -123,28 +124,28 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe const ap = d.appPrivacy; if (!ap) return null; return ( - {t('ai.apSub')}} pad={false} +
{t('ai.apSub')}} pad={false} actions={<> }>
{t('ai.apNote')}
- {!ap.items.length ? {ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')} : ( - + {!ap.items.length ? {ap.fromDataSafety ? t('ai.apNoneTitle') : t('ai.apUnknownTitle')}
{ap.fromDataSafety ? t('ai.apNoneText') : t('ai.apUnknownText')}
: ( +
{ap.items.map(x => ( - + ))} -
{t('ai.colAppleType')}{t('ai.colPurposes')}{t('ai.colLinked')}{t('ai.colTracking')}
{x.apple}
{x.category} · {x.from.map(typeLabel).join(', ')}
{x.purposes.map(p => {p})}
{x.purposes.map((p, i) => {p})}
{t('ai.ask')} {x.thirdParty ? t('ai.askLikely') : t('ai.ask')}
+
)} - + ); } @@ -154,7 +155,7 @@ function ConsoleSteps({ project, stores }: { project: Project; stores: Declarati const forms = PLAN_FORMS.filter(f => (f.store === 'apple' ? stores.apple : stores.play)); if (!forms.length) return null; return ( - +
{forms.map(f => (
@@ -167,7 +168,7 @@ function ConsoleSteps({ project, stores }: { project: Project; stores: Declarati
))}
- +
); } @@ -181,27 +182,45 @@ export function AppInfoTab({ project }: { project: Project }) { { id: 'decl-md', label: t('export.declarations'), icon: 'clipboard', kind: 'md', file: exportName(name, 'store declarations'), disabled: r.data ? false : t('export.readFirst'), build: () => declarationsMd(name, r.data!) }, { id: 'ds-csv', label: t('export.dataSafety'), icon: 'shield', kind: 'csv', file: exportName(name, 'data safety'), disabled: r.data?.dataSafety ? false : t('export.noDataSafety'), build: () => r.data!.dataSafety!.csv }, ]); - if (!r.data) return r.error ? : ; + if (!r.data) return r.error ? :
; const d = r.data; const info = d.appInfo; const enc = d.encryption; const rv = d.review; const cat = info?.categories ? [info.categories.primary, info.categories.primarySubOne, info.categories.primarySubTwo].filter(Boolean).join(' / ') : null; + const ds = d.dataSafety; + const facts: { k: string; label: string; icon: IconName; value: string; tone: 'ok' | 'warn' | 'neutral' }[] = [ + { k: 'ask', label: t('v2ins.aiOpen'), icon: 'question', value: d.ask.length ? num(d.ask.length) : t('v2ins.aiNone'), tone: d.ask.length ? 'warn' : 'ok' }, + ...(d.stores.apple && info ? [ + { k: 'cat', label: t('ai.category'), icon: 'grid' as IconName, value: cat ?? t('ai.notSet'), tone: cat ? 'neutral' as const : 'warn' as const }, + { k: 'price', label: t('ai.price'), icon: 'coins' as IconName, value: info.free ? t('ai.free') : t('ai.paid'), tone: 'neutral' as const }, + { k: 'enc', label: t('ai.encTitle'), icon: 'lock' as IconName, value: enc === null ? t('ai.notAnswered') : enc ? t('ai.encYes') : t('ai.encNo'), tone: enc === null ? 'warn' as const : 'neutral' as const }, + ] : []), + ...(d.stores.play && ds ? [{ k: 'ds', label: t('ai.dsCollects'), icon: 'shield' as IconName, value: yes(ds.collectsData), tone: ds.collectsData === null || ds.collectsData === undefined ? 'warn' as const : 'neutral' as const }] : []), + ]; return ( - <> +
+
+
{t('v2ins.aiSheet')}

{name}

+ {d.stores.apple && App Store}{d.stores.play && Google Play} +
+
+ {facts.map(f =>
{f.label}
{f.value}
)} +
+
{d.stores.apple && e?.app?.platforms.ios && !e.app.platforms.ios.ascAppId && ( }>
{t('cf.noRecordTitle')}{t('cf.noRecordText')}
)} -
- {d.stores.play ? : {t('ai.noPlayText')}} - {d.stores.apple ? : {t('ai.noAppleText')}} +
+ {d.stores.play ? :
{t('ai.noPlay')}
{t('ai.noPlayText')}
} + {d.stores.apple ? :
{t('ai.noApple')}
{t('ai.noAppleText')}
}
{d.stores.apple && ( -
- +
{t('ai.push')} @@ -212,15 +231,15 @@ export function AppInfoTab({ project }: { project: Project }) {
{t('ai.rights')}
{info!.contentRights ? {info!.contentRights === 'USES_THIRD_PARTY_CONTENT' ? t('ai.rightsThird') : t('ai.rightsOwn')} : {t('ai.notSet')}}
{t('ai.price')}
{info!.free ? t('ai.free') : t('ai.paid')}
- - +
{t('ai.encAnswer')}}>
{enc === null ? {t('ai.notAnswered')} : {enc ? t('ai.encYes') : t('ai.encNo')}} {enc === null ? t('ai.encUnset') : enc ? t('ai.encYesText') : t('ai.encNoText')}
- - +
{t('ai.push')}}>
@@ -235,13 +254,12 @@ export function AppInfoTab({ project }: { project: Project }) {
{t('ai.reviewSafe')}
- +
)} - -

{t('ai.foot', { when: ago(r.at) })}

- +

{t('ai.foot', { when: ago(r.at) })}

+
); } diff --git a/apps/desktop/src/screens/tabs/Art.tsx b/apps/desktop/src/screens/tabs/Art.tsx index 66ff5b9..412dbac 100644 --- a/apps/desktop/src/screens/tabs/Art.tsx +++ b/apps/desktop/src/screens/tabs/Art.tsx @@ -2,6 +2,9 @@ // size (`shots gallery --json`, offline), store check issues outlined. Images load one by one as they scroll into view // (`project.render`, a data: URL each: the window loads no files itself). Re-render (dry run, dialog, real run), write // the review page, open it or the folder. +import { type MenuInput, useContextMenu } from '../../ui/ContextMenu'; +import { copyToast } from '../../ui/app-menu'; +import '../../styles/art.css'; import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, errorText, type Project, type RenderImage, type RendersResult, type RenderTile, type ToolResult } from '../../api'; import { t } from '../../i18n'; @@ -9,7 +12,8 @@ import { ago, bytes, num } from '../../lib/format'; import { artRows, langIssues } from '../../lib/views'; import { useData, useRead, useWrite } from '../../store'; import { Icon } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Seg, Skeleton } from '../../ui/kit'; +import { Chip, Empty, ErrorNotice, Seg, Skeleton } from '../../ui/kit'; +import { Section, StatTile, StatusPill } from '../../ui/ds'; import { WriteButton } from '../../ui/WriteButton'; import { LiveArt } from './LiveArt'; @@ -39,7 +43,8 @@ function loadRender(projectId: string, file: string, stamp: string): Promise void }) { +function Thumb({ projectId, tile, stamp, onOpen, menu }: { projectId: string; tile: RenderTile; stamp: string; onOpen: () => void; menu: () => MenuInput }) { + const cm = useContextMenu(); const ref = useRef(null); const file = tile.kind === 'video' ? tile.poster : tile.src; const [src, setSrc] = useState(file ? cache.get(`${projectId}|${file}|${stamp}`) : null); @@ -57,11 +62,13 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re return () => { alive = false; io.disconnect(); }; }, [projectId, file, stamp, src]); const lvl = tile.issues.some(i => i.level === 'error') ? 'err' : tile.issues.length ? 'warn' : ''; + const shape = tile.h > tile.w ? 'portrait' : 'landscape'; return ( -
+
{tile.name}{tile.w}×{tile.h}{tile.bytes ? ` · ${bytes(tile.bytes)}` : ''}
{tile.issues.length > 0 &&
    {tile.issues.slice(0, 2).map((i, k) =>
  • {i.msg}
  • )}
} @@ -125,7 +132,7 @@ export function ArtTab({ project }: { project: Project }) { }, [project.id, d, w, data?.dir, r.refresh]); // the live art stays the first child in both branches, so it keeps its state when the local renders arrive - if (!data) return <>{r.error ? : }; + if (!data) return
{r.error ? :
}
; const total = data.totals.images + data.totals.videos; const toolbar = ( <> @@ -136,15 +143,15 @@ export function ArtTab({ project }: { project: Project }) { ); return ( - <> +
-
- - - - +
+ + + +
- {dir}} actions={toolbar} pad={false}> +
{dir}} actions={toolbar} pad={false}>
{data.variants.length > 1 && [x.id, x.id ? t('gr.variant', { id: x.id }) : t('art.base')] as [string, string])} />}
@@ -163,18 +170,28 @@ export function ArtTab({ project }: { project: Project }) {
{rows.map(row => (
i.level === 'error') ? ' bad' : ''}`} key={row.size}> -

{row.label}{lg}/{row.size}{row.store === 'apple' ? 'App Store' : 'Google Play'}{t('art.count', { count: row.tiles.length })}

+

{row.label}{lg}/{row.size}{row.store === 'apple' ? 'App Store' : 'Google Play'}{row.issues.length > 0 && i.level === 'error') ? 'bad' : 'warn'}>{t('v2lst.issues', { count: row.issues.length })}}{t('art.count', { count: row.tiles.length })}

{row.issues.length > 0 &&
    {row.issues.map((i, k) =>
  • {i.msg}
  • )}
} -
row.tiles[0].h ? '260px' : '150px' }}> - {row.tiles.map((tile, i) => setOpen({ tiles: row.tiles, i })} />)} +
row.tiles[0].h ? '260px' : '170px' }}> + {row.tiles.map((tile, i) => setOpen({ tiles: row.tiles, i })} menu={() => { + const path = `${dir}/${tile.src}`; + return [ + { label: t('cm.preview'), icon: 'eye', shortcut: 'Enter', onSelect: () => setOpen({ tiles: row.tiles, i }) }, + { label: t('cm.openFile'), icon: 'external', onSelect: () => reveal(path, true) }, + { label: t('cm.reveal'), icon: 'folder', onSelect: () => reveal(path) }, + { label: t('cm.copyPath'), icon: 'copy', onSelect: () => copyToast(d, path) }, + 'sep', + tile.kind === 'image' && { label: t('art.replace'), icon: 'upload', onSelect: () => { void replace(tile); } }, + ]; + }} />)}
))}
)}
{t('art.foot', { when: ago(r.at) })}
- +
{open && setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />} - +
); } diff --git a/apps/desktop/src/screens/tabs/Catalog.tsx b/apps/desktop/src/screens/tabs/Catalog.tsx index 7dedd9d..c028a26 100644 --- a/apps/desktop/src/screens/tabs/Catalog.tsx +++ b/apps/desktop/src/screens/tabs/Catalog.tsx @@ -1,5 +1,8 @@ // Catalog: products and subscriptions defined once in the config against both stores (catalog diff): a parity // matrix (present, mismatched, missing, not sold there, not read), and price gaps between the stores per region. +import { useContextMenu } from '../../ui/ContextMenu'; +import { copyToast, playUrlFor, projectConsoles } from '../../ui/app-menu'; +import '../../styles/catalog.css'; import type { Project } from '../../api'; import { type Key, t } from '../../i18n'; import { ago, num } from '../../lib/format'; @@ -8,17 +11,19 @@ import { useData, useRead, useWrite } from '../../store'; import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; import { OwnerAction } from '../../ui/OwnerAction'; import { WriteButton } from '../../ui/WriteButton'; -import { Card, Chip, Empty, ErrorNotice, Kpi, ReadControl, Skeleton } from '../../ui/kit'; +import { Chip, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatTile, StatusPill } from '../../ui/ds'; import { catalogMd, exportName } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; import { useNav } from '../../nav'; const CELL: Record = { ok: ['check', 'cat.ok'], warn: ['alert', 'cat.warn'], error: ['x', 'cat.error'], na: ['dot', 'cat.na'], unknown: ['info', 'cat.unknown'] }; -function Cell({ c }: { c: { state: CellState; notes: string[] } }) { +function Cell({ c, store }: { c: { state: CellState; notes: string[] }; store?: 'ios' | 'android' }) { const [icon, key] = CELL[c.state]; return ( -
+
+ {store && {store === 'ios' ? 'App Store' : 'Google Play'}} {t(key)} {c.notes.length > 0 &&
    {c.notes.slice(0, 3).map((n, i) =>
  • {n}
  • )}
}
@@ -33,7 +38,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project const e = d.statusOf(project); const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android; return ( - +
{android && (
@@ -60,7 +65,7 @@ function CatalogActions({ project, products, subs, refresh }: { project: Project

{t('act.catalogNote')}

- +
); } @@ -68,66 +73,83 @@ export function CatalogTab({ project }: { project: Project }) { const d = useData(); const e = d.statusOf(project); const diff = useRead(project.id, 'catalog.diff', { network: true, title: t('tab.catalog') }); + const cm = useContextMenu(); + const nav = useNav(); + const plat = e?.app?.platforms; const s = e?.summary ?? null; const name = s?.name ?? project.name; useExports('catalog', [ { id: 'catalog-md', label: t('export.catalog'), icon: 'layers', kind: 'md', file: exportName(name, 'catalog parity'), disabled: diff.data ? false : t('export.readFirst'), build: () => catalogMd(name, diff.data!) }, ]); - if (!e) return ; + if (!e) return
; const rows = parityMatrix(s, diff.data); const read = !!diff.data; const inParity = rows.filter(r => r.ios.state !== 'error' && r.ios.state !== 'warn' && r.android.state !== 'error' && r.android.state !== 'warn' && r.kind !== 'extra').length; const gaps = diff.data?.parity ?? []; if (!rows.length) { - return {t('cat.emptyText')}; + return
{t('cat.emptyTitle')} {t('cat.emptyText')}
; } return ( - <> -
- - - r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} /> - +
+
+ + + r.kind !== 'extra').length}` : '–'} sub={read ? t('cat.parityRead', { when: ago(diff.at) }) : diff.loading ? t('read.waiting') : t('kpi.notRead')} tone={read && inParity < rows.length ? 'warn' : undefined} /> +
{diff.error && } { diff.refresh(); }} /> - }> -
- - - - {rows.map(r => ( - - - - - - - - - ))} - -
{t('col.item')}{t('col.type')}{t('col.config')}App StoreGoogle Play{t('col.priceGap')}
{r.id}{r.kind === 'extra' ? {t('cat.extra')} : {r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}}{r.config ? [s?.catalog.ios.includes(r.id) ? 'iOS' : null, s?.catalog.android.includes(r.id) ? 'Android' : null].filter(Boolean).join(' + ') || t('cat.na') : {t('cat.notInConfig')}}{r.maxDiff !== null ? 25 ? 'bad' : 'warn'}>{`${num(r.maxDiff, 1)} %`} : {read ? t('cat.noGap') : '–'}}
-
-
+
    + {rows.map(r => { + const worst = [r.ios.state, r.android.state].includes('error') ? 'bad' : [r.ios.state, r.android.state].includes('warn') || r.kind === 'extra' ? 'warn' : 'ok'; + return ( +
  • [ + { label: t('cm.copyProductId'), icon: 'copy', shortcut: r.id, onSelect: () => copyToast(d, r.id) }, + { label: t('cm.showPrices'), icon: 'coins', onSelect: () => nav.go({ screen: 'app', appId: project.id, tab: 'prices' }) }, + 'sep', + ...projectConsoles(d, plat?.ios?.ascAppId, !!plat?.ios, !!plat?.android, playUrlFor(d, d.appByKey(project.id))), + ])}> +
    + +
    + {r.id} + {r.kind === 'extra' ? {t('cat.extra')} : {r.kind === 'subscription' ? t('cat.sub') : t('cat.product')}} + {t('col.config')}: {r.config ? [s?.catalog.ios.includes(r.id) ? 'iOS' : null, s?.catalog.android.includes(r.id) ? 'Android' : null].filter(Boolean).join(' + ') || t('cat.na') : t('cat.notInConfig')} +
    + + {r.maxDiff !== null ? 25 ? 'bad' : 'warn'} title={t('col.priceGap')}>{`Δ ${num(r.maxDiff, 1)} %`} : {read ? t('cat.noGap') : '–'}} +
    +
    + + +
    +
  • + ); + })} +
+ {gaps.length > 0 && ( - - +
+
{gaps.slice(0, 12).map((g, i) => ( - + [ + { label: t('cm.copyProductId'), icon: 'copy', shortcut: g.id, onSelect: () => copyToast(d, g.id) }, + { label: t('cm.copyRow'), icon: 'clipboard', onSelect: () => copyToast(d, [g.id, g.region, `${g.apple} ${g.currency}`, `${g.play} ${g.currency}`, `${g.diffPct} %`].join('\t')) }, + ])}> ))} -
{t('col.item')}{t('col.region')}App StoreGoogle Play{t('col.diff')}
{g.id}{g.region} {num(g.apple, 2)} {g.currency}{num(g.play, 2)} {g.currency} 25 ? 'bad' : 'warn'}>{`${num(g.diffPct, 1)} %`}
-
+
+ )} - +
); } diff --git a/apps/desktop/src/screens/tabs/Checks.tsx b/apps/desktop/src/screens/tabs/Checks.tsx index 1752997..233677a 100644 --- a/apps/desktop/src/screens/tabs/Checks.tsx +++ b/apps/desktop/src/screens/tabs/Checks.tsx @@ -1,5 +1,7 @@ // Checks: the offline rejection-risk scan of the project files (`scan`, every time), the offline config check and // the read-only store readiness report (`validate`, on demand) with its ordered blockers and fix hints. +import { type MenuInput, type MenuItem, useContextMenu } from '../../ui/ContextMenu'; +import { copyToast, openUrl } from '../../ui/app-menu'; import type { Project } from '../../api'; import { t } from '../../i18n'; import { ago } from '../../lib/format'; @@ -10,10 +12,13 @@ import { AiFix, aiFinding } from '../../ui/AiFix'; import { api, errorText, newJob } from '../../api'; import type { ToolResult } from '../../api'; import type { ReportData } from '../../../../../cli/lib/report'; -import { useEffect, useState } from 'react'; -import { Icon } from '../../ui/icons'; +import { type ReactNode, useEffect, useState } from 'react'; +import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; import { OpenLink } from '../../ui/OpenLink'; -import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit'; +import { Bar, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatusPill } from '../../ui/ds'; +import '../../styles/checks.css'; +import { skillGuideUrl } from '../../../../../cli/lib/skill-guide'; import { exportName, validateMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; @@ -24,6 +29,8 @@ const LEVEL: Record, action: FixAction, what: string): MenuItem => ({ label: t('checks.fix'), icon: 'wand', onSelect: () => { void w.run({ method: action.method, args: action.args, title: t('checks.fixing', { what }), done: t('checks.fixed') }); } }); export const fixable = (a: FixAction | undefined): a is FixAction => !!a && FIX_METHODS.has(a.method) && !!a.args && typeof a.args === 'object'; /** The Fix button of one finding: its action as a confirm-gated write. */ @@ -35,11 +42,32 @@ function FixButton({ action, what, w }: { action: FixAction; what: string; w: Re } const SEV: Record = { blocker: ['bad', 'alertCircle'], high: ['bad', 'alert'], medium: ['warn', 'alert'], low: ['info', 'info'] }; +const SEV_ORDER: ScanSeverity[] = ['blocker', 'high', 'medium', 'low']; -/** The `storebound scan` card: rejection risks with guideline, evidence file:line, fix and skill. */ +/** One check as a card: tone icon tile, title, detail lines, the fix, and its actions on the right. */ +function CheckCard({ tone, icon, title, children, actions, tags, menu }: { tone: 'bad' | 'warn' | 'ok' | 'info'; icon: IconName; title: ReactNode; children?: ReactNode; actions?: ReactNode; tags?: ReactNode; menu?: () => MenuInput }) { + const cm = useContextMenu(); + return ( +
+ +
+
{title}
+ {children} +
+
+ {tags &&
{tags}
} + {actions &&
{actions}
} +
+
+ ); +} + +/** The `storebound scan` section: rejection risks with guideline, evidence file:line, fix and skill; open risks first, + * accepted ones collapsed. */ function ScanCard({ project }: { project: Project }) { const s = useRead(project.id, 'scan', { network: false, title: t('checks.scan') }); const w = useWrite(project.id); + const d = useData(); const findings = Array.isArray(s.data?.findings) ? s.data!.findings : []; // Fix with AI: is claude installed (else Copy for AI); one run at a time const [claude, setClaude] = useState(null); @@ -57,43 +85,63 @@ function ScanCard({ project }: { project: Project }) { }, [project.id, firstAi, claude]); const sdks = Array.isArray(s.data?.sdks) ? s.data!.sdks : []; const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length; + const keyed = rows.map(f => ({ f, k: keyOf(f) })); + const open = keyed.filter(x => !x.f.accepted).sort((a, b) => SEV_ORDER.indexOf(a.f.severity) - SEV_ORDER.indexOf(b.f.severity)); + const accepted = keyed.filter(x => !!x.f.accepted); + const card = ({ f, k }: { f: ScanFinding; k: string }) => { + const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low; + const ev = Array.isArray(f.evidence) ? f.evidence : []; + const guide = skillGuideUrl(f.skill); + return ( + {f.guideline}{f.title}} + menu={() => [ + !f.accepted && fixable(f.action) && fixItem(w, f.action, f.title), + !f.accepted && f.url && /^https:\/\//.test(f.url) && { label: t('link.open'), icon: 'external', onSelect: () => openUrl(d, f.url!) }, + guide && { label: t('checks.openGuide'), icon: 'sparkle', shortcut: f.skill, onSelect: () => openUrl(d, guide) }, + ev[0] && { label: t('cm.revealEvidence'), icon: 'folder', shortcut: `${ev[0].file.split('/').pop()}:${ev[0].line}`, onSelect: () => { api.reveal(project.id, ev[0]!.file).catch(() => {}); } }, + 'sep', + { label: t('cm.copy'), icon: 'copy', submenu: [ + { label: t('cm.copyTitle'), icon: 'text', onSelect: () => copyToast(d, `${f.guideline} ${f.title}`) }, + f.fix && { label: t('cm.copyFix'), icon: 'wand', onSelect: () => copyToast(d, f.fix) }, + f.skill && { label: t('cm.copySkill'), icon: 'sparkle', shortcut: f.skill, onSelect: () => copyToast(d, f.skill) }, + ev.length > 0 && { label: t('cm.copyEvidence'), icon: 'clipboard', onSelect: () => copyToast(d, ev.map(e => `${e.file}:${e.line}${e.text ? ` ${e.text}` : ''}`).join('\n')) }, + ] }, + ]} + tags={<>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters[0])}{f.store === 'apple' ? 'App Store' : 'Google Play'}} + actions={<>{!f.accepted && }{!f.accepted && fixable(f.action) && }}> + {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} + {f.accepted ? {t('checks.accepted', { reason: f.accepted })} + : {f.fix} {guide ? : f.skill && {f.skill}}} + {!f.accepted && f.aiFix && !fixable(f.action) &&
{ s.refresh(); }} + stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />
} +
+ ); + }; return ( - x.name).join(', ') }) : t('checks.scanSub')} pad={false} +
x.name).join(', ') }) : t('checks.scanSub')} actions={}> - {s.error &&
} - {!s.data ? (s.loading && !s.error ?
: null) - : !rows.length ? {t('checks.scanCleanText')} : ( -
- {rows.map(f => { - const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low; - const ev = Array.isArray(f.evidence) ? f.evidence : []; - return ( -
- -
- {f.guideline} · {f.title} - {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} - {f.accepted ? {t('checks.accepted', { reason: f.accepted })} - : {f.fix} {f.skill}} - {!f.accepted && } - {!f.accepted && f.aiFix && !fixable(f.action) && { s.refresh(); }} - stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />} -
- {!f.accepted && fixable(f.action) && } - {t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters[0])} - {f.store === 'apple' ? 'App Store' : 'Google Play'} -
- ); - })} +
+ {s.error && } + {!s.data ? (s.loading && !s.error ? : null) + : !rows.length ? {t('checks.scanClean')}
{t('checks.scanCleanText')}
: ( + <> + {open.map(card)} + {accepted.length > 0 && ( +
+ {t('v2ins.cAccepted', { count: accepted.length })} +
{accepted.map(card)}
+
+ )} + + )} + {s.data && (asks > 0 || s.data.declarations?.changed) && ( +
+ {asks > 0 &&
{t('checks.scanAsk', { count: asks })}
} + {s.data.declarations?.changed &&
{t('checks.scanWrite')}
}
)} - {s.data && (asks > 0 || s.data.declarations?.changed) && ( -
- {asks > 0 &&
{t('checks.scanAsk', { count: asks })}
} - {s.data.declarations?.changed &&
{t('checks.scanWrite')}
} -
- )} - +
+
); } @@ -114,10 +162,10 @@ function ReportBar({ project }: { project: Project }) { finally { setBusy(false); } }; return ( - {t('checks.reportOpen')}}> - {null} - +
+ {t('checks.report')}{last ? reportSummary(last) : t('checks.reportSub')} + +
); } @@ -143,46 +191,85 @@ export function ChecksTab({ project }: { project: Project }) { const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])]; const warnings = e?.check?.warnings ?? []; const findings = [...(v.data?.findings ?? [])].sort((a, b) => ['blocker', 'warn', 'ok'].indexOf(a.level) - ['blocker', 'warn', 'ok'].indexOf(b.level)); + const vBlock = findings.filter(f => f.level === 'blocker'), vWarn = findings.filter(f => f.level === 'warn'), vOk = findings.filter(f => f.level === 'ok'); + const blockers = problems.length + vBlock.length; + const warns = warnings.length + vWarn.length; + const total = findings.length + problems.length + warnings.length; + const passed = vOk.length; + const checked = !!v.data && !!e; + const state: 'bad' | 'warn' | 'ok' | 'neutral' = blockers ? 'bad' : !checked ? 'neutral' : warns ? 'warn' : 'ok'; + const headline = blockers ? t('v2ins.cBlocked', { count: blockers }) : !checked ? t('v2ins.cNotRun') : t('v2ins.cReady'); + const finding = (f: Finding, i: number) => { + const [tone, icon] = LEVEL[f.level]; + return ( + [ + f.level !== 'ok' && fixable(f.action) && fixItem(w, f.action, f.msg), + f.url && /^https:\/\//.test(f.url) && { label: t('link.open'), icon: 'external', onSelect: () => openUrl(d, f.url!) }, + 'sep', + { label: t('cm.copyTitle'), icon: 'copy', onSelect: () => copyToast(d, f.msg) }, + f.fix && { label: t('cm.copyFix'), icon: 'wand', onSelect: () => copyToast(d, f.fix!) }, + { label: t('cm.copyCode'), icon: 'tag', shortcut: f.code, onSelect: () => copyToast(d, f.code) }, + ]} + tags={{f.store === 'apple' ? 'App Store' : 'Google Play'}} + actions={<>{f.level !== 'ok' && fixable(f.action) && }}> + {f.fix && {f.fix}} + + ); + }; return ( -
-
-
- d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}>{t('checks.again')}}> - {!e ?
: !problems.length && !warnings.length ? ( - {t('checks.cleanText')} - ) : ( -
- {problems.map((p, i) =>
{p}{t('checks.problem')}
)} - {warnings.map((w, i) =>
{w}{t('checks.warning')}
)} +
+
+
+ +
+ {t('v2ins.cScore')} +

{headline}

+

{v.data ? t('v2ins.cScoreSub', { ok: passed, total }) : t('checks.validateSub')}{v.data ? ` · ${v.fromCache ? t('common.cachedFrom', { when: ago(v.at) }) : t('common.readWhen', { when: ago(v.at) })}` : ''}

+ {total > 0 && }
- )} - - }> - {v.error &&
} - {!v.data ? ( - v.loading && !v.error ?
: {t('checks.validateEmptyText')} - ) : !findings.length ? {t('checks.readyText')} : ( -
- {findings.map((f, i) => { - const [tone, icon] = LEVEL[f.level]; - return ( -
- -
- {f.msg} - {f.fix && {f.fix}} -
- - {f.level !== 'ok' && fixable(f.action) && } - {f.store === 'apple' ? 'App Store' : 'Google Play'} -
- ); - })} +
+
+ {blockers}{t('v2ins.cBlockers')} + {warns}{t('v2ins.cWarnings')} + {passed}{t('v2ins.cPassedN')} +
+
+ + +
+
+ {v.error && } + +
+
+ {!e ? : null} + {problems.map((p, i) => {t('checks.problem')})} + {vBlock.map(finding)} + {!v.data && !v.error && (v.loading ? : {t('checks.validateEmpty')}
{t('checks.validateEmptyText')}
)} + {e && v.data && !blockers && {t('checks.ready')}
{t('checks.readyText')}
} +
+
+ + + + {(warnings.length > 0 || vWarn.length > 0) && ( +
+
+ {warnings.map((x, i) => {t('checks.warning')})} + {vWarn.map(finding)}
- )} - +
+ )} + + {vOk.length > 0 && ( +
+ {t('v2ins.cPassed', { count: vOk.length })} +
{vOk.map(finding)}
+
+ )} + +
); } diff --git a/apps/desktop/src/screens/tabs/Games.tsx b/apps/desktop/src/screens/tabs/Games.tsx new file mode 100644 index 0000000..26950fd --- /dev/null +++ b/apps/desktop/src/screens/tabs/Games.tsx @@ -0,0 +1,90 @@ +// Players > Game services: Game Center (App Store) and Play Games Services (Google Play) for a game, from +// `games status --json` (cli/games-status.ts). The two store panels (switches, ids, counts, sync writes with dry run + +// confirm) are the GameServicesCard; below it one row per leaderboard / achievement with its languages and each +// store's state against the config, the Game Center locale map with suggestions, and the Play API-disabled state with +// the enable link and the console plan. +import { Fragment } from 'react'; +import type { Project } from '../../api'; +import { t } from '../../i18n'; +import { useRead } from '../../store'; +import { ConsolePlanButton } from '../../ui/ConsolePlan'; +import { EmptyState, Section } from '../../ui/ds'; +import { type GameAction, type GameItemRow, GameServicesCard, type GameServicesStatus } from '../../ui/GameServicesCard'; +import { Icon, StoreGlyph } from '../../ui/icons'; +import { Chip, Notice } from '../../ui/kit'; +import { OpenLink } from '../../ui/OpenLink'; + +const ACTION_TONE: Record = { keep: 'ok', update: 'warn', create: 'info' }; +const ACTION_KEY = { keep: 'games.keep', update: 'games.update', create: 'games.create' } as const; + +function StoreState({ on, plan, read }: { on: boolean; plan: { action: GameAction; changed: string[] } | null; read: boolean }) { + if (!on) return {t('games.notOnStore')}; + if (!plan) return {read ? t('games.notListed') : t('games.notRead')}; + return {t(ACTION_KEY[plan.action])}; +} + +function ItemsTable({ rows, appleRead, playRead }: { rows: GameItemRow[]; appleRead: boolean; playRead: boolean }) { + return ( +
+ + + + + + + + {rows.map(r => { + const locs = r.apple?.locales.filter(l => l.action !== 'keep') ?? []; + return ( + + + + + + + + + ); + })} + +
{t('games.item')}{t('games.name')}{t('games.points')}{t('games.languages')}{t('gs.gameCenter')}{t('gs.playGames')}
{r.id} +
{t(r.kind === 'leaderboard' ? 'games.leaderboard' : 'games.achievement')}{r.hidden ? ` · ${t('games.hidden')}` : ''}
{r.name}{r.points ?? '–'}{r.locales.join(', ')}{locs.length > 0 &&
{t('games.localeChanges', { list: locs.map(l => `${l.locale} ${t(ACTION_KEY[l.action])}`).join(', ') })}
}
+
+ ); +} + +export function GamesTab({ project }: { project: Project }) { + const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); + const s = r.data; + const rows = s?.items ?? []; + const appleRead = !!s?.apple && !s.apple.error, playRead = !!s?.play && !s.play.error; + const map = s?.localeMap; + return ( + <> + {s?.playApiDisabled && ( + + + +
}> + {t('games.apiOffTitle')} {t('games.apiOffText', { api: s.playApiDisabled.title })} + + )} + +
r.refresh()} disabled={r.loading} aria-busy={r.loading}>{t('games.refresh')}}> + {rows.length ? + : {s ? t('games.noItems') : t('games.notReadYet')}} +

{t('games.images')}

+
+ {map && (Object.keys(map.configured).length > 0 || map.suggested.length > 0) && ( +
+
+ {Object.entries(map.configured).map(([k, v]) =>
{k}
{v} {t('games.mapSet')}
)} + {map.suggested.map(x =>
{x.id}
{x.from} → {x.to} {t('games.mapSuggested')}
)} +
+ {map.suggested.length > 0 &&

{t('games.mapHint', { entries: map.suggested.map(x => `${x.id}: '${x.to}'`).join(', ') })}

} +
+ )} + + ); +} diff --git a/apps/desktop/src/screens/tabs/Growth.tsx b/apps/desktop/src/screens/tabs/Growth.tsx index 7849f96..ed91df8 100644 --- a/apps/desktop/src/screens/tabs/Growth.tsx +++ b/apps/desktop/src/screens/tabs/Growth.tsx @@ -14,11 +14,14 @@ import { useNav } from '../../nav'; import { useData, useRead, useWrite } from '../../store'; import { useExports } from '../../ui/Exports'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit'; +import { ErrorNotice, Notice, ReadControl, Seg, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatTile, StatusPill, tintStyle, useTint } from '../../ui/ds'; +import '../../styles/growth.css'; import { LineChart } from '../../ui/LineChart'; import { RankChart } from '../../ui/RankChart'; import { WriteButton } from '../../ui/WriteButton'; -import { ascUrl, PLAY_CONSOLE } from '../AppView'; +import { ascUrl } from '../AppView'; +import { playUrlFor } from '../../ui/app-menu'; const DONE = ['COMPLETED', 'STOPPED']; const started = (x: PpoTest) => !!x.startDate && !x.endDate && !DONE.includes(x.state); @@ -43,21 +46,39 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec ); const sum = (xs: number[]) => xs.reduce((n, v) => n + v, 0); const reach = s ? sum(s.reach) : 0, inst = s ? sum(s.installs) : 0; + // deltas: the later half of the window against the earlier half (same number of days), in percent + const half = s ? Math.floor(s.dates.length / 2) : 0; + const change = (xs: number[]): number | null => { + if (half < 1) return null; + const before = sum(xs.slice(0, half)), after = sum(xs.slice(-half)); + return before ? Math.round(((after - before) / before) * 1000) / 10 : null; + }; + const convChange = (): number | null => { + if (!s || half < 1) return null; + const rb = sum(s.reach.slice(0, half)), ra = sum(s.reach.slice(-half)); + if (!rb || !ra) return null; + const cb = sum(s.installs.slice(0, half)) / rb, ca = sum(s.installs.slice(-half)) / ra; + return Math.round((ca - cb) * 1000) / 10; + }; + const deltaSub = half ? t('v2ins.gVsPrev', { count: half }) : t('gr.days', { count: s?.dates.length ?? 0 }); const effects = (a?.effects ?? []).filter(e => e.store === st).slice(-8).reverse(); return ( - {stores.length > 1 && }{actions}}> {none ? ( - {t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''} - ) : r.error ?
: !a || !s ?
: !s.dates.length ? ( - {a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''} + {t('gr.noneTitle')}
{t('gr.noneText')}{hasPlay && !playReports ? ` ${t('gr.noBucket')}` : ''}
+ ) : r.error ? : !a || !s ? : !s.dates.length ? ( + {t('gr.noDataTitle', { store: st === 'apple' ? 'App Store' : 'Google Play' })}
{a.errors?.[st] ?? t('gr.noDataText')}{st === 'play' && !playReports ? ` ${t('gr.noBucket')}` : ''}
) : ( -
-
- - - +
+
+ + + + +
+

{t('gr.days', { count: s.dates.length })} · {t('gr.conversionSub')}

{s.dates.length > 1 ? ( ) : {t('gr.oneDay')}} {effects.length > 0 && ( - +
{effects.map((e, i) => { @@ -79,17 +100,17 @@ function Analytics({ projectId, hasApple, hasPlay, name, playReports }: { projec - + ); })} -
{t('gr.change')}{t('col.date')}{t('gr.before')}{t('gr.after')}{t('col.diff')}
{date(e.event.date)} {pct(e.before.conversion ?? null, 1)}
{t('gr.daysN', { count: e.daysBefore })}
{pct(e.after.conversion ?? null, 1)}
{t('gr.daysN', { count: e.daysAfter })}
{dlt === null ? '–' : = 0 ? 'ok' : 'warn'} dot={false}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}}{dlt === null ? '–' : = 0 ? 'ok' : 'warn'}>{`${dlt >= 0 ? '+' : ''}${num(dlt, 1)} %`}}
+
)} -

{t('gr.reading')}

+

{t('gr.reading')}

)} - + ); } @@ -106,20 +127,20 @@ function Aso({ projectId }: { projectId: string }) { ranks: a.ranks.series.find(s => s.storefront === row.storefront && s.keyword === row.keyword)?.ranks ?? [], })) : []; return ( - }> - {r.error ?
: !a ?
: ( -
-
- : !a ? : ( +
+
+ - - +
{a.ranks.dates.length > 1 && lines.length > 0 && } {a.ranks.rows.length > 0 && ( - +
{a.ranks.rows.slice(0, 8).map(x => ( @@ -127,25 +148,25 @@ function Aso({ projectId }: { projectId: string }) { - + ))} -
{t('gr.asoKeyword')}{t('col.store')}{t('gr.asoRank')}{t('col.diff')}{t('gr.asoBest')}
{x.keyword} {x.storefront} {x.rank === null ? '–' : `#${x.rank}`}{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`}{x.change === null ? x.arrow : `${x.arrow} ${x.change > 0 ? '+' : ''}${x.change}`} {x.best === null ? '–' : `#${x.best}`}
+
)} {a.next.length > 0 && ( -
-
{t('gr.asoNext')}
-
    - {a.next.map((x, i) =>
  1. {x.what}
    {x.why}{x.command ? <> · {x.command} : null}
  2. )} +
    +
    {t('gr.asoNext')}
    +
      + {a.next.map((x, i) =>
    1. {i + 1}{x.what}{x.why}{x.command ? <> · {x.command} : null}
    2. )}
    )} -

    {t('gr.asoReading')}

    +

    {t('gr.asoReading')}

)} - + ); } @@ -164,21 +185,22 @@ function Experiments({ app }: { app: UnifiedApp }) { const editable = tests.find(x => !started(x) && !DONE.includes(x.state)); const asc = app.ios ? ascUrl(app.ios.app.id) : ascUrl(d.statusFor(app)?.app?.platforms.ios?.ascAppId); return ( - {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}} pad={false} +
{r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : t('gr.ppoSub')}} actions={<> }> - {r.error &&
} - {!r.data ? (r.loading && !r.error ?
: {t('gr.ppoNotReadText')}) - : !tests.length ? {variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')} : ( - +
+ {r.error && } + {!r.data ? (r.loading && !r.error ? : {t('gr.ppoNotRead')}
{t('gr.ppoNotReadText')}
) + : !tests.length ? {t('gr.ppoNone')}
{variants.length ? t('gr.ppoNoneText') : t('gr.ppoNoVariants')}
: ( +
{tests.map(x => ( - + @@ -190,10 +212,10 @@ function Experiments({ app }: { app: UnifiedApp }) { ))} -
{t('gr.test')}{t('gr.state')}{t('gr.traffic')}{t('gr.dates')}{t('gr.treatments')}{t('gr.owner')}
{x.name}{x.state.replace(/_/g, ' ').toLowerCase()}{x.state.replace(/_/g, ' ').toLowerCase()} {x.trafficProportion !== undefined ? `${x.trafficProportion} %` : '–'} {x.startDate ? `${date(x.startDate)} → ${x.endDate ? date(x.endDate) : t('gr.running')}` : t('gr.notStarted')} {x.treatments.length ? x.treatments.map(tr => t('gr.treatment', { name: tr.name, count: tr.locales })).join(' · ') : '–'}
+
)} {app.project ? ( -
+
{!variants.length ? {t('gr.ppoNoVariants')} : (
@@ -215,37 +237,40 @@ function Experiments({ app }: { app: UnifiedApp }) { )}

{t('gr.ppoNote')}

- ) :
{t('gr.ppoStoreOnly')}
} - + ) :
{t('gr.ppoStoreOnly')}
} +
+ ); } function PlayExperiments({ app }: { app: UnifiedApp }) { + const d = useData(); const nav = useNav(); return ( - {t('gr.playExpSub')}} - actions={}> -
-

{t('gr.playExpText')}

+
{t('gr.playExpSub')}} + actions={}> +
+

{t('gr.playExpText')}

{app.project &&
}
- +
); } export function GrowthTab({ app }: { app: UnifiedApp }) { const d = useData(); + const hue = useTint(d.iconFor(app), app.name); const s = d.statusFor(app)?.summary; const hasApple = !!(s?.apple ?? app.ios), hasPlay = !!(s?.play ?? app.android); - if (app.project && !s) return ; + if (app.project && !s) return
; return ( - <> +
{app.project && } {app.project && (hasApple || hasPlay) && } -
+
{hasApple && } {hasPlay && }
- +
); } diff --git a/apps/desktop/src/screens/tabs/Health.tsx b/apps/desktop/src/screens/tabs/Health.tsx index 9986790..846e64e 100644 --- a/apps/desktop/src/screens/tabs/Health.tsx +++ b/apps/desktop/src/screens/tabs/Health.tsx @@ -9,31 +9,56 @@ import { api } from '../../api'; import { appleSignatureView, hintFor, type IssueView, issueMarkdown, playIssueView } from '../../lib/issues'; import type { IssueSample } from '../../lib/model'; import { useData, useRead } from '../../store'; -import { Icon } from '../../ui/icons'; -import { Bar, Card, Chip, Empty, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit'; +import { Icon, type IconName, StoreGlyph } from '../../ui/icons'; +import { Bar, ErrorFoot, ErrorNotice, Notice, ReadControl, Skeleton, Sparkline } from '../../ui/kit'; +import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds'; +import '../../styles/health.css'; import { exportName, healthMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; import { StoreReadNotice } from '../../ui/StoreError'; -function Metric({ title, s }: { title: string; s: Series }) { +const toneWord = (tone: Series['tone']): string => (tone === 'bad' ? t('health.over') : tone === 'warn' ? t('health.near') : tone === 'ok' ? t('health.under') : t('common.unknown')); + +/** One big metric tile: the 28-day value, its state against the threshold, a large daily sparkline and its axis. */ +function Metric({ title, s, icon }: { title: string; s: Series; icon: IconName }) { return ( - {s.tone === 'bad' ? t('health.over') : s.tone === 'warn' ? t('health.near') : s.tone === 'ok' ? t('health.under') : t('common.unknown')}}> -
-
-
-
{pct(s.current)}
-
{t('health.current28')}
-
-
- {t('health.daily')} - {t('health.threshold', { value: pct(s.threshold) })} -
-
- -
{date(s.dates[0])}{date(s.dates[Math.floor(s.dates.length / 2)])}{date(s.dates.at(-1))}
+
+
+ {title} + {toneWord(s.tone)}
- +
+ {pct(s.current)} + {t('health.current28')} +
+ +
{date(s.dates[0])}{date(s.dates[Math.floor(s.dates.length / 2)])}{date(s.dates.at(-1))}
+
+ {t('health.daily')} + {t('health.threshold', { value: pct(s.threshold) })} + {t('health.window', { from: date(s.dates[0]), to: date(s.dates.at(-1)) })} +
+
+ ); +} + +/** Launch (startup) time from the App Store power and performance metrics, as the third big tile. */ +function Startup({ h }: { h: AppleHealth }) { + const m = h.metrics.find(x => /launch/i.test(`${x.category} ${x.metric}`)); + if (!m) return null; + const tone = m.goal === 'poor' ? 'bad' : m.goal === 'fair' ? 'warn' : m.goal ? 'ok' : 'neutral'; + return ( +
+
+ {t('v2ins.hStartup')} + {m.goal ? {m.goal} : null} +
+
+ {num(m.value, m.value < 10 ? 2 : 0)}{m.unit} + {m.metric} +
+
{t('v2ins.hStartupSub', { version: m.version })}
+
); } @@ -76,66 +101,58 @@ function IssueDetail({ app, v }: { app: UnifiedApp; v: IssueView }) { ); } -function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) { - const [open, setOpen] = useState(null); - const crash = healthSeries(h.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash); - const anr = healthSeries(h.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr); - const devices = (h.devices ?? []).slice(0, 6); - const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0))); +/** An issue as a card: a header button (type, title, where, counts) that opens its detail below (Enter / Space). */ +function IssueCard({ open, onToggle, tone, kind, title, where, stats, detail }: { open: boolean; onToggle: () => void; tone: 'bad' | 'warn' | 'info'; kind: string; title: string; where?: string; stats: React.ReactNode; detail: React.ReactNode }) { return ( - <> -
- - -
-
- - {devices.length ? ( -
- {devices.map(x => { - const v = Math.max(x.crash ?? 0, x.anr ?? 0); - return ( -
- {x.model} - = PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} /> - {pct(v)} -
- ); - })} -
- ) :

{t('health.noDevices')}

} -
- - {h.issues.length ? ( - - - - {h.issues.slice(0, 6).map((i, n) => ( - setOpen(open === n ? null : n)} cols={3} - head={<>
{i.type === 'ANR' ? 'ANR' : t('health.crashShort')}{i.cause || '?'}
-
{i.location}
} - cells={<>
} - detail={} /> - ))} - -
{t('col.issue')}{t('col.reports')}{t('col.users')}
{num(i.reports)}{num(i.users)}
- ) : } -
-
- +
+ + {open &&
{detail}
} +
); } -/** A table row that opens its detail row below it (click or Enter / Space). */ -function IssueRows({ open, onToggle, head, cells, detail, cols }: { open: boolean; onToggle: () => void; head: React.ReactNode; cells: React.ReactNode; detail: React.ReactNode; cols: number }) { +function Play({ h, app }: { h: PlayHealth; app: UnifiedApp }) { + const [open, setOpen] = useState(null); + const devices = (h.devices ?? []).slice(0, 6); + const worst = Math.max(PLAY_THRESHOLDS.perDevice, ...devices.map(x => Math.max(x.crash ?? 0, x.anr ?? 0))); return ( - <> - { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }} tabIndex={0} aria-expanded={open} role="button"> -
{head}
- {cells} - - {open && {detail}} - +
+
+ {h.issues.length ? ( +
+ {h.issues.slice(0, 6).map((i, n) => ( + setOpen(open === n ? null : n)} tone={i.type === 'ANR' ? 'warn' : 'bad'} + kind={i.type === 'ANR' ? 'ANR' : t('health.crashShort')} title={i.cause || '?'} where={i.location} + stats={<>{num(i.reports)}{t('col.reports')}{num(i.users)}{t('col.users')}} + detail={} /> + ))} +
+ ) : {t('health.noIssues')}} +
+
+ {devices.length ? ( +
+ {devices.map(x => { + const v = Math.max(x.crash ?? 0, x.anr ?? 0); + return ( +
+ {x.model} + = PLAY_THRESHOLDS.perDevice ? 'bad' : v >= PLAY_THRESHOLDS.perDevice * 0.8 ? 'warn' : 'ok'} label={x.model} /> + {pct(v)} +
+ ); + })} +
+ ) : {t('health.noDevices')}} +
+
); } @@ -143,70 +160,83 @@ function Apple({ h, app }: { h: AppleHealth; app: UnifiedApp }) { const [open, setOpen] = useState(null); const maxCrash = Math.max(1, ...h.betaCrashes.map(b => b.count)); return ( -
- - {h.metrics.length ? ( - - - +
+
+ {h.signatures.length ? ( +
+ {h.signatures.slice(0, 5).map((s, i) => ( + setOpen(open === i ? null : i)} tone="bad" kind={s.type} + title={s.signature || s.type} where={t('health.build', { build: s.build })} + stats={{num(s.weight * 100, 1)}%} + detail={} /> + ))} +
+ ) : {t('health.noSignatures')}} +
+
+
+ {h.metrics.length ? ( +
{h.metrics.slice(0, 8).map((m, i) => ( -
- +
+ {m.metric}{m.category} · {m.version} + {num(m.value, m.value < 10 ? 2 : 0)} {m.unit} + {m.goal ? {m.goal} : –} +
))} - -
{t('col.metric')}{t('col.value')}{t('col.version')}{t('col.goal')}
{m.metric}
{m.category}
{num(m.value, m.value < 10 ? 2 : 0)} {m.unit}{m.version}{m.goal ? {m.goal} : '–'}
- ) : } -
-
- - {h.signatures.length ? ( - - - {h.signatures.slice(0, 5).map((s, i) => ( - setOpen(open === i ? null : i)} cols={2} - head={<>
{s.signature || s.type}
{s.type} · {t('health.build', { build: s.build })}
} - cells={
} - detail={} /> - ))} - -
{num(s.weight * 100, 1)}%
- ) : } -
- +
+ ) : {t('health.noMetrics')}} + +
{h.betaCrashes.length ? ( -
{h.betaCrashes.slice(0, 5).map(b => ( -
{t('health.build', { build: b.build })}{num(b.count)}
+
{h.betaCrashes.slice(0, 5).map(b => ( +
{t('health.build', { build: b.build })}{num(b.count)}
))}
- ) :

{t('health.noBetaCrashes')}

} - + ) : {t('health.noBetaCrashes')}} +
); } export function HealthTab({ app }: { app: UnifiedApp }) { + const d = useData(); + const hue = useTint(d.iconFor(app), app.name); const r = useRead(app.target, 'health', { network: true, title: t('tab.health'), appName: app.name }); useExports('health', [{ id: 'health-md', label: t('export.health'), icon: 'activity', kind: 'md', file: exportName(app.name, 'health'), disabled: r.data ? false : t('export.readFirst'), build: () => healthMd(app.name, r.data!) }]); if (!r.data) { - if (r.loading && !r.error) return ; + if (r.loading && !r.error) return
; return ( - - {t('health.read')}}>{t('health.emptyText')} - {r.error && } - +
+
+ {t('health.read')}}> + {t('health.emptyTitle')}
{t('health.emptyText')} +
+ {r.error && } +
+
); } const h = r.data; + const play = h.play !== undefined && !isError(h.play) ? h.play : null; + const apple = h.apple !== undefined && !isError(h.apple) ? h.apple : null; + const crash = play ? healthSeries(play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash) : null; + const anr = play ? healthSeries(play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr) : null; + const tiles = !!crash || !!(apple && apple.metrics.some(x => /launch/i.test(`${x.category} ${x.metric}`))); return ( - <> -
- - -
-

Google Play

+
+
+ {tiles && ( +
+ {crash && } + {anr && } + {apple && } +
+ )} +

Google Play

{h.play === undefined ? {t('health.noPlay')} : isError(h.play) ? : <>{h.play.issuesError && }} -

App Store

+

App Store

{h.apple === undefined ? {t('health.noApple')} : isError(h.apple) ? : } - +
); } diff --git a/apps/desktop/src/screens/tabs/Listings.tsx b/apps/desktop/src/screens/tabs/Listings.tsx index 44753b1..d4aa4bf 100644 --- a/apps/desktop/src/screens/tabs/Listings.tsx +++ b/apps/desktop/src/screens/tabs/Listings.tsx @@ -1,5 +1,6 @@ // Listings: per language and store, which listing fields are written and within the store limits (from the listing // files in the app folder), and how many screenshots are rendered per language. +import '../../styles/listings.css'; import { useState } from 'react'; import type { ListingField, ListingLocale, ListingsResult, Project } from '../../api'; import { type Key, t } from '../../i18n'; @@ -7,7 +8,8 @@ import { ago, num } from '../../lib/format'; import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { WriteButton } from '../../ui/WriteButton'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, Skeleton } from '../../ui/kit'; +import { Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit'; +import { EmptyState, Section, StatTile, StatusPill } from '../../ui/ds'; import { reads } from '../../api'; import { exportName, listingsMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; @@ -39,25 +41,41 @@ function Ring({ value }: { value: number }) { ); } -function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) { - const fields = locales.find(l => l[store])?.[store]?.fields.map(f => f.field) ?? []; - const done = (l: ListingLocale) => { - const fs = l[store]?.fields.filter(f => f.required) ?? []; - return fs.length ? fs.filter(f => f.state === 'ok').length / fs.length : 0; - }; +/** One language as the store page shows it: the fields in store order, each with its length against the limit. */ +function LangPage({ store, l }: { store: 'play' | 'apple'; l: ListingLocale }) { + const fs = l[store]?.fields ?? []; + const req = fs.filter(f => f.required); + const v = req.length ? req.filter(f => f.state === 'ok').length / req.length : 0; + const bad = fs.filter(f => f.state === 'missing' || f.state === 'over').length; return ( - -
-
{t('col.language')}{fields.map(f => {t(FIELD_KEY[f] ?? 'lst.title')})}{t('lst.complete')}
- {locales.map(l => ( -
-
{l.lang}{l[store]?.code ?? ''}{l.rtl && RTL}
- {fields.map(f => x.field === f)} />)} -
+
= 1 ? ' done' : ''}`} data-lang={l.lang} aria-label={`${store === 'play' ? 'Google Play' : 'App Store'} ${l.lang}`}> +
+ {l.lang} + {l[store]?.code ?? ''} + {l.rtl && RTL} + + {bad > 0 ? {t('v2lst.toFix', { count: bad })} : v >= 1 ? {t('v2lst.ready')} : null} + +
+
+ {fs.map(f => ( +
+ {t(FIELD_KEY[f.field] ?? 'lst.title')} +
))}
- +
+ ); +} + +function StoreGrid({ store, locales }: { store: 'play' | 'apple'; locales: ListingLocale[] }) { + return ( +
{store === 'play' ? 'Google Play' : 'App Store'}} icon="text" sub={store === 'play' ? t('lst.playSub') : t('lst.appleSub')}> +
+ {locales.filter(l => l[store]).map(l => )} +
+
); } @@ -71,7 +89,7 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] }) const ios = !!e?.app?.platforms.ios, android = !!e?.app?.platforms.android; const only = lang ? { lang } : {}; return ( - +
{android && (
@@ -99,7 +117,7 @@ function StoreActions({ project, langs }: { project: Project; langs: string[] })

{t('act.note')}

- +
); } @@ -110,7 +128,7 @@ export function ListingsTab({ project }: { project: Project }) { build: async () => { const x = await reads.listingText(project.id); if (!x.data) throw new Error(x.output || t('export.failed')); return listingsMd(project.name, x.data); } }]); if (!r.data) { if (r.error) return ; - return ; + return
; } const L = r.data; const missing = L.locales.reduce((n, l) => n + [...(l.play?.fields ?? []), ...(l.apple?.fields ?? [])].filter(f => f.state === 'missing' || f.state === 'over').length, 0); @@ -118,20 +136,19 @@ export function ListingsTab({ project }: { project: Project }) { const shots = L.locales.reduce((n, l) => n + (l.screenshots ?? 0), 0); const noFiles = !L.files.play && !L.files.apple; return ( - <> -
- l.lang).join(' · ')} /> - - - l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} /> +
+
+ l.lang).join(' · ')} /> + + + l.screenshots !== null) ? shots : '–'} sub={L.locales.some(l => l.screenshots !== null) ? t('lst.screenshotsSub') : t('lst.noRenders')} />
{L.problems.map((p, i) => {p})} {!noFiles && l.lang)} />} {noFiles ? ( - - {t('lst.noFilesText')} -
storebound init{t('lst.noFilesHint')}
-
+
+ storebound init{t('lst.noFilesHint')}
}>{t('lst.noFilesTitle')} {t('lst.noFilesText')} + ) : ( <> {L.files.play && } @@ -139,6 +156,6 @@ export function ListingsTab({ project }: { project: Project }) { )}

{t('lst.foot', { when: ago(r.at) })}

- +
); } diff --git a/apps/desktop/src/screens/tabs/LiveArt.tsx b/apps/desktop/src/screens/tabs/LiveArt.tsx index 92866fd..5ec97f6 100644 --- a/apps/desktop/src/screens/tabs/LiveArt.tsx +++ b/apps/desktop/src/screens/tabs/LiveArt.tsx @@ -2,14 +2,17 @@ // thumbnails (art.thumb: one data: URL each, loaded as they scroll into view; the window loads no remote images // itself) and, for an app folder, whether the folder has the same file. "Pull from stores" runs art.pull (dry run, // native dialog, then the files are written: local only, read-only on the stores). Store-only apps: read-only. +import '../../styles/art.css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { api, type ArtThumb, type LiveArtResult, type LiveArtTile, type Project, type ToolResult } from '../../api'; import { t } from '../../i18n'; import { num } from '../../lib/format'; import { useRead, useWrite } from '../../store'; import { Icon } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; import { WriteButton } from '../../ui/WriteButton'; +import { Section, StatusPill } from '../../ui/ds'; +import { type MenuInput, useContextMenu } from '../../ui/ContextMenu'; // thumbnails by target + URL, three at a time const cache = new Map(); @@ -42,26 +45,21 @@ function LiveThumb({ target, tile, name }: { target: string; tile: LiveArtTile; const [src, setSrc] = useState(tile.thumb ? cache.get(`${target}|${tile.thumb}`) : null); useEffect(() => { if (!tile.thumb || src !== undefined) return; - const el = ref.current; - if (!el) return; + // loaded right away, not on scroll: an IntersectionObserver may never fire in a hidden (tray) window, and a store + // has at most a few dozen live images let alive = true; - const io = new IntersectionObserver(es => { - if (!es.some(x => x.isIntersecting)) return; - io.disconnect(); - loadThumb(target, tile.thumb).then(u => { if (alive) setSrc(u); }); - }, { rootMargin: '300px' }); - io.observe(el); - return () => { alive = false; io.disconnect(); }; + loadThumb(target, tile.thumb).then(u => { if (alive) setSrc(u); }); + return () => { alive = false; }; }, [target, tile.thumb, src]); const w = tile.w ?? 9, h = tile.h ?? 16; const state = tile.state === 'n/a' ? null : tile.state; return ( -
+
w ? 'portrait' : 'landscape'}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
{src ? : {src === null && tile.thumb ? <>{t('art.liveNoThumb')} : }}
{name}{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}
- {state && {t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}} + {state && {t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}}
); } @@ -82,12 +80,17 @@ export function LiveArt({ target, project }: { target: string; project?: Project { r.refresh(); } }} hint={t('art.pullHint')}>{t('art.pull')} ) : null; const actions = <>{pull}; + const cm = useContextMenu(); + const setMenu = (): MenuInput => [ + project && { label: t('art.pull'), icon: 'download', onSelect: () => { void w.run({ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }); } }, + { label: t('cm.readAgain'), icon: 'refresh', disabled: r.loading ? t('cm.reading') : false, onSelect: () => { r.refresh(); } }, + ]; if (!data) { return ( - +
{r.loading && !r.error ?
: { r.refresh(); }}>{t('art.liveRead')}}>{r.error ?? t('art.liveEmptyText')}} - +
); } const total = data.sets.reduce((n, s) => n + s.tiles.length, 0); @@ -95,7 +98,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project const missing = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'missing').length, 0); const sub = data.stores.map(s => `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.ok ? t('art.liveCount', { count: s.images }) : t('art.liveFailed')}${s.version ? ` · ${s.version}` : ''}`).join(' · '); return ( - +
{data.stores.filter(s => !s.ok).map(s =>
)}
{!project && {t('art.liveReadOnly')}} @@ -118,9 +121,9 @@ export function LiveArt({ target, project }: { target: string; project?: Project {!total ? {t('art.liveNoneText')} : (
{sets.map(s => ( -
+

{s.label}{s.lang && {s.lang}/{s.size}}{s.store === 'apple' ? 'App Store' : 'Google Play'}{t('art.count', { count: s.tiles.length })}

-
(s.tiles[0].h ?? 1) ? '260px' : '150px' }}> +
(s.tiles[0].h ?? 1) ? '260px' : '170px' }}> {s.tiles.map(tile => )}
@@ -128,6 +131,6 @@ export function LiveArt({ target, project }: { target: string; project?: Project
)}
{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}
- +
); } diff --git a/apps/desktop/src/screens/tabs/Prices.tsx b/apps/desktop/src/screens/tabs/Prices.tsx index 4f51cb1..431e6a8 100644 --- a/apps/desktop/src/screens/tabs/Prices.tsx +++ b/apps/desktop/src/screens/tabs/Prices.tsx @@ -1,6 +1,9 @@ // Prices: what every product costs in every territory on both stores (`prices export --json`, read-only), the price // sheet as a CSV (save it anywhere, or write store/prices.csv for the terminal), and a price sheet compared with the // stores (`prices import --dry --json`) and applied as an owner action: dry run, native dialog, then the real run. +import { useContextMenu, useMenuButton } from '../../ui/ContextMenu'; +import { copyToast } from '../../ui/app-menu'; +import '../../styles/prices.css'; import { useEffect, useMemo, useState } from 'react'; import { api, errorText, type PricesCurrent, type PricesDiff, type Project } from '../../api'; import { t } from '../../i18n'; @@ -10,7 +13,11 @@ import { changePct, diffSummary, priceMatrix, priceStats, type RegionRow, region import { type ReadState, useData, useRead, useWrite } from '../../store'; import { type ExportItem, useExports, useRunExport } from '../../ui/Exports'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Card, Chip, Empty, ErrorNotice, Kpi, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit'; +import { Section, StatTile } from '../../ui/ds'; + +/** A region code as a small badge (the stores use ISO country codes; longer names stay as text). */ +const Region = ({ code }: { code: string }) => {code}; import { WriteButton } from '../../ui/WriteButton'; const GAP = 10; @@ -18,7 +25,7 @@ const GAP = 10; function PriceStack({ r, apple, play }: { r: RegionRow | undefined; apple: boolean; play: boolean }) { if (!r) return –; return ( -
+
= GAP ? (r.gapPct > 25 ? ' gap-bad' : ' gap-warn') : ''}`}> {apple && {r.apple ? money(r.apple.price, r.apple.currency) : '–'}} {play && {r.play ? money(r.play.price, r.play.currency) : '–'}} {r.gapPct !== null && r.gapPct >= GAP && 25 ? 'bad' : 'warn'} dot={false}>{`Δ ${num(r.gapPct, 0)} %`}} @@ -34,7 +41,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff const sum = d ? diffSummary(d.diff, d.changed) : null; const name = sheet.csv ? sheet.csv.split(/[\\/]/).pop() : d?.csv.split(/[\\/]/).pop() ?? 'prices.csv'; return ( - {sum && sum.pairs > 0 && {t('prices.apply', { count: sum.pairs })}} @@ -51,8 +58,8 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff {sum!.down > 0 && {t('prices.dDown', { count: sum!.down })}} {t('prices.dProducts', { list: sum!.products.join(', ') })}
-
- +
+
{rows.slice(0, 200).map((r, i) => { @@ -61,7 +68,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff - + @@ -76,7 +83,7 @@ function SheetDiff({ project, diff, sheet, onApplied }: { project: Project; diff
{t('prices.applyNote')}
)} - + ); } @@ -85,7 +92,9 @@ export function PricesTab({ project }: { project: Project }) { const e = d.statusOf(project); const s = e?.summary ?? null; const w = useWrite(project.id); + const more = useMenuButton(); const runExport = useRunExport(); + const cm = useContextMenu(); const [withAuto, setWithAuto] = useState(true); const cur = useRead(project.id, 'prices.current', { network: true, args: withAuto ? { with_automatic: true } : {}, title: t('prices.readTitle') }); const [sheet, setSheet] = useState<{ csv?: string } | null>(null); @@ -109,63 +118,62 @@ export function PricesTab({ project }: { project: Project }) { try { const f = await api.pickPriceSheet(project.id); if (f) setSheet({ csv: f }); } catch (x) { d.toast({ kind: 'bad', title: t('prices.pickFailed'), detail: errorText(x) }); } }; - if (!e) return ; + if (!e) return
; if (!s?.products) { return ( - +
{t('prices.emptyText')}
products: [{'{'} id, type, usd {'}'}]{t('prices.emptyHint')}
- +
); } return ( - <> - runExport(exportItem)} disabled={!p}>{t('prices.exportCsv')}}> -
-
-
{t('prices.getSheet')}
- - {t('prices.writeProject')} -
-
-
{t('prices.compareHead')}
- - - {p && rel(p.csv) && } -
-
+
+
+ + + + }>

{t('prices.sheetNote')}

- +
{sheet && { cur.refresh(); diff.refresh(); }} />} {cur.error && } {!p ? ( - cur.loading && !cur.error ? : ( - + cur.loading && !cur.error ?
: ( +
{t('prices.readNow')}}>{t('prices.notReadText')} - +
) ) : ( <> -
- - - - `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} /> +
+ + + + `${m.product} (${m.store === 'apple' ? 'App Store' : 'Play'})`).join(', ') : t('prices.kMissingNone')} tone={stats!.missing ? 'bad' : undefined} />
- }> -
-
{t('col.item')}{t('col.store')}{t('col.region')}{t('prices.colStarts')}{t('prices.colNow')}{t('prices.colNew')}{t('col.diff')}
{r.product} {r.store === 'apple' ? 'App Store' : 'Google Play'}{r.region} {r.startDate ?? t('prices.now')} {r.from === null ? '–' : money(r.from, r.currency)} {money(r.to, r.currency)}
- {matrix!.regions.map(r => )} +
+
{t('col.item')}{r}
+ {matrix!.regions.map(r => )} {matrix!.rows.map(row => ( - setProduct(row.product)}> + setProduct(row.product)} {...cm(() => [ + { label: t('cm.showPerRegion'), icon: 'globe', onSelect: () => setProduct(row.product) }, + 'sep', + { label: t('cm.copyProductId'), icon: 'copy', shortcut: row.product, onSelect: () => copyToast(d, row.product) }, + ])}> {matrix!.regions.map(r => )} @@ -174,19 +182,23 @@ export function PricesTab({ project }: { project: Project }) {
{t('col.item')}
{row.product}
App StoreGoogle Play{t('prices.autoLegend')}{t('prices.gapLegend', { pct: GAP })}
- - +
setQ(ev.target.value)} placeholder={t('prices.searchRegion')} aria-label={t('prices.searchRegion')} />
}> -
- +
+
{apple && }{apple && }{play && } {regions.map(r => ( - - + = GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined} {...cm(() => [ + r.apple && { label: t('cm.copyPriceOf', { store: 'App Store' }), icon: 'copy', shortcut: money(r.apple.price, r.apple.currency), onSelect: () => copyToast(d, money(r.apple!.price, r.apple!.currency)) }, + r.play && { label: t('cm.copyPriceOf', { store: 'Google Play' }), icon: 'copy', shortcut: money(r.play.price, r.play.currency), onSelect: () => copyToast(d, money(r.play!.price, r.play!.currency)) }, + { label: t('cm.copyRegion'), icon: 'globe', shortcut: r.region, onSelect: () => copyToast(d, r.region) }, + ])}> + {apple && } {apple && } {play && } @@ -196,10 +208,10 @@ export function PricesTab({ project }: { project: Project }) {
{t('col.region')}App Store{t('prices.colKind')}Google Play{t('col.priceGap')}
{r.region}
{r.apple ? money(r.apple.price, r.apple.currency) : –}{r.scheduled &&
{t('prices.scheduled', { price: money(r.scheduled.price, r.scheduled.currency), date: r.scheduled.startDate ?? '' })}
}
{r.apple ? {r.apple.kind === 'auto' ? t('prices.auto') : t('prices.manual')} : null}{r.play ? money(r.play.price, r.play.currency) : –}
- +
{p.missing.length > 0 && {t('prices.missingNote', { list: p.missing.map(m => `${m.product} (${m.store === 'apple' ? 'App Store' : 'Google Play'})`).join(', ') })}} )} - +
); } diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index 29a4c53..17b99ad 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -3,7 +3,7 @@ // resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API // as console plans. Releasing an approved version (manual release) stays in App Store Connect. import { plainError } from '../../../../../cli/lib/google-errors'; -import { useState } from 'react'; +import { type CSSProperties, type ReactNode, useState } from 'react'; import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api'; import { laneGap, storesDiffer } from '../../../../../cli/lib/train'; import type { UnifiedApp } from '../../lib/identity'; @@ -11,11 +11,18 @@ import { WriteButton } from '../../ui/WriteButton'; import { type Key, t } from '../../i18n'; import { ago, date, share } from '../../lib/format'; import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model'; +import { api } from '../../api'; import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { RolloutBar } from '../../ui/charts'; -import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit'; +import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit'; +import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds'; import { AppleSubmitCard, PlayProductionCard } from './Submit'; +import { useContextMenu } from '../../ui/ContextMenu'; +import { releaseMenu } from '../../ui/app-menu'; +import { consoleUrl } from '../../../../../cli/lib/console-links'; +import { PUBLISH_PLAN_HINT } from '../../../../../cli/lib/play-publish'; +import '../../styles/releases.css'; const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' }; @@ -39,31 +46,69 @@ function gapText(lane: Lane, version: string, st: AppStatus): { who: string; lin return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') }; } -function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) { +/** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages + * as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */ +function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) { const r = lane.release; const gap = lane.at < 0 ? gapText(lane, version, st) : null; + const ph = r ? phaseLabel(r) : null; + const rolling = lane.at === STAGES.length - 1 && lane.share !== null; + const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent'; + const d = useData(); + const menu = useContextMenu(); return ( -
-
- -
-
{storeName(lane.store)}
-
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
+
releaseMenu(d, app, lane.store, r?.version ?? version, r?.build))} className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}> +
+ +
+
{storeName(lane.store)}
+
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
-
- {gap ?
{gap.line}
: STAGES.map((s, i) => { - const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : ''; - const last = s === 'rollout'; - return ( -
- {i < lane.at && } - {last && i === lane.at && lane.share !== null ? ( -
= 1 ? 'ok' : 'accent'} label={t('tl.rolloutAria')} /> - {lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`}
- ) : {stageCaption(lane, i)}} -
- ); - })} + {ph && {ph.text}} + + {gap ?
{gap.line}
: ( +
+
    + {STAGES.map((s, i) => { + const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo'; + const cap = stageCaption(lane, i); + return ( +
  1. + {i < lane.at ? : {i + 1}} + {t(STAGE_KEY[s])} + {cap && {cap}} +
  2. + ); + })} +
+ {rolling && lane.share !== null && ( +
+ +
+ {lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`} + {t('v2rel.ofUsers')} + +
+
+ )} +
+ )} + {r && r.store === 'android' && r.managed && r.phase === 'review' && !!r.liveVersion && !r.pageStaleAt && } + {r && app.project && } + + ); +} + +/** Managed publishing: the approved production release waits in Publishing overview ("Publish N changes"). */ +function PublishHint({ st }: { st: AppStatus }) { + const a = st.app?.platforms.android; + const url = consoleUrl('play.publishing', { playDeveloperId: a?.developerId, playAppId: a?.appId }); + return ( +
+ {t('roll.publishReady', { cmd: PUBLISH_PLAN_HINT })} +
); } @@ -122,42 +167,50 @@ function Train({ title, train, tf, tone, app, st }: { title: string; train: Rele // "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title; const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version; + const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null; return ( - {ver}{label}} - sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}> -
-
{t('tl.store')}{STAGES.map(s => {t(STAGE_KEY[s])})}
- {lanes.map(l => )} -
- {lanes.some(l => l.release) && app.project && ( -
- {lanes.map(l => l.release && )} +
+
+
+ {t('v2rel.version')} + {ver}
- )} - + {label} + + {created && {created}} +
+
+ {lanes.map(l => )} +
+
); } export function ReleasesTab({ app }: { app: UnifiedApp }) { const d = useData(); + const menu = useContextMenu(); const l = d.liveFor(app); const project = app.project; + const hue = useTint(d.iconFor(app), app.name); const tf = useRead(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios }); const sum = project ? d.statusOf(project)?.summary : null; const submit = project && (sum?.apple || sum?.play) ? ( -
-

{t('sub.section')}

-
+
+
+

{t('sub.section')}

+

{t('releases.ownerNote')}

+
+
{sum?.apple && } {sum?.play && }
-

{t('releases.ownerNote')}

) : null; + const wrap = (children: ReactNode) =>
{children}
; - if (!l && d.portfolioBusy === 'live') return <>{submit}; + if (!l && d.portfolioBusy === 'live') return wrap(<>{submit}); if (!l) { - return ( + return wrap( <> @@ -166,12 +219,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { }>{t('releases.notReadText')} {submit} - + , ); } const history = versionHistory(l.release.all ?? []); const errs = Object.entries(l.storeErrors); - return ( + return wrap( <> {errs.map(([s, msg]) => { const when = l.staleStores?.[s as 'ios']; @@ -179,17 +232,21 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { })} {l.release.next && } {l.release.current && } - {!l.release.next && !l.release.current && {t('releases.noVersionsText')}} + {!l.release.next && !l.release.current &&
{t('releases.noVersions')} {t('releases.noVersionsText')}
} - +
{t('v2rel.versions', { count: history.length })} : undefined}> {history.length ? ( -
- +
+
{history.map(h => ( - - + [ + ...(h.ios ? [{ label: 'App Store', icon: 'phone' as const, submenu: releaseMenu(d, app, 'ios', h.ios.version, h.ios.build) }] : []), + ...(h.android ? [{ label: 'Google Play', icon: 'phone' as const, submenu: releaseMenu(d, app, 'android', h.android.version, h.android.build) }] : []), + ])}> + @@ -200,10 +257,10 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
{t('col.version')}App Store{t('col.build')}Google Play{t('col.versionCode')}{t('col.track')}
{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}
{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')} {h.ios ? : –} {h.ios?.build ?? '–'} {h.android ? : –}
- ) : } - + ) :
{t('releases.noVersions')}
} +
{submit} - + , ); } diff --git a/apps/desktop/src/screens/tabs/Reviews.tsx b/apps/desktop/src/screens/tabs/Reviews.tsx index 95b5348..aecc2df 100644 --- a/apps/desktop/src/screens/tabs/Reviews.tsx +++ b/apps/desktop/src/screens/tabs/Reviews.tsx @@ -1,16 +1,21 @@ // Reviews: the inbox of both stores with the star distribution and filters, and per review its local reply draft // (.storebound/replies) with the send checks. Approving a draft is a local, confirmed write; sending stays in the // terminal (`storebound reviews send`): replies are public. +import { type MenuInput, useContextMenu } from '../../ui/ContextMenu'; +import { consoleItem, copyToast, openUrl, ASC_APP, playUrlFor } from '../../ui/app-menu'; +import { isEditable } from '../../ui/menu-logic'; import { useEffect, useMemo, useState } from 'react'; import type { DraftsResult, ReplyDraft } from '../../api'; import type { UnifiedApp } from '../../lib/identity'; import { t } from '../../i18n'; import { date, num } from '../../lib/format'; import { draftName, filterReviews, type Review, type ReviewFilter, starStats } from '../../lib/model'; -import { useRead, useWrite } from '../../store'; +import { useData, useRead, useWrite } from '../../store'; import { WriteButton } from '../../ui/WriteButton'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Bar, Card, Chip, Empty, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit'; +import { Bar, ErrorFoot, Kbd, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit'; +import { EmptyState, Section, StatusPill, tintStyle, Toolbar, useTint } from '../../ui/ds'; +import '../../styles/reviews.css'; import { exportName, reviewsCsv } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; @@ -29,63 +34,77 @@ function Detail({ r, draft, w, onChanged }: { r: Review; draft: ReplyDraft | und const live = text && INCENTIVE.some(x => x.test(text)) ? t('reviews.incentive') : len > limit ? t('reviews.tooLong', { limit }) : null; const file = draft ? base(draft.file) : ''; return ( - {r.title || t('reviews.noTitle')}} - sub={{r.store === 'apple' ? 'App Store' : 'Google Play'}{r.where ? ` · ${r.where}` : ''} · {date(r.date)}{r.appVersion ? ` · ${t('reviews.version', { v: r.appVersion })}` : ''}}> -
-

{r.body || t('reviews.noText')}

- {r.reply && ( -
- {t('reviews.currentReply')}{r.reply.date ? ` · ${date(r.reply.date)}` : ''} -
{r.reply.text}
-
- )} -
-
-
- {t('reviews.draft')} - {draft && (draft.sent ? {t('reviews.sentOn', { date: date(draft.sent) })} : draft.approved ? {t('reviews.approved')} : {t('reviews.notApproved')})} -
- {!draft ?

{t('reviews.noDraft')}

: draft.sent ? ( -
{draft.reply}
- ) : ( - <> -