onInit() eagerly built the isolated mise env, which blocks on a region
lookup (regionService.isInChina, for China mirror selection) whose cache
is cold on every launch. That network round-trip sat on the Background-
phase path that gates allReady(), for a value only ever consumed by
runMise() (install/reconcile/remove/search) — none of which runs during
init.
Defer the build to the first runMise() and memoize the in-flight promise
so concurrent first callers share a single build and a single region
lookup; a failed build is not cached, allowing a later retry. The lookup
now happens lazily, after the app is ready, where the mirror env is
actually needed.
Add tests covering the lazy behavior and concurrent-first-call dedup.
Also pin the Vitest timezone to UTC so date-bucketing tests are
deterministic on non-UTC dev machines.
Move src/shared/ipc/errors.ts to src/shared/ipc/errors/index.ts so each migrated domain can host its own error-code map as a sibling errors/<domain>.ts — value-importable by both processes and zod-free. The @shared/ipc/errors barrel path is unchanged, so every importer and test resolves identically (typecheck + IPC tests green).
Document the IpcErrorCode usage convention that previously lived only in code comments: the framework-code single source of truth, the open (string & {}) tail for domain codes, where domain codes belong (errors/, not schemas/, since the renderer may only import type from schemas), and why they are imported directly rather than aggregated through a barrel.
Promote the classless `utils/ipService.ts` (which wore a `*Service` suffix
despite holding no class) into a real `services/RegionService.ts` singleton.
Egress-country detection now caches through CacheService with a TTL backstop
and invalidates on proxy changes — the cache is keyed on ProxyManager's
applied-proxy key, since the detected country reflects the egress IP. Concurrent
detections are deduped via single-flight.
- Add RegionService (getCountry/isInChina) with unit tests
- Expose ProxyManager.appliedProxyKey for cache invalidation
- Route BinaryManager, CodeCliService, AppUpdaterService, TesseractRuntimeService
and the App_GetIpCountry IPC handler through RegionService
- Update consumer test mocks to the new module
### What this PR does
**Before this PR**, Cherry Studio managed external CLI binaries through
five uncoordinated mechanisms — each requiring its own download script,
IPC channel, and on-disk layout:
| Mechanism | Where it lived | How it worked |
|---|---|---|
| rtk extraction | `src/main/utils/rtk.ts` + `AgentBootstrapService` |
Copies bundled binary to `~/.cherrystudio/bin/` |
| OpenClaw installer | `resources/scripts/install-openclaw.js` |
Downloads from GitHub/mirror with custom extraction |
| CodeCliService | `src/main/services/CodeCliService.ts` | `bun install
-g` to `~/.cherrystudio/install/global/` |
| ripgrep | `node_modules/@anthropic-ai/claude-agent-sdk/vendor/` |
Vendored, hardcoded path in `FileStorage` |
| (old) MiseService | `src/main/services/MiseService.ts` | Bundled mise
+ `mise use -g` |
**After this PR**, a single `BinaryManager` lifecycle service owns all
third-party CLI binary acquisition. It wraps
[mise](https://mise.jdx.dev) as the only acquisition backend (no custom
`BinaryBackend` interface — mise's polyglot grammar already covers
`npm:`, `pipx:`, `github:`, registry entries). Tools install into an
isolated environment under `~/.cherrystudio/mise/` so user-level mise
installs are never touched. `uv`, `bun`, `rg`, and mise itself ship
bundled at build time for instant first-run availability; everything
else flows through mise on demand.
<img width=\"1304\" height=\"714\" alt=\"BinaryManager settings UI\"
src=\"https://github.com/user-attachments/assets/7a4b78ab-5aa2-4e97-9ab7-134b20a4d78d\"
/>
<img width=\"1165\" height=\"748\" alt=\"Three-state managed vs bundled
vs not-installed\"
src=\"https://github.com/user-attachments/assets/a0dcfb7d-8bc3-4acd-b563-0fc04d99e252\"
/>
<img width=\"523\" height=\"328\" alt=\"Custom tool dialog\"
src=\"https://github.com/user-attachments/assets/90c3ee95-7f2a-4daf-a334-f20de6ff5ca2\"
/>
Fixes#15183. Addresses #15370.
### Why we need it and why it was done in this way
Adding a new managed CLI tool should be a one-line preset entry — not 4+
files of bespoke download/extract/IPC code. mise is a mature polyglot
tool manager that already speaks the backends Cherry needs.
**Tradeoffs made:**
- **mise bundled at build time** (~15 MB per platform) rather than
downloaded at first run — faster first-run UX, no chicken-and-egg on a
fresh install.
- **Fully isolated mise environment** (\`HOME\`/\`XDG_*\`/\`MISE_*\` all
relocated under \`feature.binaries.data\`) — Cherry never reads or
writes the user's own \`~/.config/mise/\` or \`~/.local/share/mise/\`.
- **No custom \`BinaryBackend\` interface.** mise's grammar (\`npm:\`,
\`pipx:\`, \`github:\`, registry) is already polyglot; wrapping it would
be a shallow seam that re-implements what mise owns. Removing this
abstraction makes consumers simpler (deletion test passes).
- **Auth-token policy: opt-in only.** Ambient \`GITHUB_TOKEN\` /
\`GH_TOKEN\` are not forwarded into mise's process env. Users who hit
GitHub's unauthenticated 60 req/hr API limit can set
\`CHERRY_GITHUB_TOKEN\` to raise it to 5000 req/hr without consenting to
share their general shell token.
- **China mirror auto-detection.** \`isUserInChina()\` toggles
\`NPM_CONFIG_REGISTRY=registry.npmmirror.com\` +
\`PIP_INDEX_URL=pypi.tuna.tsinghua.edu.cn\` for every npm/pipx backend
transparently.
**Alternatives considered:**
- *Keep per-tool install scripts.* Doesn't scale — each new tool is 4+
files of duplicated logic.
- *Use mise from user's \`PATH\`.* Would depend on user having mise
installed and could conflict with their config.
- *Custom \`BinaryBackend\` abstraction.* Shallow wrapper over mise's
grammar; no second backend in sight; deletion test passes.
**Scope (what's in / what's out):**
- **In:** uv, bun, ripgrep, claude-code, openclaw, gh, opencode,
gemini-cli, lark, kimi-cli, qwen-code, iflow-cli, github-copilot-cli —
anything mise can install as a single relocatable binary.
- **Out:** \`OvmsManager\` (multi-file server provisioning, hardware
detection, generated config); Tesseract OCR data (not a binary; lives
with \`OvOcrService\`).
### Breaking changes
None at the user-facing layer. v2 data is throwaway per CLAUDE.md, so
the preference-key rename (\`feature.mise.*\` → \`feature.binaries.*\`)
intentionally ships without a migrator.
### Special notes for your reviewer
**Architecture & docs**
- \`docs/references/binary-manager/README.md\` — scope criterion,
persisted/contract surface, bundled-vs-mise state contract, China mirror
behavior, \`CHERRY_GITHUB_TOKEN\` opt-in, adding a new managed binary.
- \`CLAUDE.md\` adds a \`**MUST READ**\` link next to Lifecycle / Window
Manager / Data / Paths.
- The mise-shim-wins-over-\`cherry.bin\` precedence rule is documented
in the README's "State contract" section.
**Code orientation**
- \`src/main/services/BinaryManager.ts\` — the lifecycle service. Wraps
mise via \`runMise()\`; isolated env in \`buildIsolatedEnv()\`; bundled
extraction with atomic tmp+rename; per-tool try/catch so a single
failure can't abort init; \`isManagedBinaryReady()\` verifies the
resolved file is executable (not just that mise thinks it's installed).
- \`packages/shared/data/presets/binary-tools.ts\` —
\`PREDEFINED_BINARY_TOOLS\` registry; \`tool\` field is a mise spec.
-
\`src/renderer/src/pages/settings/McpSettings/EnvironmentDependencies.tsx\`
— three-state UI (\`managed\` / \`bundled\` / \`not-installed\`) backed
by \`Binary_GetState\` + \`Binary_ProbeBundled\`.
- \`scripts/download-binaries.js\` — build-time downloader for mise / uv
/ bun / rg (HTTPS + sha256-verified, archive-aware extraction).
**Migration**
- \`OpenClawService.install()\` is now two lines delegating to
\`BinaryManager\` — \`install-openclaw.js\` is gone.
- \`CodeCliService\` no longer uses \`bun install -g\`; the
\`BUN_INSTALL\` / \`~/.cherrystudio/install/global/\` path is removed.
- \`FileStorage.getRipgrepBinaryPath()\` now resolves via
\`getBinaryPath('rg')\`; the vendored SDK rg is no longer used.
- \`extractRtkBinaries\` + the \`AgentBootstrapService\` call are
deleted. \`rtkRewrite()\` degrades gracefully when rtk is absent;
\`rtkAvailable\` caches with a 60s TTL so install-via-mise takes effect
without restart.
**Multi-round review**
Two adversarial review rounds against this branch (Bug Hunter / Security
/ Architecture / Correctness) ran during development; both rounds'
High-severity findings are addressed in commits \`70afde6af\` and
\`1d864439d\`. R3 known follow-ups (architecture duplications in
\`CodeCliService\`'s switch statements, etc.) are tracked as Medium and
intentionally deferred.
### Checklist
- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: Leaves binary acquisition meaningfully cleaner than
before (five mechanisms → one)
- [x] Upgrade: v2 data is throwaway; no migrator needed and the absence
is intentional
- [x] Documentation: \`docs/references/binary-manager/README.md\` +
\`CLAUDE.md\` Architecture section
- [x] Self-review: Two multi-perspective review rounds against the
branch; all Highs addressed
### Release note
\`\`\`release-note
NONE
\`\`\`
---------
Signed-off-by: Vaayne <liu.vaayne@gmail.com>
Signed-off-by: Vaayne Liu <vaayne@macos.shared>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Rename PowerMonitorService -> PowerService and relocate it to
src/main/core/power/, turning the shutdown-only service into a complete,
self-contained power hub:
- Typed power events (suspend/resume/lock/unlock/power-source) with
suspend/resume + power-source de-duplication; lock/unlock pass-through.
- Bounded, cross-platform shutdown barrier: preventDefault on macOS/Linux,
blockShutdown via a minimal hidden window on Windows; handlers run under a
5s hard timeout, then the app quits through the normal quit flow.
- Best-effort, ref-counted sleep prevention: preventSleep(reason) returns a
Disposable and never throws (the provider degrades internally). The OS
blocker engages only while a hold is held AND the user enabled
app.power.prevent_sleep_when_busy. JobManager is the first registrant,
acquiring a hold per execution attempt.
Add the app.power.prevent_sleep_when_busy preference (v2-only, no v1 source)
and a Settings -> General toggle. Service phase moved Background -> WhenReady.
Renderer windows set their logger source inline via initWindowSource() in
entryPoint.tsx, which had to execute before any import-time log or the source
fell back to 'UNKNOWN'. ESM hoists imports above statements, so this ordering
was fragile and applied inconsistently across windows.
LoggerService now derives the source at construction from a
<meta name="logger-window-source"> tag in each window's index.html. The meta
is parsed before any module script runs, so the source is set before any
import-time log -- no ordering rules in entryPoint.tsx. initWindowSource() is
kept as an explicit override for documentless contexts (workers, tests) and
takes precedence over the derived value.
- Add resolveWindowSourceFromMeta() and derivedWindow with
explicit > derived > UNKNOWN precedence; export the LoggerService class
- Declare the meta in all 7 window index.html files (source strings unchanged)
- Drop the 7 inline initWindowSource() calls and the subWindow initLogger.ts
module; keep the worker's explicit initWindowSource('Worker')
- Update windows/README.md and docs/guides/logging.md
- Add LoggerService unit tests
Pagination docs were scattered across api-types.md (types + cursor
semantics), data-api-in-renderer.md (hooks), data-api-in-main.md (offset
example + keyset note), api-design-guidelines.md (query params), and
data-ordering-guide.md (cache shapes + determinism), with no single
discoverable home for the offset-vs-cursor model.
Add docs/references/data/data-pagination-guide.md as the canonical hub
(mirrors data-ordering-guide.md): two modes, four-layer quickstart, wire
contract, server impl (offset + keyset cursor + multi-band caveat),
renderer consumption, FTS pagination, gotchas, and a see-also map. Other
docs keep their authoritative slice and link to the guide; the migrated
conceptual prose is removed from api-types.md to avoid duplication.
Also fix two pre-existing broken anchors found while verifying links
(database-patterns withWriteTx; ordering guide section number).
Consolidate the per-service <key>:<id> cursor codec and the keyset
WHERE/ORDER BY tuple into services/utils/keysetCursor.ts. keysetOrdering
derives the WHERE predicate and the matching ORDER BY from one direction
spec, so the two cannot drift apart and silently skip/repeat rows.
Migrate TranslateHistory, AgentSession, AgentSessionMessage (list), and
Painting to the shared util; delegate ftsSearch's codec to parseCursor /
encodeCursor while keeping its 422-throw policy. Harden Painting's cursor
from a single key to a defensive (orderKey, id) tuple.
Startup recovery (runStartupRecovery) runs ~60s after boot and reset every running row to pending (retry/singleton) or cancelled it (abandon/cancelRequested) without checking whether the row was a job the current process was still executing. A job enqueued during the 60s quiet window and still running when the sweep fired was therefore reset and re-dispatched — running its handler twice for one enqueue — or cancelled mid-flight. All recovery:'retry' handlers (file-processing, knowledge, image-generation, agent.task) are affected; #16125 (Translate OCR via File Processing) made it frequently reproducible.
Source fix: thread an isJobInFlight predicate (backed by JobManager.inFlightExecuted) into runStartupRecovery and filter in-flight rows out at the top of the per-type loop, above every strategy and the cancelRequested override. cancel() already owns in-flight cancellation, so excluding these rows from all branches is safe.
Defense-in-depth guard: spawnExecute now skips (warn) a jobId already present in inFlightExecuted — an idempotency invariant against any future re-dispatch path double-running a job. It does not fail the job (the original execution still finalizes it once), hence warn not error.
Tests: focused runStartupRecovery unit cases (retry / cancelRequested / singleton / abandon in-flight exclusion), an end-to-end no-double-dispatch integration test, and a spawnExecute idempotency smoke test. Job-and-scheduler docs updated.
Bump Electron within the same Chromium 146 milestone. Picks up
backported security fixes (CVE-2026-6296..6363 and later batches),
a browser-process crash fix for webContents.reload()/render-process-gone,
and a macOS auto-update fix. No breaking changes; no app code changes.
Node.js updated to v24.16.0.
Analytics and Selection drove activate/deactivate with fire-and-forget
`void activate()/void deactivate()`, relying on BaseService's shared
`_activating` guard, which drops (not coalesces) an opposing toggle that
lands mid-transition — leaving the final state diverged from the user's
latest choice.
- Analytics: the async onDeactivate (`await client.destroy()`) holds the
`_activating` window open across real I/O time, so a re-enable arriving
during teardown was dropped and analytics stayed silent. Self-hold a
latest-wins reconciler keyed on desired-vs-actual; it re-reads the
desired state after the deactivation settles and re-activates.
- Selection: the deferred startup warm-up (`setImmediate(activate)`)
activated unconditionally even if the feature was disabled before the
timer fired. Route the warm-up through the reconciler so the deferred
request is level-triggered and honours a disable landing in the gap.
Both self-hold the reconciler (BaseService core unchanged) and are never
disposed (construct-once field, restart-safe). QuickAssistant is not
migrated: its onActivate/onDeactivate are synchronous (run-to-completion),
so the race is unreachable.
Follow-up to #16233 (createLatestReconciler + ApiGateway migration).