Skip to content

Epic: split backend into Tauri-independent Core and thin App adapters #1402

@shm11C3

Description

@shm11C3

Summary

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:

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

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)

  1. 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.
  2. 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.
  3. 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.
  4. Persistence runs as a separate task from the collector. They communicate through EventBus so a slow DB write never blocks sensor polling.
  5. 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

  • core::event_bus::EventBus (tokio::sync::broadcast<MetricsSnapshot>).
  • 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).
  • An explicit Quit action 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

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.

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

Change Log

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions