Phase 1 — Critical Safety: - #11: bounded IPC reads via read_bounded_line (server + client) - #13: sound Send+Sync via Arc<Mutex<RuntimeHandle>>; remove unsafe impl Sync - #10: ItemSource enum (Core/NativePlugin/ScriptPlugin) on LaunchItem; script plugin allowlist guard in launch_item() Phase 2 — Config System Overhaul: - #6: remove dead enabled_plugins field - #1: replace #[serde(flatten)] with explicit Config::plugin_config - #4: Server.config Arc<RwLock<Config>>; ConfigProvider shares same Arc - #2/#3: atomic config save (temp+rename); TOCTOU fixed — write lock held across mutation and save() in config_editor - #23: fs2 lock_exclusive() on .lock sidecar file in Config::save() - #16: SIGHUP handler reloads config; ExecReload in systemd service Phase 3 — Plugin Architecture: - #7: HostAPI v4 with get_config_string/int/bool; PLUGIN_CONFIG OnceLock in native_loader, set_shared_config() called from Server::bind() - #5: PluginEntry + Request::PluginList + Response::PluginList; plugin_registry in ProviderManager tracks active and suppressed native plugins; cmd_list_installed shows both script and native plugins - #9: suppressed native plugin log level info! → warn! - #8: ProviderType doc glossary; plugins/mod.rs terminology table Phase 4 — Data Integrity: - #12: all into_inner() in server.rs + providers/mod.rs → explicit Response::Error; watcher exits on poisoned lock - #14: FrecencyStore::prune() (180-day age + 5000-entry cap) called on load - #17: empty command guard in launch_item(); warn in lua_provider - #24: 5-min periodic frecency save thread; SIGTERM/SIGINT saves frecency before exit (replaces ctrlc handler) Phase 5 — UI & UX: - #19: provider_meta.rs ProviderMeta + meta_for(); three match blocks collapsed - #18: desktop file dedup via seen_basenames HashSet in ApplicationProvider - #20: search_filtered gains tag_filter param; non-frecency path now filters - #15: widget refresh 5s→10s; skip when user is typing Phase 6 — Hardening: - #22: catch_unwind removed from reload_runtimes(); direct drop() - #21: AtomicUsize + RAII ConnectionGuard; MAX_CONNECTIONS = 16 Deps: add fs2 = "0.4"; remove ctrlc and toml_edit from owlry-core
311 lines
9.1 KiB
Rust
311 lines
9.1 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use log::{debug, info, warn};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::paths;
|
|
|
|
/// A single frecency entry tracking launch count and recency
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FrecencyEntry {
|
|
pub launch_count: u32,
|
|
pub last_launch: DateTime<Utc>,
|
|
}
|
|
|
|
/// Persistent frecency data store
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FrecencyData {
|
|
pub version: u32,
|
|
pub entries: HashMap<String, FrecencyEntry>,
|
|
}
|
|
|
|
impl Default for FrecencyData {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: 1,
|
|
entries: HashMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_ENTRIES: usize = 5000;
|
|
const PRUNE_AGE_DAYS: i64 = 180;
|
|
const MIN_LAUNCHES_TO_KEEP: u32 = 3;
|
|
|
|
/// Frecency store for tracking and boosting recently/frequently used items
|
|
pub struct FrecencyStore {
|
|
data: FrecencyData,
|
|
path: PathBuf,
|
|
dirty: bool,
|
|
}
|
|
|
|
impl FrecencyStore {
|
|
/// Create a new frecency store, loading existing data if available
|
|
pub fn new() -> Self {
|
|
let path = Self::data_path();
|
|
let data = Self::load_from_path(&path).unwrap_or_default();
|
|
|
|
info!("Frecency store loaded with {} entries", data.entries.len());
|
|
|
|
let mut store = Self {
|
|
data,
|
|
path,
|
|
dirty: false,
|
|
};
|
|
store.prune();
|
|
store
|
|
}
|
|
|
|
/// Remove stale low-usage entries and enforce the hard cap.
|
|
///
|
|
/// Entries older than `PRUNE_AGE_DAYS` with fewer than `MIN_LAUNCHES_TO_KEEP`
|
|
/// launches are removed. After age-based pruning, entries are sorted by score
|
|
/// (descending) and the list is truncated to `MAX_ENTRIES`.
|
|
fn prune(&mut self) {
|
|
let now = Utc::now();
|
|
let cutoff = now - chrono::Duration::days(PRUNE_AGE_DAYS);
|
|
|
|
let before = self.data.entries.len();
|
|
self.data.entries.retain(|_, e| {
|
|
e.last_launch > cutoff || e.launch_count >= MIN_LAUNCHES_TO_KEEP
|
|
});
|
|
|
|
if self.data.entries.len() > MAX_ENTRIES {
|
|
// Sort by score descending and keep the top MAX_ENTRIES
|
|
let mut scored: Vec<(String, f64)> = self
|
|
.data
|
|
.entries
|
|
.iter()
|
|
.map(|(k, e)| {
|
|
(k.clone(), Self::calculate_frecency_at(e.launch_count, e.last_launch, now))
|
|
})
|
|
.collect();
|
|
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
let keep: std::collections::HashSet<String> =
|
|
scored.into_iter().take(MAX_ENTRIES).map(|(k, _)| k).collect();
|
|
self.data.entries.retain(|k, _| keep.contains(k));
|
|
}
|
|
|
|
let removed = before - self.data.entries.len();
|
|
if removed > 0 {
|
|
info!("Frecency: pruned {} stale entries ({} remaining)", removed, self.data.entries.len());
|
|
self.dirty = true;
|
|
}
|
|
}
|
|
|
|
/// Alias for new() - loads from disk or creates default
|
|
pub fn load_or_default() -> Self {
|
|
Self::new()
|
|
}
|
|
|
|
/// Get the path to the frecency data file
|
|
fn data_path() -> PathBuf {
|
|
paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json"))
|
|
}
|
|
|
|
/// Load frecency data from a file
|
|
fn load_from_path(path: &PathBuf) -> Option<FrecencyData> {
|
|
if !path.exists() {
|
|
debug!("Frecency file not found at {:?}", path);
|
|
return None;
|
|
}
|
|
|
|
let content = std::fs::read_to_string(path).ok()?;
|
|
match serde_json::from_str(&content) {
|
|
Ok(data) => Some(data),
|
|
Err(e) => {
|
|
warn!("Failed to parse frecency data: {}", e);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Save frecency data to disk
|
|
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
|
if !self.dirty {
|
|
return Ok(());
|
|
}
|
|
|
|
paths::ensure_parent_dir(&self.path)?;
|
|
|
|
let content = serde_json::to_string_pretty(&self.data)?;
|
|
std::fs::write(&self.path, content)?;
|
|
self.dirty = false;
|
|
|
|
debug!("Frecency data saved to {:?}", self.path);
|
|
Ok(())
|
|
}
|
|
|
|
/// Record a launch event for an item
|
|
pub fn record_launch(&mut self, item_id: &str) {
|
|
let now = Utc::now();
|
|
|
|
let entry = self
|
|
.data
|
|
.entries
|
|
.entry(item_id.to_string())
|
|
.or_insert(FrecencyEntry {
|
|
launch_count: 0,
|
|
last_launch: now,
|
|
});
|
|
|
|
entry.launch_count += 1;
|
|
entry.last_launch = now;
|
|
self.dirty = true;
|
|
|
|
debug!(
|
|
"Recorded launch for '{}': count={}, last={}",
|
|
item_id, entry.launch_count, entry.last_launch
|
|
);
|
|
}
|
|
|
|
/// Calculate frecency score for an item
|
|
/// Uses Firefox-style algorithm: score = launch_count * recency_weight
|
|
pub fn get_score(&self, item_id: &str) -> f64 {
|
|
match self.data.entries.get(item_id) {
|
|
Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch),
|
|
None => 0.0,
|
|
}
|
|
}
|
|
|
|
/// Calculate frecency score using a pre-sampled timestamp.
|
|
/// Use this in hot loops to avoid repeated Utc::now() syscalls.
|
|
pub fn get_score_at(&self, item_id: &str, now: DateTime<Utc>) -> f64 {
|
|
match self.data.entries.get(item_id) {
|
|
Some(entry) => Self::calculate_frecency_at(entry.launch_count, entry.last_launch, now),
|
|
None => 0.0,
|
|
}
|
|
}
|
|
|
|
/// Calculate frecency using Firefox-style algorithm
|
|
fn calculate_frecency(launch_count: u32, last_launch: DateTime<Utc>) -> f64 {
|
|
let now = Utc::now();
|
|
Self::calculate_frecency_at(launch_count, last_launch, now)
|
|
}
|
|
|
|
/// Calculate frecency using a caller-provided timestamp.
|
|
fn calculate_frecency_at(launch_count: u32, last_launch: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
|
|
let age = now.signed_duration_since(last_launch);
|
|
let age_days = age.num_hours() as f64 / 24.0;
|
|
|
|
let recency_weight = if age_days < 1.0 {
|
|
100.0
|
|
} else if age_days < 7.0 {
|
|
70.0
|
|
} else if age_days < 30.0 {
|
|
50.0
|
|
} else if age_days < 90.0 {
|
|
30.0
|
|
} else {
|
|
10.0
|
|
};
|
|
|
|
launch_count as f64 * recency_weight
|
|
}
|
|
|
|
/// Get all entries (for debugging/display)
|
|
#[allow(dead_code)]
|
|
pub fn entries(&self) -> &HashMap<String, FrecencyEntry> {
|
|
&self.data.entries
|
|
}
|
|
|
|
/// Clear all frecency data
|
|
#[allow(dead_code)]
|
|
pub fn clear(&mut self) {
|
|
self.data.entries.clear();
|
|
self.dirty = true;
|
|
}
|
|
}
|
|
|
|
impl Default for FrecencyStore {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Drop for FrecencyStore {
|
|
fn drop(&mut self) {
|
|
// Attempt to save on drop
|
|
if let Err(e) = self.save() {
|
|
warn!("Failed to save frecency data on drop: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_frecency_calculation() {
|
|
let now = Utc::now();
|
|
|
|
// Recent launch should have high score
|
|
let score_today = FrecencyStore::calculate_frecency(10, now);
|
|
assert!(score_today > 900.0); // 10 * 100
|
|
|
|
// Older launch should have lower score
|
|
let week_ago = now - chrono::Duration::days(5);
|
|
let score_week = FrecencyStore::calculate_frecency(10, week_ago);
|
|
assert!(score_week < score_today);
|
|
assert!(score_week > 600.0); // 10 * 70
|
|
|
|
// Much older launch
|
|
let month_ago = now - chrono::Duration::days(45);
|
|
let score_month = FrecencyStore::calculate_frecency(10, month_ago);
|
|
assert!(score_month < score_week);
|
|
}
|
|
|
|
#[test]
|
|
fn get_score_at_matches_get_score() {
|
|
let mut store = FrecencyStore {
|
|
data: FrecencyData {
|
|
version: 1,
|
|
entries: HashMap::new(),
|
|
},
|
|
path: PathBuf::from("/dev/null"),
|
|
dirty: false,
|
|
};
|
|
store.data.entries.insert(
|
|
"test".to_string(),
|
|
FrecencyEntry {
|
|
launch_count: 5,
|
|
last_launch: Utc::now(),
|
|
},
|
|
);
|
|
|
|
let now = Utc::now();
|
|
let score_at = store.get_score_at("test", now);
|
|
let score = store.get_score("test");
|
|
|
|
// Both should be very close (same timestamp, within rounding)
|
|
assert!((score_at - score).abs() < 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_launch_count_matters() {
|
|
let now = Utc::now();
|
|
|
|
let score_few = FrecencyStore::calculate_frecency(2, now);
|
|
let score_many = FrecencyStore::calculate_frecency(20, now);
|
|
|
|
assert!(score_many > score_few);
|
|
assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x
|
|
}
|
|
|
|
#[test]
|
|
fn record_launch_sets_dirty_without_saving() {
|
|
let mut store = FrecencyStore {
|
|
data: FrecencyData::default(),
|
|
path: PathBuf::from("/dev/null"),
|
|
dirty: false,
|
|
};
|
|
|
|
store.record_launch("test-item");
|
|
|
|
assert!(store.dirty, "record_launch should set dirty flag");
|
|
assert_eq!(store.data.entries["test-item"].launch_count, 1);
|
|
}
|
|
}
|