From c33d4cf07f374344a4f2b40ef0b499c1e17ae3b5 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 14:05:16 -0700 Subject: [PATCH 01/21] chore(m1): add notify dep, fix capabilities window name and permissions --- src-tauri/Cargo.toml | 2 ++ src-tauri/capabilities/default.json | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15ee470..b0753ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,8 @@ log = "0.4" anyhow = "1.0" tokio = { version = "1", features = ["full"] } toml = "0.8" +notify = "6" +dirs = "5" tauri = { version = "2.11.2", features = ["tray-icon"] } tauri-plugin-log = "2" tauri-plugin-notification = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..c8d468b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,9 +3,12 @@ "identifier": "default", "description": "enables the default permissions", "windows": [ - "main" + "chat" ], "permissions": [ - "core:default" + "core:default", + "notification:default", + "fs:read-all", + "shell:allow-execute" ] } -- 2.45.2 From 5216549d0a8f5e02bca7a560f3b817c5f23377d6 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 14:53:05 -0700 Subject: [PATCH 02/21] chore(m1): use notify v8, dirs v6, tighten capabilities permissions --- src-tauri/Cargo.toml | 4 ++-- src-tauri/capabilities/default.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b0753ad..da51793 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,8 +24,8 @@ log = "0.4" anyhow = "1.0" tokio = { version = "1", features = ["full"] } toml = "0.8" -notify = "6" -dirs = "5" +notify = "8" +dirs = "6" tauri = { version = "2.11.2", features = ["tray-icon"] } tauri-plugin-log = "2" tauri-plugin-notification = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c8d468b..6c670aa 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,7 +8,6 @@ "permissions": [ "core:default", "notification:default", - "fs:read-all", - "shell:allow-execute" + "shell:allow-open" ] } -- 2.45.2 From c94fc58296213ecdfc28277f7dd1d30a670c7b4f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 15:17:56 -0700 Subject: [PATCH 03/21] feat(m1): add Tier and NotificationLevel to config --- src-tauri/src/config.rs | 54 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 82ea7f2..c26d65f 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -11,6 +11,22 @@ pub enum SourceOs { Unknown, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Tier { + Free, + Paid, + Premium, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum NotificationLevel { + Off, + BadgeOnly, + BadgeAndToast, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MigrationConfig { pub source_os: SourceOs, @@ -37,14 +53,14 @@ impl Default for OllamaConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DisplayConfig { - pub show_notifications: bool, + pub notification_level: NotificationLevel, pub quiet_mode: bool, } impl Default for DisplayConfig { fn default() -> Self { Self { - show_notifications: true, + notification_level: NotificationLevel::BadgeAndToast, quiet_mode: false, } } @@ -55,6 +71,7 @@ pub struct RobinConfig { pub migration: Option, pub ollama: OllamaConfig, pub display: DisplayConfig, + pub tier: Tier, } impl Default for RobinConfig { @@ -63,6 +80,7 @@ impl Default for RobinConfig { migration: None, ollama: OllamaConfig::default(), display: DisplayConfig::default(), + tier: Tier::Free, } } } @@ -136,4 +154,36 @@ mod tests { let deserialized: RobinConfig = toml::from_str(&serialized).unwrap(); 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 + )); + } } -- 2.45.2 From cee05b5d18bb0a0d051c52a8ffb7ae84e611ec0d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 15:21:03 -0700 Subject: [PATCH 04/21] fix(m1): serde defaults on Tier and NotificationLevel, PartialEq on SourceOs --- src-tauri/src/config.rs | 10 +++++++--- src-tauri/src/lib.rs | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index c26d65f..f2c209d 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum SourceOs { Macos, @@ -11,19 +11,21 @@ pub enum SourceOs { Unknown, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[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)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum NotificationLevel { Off, BadgeOnly, + #[default] BadgeAndToast, } @@ -53,6 +55,7 @@ impl Default for OllamaConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DisplayConfig { + #[serde(default)] pub notification_level: NotificationLevel, pub quiet_mode: bool, } @@ -71,6 +74,7 @@ pub struct RobinConfig { pub migration: Option, pub ollama: OllamaConfig, pub display: DisplayConfig, + #[serde(default)] pub tier: Tier, } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6673768..c292b19 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ use std::sync::Mutex; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // TODO: log a warning when load() fails so users know their config was reset let config = RobinConfig::load().unwrap_or_default(); tauri::Builder::default() -- 2.45.2 From dc45c82aba4824af826abaa58ccb23f54ac4dc33 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 15:53:03 -0700 Subject: [PATCH 05/21] feat(m1): distro detection from /etc/os-release, update_notification_level command --- src-tauri/src/commands.rs | 26 ++++++++++++-- src-tauri/src/distro.rs | 73 +++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 ++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/distro.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0041697..94a7863 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,4 @@ -use crate::config::{RobinConfig, MigrationConfig, SourceOs}; +use crate::config::{NotificationLevel, RobinConfig, MigrationConfig, SourceOs}; use tauri::State; use std::sync::Mutex; @@ -33,11 +33,33 @@ pub fn complete_onboarding( _ => 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())?; config.migration = Some(MigrationConfig { source_os: source, - distro, + distro: detected, fluency_level: 0, }); 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()) +} diff --git a/src-tauri/src/distro.rs b/src-tauri/src/distro.rs new file mode 100644 index 0000000..3a92c35 --- /dev/null +++ b/src-tauri/src/distro.rs @@ -0,0 +1,73 @@ +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" | "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" { + Some(val.trim().trim_matches('"').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"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c292b19..618356e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod commands; mod config; +mod distro; mod tray; mod watcher; @@ -29,6 +30,7 @@ pub fn run() { commands::get_config, commands::needs_onboarding, commands::complete_onboarding, + commands::update_notification_level, ]) .run(tauri::generate_context!()) .expect("error while running Robin"); -- 2.45.2 From a94e3dbb6694ef5d36c792619815edda496da9fe Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 16:55:06 -0700 Subject: [PATCH 06/21] 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 --- src-tauri/src/distro.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/distro.rs b/src-tauri/src/distro.rs index 3a92c35..0156d87 100644 --- a/src-tauri/src/distro.rs +++ b/src-tauri/src/distro.rs @@ -7,9 +7,7 @@ pub fn detect() -> String { pub fn distro_family(id: &str) -> &'static str { match id { "arch" | "cachyos" | "endeavouros" | "manjaro" | "garuda" => "arch", - "debian" | "ubuntu" | "linuxmint" | "pop" | "elementary" | "kali" => { - "debian" - } + "debian" | "ubuntu" | "linuxmint" | "mint" | "pop" | "elementary" | "kali" => "debian", "fedora" | "rhel" | "centos" | "rocky" | "alma" => "fedora", "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => "opensuse", _ => "unknown", @@ -22,7 +20,13 @@ fn parse_id(content: &str) -> String { .find_map(|line| { let (key, val) = line.split_once('=')?; if key.trim() == "ID" { - Some(val.trim().trim_matches('"').to_lowercase()) + 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 } @@ -70,4 +74,15 @@ mod tests { 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"); + } } -- 2.45.2 From 1e733a062bde278f7eddd509ec01b66e26a5ecca Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 16:57:05 -0700 Subject: [PATCH 07/21] =?UTF-8?q?feat(m1):=20pattern=20system=20=E2=80=94?= =?UTF-8?q?=20PatternFile=20loader=20and=20classify()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/lib.rs | 1 + src-tauri/src/patterns.rs | 168 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/watcher.rs | 15 ++-- 3 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/patterns.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 618356e..3fdaa91 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod config; mod distro; +mod patterns; mod tray; mod watcher; diff --git a/src-tauri/src/patterns.rs b/src-tauri/src/patterns.rs new file mode 100644 index 0000000..31032d1 --- /dev/null +++ b/src-tauri/src/patterns.rs @@ -0,0 +1,168 @@ +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, + 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, + #[serde(default)] + pub patterns: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MatchedEvent { + pub pattern_id: String, + pub title: String, + pub body: String, + pub severity: String, + pub timestamp: u64, +} + +pub fn load(source_os: &str, distro_family: &str) -> Result { + let resource_path = format!("patterns/{}-to-{}.toml", source_os, distro_family); + let content = std::fs::read_to_string(&resource_path) + .with_context(|| format!("pattern file not found: {resource_path}"))?; + toml::from_str(&content).with_context(|| format!("failed to parse {resource_path}")) +} + +pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option { + use crate::watcher::EventSource; + + 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::*; + + 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." +"#; + + #[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(), 2); + assert_eq!( + pf.log_paths.get("steam").unwrap(), + "~/.local/share/Steam/logs/content_log.txt" + ); + } + + #[test] + fn classify_matches_journald_event() { + use crate::watcher::{EventSource, SystemEvent}; + let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); + let event = SystemEvent { + source: EventSource::Journald, + raw_line: ":: error: failed to build (foo-git)".into(), + timestamp: 0, + }; + 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() { + use crate::watcher::{EventSource, SystemEvent}; + let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); + let event = SystemEvent { + source: EventSource::Journald, + raw_line: "systemd: Started NetworkManager.service".into(), + timestamp: 0, + }; + assert!(classify(&event, &pf).is_none()); + } + + #[test] + fn classify_applog_matches_correct_source() { + use crate::watcher::{EventSource, SystemEvent}; + let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); + let event = SystemEvent { + source: EventSource::AppLog("steam".into()), + raw_line: "wine: cannot find /run/media/user/game.exe".into(), + timestamp: 0, + }; + let matched = classify(&event, &pf).unwrap(); + assert_eq!(matched.pattern_id, "proton-runtime"); + } + + #[test] + fn classify_does_not_match_wrong_source() { + use crate::watcher::{EventSource, SystemEvent}; + let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); + let event = SystemEvent { + source: EventSource::Journald, + raw_line: "wine: cannot find something".into(), + timestamp: 0, + }; + assert!(classify(&event, &pf).is_none()); + } +} diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index b0d1957..88d72e0 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -2,7 +2,6 @@ /// /// 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)] @@ -13,11 +12,17 @@ pub enum EventSeverity { Crit, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] +pub enum EventSource { + Journald, + Kmsg, + AppLog(String), +} + +#[derive(Debug, Clone)] pub struct SystemEvent { - pub severity: EventSeverity, - pub source: String, - pub message: String, + pub source: EventSource, + pub raw_line: String, pub timestamp: u64, } -- 2.45.2 From 6acf085c0f7033c11ca8a7e36e9f740d17ed13e4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:08:25 -0700 Subject: [PATCH 08/21] 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 --- src-tauri/src/patterns.rs | 168 +++++++++++++++++++++++++++++++------- src-tauri/src/watcher.rs | 7 +- 2 files changed, 141 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/patterns.rs b/src-tauri/src/patterns.rs index 31032d1..13b27c5 100644 --- a/src-tauri/src/patterns.rs +++ b/src-tauri/src/patterns.rs @@ -1,3 +1,4 @@ +use crate::watcher::{EventSource, SystemEvent}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -36,16 +37,34 @@ pub struct MatchedEvent { pub timestamp: u64, } +/// Load the pattern file for a source OS and distro family. +/// +/// Path is relative to the working directory. In tests this is the crate root +/// (where `patterns/` lives as a sibling of `src/`). At runtime, Task 10 resolves +/// the path via `tauri::path::BaseDirectory::Resource` before calling this function. pub fn load(source_os: &str, distro_family: &str) -> Result { - let resource_path = format!("patterns/{}-to-{}.toml", source_os, distro_family); + let resource_path = format!("patterns/{source_os}-to-{distro_family}.toml"); let content = std::fs::read_to_string(&resource_path) .with_context(|| format!("pattern file not found: {resource_path}"))?; - toml::from_str(&content).with_context(|| format!("failed to parse {resource_path}")) + let pf: PatternFile = + toml::from_str(&content).with_context(|| format!("failed to parse {resource_path}"))?; + for p in &pf.patterns { + anyhow::ensure!( + !p.match_text.is_empty(), + "pattern '{}' has empty match_text in {resource_path}", + p.id + ); + anyhow::ensure!( + !p.sources.is_empty(), + "pattern '{}' has empty sources list in {resource_path}", + p.id + ); + } + Ok(pf) } -pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option { - use crate::watcher::EventSource; - +#[must_use] +pub fn classify(event: &SystemEvent, pf: &PatternFile) -> Option { for pattern in &pf.patterns { if !event.raw_line.contains(&pattern.match_text) { continue; @@ -58,7 +77,7 @@ pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option match &event.source { EventSource::Journald => s == "journald", EventSource::Kmsg => s == "kmsg", - EventSource::AppLog(app) => s == &format!("applog:{app}"), + EventSource::AppLog { app } => s == &format!("applog:{app}"), } }); @@ -78,6 +97,7 @@ pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option #[cfg(test)] mod tests { use super::*; + use crate::watcher::{EventSource, SystemEvent}; const FIXTURE: &str = r#" [meta] @@ -102,13 +122,29 @@ 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(), 2); + assert_eq!(pf.patterns.len(), 3); assert_eq!( pf.log_paths.get("steam").unwrap(), "~/.local/share/Steam/logs/content_log.txt" @@ -117,13 +153,8 @@ body = "Steam Proton couldn't find a required file." #[test] fn classify_matches_journald_event() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::Journald, - raw_line: ":: error: failed to build (foo-git)".into(), - timestamp: 0, - }; + 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"); @@ -131,38 +162,113 @@ body = "Steam Proton couldn't find a required file." #[test] fn classify_no_match_returns_none() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::Journald, - raw_line: "systemd: Started NetworkManager.service".into(), - timestamp: 0, - }; + let event = make_event( + EventSource::Journald, + "systemd: Started NetworkManager.service", + ); assert!(classify(&event, &pf).is_none()); } #[test] fn classify_applog_matches_correct_source() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::AppLog("steam".into()), - raw_line: "wine: cannot find /run/media/user/game.exe".into(), - timestamp: 0, - }; + 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() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::Journald, - raw_line: "wine: cannot find something".into(), - timestamp: 0, - }; + // 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")); + } } diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index 88d72e0..9512fde 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -12,14 +12,15 @@ pub enum EventSeverity { Crit, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum EventSource { Journald, Kmsg, - AppLog(String), + AppLog { app: String }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct SystemEvent { pub source: EventSource, pub raw_line: String, -- 2.45.2 From d1bea474952e637466046581e08a9639cadc4919 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:10:37 -0700 Subject: [PATCH 09/21] 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. Updates lib.rs call site. --- src-tauri/src/lib.rs | 2 +- src-tauri/src/watcher.rs | 38 -------------- src-tauri/src/watcher/inotify.rs | 7 +++ src-tauri/src/watcher/journald.rs | 6 +++ src-tauri/src/watcher/kmsg.rs | 6 +++ src-tauri/src/watcher/mod.rs | 85 +++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 39 deletions(-) delete mode 100644 src-tauri/src/watcher.rs create mode 100644 src-tauri/src/watcher/inotify.rs create mode 100644 src-tauri/src/watcher/journald.rs create mode 100644 src-tauri/src/watcher/kmsg.rs create mode 100644 src-tauri/src/watcher/mod.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3fdaa91..ae63786 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,7 +24,7 @@ pub fn run() { }) .setup(|app| { tray::build_tray(&app.handle())?; - watcher::spawn(); + watcher::spawn(std::collections::HashMap::new()); Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs deleted file mode 100644 index 9512fde..0000000 --- a/src-tauri/src/watcher.rs +++ /dev/null @@ -1,38 +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)] -#[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, -} - -/// 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"); -} diff --git a/src-tauri/src/watcher/inotify.rs b/src-tauri/src/watcher/inotify.rs new file mode 100644 index 0000000..ecf30e2 --- /dev/null +++ b/src-tauri/src/watcher/inotify.rs @@ -0,0 +1,7 @@ +use super::SystemEvent; +use std::collections::HashMap; +use tokio::sync::mpsc; + +pub async fn watch(_log_paths: HashMap, _tx: mpsc::Sender) { + // implemented in Task 8 +} diff --git a/src-tauri/src/watcher/journald.rs b/src-tauri/src/watcher/journald.rs new file mode 100644 index 0000000..01f1d00 --- /dev/null +++ b/src-tauri/src/watcher/journald.rs @@ -0,0 +1,6 @@ +use super::SystemEvent; +use tokio::sync::mpsc; + +pub async fn watch(_tx: mpsc::Sender) { + // implemented in Task 6 +} diff --git a/src-tauri/src/watcher/kmsg.rs b/src-tauri/src/watcher/kmsg.rs new file mode 100644 index 0000000..3c2c47d --- /dev/null +++ b/src-tauri/src/watcher/kmsg.rs @@ -0,0 +1,6 @@ +use super::SystemEvent; +use tokio::sync::mpsc; + +pub async fn watch(_tx: mpsc::Sender) { + // implemented in Task 7 +} diff --git a/src-tauri/src/watcher/mod.rs b/src-tauri/src/watcher/mod.rs new file mode 100644 index 0000000..fd48b19 --- /dev/null +++ b/src-tauri/src/watcher/mod.rs @@ -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) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel::(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); + } +} -- 2.45.2 From e48536dfbe49f09e54e9c4c3a6e7cb03513ec4d0 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:13:23 -0700 Subject: [PATCH 10/21] =?UTF-8?q?feat(m1):=20journald=20watcher=20?= =?UTF-8?q?=E2=80=94=20streams=20journalctl=20JSON=20to=20event=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/watcher/journald.rs | 71 +++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/watcher/journald.rs b/src-tauri/src/watcher/journald.rs index 01f1d00..c1c3564 100644 --- a/src-tauri/src/watcher/journald.rs +++ b/src-tauri/src/watcher/journald.rs @@ -1,6 +1,71 @@ -use super::SystemEvent; +use super::{EventSource, SystemEvent, now_unix}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; use tokio::sync::mpsc; -pub async fn watch(_tx: mpsc::Sender) { - // implemented in Task 6 +pub async fn watch(tx: mpsc::Sender) { + 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; + } + } +} + +fn extract_message(line: &str) -> Option { + 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); + } } -- 2.45.2 From db7d30d4c18e76eabdd2bb5dcd67c5121aec854b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:13:36 -0700 Subject: [PATCH 11/21] =?UTF-8?q?feat(m1):=20kmsg=20watcher=20=E2=80=94=20?= =?UTF-8?q?reads=20/dev/kmsg=20kernel=20ring=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/watcher/kmsg.rs | 65 +++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/watcher/kmsg.rs b/src-tauri/src/watcher/kmsg.rs index 3c2c47d..f3c524c 100644 --- a/src-tauri/src/watcher/kmsg.rs +++ b/src-tauri/src/watcher/kmsg.rs @@ -1,6 +1,65 @@ -use super::SystemEvent; +use super::{EventSource, SystemEvent, now_unix}; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::mpsc; -pub async fn watch(_tx: mpsc::Sender) { - // implemented in Task 7 +pub async fn watch(tx: mpsc::Sender) { + 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 { + 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); + } } -- 2.45.2 From 7eaf22c130b2016ff0ce91f5bc67c363987040dd Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:14:10 -0700 Subject: [PATCH 12/21] =?UTF-8?q?feat(m1):=20inotify=20app=20log=20watcher?= =?UTF-8?q?=20=E2=80=94=20tails=20log=20files=20for=20known=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.toml | 3 + src-tauri/src/watcher/inotify.rs | 120 ++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da51793..0aeb6d2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,3 +31,6 @@ tauri-plugin-log = "2" tauri-plugin-notification = "2" tauri-plugin-shell = "2" tauri-plugin-fs = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/src/watcher/inotify.rs b/src-tauri/src/watcher/inotify.rs index ecf30e2..3013ad2 100644 --- a/src-tauri/src/watcher/inotify.rs +++ b/src-tauri/src/watcher/inotify.rs @@ -1,7 +1,121 @@ -use super::SystemEvent; +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, _tx: mpsc::Sender) { - // implemented in Task 8 +pub async fn watch(log_paths: HashMap, tx: mpsc::Sender) { + if log_paths.is_empty() { + return; + } + + let expanded: HashMap = 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 = 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(); + } + } + } + } + } + }); +} + +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, 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 content = String::new(); + let _ = file.read_to_string(&mut content); + let new_pos = from_byte + content.len() as u64; + let lines: Vec = 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); + } } -- 2.45.2 From 3c7796968003c2194217b8b9892c06cd061b1b9d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:25:30 -0700 Subject: [PATCH 13/21] =?UTF-8?q?fix(m1):=20inotify=20=E2=80=94=20use=20re?= =?UTF-8?q?ad=5Fto=5Fend=20for=20UTF-8=20resilience,=20await=20spawn=5Fblo?= =?UTF-8?q?cking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src-tauri/src/watcher/inotify.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/watcher/inotify.rs b/src-tauri/src/watcher/inotify.rs index 3013ad2..577b6be 100644 --- a/src-tauri/src/watcher/inotify.rs +++ b/src-tauri/src/watcher/inotify.rs @@ -57,7 +57,9 @@ pub async fn watch(log_paths: HashMap, tx: mpsc::Sender String { @@ -77,9 +79,10 @@ pub fn read_new_lines(path: &str, from_byte: u64) -> (Vec, u64) { if file.seek(std::io::SeekFrom::Start(from_byte)).is_err() { return (vec![], from_byte); } - let mut content = String::new(); - let _ = file.read_to_string(&mut content); - let new_pos = from_byte + content.len() as u64; + 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 = content .lines() .filter(|l| !l.is_empty()) -- 2.45.2 From 3b7653d731bf15a49e533cf3c00afb4d21410525 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:27:41 -0700 Subject: [PATCH 14/21] =?UTF-8?q?feat(m1):=20notification=20delivery=20?= =?UTF-8?q?=E2=80=94=20tray=20badge,=20desktop=20toast,=20pending=20event?= =?UTF-8?q?=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands.rs | 22 ++++++++++------- src-tauri/src/lib.rs | 2 ++ src-tauri/src/notify.rs | 50 +++++++++++++++++++++++++++++++++++++++ src-tauri/src/tray.rs | 32 ++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/notify.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 94a7863..5900532 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,6 +1,6 @@ -use crate::config::{NotificationLevel, RobinConfig, MigrationConfig, SourceOs}; -use tauri::State; +use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs}; use std::sync::Mutex; +use tauri::State; pub struct AppState { pub config: Mutex, @@ -8,14 +8,18 @@ pub struct AppState { #[tauri::command] pub fn get_config(state: State<'_, AppState>) -> Result { - state.config.lock() + state + .config + .lock() .map(|c| c.clone()) .map_err(|e| e.to_string()) } #[tauri::command] pub fn needs_onboarding(state: State<'_, AppState>) -> bool { - state.config.lock() + state + .config + .lock() .map(|c| c.needs_onboarding()) .unwrap_or(true) } @@ -49,10 +53,7 @@ pub fn complete_onboarding( } #[tauri::command] -pub fn update_notification_level( - level: String, - state: State<'_, AppState>, -) -> Result<(), String> { +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, @@ -63,3 +64,8 @@ pub fn update_notification_level( config.display.notification_level = parsed; config.save().map_err(|e| e.to_string()) } + +#[tauri::command] +pub fn get_pending_events() -> Vec { + crate::notify::take_pending() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae63786..4d3ae38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod config; mod distro; +mod notify; mod patterns; mod tray; mod watcher; @@ -32,6 +33,7 @@ pub fn run() { commands::needs_onboarding, commands::complete_onboarding, commands::update_notification_level, + commands::get_pending_events, ]) .run(tauri::generate_context!()) .expect("error while running Robin"); diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs new file mode 100644 index 0000000..2500e26 --- /dev/null +++ b/src-tauri/src/notify.rs @@ -0,0 +1,50 @@ +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> = Mutex::new(vec![]); + +pub fn dispatch(app: &AppHandle, event: MatchedEvent) { + let level = { + let state = app.state::(); + let config = state.config.lock().unwrap(); + config.display.notification_level.clone() + }; + + if let Ok(mut pending) = PENDING.lock() { + pending.push(event.clone()); + } + + 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(app: &AppHandle, event: &MatchedEvent) { + let _ = app + .notification() + .builder() + .title(&event.title) + .body(&event.body) + .show(); +} + +pub fn take_pending() -> Vec { + PENDING + .lock() + .map(|mut v| std::mem::take(&mut *v)) + .unwrap_or_default() +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 2633957..2c5f850 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, @@ -34,7 +35,7 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { Ok(()) } -fn toggle_chat_panel(app: &AppHandle) { +pub fn toggle_chat_panel(app: &AppHandle) { if let Some(window) = app.get_webview_window("chat") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); @@ -44,3 +45,32 @@ fn toggle_chat_panel(app: &AppHandle) { } } } + +static BADGE_ACTIVE: AtomicBool = AtomicBool::new(false); + +pub fn badge_on(app: &AppHandle) { + 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(app: &AppHandle) { + 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(app: &AppHandle) { + badge_off(app); + toggle_chat_panel(app); +} -- 2.45.2 From c0f046bd7cdd4a3b5dd8e1084b8ddc75ef0c23f5 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 18:07:34 -0700 Subject: [PATCH 15/21] =?UTF-8?q?fix(m1):=20notify=20=E2=80=94=20unwrap=5F?= =?UTF-8?q?or=5Fdefault=20on=20poisoned=20config=20lock,=20log=20poisoned?= =?UTF-8?q?=20PENDING,=20add=20take=5Fpending=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/notify.rs | 49 +++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index 2500e26..1cfc57a 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -9,14 +9,16 @@ use crate::patterns::MatchedEvent; static PENDING: Mutex> = Mutex::new(vec![]); pub fn dispatch(app: &AppHandle, event: MatchedEvent) { - let level = { - let state = app.state::(); - let config = state.config.lock().unwrap(); - config.display.notification_level.clone() - }; + let level = app + .state::() + .config + .lock() + .map(|c| c.display.notification_level.clone()) + .unwrap_or_default(); - if let Ok(mut pending) = PENDING.lock() { - pending.push(event.clone()); + match PENDING.lock() { + Ok(mut pending) => pending.push(event.clone()), + Err(e) => log::warn!("notify: pending queue lock poisoned — event dropped: {e}"), } match level { @@ -48,3 +50,36 @@ pub fn take_pending() -> Vec { .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"); + } +} -- 2.45.2 From 2706b2367d43e3bf675564bce8e2bce232219ad0 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 18:09:33 -0700 Subject: [PATCH 16/21] =?UTF-8?q?feat(m1):=20wire=20classifier=20loop=20?= =?UTF-8?q?=E2=80=94=20pattern=20file=20loads=20on=20startup,=20events=20d?= =?UTF-8?q?ispatch=20to=20notify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src-tauri/src/lib.rs | 45 +++++++++++++++++++++++++++++++++--- src-tauri/src/patterns.rs | 48 +++++++++++++++++++++++---------------- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d3ae38..b26a24f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,11 +8,11 @@ mod watcher; use commands::AppState; use config::RobinConfig; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; +use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // TODO: log a warning when load() fails so users know their config was reset let config = RobinConfig::load().unwrap_or_default(); tauri::Builder::default() @@ -25,7 +25,46 @@ pub fn run() { }) .setup(|app| { tray::build_tray(&app.handle())?; - watcher::spawn(std::collections::HashMap::new()); + + let state = app.state::(); + 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 mut rx = watcher::spawn(log_paths); + let pf = Arc::new(pattern_file); + let app_handle = app.handle().clone(); + + tauri::async_runtime::spawn(async move { + 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(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/patterns.rs b/src-tauri/src/patterns.rs index 13b27c5..7310d89 100644 --- a/src-tauri/src/patterns.rs +++ b/src-tauri/src/patterns.rs @@ -39,28 +39,36 @@ pub struct MatchedEvent { /// Load the pattern file for a source OS and distro family. /// -/// Path is relative to the working directory. In tests this is the crate root -/// (where `patterns/` lives as a sibling of `src/`). At runtime, Task 10 resolves -/// the path via `tauri::path::BaseDirectory::Resource` before calling this function. +/// 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 { - let resource_path = format!("patterns/{source_os}-to-{distro_family}.toml"); - let content = std::fs::read_to_string(&resource_path) - .with_context(|| format!("pattern file not found: {resource_path}"))?; - let pf: PatternFile = - toml::from_str(&content).with_context(|| format!("failed to parse {resource_path}"))?; - for p in &pf.patterns { - anyhow::ensure!( - !p.match_text.is_empty(), - "pattern '{}' has empty match_text in {resource_path}", - p.id - ); - anyhow::ensure!( - !p.sources.is_empty(), - "pattern '{}' has empty sources list in {resource_path}", - p.id - ); + 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 { + if let Ok(content) = std::fs::read_to_string(path) { + let pf: PatternFile = + toml::from_str(&content).with_context(|| format!("failed to parse {path}"))?; + 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); + } } - Ok(pf) + anyhow::bail!("pattern file not found: {filename}") } #[must_use] -- 2.45.2 From c3958553a5c06f9f8cf3d8578c21e195a900ef65 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 18:22:50 -0700 Subject: [PATCH 17/21] fix(m1): move mut rx inside async block (clippy), log+continue on parse errors in load() --- src-tauri/src/lib.rs | 3 ++- src-tauri/src/patterns.rs | 37 ++++++++++++++++++++++--------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b26a24f..b8f4429 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,11 +51,12 @@ pub fn run() { .map(|pf| pf.log_paths.clone()) .unwrap_or_default(); - let mut rx = watcher::spawn(log_paths); + 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) { diff --git a/src-tauri/src/patterns.rs b/src-tauri/src/patterns.rs index 7310d89..4821264 100644 --- a/src-tauri/src/patterns.rs +++ b/src-tauri/src/patterns.rs @@ -50,23 +50,30 @@ pub fn load(source_os: &str, distro_family: &str) -> Result { format!("/usr/share/robin/patterns/{filename}"), ]; for path in &candidates { - if let Ok(content) = std::fs::read_to_string(path) { - let pf: PatternFile = - toml::from_str(&content).with_context(|| format!("failed to parse {path}"))?; - 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 - ); + 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; } - return Ok(pf); + }; + 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}") } -- 2.45.2 From 88924aa593f7ffbdeceaa2f3d5596e415de1fbd7 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 18:24:34 -0700 Subject: [PATCH 18/21] feat(m1): ChatPanel listens for robin:event, drains pending on open --- package-lock.json | 11 +++++++++++ package.json | 1 + src/components/ChatPanel.vue | 30 +++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index abe2a6f..75332bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "robin", "version": "0.0.0", "dependencies": { + "@tauri-apps/api": "^2.11.0", "vue": "^3.5.34" }, "devDependencies": { @@ -398,6 +399,16 @@ "dev": true, "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": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", diff --git a/package.json b/package.json index 5880397..fa81de1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@tauri-apps/api": "^2.11.0", "vue": "^3.5.34" }, "devDependencies": { diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index a662e61..4a09c9d 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -33,13 +33,41 @@