Ship floating launcher, automatic discovery and bounded media search improvements

This commit is contained in:
karl committed 2026-10-14 11:44:15 +02:00
1 parent 01279a6b25
commit 521966a2ac
43 files changed
+3165 -267

No files matched your search

+539
View File
@@ -0,0 +1,539 @@
//! Bounded native application discovery. The webview receives opaque catalog
//! identifiers, never executable paths or command lines.
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
#[cfg(not(target_os = "linux"))]
use tauri_plugin_opener::OpenerExt;
const MAX_ENTRIES: usize = 8192;
const MAX_APPS: usize = 4096;
const MAX_DEPTH: usize = 8;
#[derive(Clone, Debug, Serialize)]
pub struct ApplicationResult {
pub id: String,
pub name: String,
}
struct Application {
result: ApplicationResult,
normalized_name: String,
compact_name: String,
path: PathBuf,
}
pub struct ApplicationCatalog {
apps: Vec<Application>,
#[cfg(windows)]
registered: std::sync::Arc<std::sync::Mutex<Vec<RegisteredApplication>>>,
}
impl ApplicationCatalog {
pub fn discover() -> Self {
let catalog = Self::from_roots(application_roots());
#[cfg(windows)]
{
let destination = catalog.registered.clone();
// Store/UWP enumeration is optional background work. Startup and
// searches never wait for PowerShell or model setup.
std::thread::spawn(move || {
if let Some(apps) = registered_applications() {
if let Ok(mut destination) = destination.lock() {
*destination = apps;
}
}
});
}
catalog
}
fn from_roots(roots: Vec<PathBuf>) -> Self {
let mut apps = Vec::new();
let mut visited = 0;
let mut names = HashSet::new();
#[cfg(target_os = "linux")]
let mut desktop_ids = HashSet::new();
for root in roots {
let Ok(root) = root.canonicalize() else {
continue;
};
let mut pending = vec![(root.clone(), 0)];
while let Some((directory, depth)) = pending.pop() {
let Ok(entries) = fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
if visited >= MAX_ENTRIES || apps.len() >= MAX_APPS {
break;
}
visited += 1;
let Ok(kind) = entry.file_type() else {
continue;
};
// No directory symlink traversal or external catalog paths.
if kind.is_symlink() {
continue;
}
let path = entry.path();
if kind.is_dir() && !is_app_bundle(&path) {
if depth < MAX_DEPTH {
pending.push((path, depth + 1));
}
continue;
}
#[cfg(target_os = "linux")]
if extension_is(&path, "desktop") {
// User entries (including Hidden=true) shadow the same
// desktop ID in system roots, as required by XDG.
let relative = path.strip_prefix(&root).ok().map(|path| {
path.components()
.map(|part| part.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("-")
});
if relative.is_some_and(|id| !desktop_ids.insert(id)) {
continue;
}
}
let Some(name) = application_name(&path) else {
continue;
};
let Ok(path) = path.canonicalize() else {
continue;
};
if !path.starts_with(&root) {
continue;
}
let normalized_name = name.to_lowercase();
if !names.insert(normalized_name.clone()) {
continue;
}
let id = format!(
"app:{:x}",
Sha256::digest(path.to_string_lossy().as_bytes())
);
apps.push(Application {
result: ApplicationResult { id, name },
compact_name: compact_name(&normalized_name),
normalized_name,
path,
});
}
if visited >= MAX_ENTRIES || apps.len() >= MAX_APPS {
break;
}
}
}
apps.sort_by(|a, b| a.normalized_name.cmp(&b.normalized_name));
Self {
apps,
#[cfg(windows)]
registered: Default::default(),
}
}
pub fn search(&self, query: &str, limit: usize) -> Vec<ApplicationResult> {
let query = query.trim().to_lowercase();
if query.is_empty() || query.len() > 512 {
return Vec::new();
}
let words: Vec<_> = query.split_whitespace().collect();
let compact_query = compact_name(&query);
let mut matches: Vec<_> = self
.apps
.iter()
.filter_map(|app| {
let rank = name_match_rank(
&app.normalized_name,
&app.compact_name,
&query,
&compact_query,
&words,
)?;
Some((rank, app))
})
.map(|(rank, app)| (rank, app.normalized_name.clone(), app.result.clone()))
.collect();
#[cfg(windows)]
if let Ok(registered) = self.registered.try_lock() {
for app in registered.iter() {
if matches
.iter()
.any(|(_, name, _)| name == &app.normalized_name)
{
continue;
}
let name = &app.normalized_name;
let Some(rank) =
name_match_rank(name, &app.compact_name, &query, &compact_query, &words)
else {
continue;
};
matches.push((rank, name.clone(), app.result.clone()));
}
}
matches.sort_by(|(ra, a, _), (rb, b, _)| ra.cmp(rb).then_with(|| a.cmp(b)));
matches
.into_iter()
.take(limit.min(32))
.map(|(_, _, result)| result)
.collect()
}
pub fn launch(&self, app: &tauri::AppHandle, id: &str) -> Result<(), String> {
#[cfg(windows)]
if let Some(app_id) = self.registered.lock().ok().and_then(|apps| {
apps.iter()
.find(|entry| entry.result.id == id)
.map(|entry| entry.app_id.clone())
}) {
// Explorer invokes the registered Windows application ID. It is a
// single argument from the catalog, never frontend command text.
std::process::Command::new("explorer.exe")
.arg(format!("shell:AppsFolder\\{app_id}"))
.spawn()
.map_err(|error| format!("Could not launch application: {error}"))?;
return Ok(());
}
let entry = self
.apps
.iter()
.find(|entry| entry.result.id == id)
.ok_or_else(|| "Application is not in the local catalog.".to_owned())?;
if !entry.path.exists() {
return Err(
"Application is no longer installed. Restart searchi to refresh applications."
.into(),
);
}
#[cfg(target_os = "linux")]
{
// GIO implements the freedesktop Desktop Entry launch semantics,
// including quoting/field codes. Never interpret Exec as shell text.
let status = std::process::Command::new("gio")
.arg("launch")
.arg(&entry.path)
.status()
.map_err(|error| format!("Could not launch application using GIO: {error}"))?;
if !status.success() {
return Err("The operating system could not launch this application.".into());
}
let _ = app;
Ok(())
}
#[cfg(not(target_os = "linux"))]
app.opener()
.open_path(entry.path.to_string_lossy().into_owned(), None::<&str>)
.map_err(|error| error.to_string())
}
}
fn compact_name(name: &str) -> String {
name.chars()
.filter(|character| !character.is_whitespace())
.collect()
}
fn name_match_rank(
name: &str,
compact: &str,
query: &str,
compact_query: &str,
words: &[&str],
) -> Option<u8> {
if name == query {
Some(0)
} else if name.starts_with(query) {
Some(1)
} else if name.contains(query) {
Some(2)
} else if words.iter().all(|word| name.contains(word)) {
Some(3)
} else if compact.contains(compact_query)
|| (compact.starts_with("visualstudiocode") && compact_query == "vscode")
{
Some(4)
} else {
None
}
}
fn application_roots() -> Vec<PathBuf> {
#[cfg(windows)]
return ["APPDATA", "PROGRAMDATA"]
.into_iter()
.filter_map(std::env::var_os)
.map(|root| PathBuf::from(root).join("Microsoft/Windows/Start Menu/Programs"))
.collect();
#[cfg(target_os = "macos")]
{
let mut roots = vec![
PathBuf::from("/Applications"),
PathBuf::from("/System/Applications"),
];
if let Some(home) = std::env::var_os("HOME") {
roots.insert(0, PathBuf::from(home).join("Applications"));
}
roots
}
#[cfg(not(any(windows, target_os = "macos")))]
{
let mut roots = Vec::new();
if let Some(home) = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
})
{
roots.push(home.join("applications"));
}
let directories = std::env::var_os("XDG_DATA_DIRS")
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
roots.extend(std::env::split_paths(&directories).map(|root| root.join("applications")));
roots
}
}
#[cfg(windows)]
struct RegisteredApplication {
result: ApplicationResult,
normalized_name: String,
compact_name: String,
app_id: String,
}
#[cfg(windows)]
fn registered_applications() -> Option<Vec<RegisteredApplication>> {
use std::{
io::{Read, Seek, SeekFrom},
os::windows::process::CommandExt,
time::{Duration, Instant},
};
#[derive(serde::Deserialize)]
struct Entry {
#[serde(rename = "Name")]
name: String,
#[serde(rename = "AppID")]
app_id: String,
}
let mut output = tempfile::tempfile().ok()?;
let script = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-StartApps | Select-Object -First 4096 Name,AppID | ConvertTo-Json -Compress";
let mut child = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.creation_flags(0x08000000)
.stdin(std::process::Stdio::null())
.stdout(output.try_clone().ok()?)
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(Some(_)) => return None,
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
if output.metadata().ok()?.len() > 4 * 1024 * 1024 {
return None;
}
output.seek(SeekFrom::Start(0)).ok()?;
let mut bytes = Vec::new();
output.read_to_end(&mut bytes).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let values = match value {
serde_json::Value::Array(values) => values,
value => vec![value],
};
Some(
values
.into_iter()
.take(MAX_APPS)
.filter_map(|value| {
let entry: Entry = serde_json::from_value(value).ok()?;
if !valid_registered_id(&entry.app_id)
|| entry.name.trim().is_empty()
|| entry.name.len() > 512
{
return None;
}
let normalized_name = entry.name.to_lowercase();
let id = format!(
"app:registered:{:x}",
Sha256::digest(entry.app_id.as_bytes())
);
Some(RegisteredApplication {
result: ApplicationResult {
id,
name: entry.name,
},
compact_name: compact_name(&normalized_name),
normalized_name,
app_id: entry.app_id,
})
})
.collect(),
)
}
#[cfg(any(windows, test))]
fn valid_registered_id(id: &str) -> bool {
id.len() <= 512
&& id.contains('!')
&& id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-!".contains(&byte))
}
fn is_app_bundle(path: &Path) -> bool {
cfg!(target_os = "macos") && extension_is(path, "app")
}
fn extension_is(path: &Path, expected: &str) -> bool {
path.extension()
.is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case(expected))
}
fn application_name(path: &Path) -> Option<String> {
#[cfg(windows)]
if extension_is(path, "lnk") {
return path
.file_stem()
.map(|name| name.to_string_lossy().into_owned());
}
#[cfg(target_os = "macos")]
if is_app_bundle(path) {
return path
.file_stem()
.map(|name| name.to_string_lossy().into_owned());
}
#[cfg(not(any(windows, target_os = "macos")))]
if extension_is(path, "desktop") {
if fs::metadata(path).ok()?.len() > 128 * 1024 {
return None;
}
return desktop_name(&fs::read_to_string(path).ok()?);
}
None
}
#[cfg(any(target_os = "linux", test))]
fn desktop_name(contents: &str) -> Option<String> {
let mut in_entry = false;
let mut name = None;
let mut application = false;
let mut executable = false;
for line in contents.lines().map(str::trim) {
if line.starts_with('[') {
in_entry = line == "[Desktop Entry]";
continue;
}
if !in_entry || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"Hidden" | "NoDisplay" if value == "true" => return None,
"Type" => application = value == "Application",
"Exec" => executable = !value.trim().is_empty(),
"Name" => name = Some(value.trim().to_owned()),
_ => {}
}
}
let name = name?;
(application && executable && !name.is_empty() && name.len() <= 512).then_some(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registered_ids_reject_paths_and_command_text() {
assert!(valid_registered_id(
"Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"
));
for value in [
"C:\\program.exe",
"app!id & bad",
"app!id/other",
"",
"app!id\n",
] {
assert!(!valid_registered_id(value));
}
}
#[test]
#[cfg(windows)]
#[ignore = "Reads the actual Windows registered-app catalog; launches nothing"]
fn real_windows_application_catalog_is_searchable() {
let catalog = ApplicationCatalog::discover();
assert!(
!catalog.apps.is_empty(),
"Expected installed desktop shortcuts"
);
let apps = registered_applications().expect("Windows Get-StartApps inventory");
assert!(!apps.is_empty(), "Expected registered Windows apps");
let query = apps[0].result.name.clone();
*catalog.registered.lock().unwrap() = apps;
assert!(!catalog.search(&query, 10).is_empty());
eprintln!(
"Native shortcuts: {}; registered applications: {}",
catalog.apps.len(),
catalog.registered.lock().unwrap().len()
);
}
#[test]
fn desktop_entries_require_visible_executable_applications() {
let valid = "[Desktop Entry]\nType=Application\nName=Text Editor\nExec=editor %F\n";
assert_eq!(desktop_name(valid).as_deref(), Some("Text Editor"));
for invalid in [
format!("{valid}Hidden=true\n"),
format!("{valid}NoDisplay=true\n"),
"[Other]\nType=Application\nName=Fake\nExec=bad\n".into(),
"[Desktop Entry]\nType=Link\nName=Website\nURL=https://example.org\n".into(),
] {
assert!(desktop_name(&invalid).is_none());
}
}
#[test]
fn names_match_without_exposing_launch_paths() {
let catalog = ApplicationCatalog {
#[cfg(windows)]
registered: Default::default(),
apps: ["Calculator", "Visual Studio Code", "Code"]
.into_iter()
.enumerate()
.map(|(i, name)| Application {
result: ApplicationResult {
id: format!("app:{i}"),
name: name.into(),
},
normalized_name: name.to_lowercase(),
compact_name: compact_name(&name.to_lowercase()),
path: PathBuf::from("private"),
})
.collect(),
};
assert_eq!(catalog.search("CODE", 10)[0].name, "Code");
assert_eq!(catalog.search("VS Code", 10)[0].name, "Visual Studio Code");
assert_eq!(catalog.search("vscode", 10)[0].name, "Visual Studio Code");
assert_eq!(
catalog.search("visualstudiocode", 10)[0].name,
"Visual Studio Code"
);
assert_eq!(
catalog.search("visual code", 10)[0].name,
"Visual Studio Code"
);
assert!(catalog.search("", 10).is_empty());
assert!(catalog.search("not installed", 10).is_empty());
assert!(catalog.search("code", 0).is_empty());
assert!(!serde_json::to_string(&catalog.search("code", 10))
.unwrap()
.contains("private"));
}
}
@@ -0,0 +1,189 @@
//! Platform discovery for automatic local search. No network shares are mounted.
use std::path::PathBuf;
pub fn roots(custom: &[PathBuf], enabled: bool) -> Vec<PathBuf> {
let mut candidates = custom.to_vec();
if enabled && std::env::var("SEARCHI_DISABLE_AUTOMATIC_INDEXING").as_deref() != Ok("1") {
candidates.extend(local_roots());
}
let mut normalized: Vec<PathBuf> = candidates
.into_iter()
.filter_map(|path| std::fs::canonicalize(path).ok())
.filter(|path| path.is_dir())
.collect();
normalized.sort();
normalized.dedup();
let mut result: Vec<PathBuf> = Vec::new();
for path in normalized {
if !result.iter().any(|parent| path.starts_with(parent)) {
result.push(path);
}
}
result.truncate(64);
result
}
#[cfg(windows)]
fn local_roots() -> Vec<PathBuf> {
use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives};
let mask = unsafe { GetLogicalDrives() };
(0..26)
.filter_map(|letter| {
if mask & (1 << letter) == 0 {
return None;
}
let path = format!("{}:\\", char::from(b'A' + letter));
let wide: Vec<u16> = path.encode_utf16().chain(Some(0)).collect();
// DRIVE_FIXED=3; DRIVE_REMOVABLE=2. Skip network, optical and absent media.
matches!(unsafe { GetDriveTypeW(wide.as_ptr()) }, 2 | 3).then(|| PathBuf::from(path))
})
.collect()
}
#[cfg(not(windows))]
fn local_roots() -> Vec<PathBuf> {
vec![PathBuf::from("/")]
}
pub fn exclusions() -> Vec<PathBuf> {
#[cfg(windows)]
let mut paths: Vec<PathBuf> = ["WINDIR", "ProgramFiles", "ProgramFiles(x86)", "ProgramData"]
.iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.collect();
#[cfg(not(windows))]
let mut paths: Vec<PathBuf> = [
"/proc", "/sys", "/dev", "/run", "/tmp", "/var", "/usr", "/etc", "/boot", "/snap",
]
.iter()
.map(PathBuf::from)
.collect();
#[cfg(windows)]
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
paths.push(PathBuf::from(local).join("Programs"));
}
if let Some(temp) = std::env::temp_dir().is_absolute().then(std::env::temp_dir) {
paths.push(temp);
}
paths
.into_iter()
.filter_map(|path| super::data_directory::canonical_exclusion(&path).ok())
.collect()
}
pub const EXCLUDED_DIRECTORY_NAMES: &[&str] = &[
".git",
"node_modules",
"target",
".searchi",
".cache",
".cargo",
".rustup",
".venv",
"__pycache__",
"Cache",
"Caches",
"CachedData",
"Code Cache",
"GPUCache",
"$RECYCLE.BIN",
"System Volume Information",
".ssh",
".gnupg",
".codex",
".agents",
".aws",
".azure",
".kube",
"secrets",
".secrets",
];
pub fn excluded_names() -> Vec<String> {
EXCLUDED_DIRECTORY_NAMES
.iter()
.map(|name| (*name).to_owned())
.collect()
}
/// Commit common personal folders separately before the first whole-volume
/// reconciliation so keyword search becomes useful while the larger walk runs.
pub fn first_use_scopes(root: &std::path::Path) -> Vec<PathBuf> {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.and_then(|value| std::fs::canonicalize(PathBuf::from(value)).ok());
let Some(home) = home.filter(|home| home.starts_with(root)) else {
return Vec::new();
};
[
"Desktop",
"Documents",
"Downloads",
"Pictures",
"Music",
"Videos",
]
.iter()
.map(|name| home.join(name))
.filter(|path| path.is_dir())
.filter_map(|path| path.canonicalize().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_roots_are_canonical_nonoverlapping_and_missing_roots_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("child");
std::fs::create_dir(&child).unwrap();
assert_eq!(
roots(
&[child, dir.path().to_owned(), dir.path().join("absent")],
false
),
vec![dir.path().canonicalize().unwrap()]
);
}
#[test]
fn exclusions_do_not_exclude_user_documents_by_name() {
let names = excluded_names();
assert!(names.iter().any(|name| name == "Cache"));
assert!(!names
.iter()
.any(|name| name == "Documents" || name == "AppData"));
assert!(exclusions().iter().all(|path| path.is_absolute()));
}
#[test]
fn automatic_scan_keeps_documents_and_prunes_credential_stores() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("Documents")).unwrap();
std::fs::create_dir(dir.path().join("secrets")).unwrap();
std::fs::write(
dir.path().join("Documents/notes.txt"),
"visible fixture document",
)
.unwrap();
std::fs::write(
dir.path().join("secrets/credentials.json"),
"{\"test_only\":true}",
)
.unwrap();
let db = tempfile::tempdir().unwrap();
let mut index = searchi_core::SearchIndex::open(db.path().join("index.sqlite")).unwrap();
index
.index_folder_with_options(
dir.path(),
&searchi_core::IndexOptions {
excluded_directory_names: excluded_names(),
excluded_paths: Vec::new(),
},
)
.unwrap();
assert_eq!(index.stats().unwrap().files, 1);
assert!(!index.search("notes.txt", 10).unwrap().is_empty());
assert!(index.search("credentials.json", 10).unwrap().is_empty());
}
}
+55
View File
@@ -90,6 +90,61 @@ pub fn hide_search(app: tauri::AppHandle) -> Result<(), String> {
.map_err(|error| error.to_string())
}
/// A launcher grows only when it has content, staying inside its current display.
#[tauri::command]
pub fn set_search_layout(app: tauri::AppHandle, expanded: bool) -> Result<(), String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "Search window is unavailable.".to_owned())?;
let monitor = window
.current_monitor()
.map_err(|error| error.to_string())?
.or(window
.primary_monitor()
.map_err(|error| error.to_string())?);
let (width, height) = if expanded {
(1080.0, 700.0)
} else {
(720.0, 110.0)
};
if let Some(monitor) = monitor {
let scale = monitor.scale_factor();
let area = monitor.work_area();
let size = &area.size;
let width = (width * scale)
.min(f64::from(size.width).max(80.0) - 48.0)
.max(32.0) as u32;
let height = (height * scale)
.min(f64::from(size.height).max(80.0) - 80.0)
.max(32.0) as u32;
window
.set_size(tauri::PhysicalSize::new(width, height))
.map_err(|error| error.to_string())?;
let origin = &area.position;
window
.set_position(tauri::PhysicalPosition::new(
origin.x + ((size.width - width) / 2) as i32,
origin.y + ((size.height - height) / 2) as i32,
))
.map_err(|error| error.to_string())?;
} else {
window
.set_size(tauri::LogicalSize::new(width, height))
.map_err(|error| error.to_string())?;
window.center().map_err(|error| error.to_string())?;
}
Ok(())
}
/// Dragging is limited to our launcher; no general window access is exposed.
#[tauri::command]
pub fn drag_search(app: tauri::AppHandle) -> Result<(), String> {
app.get_webview_window("main")
.ok_or_else(|| "Search window is unavailable.".to_owned())?
.start_dragging()
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
+62 -2
View File
@@ -5,6 +5,8 @@ compile_error!("Release desktop builds require --features custom-protocol to emb
mod ai_commands;
mod appearance;
mod applications;
mod automatic_indexing;
mod data_directory;
mod instance;
mod media_preview;
@@ -36,6 +38,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use tauri_plugin_opener::OpenerExt;
struct AppState {
applications: applications::ApplicationCatalog,
appearance: Arc<Mutex<appearance::AppearanceStatus>>,
ai: Arc<AiSession>,
database: PathBuf,
@@ -119,6 +122,38 @@ fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
})
}
#[tauri::command]
fn search_applications(
query: String,
state: State<'_, AppState>,
) -> Vec<applications::ApplicationResult> {
state.applications.search(&query, 20)
}
#[tauri::command]
fn launch_application(
id: String,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
state.applications.launch(&app, &id)
}
#[tauri::command]
fn set_automatic_indexing(enabled: bool, state: State<'_, AppState>) -> Result<Settings, String> {
let mut store = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?;
let mut next = store.value.clone();
next.automatic_indexing = enabled;
store.save(next)?;
state
.watching
.replace_roots(automatic_indexing::roots(&store.value.roots, enabled))?;
Ok(store.value.clone())
}
#[tauri::command]
fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> {
if state.indexing.load(Ordering::Acquire) {
@@ -512,6 +547,21 @@ fn start_folder_watching(
let result = (|| -> Result<bool, String> {
let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?;
index.set_document_worker(document_worker.clone());
if request.paths.is_none()
&& index.stats().map_err(|error| error.to_string())?.files == 0
{
for scope in automatic_indexing::first_use_scopes(&request.root) {
index
.index_paths_cancellable(
&request.root,
&[scope],
&options,
&request.cancelled,
)
.map_err(|error| error.to_string())?;
let _ = app.emit("search-index-changed", ());
}
}
let report = match request.paths {
Some(paths) => index.index_paths_cancellable(
&request.root,
@@ -613,6 +663,9 @@ fn main() {
"searchi-extract-worker"
}));
let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?));
// Install semantic schema before automatic scans acquire the writer.
// Query/model preparation can then open current stores as readers.
searchi_service::HybridSearch::prepare_database(&database)?;
let warm_database = database.clone();
let warm_target = lexical.clone();
tauri::async_runtime::spawn_blocking(move || {
@@ -676,20 +729,21 @@ fn main() {
}
});
let mut ignored = vec![directory.clone(), model_directory.clone()];
ignored.extend(automatic_indexing::exclusions());
if let Some(parent) = speech_worker.parent().filter(|path| path.is_absolute()) { ignored.push(parent.to_path_buf()); }
if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) {
ignored.push(parent.to_path_buf());
}
let index_options = searchi_core::IndexOptions {
excluded_directory_names: automatic_indexing::excluded_names(),
excluded_paths: ignored.iter()
.map(|path| data_directory::canonical_exclusion(path))
.collect::<std::io::Result<Vec<_>>>()?,
..Default::default()
};
let indexing = Arc::new(AtomicBool::new(false));
let watching = Arc::new(start_folder_watching(
app.handle().clone(), database.clone(), lexical.clone(), indexing.clone(),
settings.value.roots.clone(), index_options.clone(), document_worker.clone(),
automatic_indexing::roots(&settings.value.roots, settings.value.automatic_indexing), index_options.clone(), document_worker.clone(),
).map_err(std::io::Error::other)?);
let semantic = Arc::new(
SemanticState::new(model_directory, worker, media_tools)
@@ -715,6 +769,7 @@ speech_worker, directory.join("SpeechModels")),
let window = app.get_webview_window("main").ok_or_else(|| std::io::Error::other("Main window is unavailable."))?;
let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false);
app.manage(AppState {
applications: applications::ApplicationCatalog::discover(),
appearance: Arc::new(Mutex::new(appearance)),
ai: Arc::new(AiSession::default()),
database,
@@ -749,6 +804,11 @@ speech_worker, directory.join("SpeechModels")),
})
.invoke_handler(tauri::generate_handler![
instance::hide_search,
instance::set_search_layout,
instance::drag_search,
search_applications,
launch_application,
set_automatic_indexing,
prepare_ai_source,
generate_ai,
search,
+11 -4
View File
@@ -23,10 +23,16 @@ fn default_background_opacity() -> u8 {
const SETTINGS_VERSION: u32 = 2;
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
fn default_automatic_indexing() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub version: u32,
#[serde(default = "default_automatic_indexing")]
pub automatic_indexing: bool,
#[serde(default)]
pub background_material: BackgroundMaterial,
#[serde(default = "default_background_opacity")]
@@ -43,6 +49,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
version: SETTINGS_VERSION,
automatic_indexing: true,
background_material: BackgroundMaterial::default(),
background_opacity: default_background_opacity(),
roots: Vec::new(),
@@ -57,8 +64,8 @@ impl Settings {
if self.version != SETTINGS_VERSION {
return Err(format!("Unsupported settings version {}", self.version));
}
if !(70..=100).contains(&self.background_opacity) {
return Err("Background opacity must be between 70 and 100 percent.".into());
if !(40..=100).contains(&self.background_opacity) {
return Err("Background opacity must be between 40 and 100 percent.".into());
}
if !(1..=8).contains(&self.cpu_threads) {
return Err("CPU threads must be between 1 and 8.".into());
@@ -265,11 +272,11 @@ mod appearance_tests {
assert_eq!(store.value.background_opacity, 90);
let mut next = store.value.clone();
next.background_material = BackgroundMaterial::Glass;
next.background_opacity = 70;
next.background_opacity = 40;
store.save(next.clone()).unwrap();
assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next);
let bytes = fs::read(&path).unwrap();
for opacity in [0, 69, 101, 255] {
for opacity in [0, 39, 101, 255] {
let mut invalid = next.clone();
invalid.background_opacity = opacity;
assert!(store.save(invalid).is_err());
+48 -4
View File
@@ -243,10 +243,9 @@ fn validate(roots: &[PathBuf]) -> Result<(), String> {
fn excluded(path: &Path, shared: &Shared) -> bool {
shared.ignored.iter().any(|p| path.starts_with(p))
|| path.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some(".git" | "node_modules" | "target")
)
super::automatic_indexing::EXCLUDED_DIRECTORY_NAMES
.iter()
.any(|name| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
})
}
fn hint(shared: &Shared, event: notify::Result<Event>) {
@@ -465,6 +464,51 @@ fn run(shared: Arc<Shared>, callback: impl Fn(ScanRequest) -> ScanOutcome) {
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn excluded_cache_events_do_not_dirty_root_but_documents_do() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().to_owned();
let mut root = Root::new(Instant::now());
root.full = false;
root.first = None;
let shared = Shared {
state: Mutex::new(State {
roots: BTreeMap::from([(path.clone(), root)]),
stop: false,
}),
changed: Condvar::new(),
ignored: Vec::new(),
timing: Timing::default(),
cancelled: Arc::new(AtomicBool::new(false)),
};
for relative in [
".cargo/registry/change.bin",
"Cache/change.bin",
"cAcHe/change.bin",
] {
hint(
&shared,
Ok(Event::new(EventKind::Any).add_path(path.join(relative))),
);
let state = shared.state.lock().unwrap();
let root = &state.roots[&path];
assert!(root.first.is_none());
assert!(root.paths.is_empty());
assert!(!root.media_hint);
assert!(!root.full);
}
let document = path.join("Documents/change.txt");
hint(
&shared,
Ok(Event::new(EventKind::Any).add_path(document.clone())),
);
let state = shared.state.lock().unwrap();
let root = &state.roots[&path];
assert!(root.first.is_some());
assert_eq!(root.paths, BTreeSet::from([document]));
assert!(root.media_hint);
assert!(!root.full);
}
fn fast() -> Timing {
Timing {
debounce: Duration::from_millis(40),