feat(robin): M1 System Presence — journald/kmsg/inotify watcher, pattern classifier, tray badge, chat panel #2

Open
pyr0ball wants to merge 21 commits from feat/m1-system-presence into main
10 changed files with 46 additions and 10 deletions
Showing only changes of commit d8991905d7 - Show all commits

3
.gitignore vendored
View file

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

View file

@ -69,3 +69,13 @@ pub fn update_notification_level(level: String, state: State<'_, AppState>) -> R
pub fn get_pending_events() -> Vec<crate::patterns::MatchedEvent> { pub fn get_pending_events() -> Vec<crate::patterns::MatchedEvent> {
crate::notify::take_pending() crate::notify::take_pending()
} }
#[tauri::command]
pub fn panel_opened() {
crate::notify::set_panel_open(true);
}
#[tauri::command]
pub fn panel_closed() {
crate::notify::set_panel_open(false);
}

View file

@ -102,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<()> {

View file

@ -74,6 +74,8 @@ pub fn run() {
commands::complete_onboarding, commands::complete_onboarding,
commands::update_notification_level, commands::update_notification_level,
commands::get_pending_events, 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");

View file

@ -1,3 +1,4 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Runtime}; use tauri::{AppHandle, Emitter, Runtime};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
@ -7,6 +8,13 @@ use crate::config::NotificationLevel;
use crate::patterns::MatchedEvent; use crate::patterns::MatchedEvent;
static PENDING: Mutex<Vec<MatchedEvent>> = Mutex::new(vec![]); 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) { pub fn dispatch<R: Runtime>(app: &AppHandle<R>, event: MatchedEvent) {
let level = app let level = app
@ -16,10 +24,12 @@ pub fn dispatch<R: Runtime>(app: &AppHandle<R>, event: MatchedEvent) {
.map(|c| c.display.notification_level.clone()) .map(|c| c.display.notification_level.clone())
.unwrap_or_default(); .unwrap_or_default();
if !PANEL_OPEN.load(Ordering::Relaxed) {
match PENDING.lock() { match PENDING.lock() {
Ok(mut pending) => pending.push(event.clone()), Ok(mut pending) => pending.push(event.clone()),
Err(e) => log::warn!("notify: pending queue lock poisoned — event dropped: {e}"), Err(e) => log::warn!("notify: pending queue lock poisoned — event dropped: {e}"),
} }
}
match level { match level {
NotificationLevel::Off => {} NotificationLevel::Off => {}

View file

@ -1,4 +1,4 @@
use super::{EventSource, SystemEvent, now_unix}; use super::{now_unix, EventSource, SystemEvent};
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,6 +33,9 @@ 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> {

View file

@ -1,4 +1,4 @@
use super::{EventSource, SystemEvent, now_unix}; use super::{now_unix, EventSource, SystemEvent};
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,7 +54,10 @@ 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!(parse_kmsg(line), Some("plain message without header".to_string())); assert_eq!(
parse_kmsg(line),
Some("plain message without header".to_string())
);
} }
#[test] #[test]

View file

@ -46,6 +46,11 @@ const messagesEl = ref<HTMLElement | null>(null)
let unlisten: UnlistenFn | null = null let unlisten: UnlistenFn | null = null
onMounted(async () => { 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 { try {
const pending = await invoke<RobinEvent[]>('get_pending_events') const pending = await invoke<RobinEvent[]>('get_pending_events')
for (const e of pending) { for (const e of pending) {
@ -63,6 +68,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
unlisten?.() unlisten?.()
invoke('panel_closed').catch(() => {})
}) })
function pushRobinEvent(e: RobinEvent) { function pushRobinEvent(e: RobinEvent) {