feat(robin): M1 System Presence — journald/kmsg/inotify watcher, pattern classifier, tray badge, chat panel #2
23 changed files with 1193 additions and 50 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -29,3 +29,6 @@ src-tauri/target/
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# Visual companion brainstorm sessions
|
||||||
|
.superpowers/
|
||||||
|
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "robin",
|
"name": "robin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -398,6 +399,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,13 @@ log = "0.4"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
notify = "8"
|
||||||
|
dirs = "6"
|
||||||
tauri = { version = "2.11.2", features = ["tray-icon"] }
|
tauri = { version = "2.11.2", features = ["tray-icon"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "enables the default permissions",
|
"description": "enables the default permissions",
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"chat"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default"
|
"core:default",
|
||||||
|
"notification:default",
|
||||||
|
"shell:allow-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
src-tauri/patterns/macos-to-arch.toml
Normal file
65
src-tauri/patterns/macos-to-arch.toml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
[meta]
|
||||||
|
source_os = "macos"
|
||||||
|
target_distro_family = "arch"
|
||||||
|
|
||||||
|
[log_paths]
|
||||||
|
steam = "~/.local/share/Steam/logs/content_log.txt"
|
||||||
|
proton = "~/.local/share/Steam/logs/proton_log.txt"
|
||||||
|
retroarch = "~/.config/retroarch/retroarch.log"
|
||||||
|
lutris = "~/.cache/lutris/logs/lutris.log"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "aur-build-failure"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "error: failed to build"
|
||||||
|
severity = "warn"
|
||||||
|
title = "AUR package build failed"
|
||||||
|
body = "A package failed to compile from source. This usually means a missing dependency or a broken AUR package."
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "aur-pgp-key"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "unknown public key"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Missing PGP key for AUR package"
|
||||||
|
body = "The package signature can't be verified. Run: gpg --recv-keys <keyid>"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "proton-runtime-missing"
|
||||||
|
sources = ["applog:proton"]
|
||||||
|
match_text = "wine: cannot find"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Proton runtime issue"
|
||||||
|
body = "Steam Proton couldn't find a required file. Try: right-click the game -> Properties -> Local Files -> Verify game files."
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "steam-disk-write"
|
||||||
|
sources = ["applog:steam"]
|
||||||
|
match_text = "ERROR: failed to write"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Steam disk write error"
|
||||||
|
body = "Steam can't write to its library folder. Check that you own the directory: ls -la ~/.local/share/Steam"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "retroarch-shader-fail"
|
||||||
|
sources = ["applog:retroarch"]
|
||||||
|
match_text = "Failed to compile shader"
|
||||||
|
severity = "info"
|
||||||
|
title = "RetroArch shader failed to compile"
|
||||||
|
body = "A graphical shader couldn't load. Try switching to a different shader preset in Settings -> Video -> Shaders."
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "kernel-driver-firmware"
|
||||||
|
sources = ["kmsg"]
|
||||||
|
match_text = "firmware: failed to load"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Missing firmware for hardware"
|
||||||
|
body = "Your system is missing a firmware file for a hardware component. On Arch, try: sudo pacman -S linux-firmware"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "missing-codec"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "GStreamer: Failed to find plugin"
|
||||||
|
severity = "info"
|
||||||
|
title = "Missing media codec"
|
||||||
|
body = "A media codec isn't installed. On Arch: sudo pacman -S gst-plugins-good gst-plugins-bad gst-libav"
|
||||||
56
src-tauri/patterns/windows-to-debian.toml
Normal file
56
src-tauri/patterns/windows-to-debian.toml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
[meta]
|
||||||
|
source_os = "windows"
|
||||||
|
target_distro_family = "debian"
|
||||||
|
|
||||||
|
[log_paths]
|
||||||
|
steam = "~/.local/share/Steam/logs/content_log.txt"
|
||||||
|
proton = "~/.local/share/Steam/logs/proton_log.txt"
|
||||||
|
retroarch = "~/.config/retroarch/retroarch.log"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "apt-lock"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "Could not get lock /var/lib/dpkg/lock"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Package manager is locked"
|
||||||
|
body = "Another process is using apt. Wait a moment and try again. If it's stuck: sudo rm /var/lib/dpkg/lock-frontend"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "apt-unmet-dependency"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "Unmet dependencies"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Package dependency conflict"
|
||||||
|
body = "apt can't resolve a dependency. Try: sudo apt --fix-broken install"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "proton-runtime-missing"
|
||||||
|
sources = ["applog:proton"]
|
||||||
|
match_text = "wine: cannot find"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Proton runtime issue"
|
||||||
|
body = "Steam Proton couldn't find a required file. Right-click the game -> Properties -> Local Files -> Verify game files."
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "kernel-driver-firmware"
|
||||||
|
sources = ["kmsg"]
|
||||||
|
match_text = "firmware: failed to load"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Missing firmware for hardware"
|
||||||
|
body = "Your system is missing a firmware file. On Debian/Ubuntu/Mint: sudo apt install firmware-linux firmware-linux-nonfree"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "missing-codec"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "GStreamer: Failed to find plugin"
|
||||||
|
severity = "info"
|
||||||
|
title = "Missing media codec"
|
||||||
|
body = "A media codec isn't installed. Try: sudo apt install ubuntu-restricted-extras"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "snap-confinement"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "snap: cannot use strict"
|
||||||
|
severity = "info"
|
||||||
|
title = "Snap package confinement issue"
|
||||||
|
body = "A Snap package is having permission trouble. Try running it with --devmode, or look for a Flatpak or apt alternative."
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::config::{RobinConfig, MigrationConfig, SourceOs};
|
use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs};
|
||||||
use tauri::State;
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<RobinConfig>,
|
pub config: Mutex<RobinConfig>,
|
||||||
|
|
@ -8,14 +8,18 @@ pub struct AppState {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_config(state: State<'_, AppState>) -> Result<RobinConfig, String> {
|
pub fn get_config(state: State<'_, AppState>) -> Result<RobinConfig, String> {
|
||||||
state.config.lock()
|
state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
.map(|c| c.clone())
|
.map(|c| c.clone())
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn needs_onboarding(state: State<'_, AppState>) -> bool {
|
pub fn needs_onboarding(state: State<'_, AppState>) -> bool {
|
||||||
state.config.lock()
|
state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
.map(|c| c.needs_onboarding())
|
.map(|c| c.needs_onboarding())
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
@ -33,11 +37,45 @@ pub fn complete_onboarding(
|
||||||
_ => SourceOs::Unknown,
|
_ => SourceOs::Unknown,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let detected = if distro == "unknown" || distro.is_empty() {
|
||||||
|
crate::distro::detect()
|
||||||
|
} else {
|
||||||
|
distro
|
||||||
|
};
|
||||||
|
|
||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.migration = Some(MigrationConfig {
|
config.migration = Some(MigrationConfig {
|
||||||
source_os: source,
|
source_os: source,
|
||||||
distro,
|
distro: detected,
|
||||||
fluency_level: 0,
|
fluency_level: 0,
|
||||||
});
|
});
|
||||||
config.save().map_err(|e| e.to_string())
|
config.save().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_notification_level(level: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let parsed = match level.as_str() {
|
||||||
|
"off" => NotificationLevel::Off,
|
||||||
|
"badge_only" => NotificationLevel::BadgeOnly,
|
||||||
|
"badge_and_toast" => NotificationLevel::BadgeAndToast,
|
||||||
|
_ => return Err(format!("unknown notification level: {level}")),
|
||||||
|
};
|
||||||
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
config.display.notification_level = parsed;
|
||||||
|
config.save().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_pending_events() -> Vec<crate::patterns::MatchedEvent> {
|
||||||
|
crate::notify::take_pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn panel_opened() {
|
||||||
|
crate::notify::set_panel_open(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn panel_closed() {
|
||||||
|
crate::notify::set_panel_open(false);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SourceOs {
|
pub enum SourceOs {
|
||||||
Macos,
|
Macos,
|
||||||
|
|
@ -11,6 +11,24 @@ pub enum SourceOs {
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Tier {
|
||||||
|
#[default]
|
||||||
|
Free,
|
||||||
|
Paid,
|
||||||
|
Premium,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NotificationLevel {
|
||||||
|
Off,
|
||||||
|
BadgeOnly,
|
||||||
|
#[default]
|
||||||
|
BadgeAndToast,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MigrationConfig {
|
pub struct MigrationConfig {
|
||||||
pub source_os: SourceOs,
|
pub source_os: SourceOs,
|
||||||
|
|
@ -37,14 +55,15 @@ impl Default for OllamaConfig {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DisplayConfig {
|
pub struct DisplayConfig {
|
||||||
pub show_notifications: bool,
|
#[serde(default)]
|
||||||
|
pub notification_level: NotificationLevel,
|
||||||
pub quiet_mode: bool,
|
pub quiet_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DisplayConfig {
|
impl Default for DisplayConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
show_notifications: true,
|
notification_level: NotificationLevel::BadgeAndToast,
|
||||||
quiet_mode: false,
|
quiet_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +74,8 @@ pub struct RobinConfig {
|
||||||
pub migration: Option<MigrationConfig>,
|
pub migration: Option<MigrationConfig>,
|
||||||
pub ollama: OllamaConfig,
|
pub ollama: OllamaConfig,
|
||||||
pub display: DisplayConfig,
|
pub display: DisplayConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tier: Tier,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RobinConfig {
|
impl Default for RobinConfig {
|
||||||
|
|
@ -63,6 +84,7 @@ impl Default for RobinConfig {
|
||||||
migration: None,
|
migration: None,
|
||||||
ollama: OllamaConfig::default(),
|
ollama: OllamaConfig::default(),
|
||||||
display: DisplayConfig::default(),
|
display: DisplayConfig::default(),
|
||||||
|
tier: Tier::Free,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,8 +102,7 @@ impl RobinConfig {
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(&path)
|
let content = std::fs::read_to_string(&path)
|
||||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
toml::from_str(&content)
|
toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))
|
||||||
.with_context(|| format!("failed to parse {}", path.display()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
|
|
@ -136,4 +157,36 @@ mod tests {
|
||||||
let deserialized: RobinConfig = toml::from_str(&serialized).unwrap();
|
let deserialized: RobinConfig = toml::from_str(&serialized).unwrap();
|
||||||
assert!(!deserialized.needs_onboarding());
|
assert!(!deserialized.needs_onboarding());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_level_default_is_badge_and_toast() {
|
||||||
|
let config = RobinConfig::default();
|
||||||
|
assert!(matches!(
|
||||||
|
config.display.notification_level,
|
||||||
|
NotificationLevel::BadgeAndToast
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_default_is_free() {
|
||||||
|
let config = RobinConfig::default();
|
||||||
|
assert!(matches!(config.tier, Tier::Free));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_level_roundtrips_toml() {
|
||||||
|
let config = RobinConfig {
|
||||||
|
display: DisplayConfig {
|
||||||
|
notification_level: NotificationLevel::BadgeOnly,
|
||||||
|
quiet_mode: false,
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let toml = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let back: RobinConfig = toml::from_str(&toml).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
back.display.notification_level,
|
||||||
|
NotificationLevel::BadgeOnly
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
src-tauri/src/distro.rs
Normal file
88
src-tauri/src/distro.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
pub fn detect() -> String {
|
||||||
|
std::fs::read_to_string("/etc/os-release")
|
||||||
|
.map(|s| parse_id(&s))
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn distro_family(id: &str) -> &'static str {
|
||||||
|
match id {
|
||||||
|
"arch" | "cachyos" | "endeavouros" | "manjaro" | "garuda" => "arch",
|
||||||
|
"debian" | "ubuntu" | "linuxmint" | "mint" | "pop" | "elementary" | "kali" => "debian",
|
||||||
|
"fedora" | "rhel" | "centos" | "rocky" | "alma" => "fedora",
|
||||||
|
"opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => "opensuse",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_id(content: &str) -> String {
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
let (key, val) = line.split_once('=')?;
|
||||||
|
if key.trim() == "ID" {
|
||||||
|
let raw = val.trim();
|
||||||
|
let unquoted = raw
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|s| s.strip_suffix('"'))
|
||||||
|
.or_else(|| raw.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
|
||||||
|
.unwrap_or(raw);
|
||||||
|
Some(unquoted.to_lowercase())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_arch_id() {
|
||||||
|
let content = "ID=arch\nID_LIKE=\nPRETTY_NAME=Arch Linux\n";
|
||||||
|
assert_eq!(parse_id(content), "arch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cachyos_id() {
|
||||||
|
let content = "ID=cachyos\nID_LIKE=arch\nPRETTY_NAME=CachyOS\n";
|
||||||
|
assert_eq!(parse_id(content), "cachyos");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_linuxmint_id() {
|
||||||
|
let content = "ID=linuxmint\nID_LIKE=ubuntu\nPRETTY_NAME=Linux Mint 22\n";
|
||||||
|
assert_eq!(parse_id(content), "linuxmint");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distro_family_arch_based() {
|
||||||
|
assert_eq!(distro_family("cachyos"), "arch");
|
||||||
|
assert_eq!(distro_family("arch"), "arch");
|
||||||
|
assert_eq!(distro_family("manjaro"), "arch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distro_family_debian_based() {
|
||||||
|
assert_eq!(distro_family("linuxmint"), "debian");
|
||||||
|
assert_eq!(distro_family("ubuntu"), "debian");
|
||||||
|
assert_eq!(distro_family("debian"), "debian");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distro_family_unknown() {
|
||||||
|
assert_eq!(distro_family("slackware"), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_single_quoted_id() {
|
||||||
|
let content = "ID='arch'\nPRETTY_NAME='Arch Linux'\n";
|
||||||
|
assert_eq!(parse_id(content), "arch");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distro_family_mint_legacy() {
|
||||||
|
assert_eq!(distro_family("mint"), "debian");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod distro;
|
||||||
|
mod notify;
|
||||||
|
mod patterns;
|
||||||
mod tray;
|
mod tray;
|
||||||
mod watcher;
|
mod watcher;
|
||||||
|
|
||||||
use commands::AppState;
|
use commands::AppState;
|
||||||
use config::RobinConfig;
|
use config::RobinConfig;
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
|
@ -21,13 +25,57 @@ pub fn run() {
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
tray::build_tray(&app.handle())?;
|
tray::build_tray(&app.handle())?;
|
||||||
watcher::spawn();
|
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let cfg = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let pattern_file = if let Some(ref migration) = cfg.migration {
|
||||||
|
let family = distro::distro_family(&migration.distro);
|
||||||
|
let source = match migration.source_os {
|
||||||
|
config::SourceOs::Macos => "macos",
|
||||||
|
config::SourceOs::Windows => "windows",
|
||||||
|
config::SourceOs::Linux => "linux",
|
||||||
|
config::SourceOs::Unknown => "unknown",
|
||||||
|
};
|
||||||
|
patterns::load(source, family).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let log_paths = pattern_file
|
||||||
|
.as_ref()
|
||||||
|
.map(|pf| pf.log_paths.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let rx = watcher::spawn(log_paths);
|
||||||
|
let pf = Arc::new(pattern_file);
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut rx = rx;
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
if let Some(ref pf) = *pf {
|
||||||
|
if let Some(matched) = patterns::classify(&event, pf) {
|
||||||
|
notify::dispatch(&app_handle, matched);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::get_config,
|
commands::get_config,
|
||||||
commands::needs_onboarding,
|
commands::needs_onboarding,
|
||||||
commands::complete_onboarding,
|
commands::complete_onboarding,
|
||||||
|
commands::update_notification_level,
|
||||||
|
commands::get_pending_events,
|
||||||
|
commands::panel_opened,
|
||||||
|
commands::panel_closed,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Robin");
|
.expect("error while running Robin");
|
||||||
|
|
|
||||||
95
src-tauri/src/notify.rs
Normal file
95
src-tauri/src/notify.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter, Runtime};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
|
use crate::commands::AppState;
|
||||||
|
use crate::config::NotificationLevel;
|
||||||
|
use crate::patterns::MatchedEvent;
|
||||||
|
|
||||||
|
static PENDING: Mutex<Vec<MatchedEvent>> = Mutex::new(vec![]);
|
||||||
|
// True while ChatPanel is mounted and listening for live events.
|
||||||
|
// When true, dispatch skips PENDING so events are not shown twice on re-open.
|
||||||
|
static PANEL_OPEN: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub fn set_panel_open(open: bool) {
|
||||||
|
PANEL_OPEN.store(open, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch<R: Runtime>(app: &AppHandle<R>, event: MatchedEvent) {
|
||||||
|
let level = app
|
||||||
|
.state::<AppState>()
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map(|c| c.display.notification_level.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !PANEL_OPEN.load(Ordering::Relaxed) {
|
||||||
|
match PENDING.lock() {
|
||||||
|
Ok(mut pending) => pending.push(event.clone()),
|
||||||
|
Err(e) => log::warn!("notify: pending queue lock poisoned — event dropped: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match level {
|
||||||
|
NotificationLevel::Off => {}
|
||||||
|
NotificationLevel::BadgeOnly => {
|
||||||
|
crate::tray::badge_on(app);
|
||||||
|
}
|
||||||
|
NotificationLevel::BadgeAndToast => {
|
||||||
|
crate::tray::badge_on(app);
|
||||||
|
send_toast(app, &event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app.emit("robin:event", &event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_toast<R: Runtime>(app: &AppHandle<R>, event: &MatchedEvent) {
|
||||||
|
let _ = app
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(&event.title)
|
||||||
|
.body(&event.body)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_pending() -> Vec<MatchedEvent> {
|
||||||
|
PENDING
|
||||||
|
.lock()
|
||||||
|
.map(|mut v| std::mem::take(&mut *v))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_event(id: &str) -> MatchedEvent {
|
||||||
|
MatchedEvent {
|
||||||
|
pattern_id: id.into(),
|
||||||
|
title: "Title".into(),
|
||||||
|
body: "Body".into(),
|
||||||
|
severity: "warn".into(),
|
||||||
|
timestamp: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_pending_drains_queue() {
|
||||||
|
// Ensure clean state — drain any events from other tests sharing the static
|
||||||
|
let _ = take_pending();
|
||||||
|
|
||||||
|
if let Ok(mut q) = PENDING.lock() {
|
||||||
|
q.push(make_event("a"));
|
||||||
|
q.push(make_event("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = take_pending();
|
||||||
|
assert_eq!(first.len(), 2);
|
||||||
|
assert_eq!(first[0].pattern_id, "a");
|
||||||
|
|
||||||
|
let second = take_pending();
|
||||||
|
assert!(second.is_empty(), "queue must be empty after drain");
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src-tauri/src/patterns.rs
Normal file
289
src-tauri/src/patterns.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
use crate::watcher::{EventSource, SystemEvent};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct PatternMeta {
|
||||||
|
pub source_os: String,
|
||||||
|
pub target_distro_family: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Pattern {
|
||||||
|
pub id: String,
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
pub match_text: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct PatternFile {
|
||||||
|
pub meta: PatternMeta,
|
||||||
|
#[serde(default)]
|
||||||
|
pub log_paths: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub patterns: Vec<Pattern>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MatchedEvent {
|
||||||
|
pub pattern_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the pattern file for a source OS and distro family.
|
||||||
|
///
|
||||||
|
/// Tries candidates in order: dev-relative path, src-tauri-relative path,
|
||||||
|
/// system path. The Tauri resource directory is the authoritative location
|
||||||
|
/// at runtime; passing a base path is handled in Task 10's caller via candidate list.
|
||||||
|
pub fn load(source_os: &str, distro_family: &str) -> Result<PatternFile> {
|
||||||
|
let filename = format!("{source_os}-to-{distro_family}.toml");
|
||||||
|
let candidates = [
|
||||||
|
format!("patterns/{filename}"),
|
||||||
|
format!("src-tauri/patterns/{filename}"),
|
||||||
|
format!("/usr/share/robin/patterns/{filename}"),
|
||||||
|
];
|
||||||
|
for path in &candidates {
|
||||||
|
let content = match std::fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let pf: PatternFile = match toml::from_str(&content) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("patterns: failed to parse {path}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for p in &pf.patterns {
|
||||||
|
anyhow::ensure!(
|
||||||
|
!p.match_text.is_empty(),
|
||||||
|
"pattern '{}' has empty match_text in {path}",
|
||||||
|
p.id
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
!p.sources.is_empty(),
|
||||||
|
"pattern '{}' has empty sources list in {path}",
|
||||||
|
p.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(pf);
|
||||||
|
}
|
||||||
|
anyhow::bail!("pattern file not found: {filename}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn classify(event: &SystemEvent, pf: &PatternFile) -> Option<MatchedEvent> {
|
||||||
|
for pattern in &pf.patterns {
|
||||||
|
if !event.raw_line.contains(&pattern.match_text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_matches = pattern.sources.iter().any(|s| {
|
||||||
|
if s == "any" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match &event.source {
|
||||||
|
EventSource::Journald => s == "journald",
|
||||||
|
EventSource::Kmsg => s == "kmsg",
|
||||||
|
EventSource::AppLog { app } => s == &format!("applog:{app}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if source_matches {
|
||||||
|
return Some(MatchedEvent {
|
||||||
|
pattern_id: pattern.id.clone(),
|
||||||
|
title: pattern.title.clone(),
|
||||||
|
body: pattern.body.clone(),
|
||||||
|
severity: pattern.severity.clone(),
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::watcher::{EventSource, SystemEvent};
|
||||||
|
|
||||||
|
const FIXTURE: &str = r#"
|
||||||
|
[meta]
|
||||||
|
source_os = "macos"
|
||||||
|
target_distro_family = "arch"
|
||||||
|
|
||||||
|
[log_paths]
|
||||||
|
steam = "~/.local/share/Steam/logs/content_log.txt"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "aur-build-failure"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = "error: failed to build"
|
||||||
|
severity = "warn"
|
||||||
|
title = "AUR package build failed"
|
||||||
|
body = "A package failed to compile from source."
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "proton-runtime"
|
||||||
|
sources = ["applog:steam"]
|
||||||
|
match_text = "wine: cannot find"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Proton runtime issue"
|
||||||
|
body = "Steam Proton couldn't find a required file."
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "any-source-pattern"
|
||||||
|
sources = ["any"]
|
||||||
|
match_text = "kernel panic"
|
||||||
|
severity = "crit"
|
||||||
|
title = "Kernel panic"
|
||||||
|
body = "The kernel encountered a fatal error."
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn make_event(source: EventSource, raw_line: &str) -> SystemEvent {
|
||||||
|
SystemEvent {
|
||||||
|
source,
|
||||||
|
raw_line: raw_line.into(),
|
||||||
|
timestamp: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_pattern_file_from_toml() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
assert_eq!(pf.meta.source_os, "macos");
|
||||||
|
assert_eq!(pf.patterns.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
pf.log_paths.get("steam").unwrap(),
|
||||||
|
"~/.local/share/Steam/logs/content_log.txt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_matches_journald_event() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
let event = make_event(EventSource::Journald, ":: error: failed to build (foo-git)");
|
||||||
|
let matched = classify(&event, &pf).unwrap();
|
||||||
|
assert_eq!(matched.pattern_id, "aur-build-failure");
|
||||||
|
assert_eq!(matched.title, "AUR package build failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_no_match_returns_none() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
let event = make_event(
|
||||||
|
EventSource::Journald,
|
||||||
|
"systemd: Started NetworkManager.service",
|
||||||
|
);
|
||||||
|
assert!(classify(&event, &pf).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_applog_matches_correct_source() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
let event = make_event(
|
||||||
|
EventSource::AppLog {
|
||||||
|
app: "steam".into(),
|
||||||
|
},
|
||||||
|
"wine: cannot find /run/media/user/game.exe",
|
||||||
|
);
|
||||||
|
let matched = classify(&event, &pf).unwrap();
|
||||||
|
assert_eq!(matched.pattern_id, "proton-runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_does_not_match_wrong_source() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
// proton pattern is applog:steam — journald event must not match
|
||||||
|
let event = make_event(EventSource::Journald, "wine: cannot find something");
|
||||||
|
assert!(classify(&event, &pf).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_applog_does_not_match_different_app() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
let event = make_event(
|
||||||
|
EventSource::AppLog {
|
||||||
|
app: "retroarch".into(),
|
||||||
|
},
|
||||||
|
"wine: cannot find libvulkan.so",
|
||||||
|
);
|
||||||
|
assert!(classify(&event, &pf).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_any_source_matches_kmsg() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
let event = make_event(EventSource::Kmsg, "kernel panic - not syncing: VFS");
|
||||||
|
let matched = classify(&event, &pf).unwrap();
|
||||||
|
assert_eq!(matched.pattern_id, "any-source-pattern");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_any_source_matches_journald() {
|
||||||
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
|
let event = make_event(EventSource::Journald, "kernel panic - not syncing: VFS");
|
||||||
|
let matched = classify(&event, &pf).unwrap();
|
||||||
|
assert_eq!(matched.pattern_id, "any-source-pattern");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_rejects_empty_match_text() {
|
||||||
|
let bad = r#"
|
||||||
|
[meta]
|
||||||
|
source_os = "macos"
|
||||||
|
target_distro_family = "arch"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "bad"
|
||||||
|
sources = ["journald"]
|
||||||
|
match_text = ""
|
||||||
|
severity = "warn"
|
||||||
|
title = "Bad"
|
||||||
|
body = "Bad pattern."
|
||||||
|
"#;
|
||||||
|
let pf: PatternFile = toml::from_str(bad).unwrap();
|
||||||
|
// Validate manually since load() reads from filesystem; test the invariant directly
|
||||||
|
let result: anyhow::Result<()> = (|| {
|
||||||
|
for p in &pf.patterns {
|
||||||
|
anyhow::ensure!(!p.match_text.is_empty(), "empty match_text in '{}'", p.id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("empty match_text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_rejects_empty_sources() {
|
||||||
|
let bad = r#"
|
||||||
|
[meta]
|
||||||
|
source_os = "macos"
|
||||||
|
target_distro_family = "arch"
|
||||||
|
|
||||||
|
[[patterns]]
|
||||||
|
id = "bad"
|
||||||
|
sources = []
|
||||||
|
match_text = "something"
|
||||||
|
severity = "warn"
|
||||||
|
title = "Bad"
|
||||||
|
body = "Bad pattern."
|
||||||
|
"#;
|
||||||
|
let pf: PatternFile = toml::from_str(bad).unwrap();
|
||||||
|
let result: anyhow::Result<()> = (|| {
|
||||||
|
for p in &pf.patterns {
|
||||||
|
anyhow::ensure!(!p.sources.is_empty(), "empty sources in '{}'", p.id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("empty sources"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{Menu, MenuItem},
|
menu::{Menu, MenuItem},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
|
@ -34,7 +35,7 @@ pub fn build_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
|
pub fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
|
||||||
if let Some(window) = app.get_webview_window("chat") {
|
if let Some(window) = app.get_webview_window("chat") {
|
||||||
if window.is_visible().unwrap_or(false) {
|
if window.is_visible().unwrap_or(false) {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
|
|
@ -44,3 +45,32 @@ fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static BADGE_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub fn badge_on<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
if BADGE_ACTIVE.swap(true, Ordering::Relaxed) {
|
||||||
|
return; // already badged
|
||||||
|
}
|
||||||
|
if let Some(tray) = app.tray_by_id("robin-tray") {
|
||||||
|
if let Some(icon) = app.default_window_icon().cloned() {
|
||||||
|
let _ = tray.set_icon(Some(icon));
|
||||||
|
}
|
||||||
|
let _ = tray.set_tooltip(Some("Robin — something to show you"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn badge_off<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
BADGE_ACTIVE.store(false, Ordering::Relaxed);
|
||||||
|
if let Some(tray) = app.tray_by_id("robin-tray") {
|
||||||
|
if let Some(icon) = app.default_window_icon().cloned() {
|
||||||
|
let _ = tray.set_icon(Some(icon));
|
||||||
|
}
|
||||||
|
let _ = tray.set_tooltip(Some("Robin"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_badge_and_open<R: Runtime>(app: &AppHandle<R>) {
|
||||||
|
badge_off(app);
|
||||||
|
toggle_chat_panel(app);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
124
src-tauri/src/watcher/inotify.rs
Normal file
124
src-tauri/src/watcher/inotify.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
use super::{now_unix, EventSource, SystemEvent};
|
||||||
|
use notify::{recommended_watcher, RecursiveMode, Watcher};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Read, Seek};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub async fn watch(log_paths: HashMap<String, String>, tx: mpsc::Sender<SystemEvent>) {
|
||||||
|
if log_paths.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expanded: HashMap<String, String> = log_paths
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), expand_tilde(v)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let (notify_tx, notify_rx) = std::sync::mpsc::channel();
|
||||||
|
let mut watcher = match recommended_watcher(notify_tx) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("inotify watcher init failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// positions: path -> (app_name, byte_offset)
|
||||||
|
let mut positions: HashMap<String, (String, u64)> = HashMap::new();
|
||||||
|
|
||||||
|
for (app_name, path) in &expanded {
|
||||||
|
let pb = std::path::Path::new(path);
|
||||||
|
if pb.exists() {
|
||||||
|
let len = std::fs::metadata(pb).map(|m| m.len()).unwrap_or(0);
|
||||||
|
positions.insert(path.clone(), (app_name.clone(), len));
|
||||||
|
watcher.watch(pb, RecursiveMode::NonRecursive).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for result in notify_rx {
|
||||||
|
if let Ok(event) = result {
|
||||||
|
for path in event.paths {
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
if let Some((app_name, pos)) = positions.get_mut(&path_str) {
|
||||||
|
let (lines, new_pos) = read_new_lines(&path_str, *pos);
|
||||||
|
*pos = new_pos;
|
||||||
|
for line in lines {
|
||||||
|
tx.blocking_send(SystemEvent {
|
||||||
|
source: EventSource::AppLog {
|
||||||
|
app: app_name.clone(),
|
||||||
|
},
|
||||||
|
raw_line: line,
|
||||||
|
timestamp: now_unix(),
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expand_tilde(path: &str) -> String {
|
||||||
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
|
||||||
|
format!("{home}/{rest}")
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_new_lines(path: &str, from_byte: u64) -> (Vec<String>, u64) {
|
||||||
|
let mut file = match std::fs::File::open(path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return (vec![], from_byte),
|
||||||
|
};
|
||||||
|
if file.seek(std::io::SeekFrom::Start(from_byte)).is_err() {
|
||||||
|
return (vec![], from_byte);
|
||||||
|
}
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
let bytes_read = file.read_to_end(&mut raw).unwrap_or(0);
|
||||||
|
let content = String::from_utf8_lossy(&raw).into_owned();
|
||||||
|
let new_pos = from_byte + bytes_read as u64;
|
||||||
|
let lines: Vec<String> = content
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect();
|
||||||
|
(lines, new_pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_home() {
|
||||||
|
let home = std::env::var("HOME").unwrap_or("/home/user".into());
|
||||||
|
let expanded = expand_tilde("~/.config/retroarch/retroarch.log");
|
||||||
|
assert!(expanded.starts_with(&home));
|
||||||
|
assert!(expanded.ends_with(".config/retroarch/retroarch.log"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_no_tilde() {
|
||||||
|
let path = "/absolute/path/to/file.log";
|
||||||
|
assert_eq!(expand_tilde(path), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reads_new_lines_from_file() {
|
||||||
|
use std::io::Write;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.log");
|
||||||
|
let mut f = std::fs::File::create(&path).unwrap();
|
||||||
|
write!(f, "line one\n").unwrap();
|
||||||
|
|
||||||
|
let (lines, pos) = read_new_lines(path.to_str().unwrap(), 0);
|
||||||
|
assert_eq!(lines, vec!["line one".to_string()]);
|
||||||
|
assert!(pos > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src-tauri/src/watcher/journald.rs
Normal file
74
src-tauri/src/watcher/journald.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
use super::{now_unix, EventSource, SystemEvent};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub async fn watch(tx: mpsc::Sender<SystemEvent>) {
|
||||||
|
let mut child = match Command::new("journalctl")
|
||||||
|
.args(["--follow", "--output=json", "--lines=0"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("journald watcher: failed to spawn journalctl: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = match child.stdout.take() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if let Some(msg) = extract_message(&line) {
|
||||||
|
let _ = tx
|
||||||
|
.send(SystemEvent {
|
||||||
|
source: EventSource::Journald,
|
||||||
|
raw_line: msg,
|
||||||
|
timestamp: now_unix(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!("journald watcher: journalctl exited — no new journal events will be observed");
|
||||||
|
let _ = child.wait().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_message(line: &str) -> Option<String> {
|
||||||
|
let json: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||||
|
let msg = json.get("MESSAGE")?.as_str()?;
|
||||||
|
if msg.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(msg.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_message_from_journald_json() {
|
||||||
|
let line = r#"{"MESSAGE":"AUR build failed for foo","PRIORITY":"3","_COMM":"makepkg"}"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_message(line),
|
||||||
|
Some("AUR build failed for foo".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_message_missing_returns_none() {
|
||||||
|
let line = r#"{"PRIORITY":"6","_COMM":"systemd"}"#;
|
||||||
|
assert_eq!(extract_message(line), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_message_empty_skipped() {
|
||||||
|
let line = r#"{"MESSAGE":"","PRIORITY":"6"}"#;
|
||||||
|
assert_eq!(extract_message(line), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src-tauri/src/watcher/kmsg.rs
Normal file
68
src-tauri/src/watcher/kmsg.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
use super::{now_unix, EventSource, SystemEvent};
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub async fn watch(tx: mpsc::Sender<SystemEvent>) {
|
||||||
|
let file = match File::open("/dev/kmsg").await {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("kmsg watcher: cannot open /dev/kmsg: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = BufReader::new(file).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if let Some(msg) = parse_kmsg(&line) {
|
||||||
|
let _ = tx
|
||||||
|
.send(SystemEvent {
|
||||||
|
source: EventSource::Kmsg,
|
||||||
|
raw_line: msg,
|
||||||
|
timestamp: now_unix(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_kmsg(line: &str) -> Option<String> {
|
||||||
|
let msg = if let Some(pos) = line.find(';') {
|
||||||
|
&line[pos + 1..]
|
||||||
|
} else {
|
||||||
|
line
|
||||||
|
};
|
||||||
|
if msg.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(msg.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_kmsg_line_with_semicolon() {
|
||||||
|
let line = "6,1234,56789,-;usb 1-1: new full-speed USB device number 2";
|
||||||
|
assert_eq!(
|
||||||
|
parse_kmsg(line),
|
||||||
|
Some("usb 1-1: new full-speed USB device number 2".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_kmsg_line_without_semicolon() {
|
||||||
|
let line = "plain message without header";
|
||||||
|
assert_eq!(
|
||||||
|
parse_kmsg(line),
|
||||||
|
Some("plain message without header".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_empty_message_after_semicolon() {
|
||||||
|
let line = "6,1234,56789,-;";
|
||||||
|
assert_eq!(parse_kmsg(line), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src-tauri/src/watcher/mod.rs
Normal file
85
src-tauri/src/watcher/mod.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
pub mod inotify;
|
||||||
|
pub mod journald;
|
||||||
|
pub mod kmsg;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EventSeverity {
|
||||||
|
Info,
|
||||||
|
Warn,
|
||||||
|
Crit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum EventSource {
|
||||||
|
Journald,
|
||||||
|
Kmsg,
|
||||||
|
AppLog { app: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SystemEvent {
|
||||||
|
pub source: EventSource,
|
||||||
|
pub raw_line: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_unix() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns all watcher tasks. Returns the receiver end of the event channel.
|
||||||
|
/// `log_paths` comes from the loaded PatternFile.
|
||||||
|
pub fn spawn(log_paths: HashMap<String, String>) -> mpsc::Receiver<SystemEvent> {
|
||||||
|
let (tx, rx) = mpsc::channel::<SystemEvent>(256);
|
||||||
|
|
||||||
|
let tx_j = tx.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
journald::watch(tx_j).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let tx_k = tx.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
kmsg::watch(tx_k).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
inotify::watch(log_paths, tx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_source_can_be_cloned() {
|
||||||
|
let s = EventSource::AppLog {
|
||||||
|
app: "steam".into(),
|
||||||
|
};
|
||||||
|
let _ = s.clone();
|
||||||
|
assert!(matches!(s, EventSource::AppLog { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_event_constructed() {
|
||||||
|
let e = SystemEvent {
|
||||||
|
source: EventSource::Journald,
|
||||||
|
raw_line: "test line".into(),
|
||||||
|
timestamp: now_unix(),
|
||||||
|
};
|
||||||
|
assert_eq!(e.raw_line, "test line");
|
||||||
|
assert!(e.timestamp > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,9 @@
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["deb", "rpm", "appimage"],
|
"targets": ["deb", "rpm", "appimage"],
|
||||||
|
"resources": {
|
||||||
|
"patterns/*": "patterns/"
|
||||||
|
},
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,50 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
|
|
||||||
interface Message { role: 'user' | 'robin'; content: string }
|
interface Message { role: 'user' | 'robin'; content: string }
|
||||||
|
interface RobinEvent { pattern_id: string; title: string; body: string; severity: string; timestamp: number }
|
||||||
|
|
||||||
const messages = ref<Message[]>([])
|
const messages = ref<Message[]>([])
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
const messagesEl = ref<HTMLElement | null>(null)
|
const messagesEl = ref<HTMLElement | null>(null)
|
||||||
|
let unlisten: UnlistenFn | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Signal backend: stop queuing events into PENDING while panel is open.
|
||||||
|
// Must happen before drain so new events that arrive during mount are not
|
||||||
|
// double-delivered on the next open cycle.
|
||||||
|
try { await invoke('panel_opened') } catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pending = await invoke<RobinEvent[]>('get_pending_events')
|
||||||
|
for (const e of pending) {
|
||||||
|
pushRobinEvent(e)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Robin: failed to drain pending events:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always set up the live listener, even if drain failed
|
||||||
|
unlisten = await listen<RobinEvent>('robin:event', ({ payload }) => {
|
||||||
|
pushRobinEvent(payload)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlisten?.()
|
||||||
|
invoke('panel_closed').catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
function pushRobinEvent(e: RobinEvent) {
|
||||||
|
messages.value.push({ role: 'robin', content: `**${e.title}**\n\n${e.body}` })
|
||||||
|
nextTick(() => {
|
||||||
|
messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue