diff --git a/.gitignore b/.gitignore index a37692b..b2f91ef 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ src-tauri/target/ # Secrets .env .env.local + +# Visual companion brainstorm sessions +.superpowers/ 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-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15ee470..0aeb6d2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,8 +24,13 @@ log = "0.4" anyhow = "1.0" tokio = { version = "1", features = ["full"] } toml = "0.8" +notify = "8" +dirs = "6" tauri = { version = "2.11.2", features = ["tray-icon"] } 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/build.rs b/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..6c670aa 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,9 +3,11 @@ "identifier": "default", "description": "enables the default permissions", "windows": [ - "main" + "chat" ], "permissions": [ - "core:default" + "core:default", + "notification:default", + "shell:allow-open" ] } diff --git a/src-tauri/patterns/macos-to-arch.toml b/src-tauri/patterns/macos-to-arch.toml new file mode 100644 index 0000000..c12184a --- /dev/null +++ b/src-tauri/patterns/macos-to-arch.toml @@ -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 " + +[[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" diff --git a/src-tauri/patterns/windows-to-debian.toml b/src-tauri/patterns/windows-to-debian.toml new file mode 100644 index 0000000..29ac17d --- /dev/null +++ b/src-tauri/patterns/windows-to-debian.toml @@ -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." diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0041697..cf96e71 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,6 +1,6 @@ -use crate::config::{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) } @@ -33,11 +37,45 @@ 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()) +} + +#[tauri::command] +pub fn get_pending_events() -> Vec { + 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); +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 82ea7f2..0197af2 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,6 +11,24 @@ pub enum SourceOs { 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)] pub struct MigrationConfig { pub source_os: SourceOs, @@ -37,14 +55,15 @@ impl Default for OllamaConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DisplayConfig { - pub show_notifications: bool, + #[serde(default)] + 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 +74,8 @@ pub struct RobinConfig { pub migration: Option, pub ollama: OllamaConfig, pub display: DisplayConfig, + #[serde(default)] + pub tier: Tier, } impl Default for RobinConfig { @@ -63,6 +84,7 @@ impl Default for RobinConfig { migration: None, ollama: OllamaConfig::default(), display: DisplayConfig::default(), + tier: Tier::Free, } } } @@ -80,8 +102,7 @@ impl RobinConfig { } let content = std::fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; - toml::from_str(&content) - .with_context(|| format!("failed to parse {}", path.display())) + toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display())) } pub fn save(&self) -> Result<()> { @@ -136,4 +157,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 + )); + } } diff --git a/src-tauri/src/distro.rs b/src-tauri/src/distro.rs new file mode 100644 index 0000000..0156d87 --- /dev/null +++ b/src-tauri/src/distro.rs @@ -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"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6673768..5da7576 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,15 @@ mod commands; mod config; +mod distro; +mod notify; +mod patterns; mod tray; 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() { @@ -21,13 +25,57 @@ pub fn run() { }) .setup(|app| { tray::build_tray(&app.handle())?; - watcher::spawn(); + + 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 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(()) }) .invoke_handler(tauri::generate_handler![ commands::get_config, commands::needs_onboarding, commands::complete_onboarding, + commands::update_notification_level, + commands::get_pending_events, + commands::panel_opened, + commands::panel_closed, ]) .run(tauri::generate_context!()) .expect("error while running Robin"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ad5fe83..69c3a72 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs new file mode 100644 index 0000000..7dc9637 --- /dev/null +++ b/src-tauri/src/notify.rs @@ -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> = 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(app: &AppHandle, event: MatchedEvent) { + let level = app + .state::() + .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(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() +} + +#[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"); + } +} diff --git a/src-tauri/src/patterns.rs b/src-tauri/src/patterns.rs new file mode 100644 index 0000000..4821264 --- /dev/null +++ b/src-tauri/src/patterns.rs @@ -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, + 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, +} + +/// 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 { + 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 { + 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")); + } +} 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); +} diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs deleted file mode 100644 index b0d1957..0000000 --- a/src-tauri/src/watcher.rs +++ /dev/null @@ -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"); -} diff --git a/src-tauri/src/watcher/inotify.rs b/src-tauri/src/watcher/inotify.rs new file mode 100644 index 0000000..577b6be --- /dev/null +++ b/src-tauri/src/watcher/inotify.rs @@ -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, 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(); + } + } + } + } + } + }) + .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, 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 = 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); + } +} diff --git a/src-tauri/src/watcher/journald.rs b/src-tauri/src/watcher/journald.rs new file mode 100644 index 0000000..3c170a6 --- /dev/null +++ b/src-tauri/src/watcher/journald.rs @@ -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) { + 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 { + 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); + } +} diff --git a/src-tauri/src/watcher/kmsg.rs b/src-tauri/src/watcher/kmsg.rs new file mode 100644 index 0000000..7dd7582 --- /dev/null +++ b/src-tauri/src/watcher/kmsg.rs @@ -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) { + 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); + } +} 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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d97f2a1..b14185d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -38,6 +38,9 @@ "bundle": { "active": true, "targets": ["deb", "rpm", "appimage"], + "resources": { + "patterns/*": "patterns/" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index a662e61..193c636 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -33,13 +33,50 @@