Backend Architecture
July 11, 2026 · View on GitHub
This document describes the current backend shape after the Core / App split. The backend is now split across two Rust crates:
core/(hardviz-core): Tauri-independent hardware collection, platform access, persistence primitives, settings consumed by Core, and shared data models.src-tauri/(hardware_visualizer): Tauri app boundary, commands, App-side services, event adapters, UI-only settings, lifecycle, plugins, and Tauri runtime wiring.
Use this document together with:
High-Level Flow
The main dependency direction is:
Frontend
-> Tauri commands (`src-tauri/src/commands`)
-> App services / Core APIs (`src-tauri/src/services`, `hardviz_core::*`)
-> Core collector / platform / persistence (`core/src/*`)
-> OS APIs, system providers, SQLite
Realtime sensor updates use a separate event flow:
Core collector
-> `hardviz_core::event_bus::EventBus`
-> App adapters (`src-tauri/src/adapters`)
-> Tauri events
-> Frontend listeners
Workspace Layout
core/src/
├── collector/ # Sampling loop, history store, monitor controller
├── enums/ # Core-level error, hardware, and settings enums
├── event_bus.rs # In-process broadcast bus for MetricsSnapshot
├── infrastructure/
│ ├── database/ # Core DB initialization and access
│ └── providers/ # OS / vendor / system providers
├── models/ # Core data models, including MetricsSnapshot
├── monitoring/ # Monitoring state types
├── persistence/ # Archive, preflight, cleanup, storage health workers
├── platform/ # Platform traits, factory, and OS implementations
├── settings/ # Core-consumed settings subset
└── utils/ # Core helpers and logging macros
src-tauri/src/
├── adapters/ # Core EventBus subscribers -> Tauri event/tray output
├── app/ # App startup helpers
├── commands/ # Tauri command handlers and tauri-specta bindings
├── enums/ # App-side enums exposed at the Tauri boundary
├── infrastructure/ # App-owned infrastructure, including SQL migrations
├── lifecycle.rs # Window close / second instance / run event handling
├── models/ # App-side DTOs with specta/tauri-specta types
├── services/ # App-side business logic and Core API wrappers
├── tray/ # Tray widget UI/runtime code
├── utils/ # Tauri-aware helpers
└── workers/ # Handles for background controllers/adapters
Crate Responsibilities
hardviz-core (core/)
Core owns code that does not need a Tauri context:
- sampling CPU, memory, GPU, process, and related hardware metrics;
- keeping realtime history in
collector::HistoryStore; - publishing
MetricsSnapshotvalues throughevent_bus::EventBus; - defining platform traits and OS-specific platform implementations;
- using infrastructure providers for OS, vendor, kernel, and system data;
- running persistence workers that do not need Tauri objects;
- reading and writing settings that affect Core behavior.
Core must not depend on Tauri. This boundary is enforced by
core/Cargo.toml: adding tauri there is a design violation.
hardware_visualizer (src-tauri/)
The App crate owns code that needs Tauri, frontend bindings, or UI-level state:
- registering Tauri commands and generating TypeScript bindings through
tauri-specta; - holding Tauri
State,AppHandle, windows, plugins, and lifecycle hooks; - converting Core events into frontend-visible Tauri events;
- applying presentation settings at the App boundary, such as temperature unit conversion;
- owning UI-only settings such as theme, language, graph display options, background image choices, tray widget settings, and close-to-tray behavior;
- defining the ordered SQL migration set supplied to Core at startup;
- holding worker/controller handles so shutdown can terminate background work.
When Close to Tray is enabled, closing the main window and exiting the process are different lifecycle events. Code that starts, stops, flushes, or cleans up workers must key off the process/App shutdown path when it needs finalization, not merely the window-close path.
Elevated Startup Mode is also App-owned lifecycle behavior. It restarts the whole Tauri process with Windows administrator privileges when enabled and the current process is not already elevated. Core cannot be elevated independently because it is linked into the App process; a Core-only elevation model would require a separate helper or service process and IPC boundary.
Layer Responsibilities
Commands (src-tauri/src/commands/)
Commands are the frontend-facing IPC boundary.
Responsibilities:
- validate and normalize command inputs;
- call App services or Core APIs;
- return frontend-friendly DTOs and errors;
- participate in
tauri-spectacommand collection for generated TypeScript bindings.
Commands should not contain sensor collection logic, OS access, long-running worker logic, or database schema decisions.
App Services (src-tauri/src/services/)
App services coordinate command-level use cases.
Responsibilities:
- wrap Tauri plugins or App-only state;
- call Core APIs where hardware data or persisted Core behavior is needed;
- keep Tauri-specific DTO conversion out of Core;
- handle UI-owned settings and presentation behavior.
- contain a mix of thin Core wrappers and App-owned services. See
src-tauri/README.mdfor App crate details.
App Adapters (src-tauri/src/adapters/)
Adapters subscribe to Core outputs and translate them into App outputs.
Known adapters:
window.rs: receivesMetricsSnapshotfromEventBus, converts it intoHardwareMonitorUpdate, applies the user's temperature unit, and emits the Tauri event.tray.rs: subscribes to the same Core event bus for tray-related output.
Core should publish data; adapters decide how that data is exposed to Tauri.
Core Collector (core/src/collector/)
The collector owns realtime sampling and history.
Responsibilities:
- periodically sample system and GPU metrics;
- update
HistoryStore; - publish snapshots through
EventBus; - run under a Tokio runtime handle supplied by the App crate.
The collector does not emit Tauri events directly.
Core Platform (core/src/platform/)
The platform layer abstracts OS-specific hardware access.
Responsibilities:
- define common hardware access traits in
traits.rs; - create the current OS implementation through
PlatformFactory; - keep OS-specific differences under
windows/,linux/, andmacos/; - call infrastructure providers or OS APIs as needed.
Current platform traits include memory, GPU, network, and motherboard access.
Core Infrastructure (core/src/infrastructure/)
Core infrastructure contains lower-level external access used by Core:
database/: Core database initialization and DB access used by persistence;providers/: OS, vendor, and system data providers. Seecore/README.mdfor Core-specific provider details.
Windows sensor providers that depend on external runtime components, such as
PawnIO and its CPU-specific module blobs, are documented in
windows-sensor-external-components.md.
Persistence (core/src/persistence/, src-tauri/src/infrastructure/)
Persistence is split:
- Core owns persistence workers and DB operations that are independent of Tauri.
- App owns the ordered migration definitions and passes the resolved SQLite path and migration set to Core during startup.
- Core owns the database pool and executes the App-supplied migration set
through
hardviz_core::infrastructure::database::migrate.
Startup flow:
- App resolves the SQLite database path.
- App initializes Core's DB location.
- App checks schema compatibility through Core preflight.
- App supplies the ordered migration definitions to Core's migrator when the DB is compatible.
- DB-dependent Core workers start only when startup preflight allows it.
The Hardware Archive Retention Period is controlled by
hardwareArchive.retentionDays. The scheduledDataDeletion flag controls
whether cleanup for records older than the Retention Period runs at startup; it
does not create a continuously scheduled deletion task. This was a deliberate
simplification from the period before close-to-tray/background execution was a
supported app behavior, when the app was not expected to stay running
continuously.
Process Insight data is a sampled and ranked summary derived from realtime process observations. It is not a complete process audit log, and persistence code should preserve that expectation unless a new feature explicitly changes the product contract.
Storage Health Records are intentionally retained separately from the Hardware Archive. Dashboard display can use the latest record and recent changes, while future historical views can keep following long-term storage health even if short-window utilization archive settings are reduced.
Settings Ownership
Settings are split by consumer:
- Core settings affect sampling, persistence, retention, or other Core behavior.
- App settings affect UI presentation, language/theme, graph options, tray behavior, window behavior, process launch behavior, and Tauri-only features.
- Both sides share a single top-level
settings.jsonobject. Core deserializes only Core-owned keys and ignores App-owned keys; App deserializes App-owned keys and preserves existing unknown keys when writing. - Settings writes must merge into the existing JSON object so the writer does not drop keys owned by the other crate.
When adding a setting:
- Decide whether Core or App consumes the value.
- Persist user-facing preferences through the Rust settings service and typed Tauri commands.
- Do not write user-facing application preferences directly from the frontend through Tauri Store.
- Regenerate
src/rspc/bindings.tsby runningnpm run tauri:devwhen command types change.
Design Rules
- Core has no Tauri dependency. No
tauricrate and nouse tauri::*undercore/src/. - Commands stay thin. Put business logic in App services or Core.
- Core publishes snapshots, App emits events.
window.emit(...)belongs in App adapters, not in Core. - Use
PlatformFactoryfor platform access. Do not instantiate OS platform implementations directly from App command code. - Presentation stays in App. Core stores raw values; UI-specific conversion happens at the App boundary.
- Generated bindings are not edited by hand. Update Rust commands and
regenerate
src/rspc/bindings.ts. - Respect settings ownership. Core-owned and App-owned settings may share the same file, but each side should preserve the other side's keys.
Common Change Paths
Add a New Frontend-Callable Backend Command
- Add or update the App service in
src-tauri/src/services/. - Add the command handler in
src-tauri/src/commands/. - Register the command in
collect_commands![...]insrc-tauri/src/lib.rs. - Run
npm run tauri:devto regenerate TypeScript bindings. - Use the generated binding from frontend code.
Add a New Hardware Data Source
- Add or update the relevant Core platform trait if the capability is shared.
- Implement OS-specific behavior under
core/src/platform/{windows,linux,macos}. - Add provider code under
core/src/infrastructure/providers/if low-level OS or vendor access is needed. - Surface the data through Core models and collector snapshots if it is realtime.
- Translate Core data to App DTOs in
src-tauri/src/adapters/or App services.
Missing or unsupported sensor data should be treated as best-effort where the
existing command/service contract allows it: prefer partial results, None, or
empty lists over failing an aggregate response. Check the target service before
choosing the fallback shape, because some commands still return an error for
platform initialization or required provider failures.
For vendor- or OS-dependent metrics, absence is usually a data-availability condition rather than a fault. Preserve the distinction in new models and DTOs: use nullable fields for unavailable per-metric values, keep source information when it helps explain partial support, and avoid converting a partially available device into an all-or-nothing failure unless the caller cannot produce a useful result without that data.
External Component Guidance follows the same best-effort rule. Core may produce
structured guidance candidates when an optional runtime component such as
PawnIO or smartctl was actually attempted, could not be used, and fallback
collection still leaves user-visible hardware data unavailable. Those
candidates are diagnostic side data only: they must not change collection
results, fallback order, or aggregate success/failure behavior.
Core candidates should describe stable facts such as component, usage,
reason-kind, missing important signals, and optional diagnostic detail. App owns
the user-facing policy: it checks externalComponentGuidance.acknowledgedKeys
in settings.json, holds unshown candidates only in session memory, maps
guidance keys to detail URLs, returns candidates only while a relevant view can
show them, and persists acknowledgements through typed settings commands.
The initial candidate shape should stay minimal: a stable guidance key, component, usage, reason kind, missing signal names, optional affected device count, and optional diagnostic detail. Raw provider errors can be carried as diagnostic detail for logs or expandable UI, but translated user-facing copy should be driven by component, usage, and reason kind.
The initial implementation scope is limited to PawnIO for Windows CPU package
temperature and smartctl for Storage Health. Other vendor libraries, drivers,
or OS APIs should not be folded into this guidance path until their component,
usage, fallback behavior, and user action are explicit.
The first implementation slice should wire both initial guidance keys through
the shared candidate, settings, command, and dialog path rather than shipping
only one component first.
The minimum validation should cover both included components. PawnIO guidance
appears only when PawnIO cannot provide CPU package temperature and no ACPI CPU
temperature fallback is available. smartctl guidance appears only when
smartctl cannot be used and fallback Storage Health collection still lacks an
important health signal for at least one device. In both cases, tests should
also cover the inverse path where the optional component is unavailable but
fallback collection provides the user-visible data, so no guidance candidate is
produced.
App should aggregate unacknowledged guidance candidates by stable key. Repeated detections of the same key replace the session-held candidate instead of growing a queue, and already acknowledged keys are discarded before display. If more than one unacknowledged key is relevant to the current view, the frontend should show them one at a time.
Relevant frontend views should request pending guidance through a typed command when they mount or become visible, rather than relying on a backend push event. That command should filter candidates by view and return only guidance that can be shown in the current UI context. Acknowledgement is a separate typed command that records the guidance key in App-owned settings.
The frontend's "later" action should suppress the same guidance key only for the current app session. It must not write an acknowledgement to settings, so a future app session can show the guidance again if the same collection gap still exists.
Add or Change Persisted Data
- Keep the App-owned ordered migration definitions in
src-tauri/src/infrastructure/database/. - Keep Tauri-independent persistence code in
core/src/persistence/orcore/src/infrastructure/database/. - Update startup compatibility checks if schema compatibility changes.
- Add tests for migration or preflight behavior where practical.
Testing
Use the root Cargo aliases for CI parity:
cargo tauri-fmt
cargo tauri-lint
cargo tauri-test
For Core-only checks:
cargo build -p hardviz-core
cargo test -p hardviz-core
Frontend and TypeScript checks are run from the repository root:
npm run lint
npm test
Test placement follows the ownership boundary:
- Core pure/helper tests should live near the Core module they exercise.
- App command/service tests should live either next to the module or under
src-tauri/src/_tests/when they need shared App test setup. - Frontend tests should stay co-located under
src/**with the component, hook, or feature they cover.