Compare commits

..

21 commits

Author SHA1 Message Date
d8991905d7 fix(m1): address HIGH review findings — journald zombie, silent exit, double-delivery 2026-05-18 18:56:45 -07:00
db3694d9cf fix(m1): wrap onMounted drain in try/catch so listen is always set up 2026-05-18 18:51:56 -07:00
8195ad98b0 feat(m1): starter pattern files — macos-to-arch and windows-to-debian 2026-05-18 18:25:11 -07:00
88924aa593 feat(m1): ChatPanel listens for robin:event, drains pending on open 2026-05-18 18:24:34 -07:00
c3958553a5 fix(m1): move mut rx inside async block (clippy), log+continue on parse errors in load() 2026-05-18 18:22:50 -07:00
2706b2367d feat(m1): wire classifier loop — pattern file loads on startup, events dispatch to notify
- lib.rs: replaces stub setup with full wiring: loads PatternFile from
  config, extracts log_paths, spawns watcher, runs classifier loop in
  async task, dispatches MatchedEvents via notify::dispatch
- lib.rs: config Mutex lock uses unwrap_or_else(|e| e.into_inner()) to
  recover from poison instead of panicking
- patterns.rs: load() now tries three path candidates in order
  (dev-relative, src-tauri-relative, system) before returning bail!
  Validation loop (match_text, sources) retained inside candidate loop
2026-05-18 18:09:33 -07:00
c0f046bd7c fix(m1): notify — unwrap_or_default on poisoned config lock, log poisoned PENDING, add take_pending test 2026-05-18 18:07:34 -07:00
3b7653d731 feat(m1): notification delivery — tray badge, desktop toast, pending event queue 2026-05-18 17:27:41 -07:00
3c77969680 fix(m1): inotify — use read_to_end for UTF-8 resilience, await spawn_blocking
- read_to_end + from_utf8_lossy replaces read_to_string so Wine/game logs
  with Latin-1 bytes are handled via U+FFFD replacement instead of silently
  dropping all events from that file
- bytes_read from I/O call used for new_pos (not content.len()) for correct
  byte position accounting
- spawn_blocking handle is now awaited so panics inside the blocking task
  surface to the caller instead of being silently swallowed
2026-05-18 17:25:30 -07:00
7eaf22c130 feat(m1): inotify app log watcher — tails log files for known apps 2026-05-18 17:14:10 -07:00
db7d30d4c1 feat(m1): kmsg watcher — reads /dev/kmsg kernel ring buffer 2026-05-18 17:13:36 -07:00
e48536dfbe feat(m1): journald watcher — streams journalctl JSON to event channel 2026-05-18 17:13:23 -07:00
d1bea47495 refactor(m1): restructure watcher into module with EventSource, SystemEvent
Replace flat watcher.rs with watcher/ module containing mod.rs plus stub
sub-modules for journald, kmsg, and inotify. Upgrades spawn() to accept
log_paths and return mpsc::Receiver<SystemEvent>. Updates lib.rs call site.
2026-05-18 17:10:37 -07:00
6acf085c0f fix(m1): validate patterns at load, add Serialize to EventSource, expand tests
- load() now rejects patterns with empty match_text or empty sources list
- EventSource derives Serialize/Deserialize with serde tag for emit() readiness
- AppLog variant changed to struct form (AppLog { app }) for tagged enum compat
- classify() takes &SystemEvent directly (top-level use import, not per-fn)
- #[must_use] on classify()
- 5 new tests: any-source wildcard (journald+kmsg), applog mismatch, empty-field validation
2026-05-18 17:08:25 -07:00
1e733a062b feat(m1): pattern system — PatternFile loader and classify()
Add EventSource enum and update SystemEvent in watcher.rs (M0 stub
updated to support Task 5 clean deletion). Create patterns.rs with
PatternFile/Pattern/MatchedEvent types, TOML loader, and classify()
matching against source + text. Five unit tests covering journald,
applog, no-match, and source discrimination cases.
2026-05-18 16:57:05 -07:00
a94e3dbb66 fix(distro): handle single-quoted IDs, add mint, run cargo fmt
- parse_id now strips both double and single quotes per os-release spec
- distro_family debian arm includes "mint" (legacy Linux Mint ID)
- Add tests: single-quoted ID round-trip, mint family classification
2026-05-18 16:55:06 -07:00
dc45c82aba feat(m1): distro detection from /etc/os-release, update_notification_level command 2026-05-18 15:53:03 -07:00
cee05b5d18 fix(m1): serde defaults on Tier and NotificationLevel, PartialEq on SourceOs 2026-05-18 15:21:03 -07:00
c94fc58296 feat(m1): add Tier and NotificationLevel to config 2026-05-18 15:17:56 -07:00
5216549d0a chore(m1): use notify v8, dirs v6, tighten capabilities permissions 2026-05-18 14:53:05 -07:00
c33d4cf07f chore(m1): add notify dep, fix capabilities window name and permissions 2026-05-18 14:05:16 -07:00
23 changed files with 1193 additions and 50 deletions

3
.gitignore vendored
View file

@ -29,3 +29,6 @@ src-tauri/target/
# Secrets # Secrets
.env .env
.env.local .env.local
# Visual companion brainstorm sessions
.superpowers/

11
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "robin", "name": "robin",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.0",
"vue": "^3.5.34" "vue": "^3.5.34"
}, },
"devDependencies": { "devDependencies": {
@ -398,6 +399,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",

View file

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.0",
"vue": "^3.5.34" "vue": "^3.5.34"
}, },
"devDependencies": { "devDependencies": {

View file

@ -24,8 +24,13 @@ log = "0.4"
anyhow = "1.0" anyhow = "1.0"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
toml = "0.8" toml = "0.8"
notify = "8"
dirs = "6"
tauri = { version = "2.11.2", features = ["tray-icon"] } tauri = { version = "2.11.2", features = ["tray-icon"] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
[dev-dependencies]
tempfile = "3"

View file

@ -3,9 +3,11 @@
"identifier": "default", "identifier": "default",
"description": "enables the default permissions", "description": "enables the default permissions",
"windows": [ "windows": [
"main" "chat"
], ],
"permissions": [ "permissions": [
"core:default" "core:default",
"notification:default",
"shell:allow-open"
] ]
} }

View file

@ -0,0 +1,65 @@
[meta]
source_os = "macos"
target_distro_family = "arch"
[log_paths]
steam = "~/.local/share/Steam/logs/content_log.txt"
proton = "~/.local/share/Steam/logs/proton_log.txt"
retroarch = "~/.config/retroarch/retroarch.log"
lutris = "~/.cache/lutris/logs/lutris.log"
[[patterns]]
id = "aur-build-failure"
sources = ["journald"]
match_text = "error: failed to build"
severity = "warn"
title = "AUR package build failed"
body = "A package failed to compile from source. This usually means a missing dependency or a broken AUR package."
[[patterns]]
id = "aur-pgp-key"
sources = ["journald"]
match_text = "unknown public key"
severity = "warn"
title = "Missing PGP key for AUR package"
body = "The package signature can't be verified. Run: gpg --recv-keys <keyid>"
[[patterns]]
id = "proton-runtime-missing"
sources = ["applog:proton"]
match_text = "wine: cannot find"
severity = "warn"
title = "Proton runtime issue"
body = "Steam Proton couldn't find a required file. Try: right-click the game -> Properties -> Local Files -> Verify game files."
[[patterns]]
id = "steam-disk-write"
sources = ["applog:steam"]
match_text = "ERROR: failed to write"
severity = "warn"
title = "Steam disk write error"
body = "Steam can't write to its library folder. Check that you own the directory: ls -la ~/.local/share/Steam"
[[patterns]]
id = "retroarch-shader-fail"
sources = ["applog:retroarch"]
match_text = "Failed to compile shader"
severity = "info"
title = "RetroArch shader failed to compile"
body = "A graphical shader couldn't load. Try switching to a different shader preset in Settings -> Video -> Shaders."
[[patterns]]
id = "kernel-driver-firmware"
sources = ["kmsg"]
match_text = "firmware: failed to load"
severity = "warn"
title = "Missing firmware for hardware"
body = "Your system is missing a firmware file for a hardware component. On Arch, try: sudo pacman -S linux-firmware"
[[patterns]]
id = "missing-codec"
sources = ["journald"]
match_text = "GStreamer: Failed to find plugin"
severity = "info"
title = "Missing media codec"
body = "A media codec isn't installed. On Arch: sudo pacman -S gst-plugins-good gst-plugins-bad gst-libav"

View file

@ -0,0 +1,56 @@
[meta]
source_os = "windows"
target_distro_family = "debian"
[log_paths]
steam = "~/.local/share/Steam/logs/content_log.txt"
proton = "~/.local/share/Steam/logs/proton_log.txt"
retroarch = "~/.config/retroarch/retroarch.log"
[[patterns]]
id = "apt-lock"
sources = ["journald"]
match_text = "Could not get lock /var/lib/dpkg/lock"
severity = "warn"
title = "Package manager is locked"
body = "Another process is using apt. Wait a moment and try again. If it's stuck: sudo rm /var/lib/dpkg/lock-frontend"
[[patterns]]
id = "apt-unmet-dependency"
sources = ["journald"]
match_text = "Unmet dependencies"
severity = "warn"
title = "Package dependency conflict"
body = "apt can't resolve a dependency. Try: sudo apt --fix-broken install"
[[patterns]]
id = "proton-runtime-missing"
sources = ["applog:proton"]
match_text = "wine: cannot find"
severity = "warn"
title = "Proton runtime issue"
body = "Steam Proton couldn't find a required file. Right-click the game -> Properties -> Local Files -> Verify game files."
[[patterns]]
id = "kernel-driver-firmware"
sources = ["kmsg"]
match_text = "firmware: failed to load"
severity = "warn"
title = "Missing firmware for hardware"
body = "Your system is missing a firmware file. On Debian/Ubuntu/Mint: sudo apt install firmware-linux firmware-linux-nonfree"
[[patterns]]
id = "missing-codec"
sources = ["journald"]
match_text = "GStreamer: Failed to find plugin"
severity = "info"
title = "Missing media codec"
body = "A media codec isn't installed. Try: sudo apt install ubuntu-restricted-extras"
[[patterns]]
id = "snap-confinement"
sources = ["journald"]
match_text = "snap: cannot use strict"
severity = "info"
title = "Snap package confinement issue"
body = "A Snap package is having permission trouble. Try running it with --devmode, or look for a Flatpak or apt alternative."

View file

@ -1,6 +1,6 @@
use crate::config::{RobinConfig, MigrationConfig, SourceOs}; use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs};
use tauri::State;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::State;
pub struct AppState { pub struct AppState {
pub config: Mutex<RobinConfig>, pub config: Mutex<RobinConfig>,
@ -8,14 +8,18 @@ pub struct AppState {
#[tauri::command] #[tauri::command]
pub fn get_config(state: State<'_, AppState>) -> Result<RobinConfig, String> { pub fn get_config(state: State<'_, AppState>) -> Result<RobinConfig, String> {
state.config.lock() state
.config
.lock()
.map(|c| c.clone()) .map(|c| c.clone())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub fn needs_onboarding(state: State<'_, AppState>) -> bool { pub fn needs_onboarding(state: State<'_, AppState>) -> bool {
state.config.lock() state
.config
.lock()
.map(|c| c.needs_onboarding()) .map(|c| c.needs_onboarding())
.unwrap_or(true) .unwrap_or(true)
} }
@ -33,11 +37,45 @@ pub fn complete_onboarding(
_ => SourceOs::Unknown, _ => SourceOs::Unknown,
}; };
let detected = if distro == "unknown" || distro.is_empty() {
crate::distro::detect()
} else {
distro
};
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.migration = Some(MigrationConfig { config.migration = Some(MigrationConfig {
source_os: source, source_os: source,
distro, distro: detected,
fluency_level: 0, fluency_level: 0,
}); });
config.save().map_err(|e| e.to_string()) config.save().map_err(|e| e.to_string())
} }
#[tauri::command]
pub fn update_notification_level(level: String, state: State<'_, AppState>) -> Result<(), String> {
let parsed = match level.as_str() {
"off" => NotificationLevel::Off,
"badge_only" => NotificationLevel::BadgeOnly,
"badge_and_toast" => NotificationLevel::BadgeAndToast,
_ => return Err(format!("unknown notification level: {level}")),
};
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.display.notification_level = parsed;
config.save().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_pending_events() -> Vec<crate::patterns::MatchedEvent> {
crate::notify::take_pending()
}
#[tauri::command]
pub fn panel_opened() {
crate::notify::set_panel_open(true);
}
#[tauri::command]
pub fn panel_closed() {
crate::notify::set_panel_open(false);
}

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum SourceOs { pub enum SourceOs {
Macos, Macos,
@ -11,6 +11,24 @@ pub enum SourceOs {
Unknown, Unknown,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
#[default]
Free,
Paid,
Premium,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationLevel {
Off,
BadgeOnly,
#[default]
BadgeAndToast,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationConfig { pub struct MigrationConfig {
pub source_os: SourceOs, pub source_os: SourceOs,
@ -37,14 +55,15 @@ impl Default for OllamaConfig {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisplayConfig { pub struct DisplayConfig {
pub show_notifications: bool, #[serde(default)]
pub notification_level: NotificationLevel,
pub quiet_mode: bool, pub quiet_mode: bool,
} }
impl Default for DisplayConfig { impl Default for DisplayConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
show_notifications: true, notification_level: NotificationLevel::BadgeAndToast,
quiet_mode: false, quiet_mode: false,
} }
} }
@ -55,6 +74,8 @@ pub struct RobinConfig {
pub migration: Option<MigrationConfig>, pub migration: Option<MigrationConfig>,
pub ollama: OllamaConfig, pub ollama: OllamaConfig,
pub display: DisplayConfig, pub display: DisplayConfig,
#[serde(default)]
pub tier: Tier,
} }
impl Default for RobinConfig { impl Default for RobinConfig {
@ -63,6 +84,7 @@ impl Default for RobinConfig {
migration: None, migration: None,
ollama: OllamaConfig::default(), ollama: OllamaConfig::default(),
display: DisplayConfig::default(), display: DisplayConfig::default(),
tier: Tier::Free,
} }
} }
} }
@ -80,8 +102,7 @@ impl RobinConfig {
} }
let content = std::fs::read_to_string(&path) let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?; .with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&content) toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))
.with_context(|| format!("failed to parse {}", path.display()))
} }
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
@ -136,4 +157,36 @@ mod tests {
let deserialized: RobinConfig = toml::from_str(&serialized).unwrap(); let deserialized: RobinConfig = toml::from_str(&serialized).unwrap();
assert!(!deserialized.needs_onboarding()); assert!(!deserialized.needs_onboarding());
} }
#[test]
fn notification_level_default_is_badge_and_toast() {
let config = RobinConfig::default();
assert!(matches!(
config.display.notification_level,
NotificationLevel::BadgeAndToast
));
}
#[test]
fn tier_default_is_free() {
let config = RobinConfig::default();
assert!(matches!(config.tier, Tier::Free));
}
#[test]
fn notification_level_roundtrips_toml() {
let config = RobinConfig {
display: DisplayConfig {
notification_level: NotificationLevel::BadgeOnly,
quiet_mode: false,
},
..Default::default()
};
let toml = toml::to_string_pretty(&config).unwrap();
let back: RobinConfig = toml::from_str(&toml).unwrap();
assert!(matches!(
back.display.notification_level,
NotificationLevel::BadgeOnly
));
}
} }

88
src-tauri/src/distro.rs Normal file
View file

@ -0,0 +1,88 @@
pub fn detect() -> String {
std::fs::read_to_string("/etc/os-release")
.map(|s| parse_id(&s))
.unwrap_or_else(|_| "unknown".to_string())
}
pub fn distro_family(id: &str) -> &'static str {
match id {
"arch" | "cachyos" | "endeavouros" | "manjaro" | "garuda" => "arch",
"debian" | "ubuntu" | "linuxmint" | "mint" | "pop" | "elementary" | "kali" => "debian",
"fedora" | "rhel" | "centos" | "rocky" | "alma" => "fedora",
"opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => "opensuse",
_ => "unknown",
}
}
fn parse_id(content: &str) -> String {
content
.lines()
.find_map(|line| {
let (key, val) = line.split_once('=')?;
if key.trim() == "ID" {
let raw = val.trim();
let unquoted = raw
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| raw.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(raw);
Some(unquoted.to_lowercase())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_arch_id() {
let content = "ID=arch\nID_LIKE=\nPRETTY_NAME=Arch Linux\n";
assert_eq!(parse_id(content), "arch");
}
#[test]
fn parses_cachyos_id() {
let content = "ID=cachyos\nID_LIKE=arch\nPRETTY_NAME=CachyOS\n";
assert_eq!(parse_id(content), "cachyos");
}
#[test]
fn parses_linuxmint_id() {
let content = "ID=linuxmint\nID_LIKE=ubuntu\nPRETTY_NAME=Linux Mint 22\n";
assert_eq!(parse_id(content), "linuxmint");
}
#[test]
fn distro_family_arch_based() {
assert_eq!(distro_family("cachyos"), "arch");
assert_eq!(distro_family("arch"), "arch");
assert_eq!(distro_family("manjaro"), "arch");
}
#[test]
fn distro_family_debian_based() {
assert_eq!(distro_family("linuxmint"), "debian");
assert_eq!(distro_family("ubuntu"), "debian");
assert_eq!(distro_family("debian"), "debian");
}
#[test]
fn distro_family_unknown() {
assert_eq!(distro_family("slackware"), "unknown");
}
#[test]
fn parses_single_quoted_id() {
let content = "ID='arch'\nPRETTY_NAME='Arch Linux'\n";
assert_eq!(parse_id(content), "arch");
}
#[test]
fn distro_family_mint_legacy() {
assert_eq!(distro_family("mint"), "debian");
}
}

View file

@ -1,11 +1,15 @@
mod commands; mod commands;
mod config; mod config;
mod distro;
mod notify;
mod patterns;
mod tray; mod tray;
mod watcher; mod watcher;
use commands::AppState; use commands::AppState;
use config::RobinConfig; use config::RobinConfig;
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@ -21,13 +25,57 @@ pub fn run() {
}) })
.setup(|app| { .setup(|app| {
tray::build_tray(&app.handle())?; tray::build_tray(&app.handle())?;
watcher::spawn();
let state = app.state::<AppState>();
let cfg = state
.config
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let pattern_file = if let Some(ref migration) = cfg.migration {
let family = distro::distro_family(&migration.distro);
let source = match migration.source_os {
config::SourceOs::Macos => "macos",
config::SourceOs::Windows => "windows",
config::SourceOs::Linux => "linux",
config::SourceOs::Unknown => "unknown",
};
patterns::load(source, family).ok()
} else {
None
};
let log_paths = pattern_file
.as_ref()
.map(|pf| pf.log_paths.clone())
.unwrap_or_default();
let rx = watcher::spawn(log_paths);
let pf = Arc::new(pattern_file);
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let mut rx = rx;
while let Some(event) = rx.recv().await {
if let Some(ref pf) = *pf {
if let Some(matched) = patterns::classify(&event, pf) {
notify::dispatch(&app_handle, matched);
}
}
}
});
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::get_config, commands::get_config,
commands::needs_onboarding, commands::needs_onboarding,
commands::complete_onboarding, commands::complete_onboarding,
commands::update_notification_level,
commands::get_pending_events,
commands::panel_opened,
commands::panel_closed,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running Robin"); .expect("error while running Robin");

95
src-tauri/src/notify.rs Normal file
View file

@ -0,0 +1,95 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Runtime};
use tauri_plugin_notification::NotificationExt;
use crate::commands::AppState;
use crate::config::NotificationLevel;
use crate::patterns::MatchedEvent;
static PENDING: Mutex<Vec<MatchedEvent>> = Mutex::new(vec![]);
// True while ChatPanel is mounted and listening for live events.
// When true, dispatch skips PENDING so events are not shown twice on re-open.
static PANEL_OPEN: AtomicBool = AtomicBool::new(false);
pub fn set_panel_open(open: bool) {
PANEL_OPEN.store(open, Ordering::Relaxed);
}
pub fn dispatch<R: Runtime>(app: &AppHandle<R>, event: MatchedEvent) {
let level = app
.state::<AppState>()
.config
.lock()
.map(|c| c.display.notification_level.clone())
.unwrap_or_default();
if !PANEL_OPEN.load(Ordering::Relaxed) {
match PENDING.lock() {
Ok(mut pending) => pending.push(event.clone()),
Err(e) => log::warn!("notify: pending queue lock poisoned — event dropped: {e}"),
}
}
match level {
NotificationLevel::Off => {}
NotificationLevel::BadgeOnly => {
crate::tray::badge_on(app);
}
NotificationLevel::BadgeAndToast => {
crate::tray::badge_on(app);
send_toast(app, &event);
}
}
let _ = app.emit("robin:event", &event);
}
fn send_toast<R: Runtime>(app: &AppHandle<R>, event: &MatchedEvent) {
let _ = app
.notification()
.builder()
.title(&event.title)
.body(&event.body)
.show();
}
pub fn take_pending() -> Vec<MatchedEvent> {
PENDING
.lock()
.map(|mut v| std::mem::take(&mut *v))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event(id: &str) -> MatchedEvent {
MatchedEvent {
pattern_id: id.into(),
title: "Title".into(),
body: "Body".into(),
severity: "warn".into(),
timestamp: 0,
}
}
#[test]
fn take_pending_drains_queue() {
// Ensure clean state — drain any events from other tests sharing the static
let _ = take_pending();
if let Ok(mut q) = PENDING.lock() {
q.push(make_event("a"));
q.push(make_event("b"));
}
let first = take_pending();
assert_eq!(first.len(), 2);
assert_eq!(first[0].pattern_id, "a");
let second = take_pending();
assert!(second.is_empty(), "queue must be empty after drain");
}
}

289
src-tauri/src/patterns.rs Normal file
View file

@ -0,0 +1,289 @@
use crate::watcher::{EventSource, SystemEvent};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct PatternMeta {
pub source_os: String,
pub target_distro_family: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Pattern {
pub id: String,
pub sources: Vec<String>,
pub match_text: String,
pub severity: String,
pub title: String,
pub body: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PatternFile {
pub meta: PatternMeta,
#[serde(default)]
pub log_paths: HashMap<String, String>,
#[serde(default)]
pub patterns: Vec<Pattern>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MatchedEvent {
pub pattern_id: String,
pub title: String,
pub body: String,
pub severity: String,
pub timestamp: u64,
}
/// Load the pattern file for a source OS and distro family.
///
/// Tries candidates in order: dev-relative path, src-tauri-relative path,
/// system path. The Tauri resource directory is the authoritative location
/// at runtime; passing a base path is handled in Task 10's caller via candidate list.
pub fn load(source_os: &str, distro_family: &str) -> Result<PatternFile> {
let filename = format!("{source_os}-to-{distro_family}.toml");
let candidates = [
format!("patterns/{filename}"),
format!("src-tauri/patterns/{filename}"),
format!("/usr/share/robin/patterns/{filename}"),
];
for path in &candidates {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let pf: PatternFile = match toml::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!("patterns: failed to parse {path}: {e}");
continue;
}
};
for p in &pf.patterns {
anyhow::ensure!(
!p.match_text.is_empty(),
"pattern '{}' has empty match_text in {path}",
p.id
);
anyhow::ensure!(
!p.sources.is_empty(),
"pattern '{}' has empty sources list in {path}",
p.id
);
}
return Ok(pf);
}
anyhow::bail!("pattern file not found: {filename}")
}
#[must_use]
pub fn classify(event: &SystemEvent, pf: &PatternFile) -> Option<MatchedEvent> {
for pattern in &pf.patterns {
if !event.raw_line.contains(&pattern.match_text) {
continue;
}
let source_matches = pattern.sources.iter().any(|s| {
if s == "any" {
return true;
}
match &event.source {
EventSource::Journald => s == "journald",
EventSource::Kmsg => s == "kmsg",
EventSource::AppLog { app } => s == &format!("applog:{app}"),
}
});
if source_matches {
return Some(MatchedEvent {
pattern_id: pattern.id.clone(),
title: pattern.title.clone(),
body: pattern.body.clone(),
severity: pattern.severity.clone(),
timestamp: event.timestamp,
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::watcher::{EventSource, SystemEvent};
const FIXTURE: &str = r#"
[meta]
source_os = "macos"
target_distro_family = "arch"
[log_paths]
steam = "~/.local/share/Steam/logs/content_log.txt"
[[patterns]]
id = "aur-build-failure"
sources = ["journald"]
match_text = "error: failed to build"
severity = "warn"
title = "AUR package build failed"
body = "A package failed to compile from source."
[[patterns]]
id = "proton-runtime"
sources = ["applog:steam"]
match_text = "wine: cannot find"
severity = "warn"
title = "Proton runtime issue"
body = "Steam Proton couldn't find a required file."
[[patterns]]
id = "any-source-pattern"
sources = ["any"]
match_text = "kernel panic"
severity = "crit"
title = "Kernel panic"
body = "The kernel encountered a fatal error."
"#;
fn make_event(source: EventSource, raw_line: &str) -> SystemEvent {
SystemEvent {
source,
raw_line: raw_line.into(),
timestamp: 0,
}
}
#[test]
fn loads_pattern_file_from_toml() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
assert_eq!(pf.meta.source_os, "macos");
assert_eq!(pf.patterns.len(), 3);
assert_eq!(
pf.log_paths.get("steam").unwrap(),
"~/.local/share/Steam/logs/content_log.txt"
);
}
#[test]
fn classify_matches_journald_event() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(EventSource::Journald, ":: error: failed to build (foo-git)");
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "aur-build-failure");
assert_eq!(matched.title, "AUR package build failed");
}
#[test]
fn classify_no_match_returns_none() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(
EventSource::Journald,
"systemd: Started NetworkManager.service",
);
assert!(classify(&event, &pf).is_none());
}
#[test]
fn classify_applog_matches_correct_source() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(
EventSource::AppLog {
app: "steam".into(),
},
"wine: cannot find /run/media/user/game.exe",
);
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "proton-runtime");
}
#[test]
fn classify_does_not_match_wrong_source() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
// proton pattern is applog:steam — journald event must not match
let event = make_event(EventSource::Journald, "wine: cannot find something");
assert!(classify(&event, &pf).is_none());
}
#[test]
fn classify_applog_does_not_match_different_app() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(
EventSource::AppLog {
app: "retroarch".into(),
},
"wine: cannot find libvulkan.so",
);
assert!(classify(&event, &pf).is_none());
}
#[test]
fn classify_any_source_matches_kmsg() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(EventSource::Kmsg, "kernel panic - not syncing: VFS");
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "any-source-pattern");
}
#[test]
fn classify_any_source_matches_journald() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(EventSource::Journald, "kernel panic - not syncing: VFS");
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "any-source-pattern");
}
#[test]
fn load_rejects_empty_match_text() {
let bad = r#"
[meta]
source_os = "macos"
target_distro_family = "arch"
[[patterns]]
id = "bad"
sources = ["journald"]
match_text = ""
severity = "warn"
title = "Bad"
body = "Bad pattern."
"#;
let pf: PatternFile = toml::from_str(bad).unwrap();
// Validate manually since load() reads from filesystem; test the invariant directly
let result: anyhow::Result<()> = (|| {
for p in &pf.patterns {
anyhow::ensure!(!p.match_text.is_empty(), "empty match_text in '{}'", p.id);
}
Ok(())
})();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty match_text"));
}
#[test]
fn load_rejects_empty_sources() {
let bad = r#"
[meta]
source_os = "macos"
target_distro_family = "arch"
[[patterns]]
id = "bad"
sources = []
match_text = "something"
severity = "warn"
title = "Bad"
body = "Bad pattern."
"#;
let pf: PatternFile = toml::from_str(bad).unwrap();
let result: anyhow::Result<()> = (|| {
for p in &pf.patterns {
anyhow::ensure!(!p.sources.is_empty(), "empty sources in '{}'", p.id);
}
Ok(())
})();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty sources"));
}
}

View file

@ -1,3 +1,4 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
@ -34,7 +35,7 @@ pub fn build_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
Ok(()) Ok(())
} }
fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) { pub fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("chat") { if let Some(window) = app.get_webview_window("chat") {
if window.is_visible().unwrap_or(false) { if window.is_visible().unwrap_or(false) {
let _ = window.hide(); let _ = window.hide();
@ -44,3 +45,32 @@ fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
} }
} }
} }
static BADGE_ACTIVE: AtomicBool = AtomicBool::new(false);
pub fn badge_on<R: Runtime>(app: &AppHandle<R>) {
if BADGE_ACTIVE.swap(true, Ordering::Relaxed) {
return; // already badged
}
if let Some(tray) = app.tray_by_id("robin-tray") {
if let Some(icon) = app.default_window_icon().cloned() {
let _ = tray.set_icon(Some(icon));
}
let _ = tray.set_tooltip(Some("Robin — something to show you"));
}
}
pub fn badge_off<R: Runtime>(app: &AppHandle<R>) {
BADGE_ACTIVE.store(false, Ordering::Relaxed);
if let Some(tray) = app.tray_by_id("robin-tray") {
if let Some(icon) = app.default_window_icon().cloned() {
let _ = tray.set_icon(Some(icon));
}
let _ = tray.set_tooltip(Some("Robin"));
}
}
pub fn clear_badge_and_open<R: Runtime>(app: &AppHandle<R>) {
badge_off(app);
toggle_chat_panel(app);
}

View file

@ -1,32 +0,0 @@
/// System event watcher — M1 implementation.
///
/// M0 stub: defines the types and the spawn interface so the rest of the app
/// can wire up event handling now. Actual journald/dmesg reading lands in M1.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventSeverity {
Info,
Warn,
Crit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemEvent {
pub severity: EventSeverity,
pub source: String,
pub message: String,
pub timestamp: u64,
}
/// Starts the background watcher task.
/// M0: no-op placeholder — returns immediately.
/// M1: spawns a tokio task reading journald + dmesg and emitting events.
pub fn spawn() {
// TODO(M1): spawn tokio::task reading journald via sd-journal crate
// TODO(M1): spawn dmesg poller for kernel messages
// TODO(M1): emit SystemEvent via tauri app_handle.emit()
log::info!("watcher: stub — no-op until M1");
}

View file

@ -0,0 +1,124 @@
use super::{now_unix, EventSource, SystemEvent};
use notify::{recommended_watcher, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::io::{Read, Seek};
use tokio::sync::mpsc;
pub async fn watch(log_paths: HashMap<String, String>, tx: mpsc::Sender<SystemEvent>) {
if log_paths.is_empty() {
return;
}
let expanded: HashMap<String, String> = log_paths
.iter()
.map(|(k, v)| (k.clone(), expand_tilde(v)))
.collect();
tokio::task::spawn_blocking(move || {
let (notify_tx, notify_rx) = std::sync::mpsc::channel();
let mut watcher = match recommended_watcher(notify_tx) {
Ok(w) => w,
Err(e) => {
log::error!("inotify watcher init failed: {e}");
return;
}
};
// positions: path -> (app_name, byte_offset)
let mut positions: HashMap<String, (String, u64)> = HashMap::new();
for (app_name, path) in &expanded {
let pb = std::path::Path::new(path);
if pb.exists() {
let len = std::fs::metadata(pb).map(|m| m.len()).unwrap_or(0);
positions.insert(path.clone(), (app_name.clone(), len));
watcher.watch(pb, RecursiveMode::NonRecursive).ok();
}
}
for result in notify_rx {
if let Ok(event) = result {
for path in event.paths {
let path_str = path.to_string_lossy().to_string();
if let Some((app_name, pos)) = positions.get_mut(&path_str) {
let (lines, new_pos) = read_new_lines(&path_str, *pos);
*pos = new_pos;
for line in lines {
tx.blocking_send(SystemEvent {
source: EventSource::AppLog {
app: app_name.clone(),
},
raw_line: line,
timestamp: now_unix(),
})
.ok();
}
}
}
}
}
})
.await
.ok();
}
pub fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
format!("{home}/{rest}")
} else {
path.to_string()
}
}
pub fn read_new_lines(path: &str, from_byte: u64) -> (Vec<String>, u64) {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return (vec![], from_byte),
};
if file.seek(std::io::SeekFrom::Start(from_byte)).is_err() {
return (vec![], from_byte);
}
let mut raw = Vec::new();
let bytes_read = file.read_to_end(&mut raw).unwrap_or(0);
let content = String::from_utf8_lossy(&raw).into_owned();
let new_pos = from_byte + bytes_read as u64;
let lines: Vec<String> = content
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
(lines, new_pos)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_home() {
let home = std::env::var("HOME").unwrap_or("/home/user".into());
let expanded = expand_tilde("~/.config/retroarch/retroarch.log");
assert!(expanded.starts_with(&home));
assert!(expanded.ends_with(".config/retroarch/retroarch.log"));
}
#[test]
fn expand_tilde_no_tilde() {
let path = "/absolute/path/to/file.log";
assert_eq!(expand_tilde(path), path);
}
#[tokio::test]
async fn reads_new_lines_from_file() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
let mut f = std::fs::File::create(&path).unwrap();
write!(f, "line one\n").unwrap();
let (lines, pos) = read_new_lines(path.to_str().unwrap(), 0);
assert_eq!(lines, vec!["line one".to_string()]);
assert!(pos > 0);
}
}

View file

@ -0,0 +1,74 @@
use super::{now_unix, EventSource, SystemEvent};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
pub async fn watch(tx: mpsc::Sender<SystemEvent>) {
let mut child = match Command::new("journalctl")
.args(["--follow", "--output=json", "--lines=0"])
.stdout(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
log::error!("journald watcher: failed to spawn journalctl: {e}");
return;
}
};
let stdout = match child.stdout.take() {
Some(s) => s,
None => return,
};
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(msg) = extract_message(&line) {
let _ = tx
.send(SystemEvent {
source: EventSource::Journald,
raw_line: msg,
timestamp: now_unix(),
})
.await;
}
}
log::warn!("journald watcher: journalctl exited — no new journal events will be observed");
let _ = child.wait().await;
}
fn extract_message(line: &str) -> Option<String> {
let json: serde_json::Value = serde_json::from_str(line).ok()?;
let msg = json.get("MESSAGE")?.as_str()?;
if msg.is_empty() {
return None;
}
Some(msg.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_message_from_journald_json() {
let line = r#"{"MESSAGE":"AUR build failed for foo","PRIORITY":"3","_COMM":"makepkg"}"#;
assert_eq!(
extract_message(line),
Some("AUR build failed for foo".to_string())
);
}
#[test]
fn extract_message_missing_returns_none() {
let line = r#"{"PRIORITY":"6","_COMM":"systemd"}"#;
assert_eq!(extract_message(line), None);
}
#[test]
fn extract_message_empty_skipped() {
let line = r#"{"MESSAGE":"","PRIORITY":"6"}"#;
assert_eq!(extract_message(line), None);
}
}

View file

@ -0,0 +1,68 @@
use super::{now_unix, EventSource, SystemEvent};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
pub async fn watch(tx: mpsc::Sender<SystemEvent>) {
let file = match File::open("/dev/kmsg").await {
Ok(f) => f,
Err(e) => {
log::warn!("kmsg watcher: cannot open /dev/kmsg: {e}");
return;
}
};
let mut lines = BufReader::new(file).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(msg) = parse_kmsg(&line) {
let _ = tx
.send(SystemEvent {
source: EventSource::Kmsg,
raw_line: msg,
timestamp: now_unix(),
})
.await;
}
}
}
fn parse_kmsg(line: &str) -> Option<String> {
let msg = if let Some(pos) = line.find(';') {
&line[pos + 1..]
} else {
line
};
if msg.is_empty() {
return None;
}
Some(msg.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_kmsg_line_with_semicolon() {
let line = "6,1234,56789,-;usb 1-1: new full-speed USB device number 2";
assert_eq!(
parse_kmsg(line),
Some("usb 1-1: new full-speed USB device number 2".to_string())
);
}
#[test]
fn parses_kmsg_line_without_semicolon() {
let line = "plain message without header";
assert_eq!(
parse_kmsg(line),
Some("plain message without header".to_string())
);
}
#[test]
fn skips_empty_message_after_semicolon() {
let line = "6,1234,56789,-;";
assert_eq!(parse_kmsg(line), None);
}
}

View file

@ -0,0 +1,85 @@
pub mod inotify;
pub mod journald;
pub mod kmsg;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventSeverity {
Info,
Warn,
Crit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EventSource {
Journald,
Kmsg,
AppLog { app: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct SystemEvent {
pub source: EventSource,
pub raw_line: String,
pub timestamp: u64,
}
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Spawns all watcher tasks. Returns the receiver end of the event channel.
/// `log_paths` comes from the loaded PatternFile.
pub fn spawn(log_paths: HashMap<String, String>) -> mpsc::Receiver<SystemEvent> {
let (tx, rx) = mpsc::channel::<SystemEvent>(256);
let tx_j = tx.clone();
tauri::async_runtime::spawn(async move {
journald::watch(tx_j).await;
});
let tx_k = tx.clone();
tauri::async_runtime::spawn(async move {
kmsg::watch(tx_k).await;
});
tauri::async_runtime::spawn(async move {
inotify::watch(log_paths, tx).await;
});
rx
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_source_can_be_cloned() {
let s = EventSource::AppLog {
app: "steam".into(),
};
let _ = s.clone();
assert!(matches!(s, EventSource::AppLog { .. }));
}
#[test]
fn system_event_constructed() {
let e = SystemEvent {
source: EventSource::Journald,
raw_line: "test line".into(),
timestamp: now_unix(),
};
assert_eq!(e.raw_line, "test line");
assert!(e.timestamp > 0);
}
}

View file

@ -38,6 +38,9 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["deb", "rpm", "appimage"], "targets": ["deb", "rpm", "appimage"],
"resources": {
"patterns/*": "patterns/"
},
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",

View file

@ -33,13 +33,50 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick } from 'vue' import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
interface Message { role: 'user' | 'robin'; content: string } interface Message { role: 'user' | 'robin'; content: string }
interface RobinEvent { pattern_id: string; title: string; body: string; severity: string; timestamp: number }
const messages = ref<Message[]>([]) const messages = ref<Message[]>([])
const input = ref('') const input = ref('')
const messagesEl = ref<HTMLElement | null>(null) const messagesEl = ref<HTMLElement | null>(null)
let unlisten: UnlistenFn | null = null
onMounted(async () => {
// Signal backend: stop queuing events into PENDING while panel is open.
// Must happen before drain so new events that arrive during mount are not
// double-delivered on the next open cycle.
try { await invoke('panel_opened') } catch {}
try {
const pending = await invoke<RobinEvent[]>('get_pending_events')
for (const e of pending) {
pushRobinEvent(e)
}
} catch (err) {
console.warn('Robin: failed to drain pending events:', err)
}
// Always set up the live listener, even if drain failed
unlisten = await listen<RobinEvent>('robin:event', ({ payload }) => {
pushRobinEvent(payload)
})
})
onUnmounted(() => {
unlisten?.()
invoke('panel_closed').catch(() => {})
})
function pushRobinEvent(e: RobinEvent) {
messages.value.push({ role: 'robin', content: `**${e.title}**\n\n${e.body}` })
nextTick(() => {
messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' })
})
}
async function send() { async function send() {
const text = input.value.trim() const text = input.value.trim()