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:
parent
ffab9eda2f
commit
9ae81c08c3
4 changed files with 790 additions and 326 deletions
256
crates/waybetter-host/src/alerts.rs
Normal file
256
crates/waybetter-host/src/alerts.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue