You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Refactor the Tauri backend into two cleanly separated layers:
Core — a Tauri-independent Rust crate at the repo root (core/) that owns sensor collection, persistence, monitoring state, and a single in-process event bus. Compiles and tests without any Tauri context.
App — thin Tauri-aware adapters (window emitter, tray, command handlers, error dialogs) under src-tauri/src/ that depend on the core crate, subscribe to Core events, and forward user actions into Core.
The repo becomes a Cargo workspace with two members (core and src-tauri). This is the foundation work that unblocks #1275 (background monitoring), #1401 (live tray/menu-bar widget), and the longer-term roadmap items (in-game overlay, floating window, alerts).
Motivation
The current backend mixes concerns:
workers::system_monitor::SystemMonitorController::setup(MonitorResources, app.handle()) takes a Tauri AppHandle and emits Tauri events from inside the collector loop. There is no way to run collection without a Tauri context.
models::hardware::HardwareMonitorState exposes raw Arc<Mutex<...>> bags (CPU history, GPU history, etc.) directly via app.manage(). Commands read these primitives, not a Core API.
The whole process exits when the main window's CloseRequested fires (WorkersState::terminate_all() → app.exit(0)). Lifecycle is tied to the window.
This makes everything we want next either impossible or invasive:
Future overlay / floating window / alerts would each need to add another app.handle() consumer to SystemMonitorController, deepening the coupling.
A Core/App split solves all of this once.
Goals
A core crate at the repo root with no tauri dependency (enforced at compile time by the Cargo dependency graph).
A single in-process EventBus that fans out MetricsSnapshot to multiple subscribers (window, tray, future overlay, future alerts).
Core lifecycle independent of any window. Core is started in app setup and stopped only by an explicit Quit action.
Settings are split by consumer: core::settings holds only fields that change Core behavior; UI-only fields (theme, graph styling, burn-in shift, …) stay App-side. The persistence backend (Tauri Store / SQLite) is shared infrastructure.
Existing frontend behavior unchanged from a user's perspective at the end of this Epic. No feature additions, no UX changes.
Unit tests for collector and persistence run via cargo test -p hwviz-core without spinning up a Tauri runtime.
Non-Goals
Splitting Core into a separate OS process / daemon (Windows Service, launchd agent, etc.). We keep a single Tauri process for now; the workspace boundary just makes a future split feasible (a new bin target instead of further file relocation).
Rewriting the platform abstraction (platform/{windows,linux,macos}) — only relocate it into the core crate.
Migrating frontend code beyond the bindings that change as a side-effect.
Target Structure
HardwareVisualizer/
├── Cargo.toml ← Workspace root (added in #1411).
├── core/ ← Tauri-independent crate. No `tauri` in dependencies.
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── collector/ ← was: src-tauri/src/workers/system_monitor.rs
│ ├── persistence/ ← was: src-tauri/src/workers/hardware_archive.rs + parts of infrastructure/database/
│ ├── monitoring/ ← Running / Paused / Stopped state machine (NEW)
│ ├── event_bus.rs ← tokio::sync::broadcast<MetricsSnapshot> (NEW, #1404)
│ ├── settings.rs ← Core-consumed settings only (archive enable/interval, polling, …)
│ ├── platform/ ← moved from src-tauri/src/platform/
│ ├── infrastructure/ ← OS APIs (NVAPI, WMI, procfs…), moved from src-tauri/src/infrastructure/providers/
│ └── models/ ← shared snapshot types (was models/hardware.rs)
│
└── src-tauri/ ← Tauri-aware. Depends on the `core` crate via path.
├── Cargo.toml ← `hwviz-core = { path = "../core" }`
└── src/
├── commands/ ← thin delegation to core API
├── adapters/ ← window.rs (#1404), tray.rs (skeleton in Phase 6), error.rs
├── settings/ ← UI-only settings (theme, line graph styling, burn-in shift, …)
├── lifecycle.rs ← Core start/stop, window create/destroy, quit handling
└── lib.rs / main.rs ← wiring only
Design Rules (enforced)
The core crate has no tauri dependency. Enforced at compile time by the Cargo dependency graph — core/Cargo.toml does not list tauri, so any use tauri::*; under core/src/ simply fails to compile. No CI grep is needed.
No window.emit(...) outside src-tauri/src/adapters/. Core publishes to EventBus; only WindowAdapter translates that to a Tauri event. Same pattern for error_event.
Core lifecycle does not depend on a window. Closing the window must not stop the collector. Only an explicit Quit action (in lifecycle.rs) stops Core.
Persistence runs as a separate task from the collector. They communicate through EventBus so a slow DB write never blocks sensor polling.
Settings split by consumer.core::settings only holds fields whose change affects Core behavior. UI-only fields stay App-side; values like temperature_unit are presentation-only — Core stores °C internally.
Migration Plan (separate PRs, each shippable on its own)
Each phase preserves user-visible behavior.
Phase 1 — Cargo workspace setup
Convert the repo into a Cargo workspace; add core/ as a root-level crate with no tauri dependency.
src-tauri declares the new crate as a path dependency.
Add empty pub mod placeholders inside core/src/lib.rs.
No code is moved. The boundary is now enforced at compile time.
Phase 2 — Introduce EventBus, route existing event through it
SystemMonitorController (still under src-tauri/) publishes to the EventBus via the core API instead of calling app.handle() directly.
New adapters::window::WindowAdapter subscribes and emits HardwareMonitorUpdate. Frontend behavior unchanged.
Phase 3 — Move collector into core/src/collector/
Relocate workers/system_monitor.rs and the Arc<Mutex<...>> history bag (models::hardware::HardwareMonitorState) into the core crate, behind a Core-owned read API.
Replace direct mutex access in commands/hardware.rs with Core API calls.
Drop AppHandle from SystemMonitorController::setup.
Phase 3.5 — Settings split by consumer
Split services::settings_service into core::settings (archive/polling-relevant) and src-tauri/src/settings/ (UI-only).
infrastructure/database/preflight::check_db_compatibility() moves into the core crate (core::persistence::preflight); the dialog and recovery flow stay App-side (db_startup_service).
temperature_unit becomes presentation-only: Core always stores °C.
Phase 4 — Move persistence into core/src/persistence/
Relocate workers/hardware_archive.rs and the relevant DB writers into the core crate.
Persistence subscribes to EventBus rather than sharing the MonitorResources Arc bag with the collector. Decouples write latency from sensor polling.
Phase 5 — MonitoringState state machine & lifecycle decoupling
core::monitoring::MonitoringState: Running / Paused / Stopped with explicit transitions.
src-tauri/src/lifecycle.rs owns Core start/stop. Window CloseRequested no longer calls WorkersState::terminate_all() and no longer calls app.exit(0).
Provides the way back to the window once Phase 5 stops auto-quitting on close.
Acceptance Criteria
The core crate has no tauri dependency in its Cargo.toml; any attempt to use tauri::*; under core/src/ fails to compile.
HardwareMonitorUpdate is emitted to the frontend by WindowAdapter, not from inside the collector loop.
Closing the main window no longer stops sensor collection or DB archiving.
An explicit Quit action stops Core cleanly (no orphaned tasks, DB writes flushed).
Collector and persistence have unit tests that run without a Tauri runtime (cargo test -p hwviz-core passes).
core::settings deserializes only Core-relevant fields; UI-only fields are not visible to Core.
No user-visible regression on the dashboard, history view, archive, or settings.
cargo tauri-fmt, cargo tauri-lint, cargo tauri-test, npm run lint, npm run test all pass at the end of every phase.
Risk & Mitigation
Regression risk during Phase 3 (collector relocation) — most existing code reaches into the Arc<Mutex<...>> bag. Mitigation: introduce the Core read API additively first (3a), migrate callers (3b), remove the old path (3c).
DB write blocking sensor polling currently goes unnoticed — splitting them in Phase 4 may surface latency that was hidden. Mitigation: add an integration test that simulates a slow DB and asserts polling cadence.
Lifecycle change in Phase 5 changes user expectations on close — historically closing has quit the app. Mitigation: gate the new behavior behind a debug flag; the user-facing change is owned by feat: add background monitoring mode with system tray support #1275, which adds the tray UX and an explicit setting.
Workspace conversion side effects — CI scripts, IDE configs, and tauri-* cargo aliases assumed src-tauri/ was the cargo root. Mitigation: Phase 1 covers all known tooling updates; verify tauri dev / tauri build end-to-end before merging.
#1403 was the original Phase 1 plan (CI-grep approach) and is closed as not planned in favor of #1411. Phase 6 (#1409) was originally part of this Epic but has been rescoped out — see the Change Log entry from 2026-04-30 below; the tray adapter now lives in the standalone feature issue #1422.
Do we keep HardwareMonitorUpdate as the wire format to the frontend, or define a new MetricsSnapshot type in Core and translate at the adapter? Keeping the existing event minimizes frontend churn during this Epic; a rename can happen later.
Summary
Refactor the Tauri backend into two cleanly separated layers:
core/) that owns sensor collection, persistence, monitoring state, and a single in-process event bus. Compiles and tests without any Tauri context.src-tauri/src/that depend on thecorecrate, subscribe to Core events, and forward user actions into Core.The repo becomes a Cargo workspace with two members (
coreandsrc-tauri). This is the foundation work that unblocks #1275 (background monitoring), #1401 (live tray/menu-bar widget), and the longer-term roadmap items (in-game overlay, floating window, alerts).Motivation
The current backend mixes concerns:
workers::system_monitor::SystemMonitorController::setup(MonitorResources, app.handle())takes a TauriAppHandleand emits Tauri events from inside the collector loop. There is no way to run collection without a Tauri context.models::hardware::HardwareMonitorStateexposes rawArc<Mutex<...>>bags (CPU history, GPU history, etc.) directly viaapp.manage(). Commands read these primitives, not a Core API.CloseRequestedfires (WorkersState::terminate_all()→app.exit(0)). Lifecycle is tied to the window.This makes everything we want next either impossible or invasive:
app.handle()consumer toSystemMonitorController, deepening the coupling.A Core/App split solves all of this once.
Goals
corecrate at the repo root with notauridependency (enforced at compile time by the Cargo dependency graph).EventBusthat fans outMetricsSnapshotto multiple subscribers (window, tray, future overlay, future alerts).setupand stopped only by an explicitQuitaction.core::settingsholds only fields that change Core behavior; UI-only fields (theme, graph styling, burn-in shift, …) stay App-side. The persistence backend (Tauri Store / SQLite) is shared infrastructure.cargo test -p hwviz-corewithout spinning up a Tauri runtime.Non-Goals
platform/{windows,linux,macos}) — only relocate it into thecorecrate.Target Structure
Design Rules (enforced)
corecrate has notauridependency. Enforced at compile time by the Cargo dependency graph —core/Cargo.tomldoes not listtauri, so anyuse tauri::*;undercore/src/simply fails to compile. No CI grep is needed.window.emit(...)outsidesrc-tauri/src/adapters/. Core publishes toEventBus; onlyWindowAdaptertranslates that to a Tauri event. Same pattern forerror_event.Quitaction (inlifecycle.rs) stops Core.EventBusso a slow DB write never blocks sensor polling.core::settingsonly holds fields whose change affects Core behavior. UI-only fields stay App-side; values liketemperature_unitare presentation-only — Core stores °C internally.Migration Plan (separate PRs, each shippable on its own)
Each phase preserves user-visible behavior.
Phase 1 — Cargo workspace setup
core/as a root-level crate with notauridependency.src-taurideclares the new crate as a path dependency.pub modplaceholders insidecore/src/lib.rs.Phase 2 — Introduce
EventBus, route existing event through itcore::event_bus::EventBus(tokio::sync::broadcast<MetricsSnapshot>).SystemMonitorController(still undersrc-tauri/) publishes to the EventBus via thecoreAPI instead of callingapp.handle()directly.adapters::window::WindowAdaptersubscribes and emitsHardwareMonitorUpdate. Frontend behavior unchanged.Phase 3 — Move collector into
core/src/collector/workers/system_monitor.rsand theArc<Mutex<...>>history bag (models::hardware::HardwareMonitorState) into thecorecrate, behind a Core-owned read API.commands/hardware.rswith Core API calls.AppHandlefromSystemMonitorController::setup.Phase 3.5 — Settings split by consumer
services::settings_serviceintocore::settings(archive/polling-relevant) andsrc-tauri/src/settings/(UI-only).infrastructure/database/preflight::check_db_compatibility()moves into thecorecrate (core::persistence::preflight); the dialog and recovery flow stay App-side (db_startup_service).temperature_unitbecomes presentation-only: Core always stores °C.Phase 4 — Move persistence into
core/src/persistence/workers/hardware_archive.rsand the relevant DB writers into thecorecrate.EventBusrather than sharing theMonitorResourcesArc bag with the collector. Decouples write latency from sensor polling.Phase 5 —
MonitoringStatestate machine & lifecycle decouplingcore::monitoring::MonitoringState:Running/Paused/Stoppedwith explicit transitions.src-tauri/src/lifecycle.rsowns Core start/stop. WindowCloseRequestedno longer callsWorkersState::terminate_all()and no longer callsapp.exit(0).Quitaction stops Core cleanly. Default release behavior is preserved by gating the new behavior behind a debug flag — the user-facing change ships with feat: add background monitoring mode with system tray support #1275.Phase 6 — Tray adapter skeleton
adapters::tray::TrayAdapterwith a minimalOpen/Quitmenu. No live metric rendering — that's feat: live hardware metrics in system tray / menu bar (Stats-like widget) #1401.Acceptance Criteria
corecrate has notauridependency in itsCargo.toml; any attempt touse tauri::*;undercore/src/fails to compile.HardwareMonitorUpdateis emitted to the frontend byWindowAdapter, not from inside the collector loop.Quitaction stops Core cleanly (no orphaned tasks, DB writes flushed).cargo test -p hwviz-corepasses).core::settingsdeserializes only Core-relevant fields; UI-only fields are not visible to Core.cargo tauri-fmt,cargo tauri-lint,cargo tauri-test,npm run lint,npm run testall pass at the end of every phase.Risk & Mitigation
Arc<Mutex<...>>bag. Mitigation: introduce the Core read API additively first (3a), migrate callers (3b), remove the old path (3c).tauri-*cargo aliases assumedsrc-tauri/was the cargo root. Mitigation: Phase 1 covers all known tooling updates; verifytauri dev/tauri buildend-to-end before merging.Children
#1403 was the original Phase 1 plan (CI-grep approach) and is closed as not planned in favor of #1411. Phase 6 (#1409) was originally part of this Epic but has been rescoped out — see the Change Log entry from 2026-04-30 below; the tray adapter now lives in the standalone feature issue #1422.
Related
Open Questions
HardwareMonitorUpdateas the wire format to the frontend, or define a newMetricsSnapshottype in Core and translate at the adapter? Keeping the existing event minimizes frontend churn during this Epic; a rename can happen later.temperature_unit: confirm Core always stores °C and App formats per user preference (recommended in Phase 3.5).Final package name for the— resolved in refactor(core): Phase 1 — set up Cargo workspace with root-level core crate #1411 / refactor(core): Phase 1 — set up Cargo workspace with root-level core crate (#1411) #1412 ascorecratehwviz-core(lib namehwviz_core).Change Log
refactor/1409-phase-6-tray-adapter(commit02ae710) is preserved for reuse in feat(app): system tray adapter — Open / Quit menu skeleton #1422 once feat: add background monitoring mode with system tray support #1275 is ready to consume the tray. The Phase 5 lifecycle decoupling (refactor(core): Phase 5 — introduce MonitoringState machine and decouple lifecycle from window close #1408) already ships everything feat(app): system tray adapter — Open / Quit menu skeleton #1422 depends on (lifecycle::request_quit).src-tauri/src/app/namespace from the Target Structure. With Core promoted to a workspace crate in refactor(core): Phase 1 — set up Cargo workspace with root-level core crate #1411, thesrc-tauri/crate is already entirely Tauri-aware, soadapters/,lifecycle.rs, and the future UI-onlysettings/live directly undersrc-tauri/src/. Phase 1's emptyapp/mod.rsplaceholder is removed in refactor(core): Phase 2 — introduce in-process EventBus for metric snapshots (#1404) #1413 (Phase 2). Substance of Phases 2–5 is unchanged; only path prefixes shift (app::lifecycle→lifecycle,app::settings→settings). See this comment for the full rationale.src-tauri/src/core/(CI-grep enforced) to a root-level Cargo workspace crate atcore/(compile-time enforced via the dependency graph). Phase 1 (refactor(core): Phase 1 — add empty core/ and app/ modules with no-tauri-import lint #1403) is closed and replaced by refactor(core): Phase 1 — set up Cargo workspace with root-level core crate #1411; Phases 2–6 are unchanged in substance, only their paths shift fromsrc-tauri/src/core/...tocore/src/.... See the Update comment on this issue for the full rationale.