host: keyword alerts and native notifications

alerts: keyword settings (normalized, deduplicated, bounded), the
configured-keyword check, plain bounded snippets, the per-keyword and
global Throttle and an AlertGate over any KeywordMatch (one notification
per item). With the tauri feature, alerts::notify shows a native OS
notification (tauri-plugin-notification, registered by the app).
This commit is contained in:
CRTkafa 2026-09-29 11:05:49 +07:00
parent ffab9eda2f
commit 9ae81c08c3
4 changed files with 790 additions and 326 deletions

View file

@ -0,0 +1,256 @@
//! Local-only keyword alerts: the owner's keyword list, keyword matching, snippets and rate
//! limiting, plus (with the `tauri` feature) the native notification itself. Nothing here touches
//! the network; matches come from what the site's own UI already fetched, reported by the page and
//! re-checked by the app (what a match is about, e.g. a post id or an author, is the app's).
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::{Duration, Instant};
pub const MAX_KEYWORDS: usize = 32;
pub const MIN_KEYWORD_CHARS: usize = 2;
pub const MAX_KEYWORD_CHARS: usize = 64;
pub const MAX_SNIPPET_CHARS: usize = 280;
/// One notification per keyword per this interval.
pub const PER_KEY_INTERVAL: Duration = Duration::from_secs(60);
/// At most `GLOBAL_LIMIT` keyword notifications per `GLOBAL_WINDOW`.
pub const GLOBAL_WINDOW: Duration = Duration::from_secs(600);
pub const GLOBAL_LIMIT: usize = 6;
/// Items (posts, messages...) remembered as already notified.
const SEEN_ITEMS: usize = 2_048;
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", default)]
pub struct AlertSettings {
pub enabled: bool,
pub keywords: Vec<String>,
}
fn collapse_spaces(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn normalize_keyword(value: &str) -> String {
collapse_spaces(value).to_lowercase()
}
impl AlertSettings {
/// Trims, lowercases and de-duplicates keywords; rejects oversized lists or odd keywords.
pub fn validated(self) -> Result<Self, String> {
let mut seen = HashSet::new();
let mut keywords = Vec::new();
for raw in self.keywords {
let keyword = normalize_keyword(&raw);
if keyword.is_empty() {
continue;
}
let chars = keyword.chars().count();
if !(MIN_KEYWORD_CHARS..=MAX_KEYWORD_CHARS).contains(&chars) || keyword.chars().any(char::is_control) {
return Err(format!(
"VALIDATION: keywords must be {MIN_KEYWORD_CHARS}-{MAX_KEYWORD_CHARS} characters"
));
}
if seen.insert(keyword.clone()) {
keywords.push(keyword);
}
}
if keywords.len() > MAX_KEYWORDS {
return Err(format!("VALIDATION: at most {MAX_KEYWORDS} keywords"));
}
Ok(Self { enabled: self.enabled, keywords })
}
/// Keywords the site page should watch for; empty when alerts are off.
pub fn active_keywords(&self) -> &[String] {
if self.enabled {
&self.keywords
} else {
&[]
}
}
/// The configured keyword a page-reported `keyword` stands for (normalized), if alerts are on.
pub fn configured(&self, keyword: &str) -> Result<String, String> {
let keyword = normalize_keyword(keyword);
if !self.active_keywords().contains(&keyword) {
return Err("FORBIDDEN: keyword is not configured".into());
}
Ok(keyword)
}
}
/// The notification snippet for page-reported `text`: control characters and runs of white space
/// collapsed, at most [`MAX_SNIPPET_CHARS`]. The text must really contain the (normalized) keyword.
pub fn snippet(text: &str, keyword: &str) -> Result<String, String> {
if text.len() > MAX_SNIPPET_CHARS * 8 {
return Err("VALIDATION: text too long".into());
}
let text = collapse_spaces(&text.chars().map(|ch| if ch.is_control() { ' ' } else { ch }).collect::<String>());
if !text.to_lowercase().contains(keyword) {
return Err("VALIDATION: text does not contain the keyword".into());
}
Ok(text.chars().take(MAX_SNIPPET_CHARS).collect())
}
/// Per-key minimum interval plus a global sliding-window cap.
pub struct Throttle {
per_key: Duration,
window: Duration,
limit: usize,
last: HashMap<String, Instant>,
recent: VecDeque<Instant>,
}
impl Throttle {
pub fn new(per_key: Duration, window: Duration, limit: usize) -> Self {
Self { per_key, window, limit, last: HashMap::new(), recent: VecDeque::new() }
}
pub fn allow(&mut self, key: &str, now: Instant) -> bool {
while self.recent.front().is_some_and(|at| now.duration_since(*at) >= self.window) {
self.recent.pop_front();
}
if self.recent.len() >= self.limit {
return false;
}
if self.last.get(key).is_some_and(|at| now.duration_since(*at) < self.per_key) {
return false;
}
if self.last.len() > 256 {
let per_key = self.per_key;
self.last.retain(|_, at| now.duration_since(*at) < per_key);
}
self.last.insert(key.to_owned(), now);
self.recent.push_back(now);
true
}
}
/// A keyword match about one item of the site (a post, a message...).
pub trait KeywordMatch {
/// The item's id: each item notifies at most once.
fn item_id(&self) -> &str;
/// The keyword it matched, as the page reported it.
fn keyword(&self) -> &str;
}
/// Keyword alerts: each item notifies at most once, then the throttle applies (per keyword, and
/// at most [`GLOBAL_LIMIT`] per [`GLOBAL_WINDOW`]).
pub struct AlertGate {
seen: HashSet<String>,
order: VecDeque<String>,
throttle: Throttle,
}
impl Default for AlertGate {
fn default() -> Self {
Self {
seen: HashSet::new(),
order: VecDeque::new(),
throttle: Throttle::new(PER_KEY_INTERVAL, GLOBAL_WINDOW, GLOBAL_LIMIT),
}
}
}
impl AlertGate {
pub fn allow(&mut self, hit: &impl KeywordMatch, now: Instant) -> bool {
if !self.seen.insert(hit.item_id().to_owned()) {
return false;
}
self.order.push_back(hit.item_id().to_owned());
if self.order.len() > SEEN_ITEMS {
if let Some(old) = self.order.pop_front() {
self.seen.remove(&old);
}
}
self.throttle.allow(&normalize_keyword(hit.keyword()), now)
}
}
/// Shows a native OS notification (the app registers `tauri-plugin-notification`); a failure is
/// logged under the product name and otherwise ignored.
#[cfg(feature = "tauri")]
pub fn notify<R: tauri::Runtime>(app: &tauri::AppHandle<R>, product: &str, title: &str, body: &str) {
use tauri_plugin_notification::NotificationExt;
if let Err(error) = app.notification().builder().title(title).body(body).show() {
eprintln!("{product}: notification failed: {error}");
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Hit(&'static str, &'static str);
impl KeywordMatch for Hit {
fn item_id(&self) -> &str {
self.0
}
fn keyword(&self) -> &str {
self.1
}
}
fn settings(words: &[&str]) -> AlertSettings {
AlertSettings { enabled: true, keywords: words.iter().map(|w| w.to_string()).collect() }
.validated()
.unwrap()
}
#[test]
fn settings_normalize_and_dedupe() {
let s = settings(&[" Rust Lang ", "rust lang", "", "Tauri"]);
assert_eq!(s.keywords, vec!["rust lang".to_string(), "tauri".to_string()]);
}
#[test]
fn settings_reject_bad_keywords() {
assert!(AlertSettings { enabled: true, keywords: vec!["a".into()] }.validated().is_err());
assert!(AlertSettings { enabled: true, keywords: vec!["x".repeat(65)] }.validated().is_err());
let many = AlertSettings { enabled: true, keywords: (0..33).map(|i| format!("kw{i}")).collect() };
assert!(many.validated().is_err());
assert!(AlertSettings { enabled: true, keywords: vec!["a\u{7}b".into()] }.validated().is_err());
}
#[test]
fn only_configured_keywords_while_enabled() {
let mut s = settings(&["tauri"]);
assert_eq!(s.configured(" TAURI ").unwrap(), "tauri");
assert!(s.configured("electron").is_err());
s.enabled = false;
assert!(s.active_keywords().is_empty());
assert!(s.configured("tauri").is_err());
}
#[test]
fn snippets_are_plain_bounded_and_contain_the_keyword() {
assert_eq!(snippet("I love\nTauri 2", "tauri").unwrap(), "I love Tauri 2");
assert!(snippet("nothing here", "tauri").is_err());
let long = format!("tauri {}", "a".repeat(500));
assert_eq!(snippet(&long, "tauri").unwrap().chars().count(), MAX_SNIPPET_CHARS);
assert!(snippet(&"tauri ".repeat(MAX_SNIPPET_CHARS * 2), "tauri").unwrap_err().contains("too long"));
}
#[test]
fn throttle_limits_per_key_and_globally() {
let mut t = Throttle::new(Duration::from_secs(60), Duration::from_secs(600), 3);
let now = Instant::now();
assert!(t.allow("a", now));
assert!(!t.allow("a", now + Duration::from_secs(30)));
assert!(t.allow("a", now + Duration::from_secs(61)));
assert!(t.allow("b", now + Duration::from_secs(62)));
assert!(!t.allow("c", now + Duration::from_secs(63)));
assert!(t.allow("c", now + Duration::from_secs(601)));
}
#[test]
fn gate_notifies_each_item_once_and_throttles_per_keyword() {
let mut gate = AlertGate::default();
let now = Instant::now();
assert!(gate.allow(&Hit("1", "tauri"), now));
assert!(!gate.allow(&Hit("1", "tauri"), now + Duration::from_secs(120)));
assert!(gate.allow(&Hit("2", "Tauri"), now + Duration::from_secs(120)));
assert!(!gate.allow(&Hit("3", "TAURI "), now + Duration::from_secs(130)), "same keyword within a minute");
}
}

View file

@ -20,12 +20,14 @@
//! - [`sync`]: encrypted settings sync through a folder the owner syncs (Argon2id + XChaCha20-Poly1305).
//! - [`workspaces`]: saved tab/pane/deck layouts, bounded and well-formed (never evaluated).
//! - [`devmode`]: developer mode's debounced watcher of plugins, custom CSS and a theme file.
//! - [`alerts`]: local keyword alerts (keywords, snippets, per-item/per-keyword rate limits).
//! - [`store`]: installing themes and plugins from `waybetter://install` links, dropped files and the
//! signed multi-app wayBetter Store (allow-list, caps, catalog signatures, staging, install records);
//! downloads with the `net` feature.
//!
//! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state.
pub mod alerts;
pub mod backup;
pub mod devmode;
pub mod hotfix;