Compare commits
No commits in common. "d8991905d75565da31655d2287b21f2b849c172d" and "db7d30d4c18e76eabdd2bb5dcd67c5121aec854b" have entirely different histories.
d8991905d7
...
db7d30d4c1
19 changed files with 43 additions and 544 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -29,6 +29,3 @@ 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,7 +8,6 @@
|
||||||
"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": {
|
||||||
|
|
@ -399,16 +398,6 @@
|
||||||
"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,7 +9,6 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0",
|
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,3 @@ 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"
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
[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"
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
[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::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs};
|
use crate::config::{NotificationLevel, RobinConfig, MigrationConfig, SourceOs};
|
||||||
use std::sync::Mutex;
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<RobinConfig>,
|
pub config: Mutex<RobinConfig>,
|
||||||
|
|
@ -8,18 +8,14 @@ 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
|
state.config.lock()
|
||||||
.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
|
state.config.lock()
|
||||||
.config
|
|
||||||
.lock()
|
|
||||||
.map(|c| c.needs_onboarding())
|
.map(|c| c.needs_onboarding())
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +49,10 @@ pub fn complete_onboarding(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_notification_level(level: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn update_notification_level(
|
||||||
|
level: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let parsed = match level.as_str() {
|
let parsed = match level.as_str() {
|
||||||
"off" => NotificationLevel::Off,
|
"off" => NotificationLevel::Off,
|
||||||
"badge_only" => NotificationLevel::BadgeOnly,
|
"badge_only" => NotificationLevel::BadgeOnly,
|
||||||
|
|
@ -64,18 +63,3 @@ pub fn update_notification_level(level: String, state: State<'_, AppState>) -> R
|
||||||
config.display.notification_level = parsed;
|
config.display.notification_level = parsed;
|
||||||
config.save().map_err(|e| e.to_string())
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,8 @@ 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).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<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod distro;
|
mod distro;
|
||||||
mod notify;
|
|
||||||
mod patterns;
|
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::{Arc, Mutex};
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// TODO: log a warning when load() fails so users know their config was reset
|
||||||
let config = RobinConfig::load().unwrap_or_default();
|
let config = RobinConfig::load().unwrap_or_default();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -25,47 +24,7 @@ pub fn run() {
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
tray::build_tray(&app.handle())?;
|
tray::build_tray(&app.handle())?;
|
||||||
|
watcher::spawn(std::collections::HashMap::new());
|
||||||
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![
|
||||||
|
|
@ -73,9 +32,6 @@ pub fn run() {
|
||||||
commands::needs_onboarding,
|
commands::needs_onboarding,
|
||||||
commands::complete_onboarding,
|
commands::complete_onboarding,
|
||||||
commands::update_notification_level,
|
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");
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -39,43 +39,28 @@ pub struct MatchedEvent {
|
||||||
|
|
||||||
/// Load the pattern file for a source OS and distro family.
|
/// Load the pattern file for a source OS and distro family.
|
||||||
///
|
///
|
||||||
/// Tries candidates in order: dev-relative path, src-tauri-relative path,
|
/// Path is relative to the working directory. In tests this is the crate root
|
||||||
/// system path. The Tauri resource directory is the authoritative location
|
/// (where `patterns/` lives as a sibling of `src/`). At runtime, Task 10 resolves
|
||||||
/// at runtime; passing a base path is handled in Task 10's caller via candidate list.
|
/// the path via `tauri::path::BaseDirectory::Resource` before calling this function.
|
||||||
pub fn load(source_os: &str, distro_family: &str) -> Result<PatternFile> {
|
pub fn load(source_os: &str, distro_family: &str) -> Result<PatternFile> {
|
||||||
let filename = format!("{source_os}-to-{distro_family}.toml");
|
let resource_path = format!("patterns/{source_os}-to-{distro_family}.toml");
|
||||||
let candidates = [
|
let content = std::fs::read_to_string(&resource_path)
|
||||||
format!("patterns/{filename}"),
|
.with_context(|| format!("pattern file not found: {resource_path}"))?;
|
||||||
format!("src-tauri/patterns/{filename}"),
|
let pf: PatternFile =
|
||||||
format!("/usr/share/robin/patterns/{filename}"),
|
toml::from_str(&content).with_context(|| format!("failed to parse {resource_path}"))?;
|
||||||
];
|
|
||||||
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 {
|
for p in &pf.patterns {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!p.match_text.is_empty(),
|
!p.match_text.is_empty(),
|
||||||
"pattern '{}' has empty match_text in {path}",
|
"pattern '{}' has empty match_text in {resource_path}",
|
||||||
p.id
|
p.id
|
||||||
);
|
);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!p.sources.is_empty(),
|
!p.sources.is_empty(),
|
||||||
"pattern '{}' has empty sources list in {path}",
|
"pattern '{}' has empty sources list in {resource_path}",
|
||||||
p.id
|
p.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Ok(pf);
|
Ok(pf)
|
||||||
}
|
|
||||||
anyhow::bail!("pattern file not found: {filename}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
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},
|
||||||
|
|
@ -35,7 +34,7 @@ pub fn build_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
|
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();
|
||||||
|
|
@ -45,32 +44,3 @@ pub 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,124 +1,7 @@
|
||||||
use super::{now_unix, EventSource, SystemEvent};
|
use super::SystemEvent;
|
||||||
use notify::{recommended_watcher, RecursiveMode, Watcher};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Seek};
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
pub async fn watch(log_paths: HashMap<String, String>, tx: mpsc::Sender<SystemEvent>) {
|
pub async fn watch(_log_paths: HashMap<String, String>, _tx: mpsc::Sender<SystemEvent>) {
|
||||||
if log_paths.is_empty() {
|
// implemented in Task 8
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{now_unix, EventSource, SystemEvent};
|
use super::{EventSource, SystemEvent, now_unix};
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
@ -33,9 +33,6 @@ pub async fn watch(tx: mpsc::Sender<SystemEvent>) {
|
||||||
.await;
|
.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> {
|
fn extract_message(line: &str) -> Option<String> {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{now_unix, EventSource, SystemEvent};
|
use super::{EventSource, SystemEvent, now_unix};
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
@ -54,10 +54,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_kmsg_line_without_semicolon() {
|
fn parses_kmsg_line_without_semicolon() {
|
||||||
let line = "plain message without header";
|
let line = "plain message without header";
|
||||||
assert_eq!(
|
assert_eq!(parse_kmsg(line), Some("plain message without header".to_string()));
|
||||||
parse_kmsg(line),
|
|
||||||
Some("plain message without header".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,6 @@
|
||||||
"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,50 +33,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, nextTick } 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