Some optimizations and another fix for the shortcut system.
All checks were successful
CI / linux (push) Successful in 11m53s
CI / windows-cross (push) Successful in 4m50s
CI / release (push) Successful in 13s

This commit is contained in:
Jeremy Karst 2026-09-06 19:43:57 -04:00
parent 22d52d5ac0
commit 658e32159a
32 changed files with 2181 additions and 572 deletions

7
Cargo.lock generated
View file

@ -3152,21 +3152,20 @@ dependencies = [
name = "quicksearch-gui" name = "quicksearch-gui"
version = "1.1.7" version = "1.1.7"
dependencies = [ dependencies = [
"ashpd",
"chrono", "chrono",
"eframe", "eframe",
"egui", "egui",
"egui_extras", "egui_extras",
"futures-channel",
"futures-util",
"global-hotkey", "global-hotkey",
"keyring", "keyring",
"open", "open",
"pollster",
"quicksearch-core", "quicksearch-core",
"raw-window-handle", "raw-window-handle",
"rfd", "rfd",
"rpassword", "rpassword",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"windows-sys 0.59.0", "windows-sys 0.59.0",
"x11rb", "x11rb",
"zeroize", "zeroize",

View file

@ -38,9 +38,10 @@ tracking: https://github.com/DataScienceDIY/quick_search
- **Password protection** — optionally encrypt the index, since it contains - **Password protection** — optionally encrypt the index, since it contains
the names and text of everything indexed. The password can be remembered the names and text of everything indexed. The password can be remembered
in your system keychain. in your system keychain.
- **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere - **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere,
while QuickSearch is running, and `quicksearch --toggle` can be bound in starting QuickSearch if it is closed. One click on the Settings tab binds
your desktop's keyboard settings to also start it. it on KDE and GNOME; elsewhere, bind `quicksearch --toggle` in your
desktop's keyboard settings.
- **Terminal search**`quicksearch <query>` prints ranked, pipe-friendly - **Terminal search**`quicksearch <query>` prints ranked, pipe-friendly
paths; `--long` adds sizes, dates, and highlighted snippets. paths; `--long` adds sizes, dates, and highlighted snippets.
- **Portable mode** — keep the program, its config, and its index together - **Portable mode** — keep the program, its config, and its index together

View file

@ -94,8 +94,6 @@ batch_size = 500
# leave another root's walkers parked behind it. 0 gives each turn one # leave another root's walkers parked behind it. 0 gives each turn one
# batch_size quantum and no more. Clamped to 0..10000 on load. # batch_size quantum and no more. Clamped to 0..10000 on load.
writer_turn_slice_ms = 100 writer_turn_slice_ms = 100
# Files per transaction for incremental FTS updates.
fts_update_batch_size = 1000
# How large the write-ahead log (index.sqlite-wal) may grow during an # How large the write-ahead log (index.sqlite-wal) may grow during an
# indexing run before the indexer forces a checkpoint (bytes); left # indexing run before the indexer forces a checkpoint (bytes); left
# alone, the log grows for the whole run. Not a safety knob: on a volume # alone, the log grows for the whole run. Not a safety knob: on a volume
@ -150,8 +148,9 @@ watch_cap_warned_roots = []
# The shortcut QuickSearch claims for itself while it is running: brings it to # The shortcut QuickSearch claims for itself while it is running: brings it to
# the front, switches to the Search tab and selects whatever is in the search # the front, switches to the Search tab and selects whatever is in the search
# box. Modifiers are Ctrl, Alt and Shift, joined to one key with "+". Leave it # box. Modifiers are Ctrl, Alt and Shift, joined to one key with "+". Leave it
# empty ("") for no shortcut. To start QuickSearch when it is closed, bind # empty ("") for no shortcut. To start QuickSearch when it is closed, use
# "quicksearch --toggle" in your desktop's keyboard settings. # "Set up system shortcut" on the Settings tab, or bind "quicksearch
# --toggle" in your desktop's keyboard settings yourself.
search_hotkey = "Ctrl+Shift+F" search_hotkey = "Ctrl+Shift+F"
# 'dark' or 'light'; anything other than 'light' is dark. Applied as soon # 'dark' or 'light'; anything other than 'light' is dark. Applied as soon
# as it is changed on the Settings tab. Following the desktop's own # as it is changed on the Settings tab. Following the desktop's own
@ -172,7 +171,10 @@ show_advanced_settings = false
tutorial_seen = false tutorial_seen = false
[search] [search]
# Start with the fuzzy passes enabled. # Whether the Fuzzy box on the Search tab starts ticked. Off by default: the
# fuzzy passes cost noticeable time on every keystroke, and most searches do
# not need them. Tick it in the GUI whenever you want typo tolerance for a
# session, or set it here to start that way every time.
fuzzy_default = false fuzzy_default = false
# Ceiling on the fuzzy stages' typo budget. The allowance grows with the # Ceiling on the fuzzy stages' typo budget. The allowance grows with the
# search term, one edit per three characters, up to this value, so 2 # search term, one edit per three characters, up to this value, so 2

View file

@ -136,3 +136,103 @@ mod mime_sniff {
} }
} }
/// `repo::update_file_basic`'s state narrowing: an NA→NA update — the whole
/// of a re-run over a text-free tree — pays one statement instead of four.
/// The pair: `clear_always` reimplements the pre-narrowing shape (the control
/// that must not move between builds); `narrowed` is the shipped function.
/// NA→NA is idempotent, so iterations are stable without re-seeding.
/// DONE→PENDING is deliberately not benched: its statement count is identical
/// in both shapes (clearing that must happen either way), so there is nothing
/// to regress.
mod update_narrowing {
use super::*;
use quicksearch_core::db::repo::{self, NewFile};
use quicksearch_core::mime::FileType;
use quicksearch_core::testutil::Scratch;
use rusqlite::{params, Connection, OptionalExtension};
const ROWS: usize = 1000;
fn new_file(name: &str) -> NewFile<'_> {
NewFile {
name,
parent: "/b/",
size: 7,
mtime: 7,
mime: None,
ftype: FileType::EMPTY,
hash: None,
needs_content: false,
}
}
fn seeded_na(tag: &str) -> (Scratch, Connection) {
let (dir, p) = Scratch::db(tag);
let mut conn =
quicksearch_core::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let tx = conn.transaction().unwrap();
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
for name in &names {
repo::insert_file(&tx, &new_file(name)).unwrap().unwrap();
}
tx.commit().unwrap();
(dir, conn)
}
#[divan::bench]
fn clear_always(bencher: Bencher) {
let (_dir, mut conn) = seeded_na("bench-update-old");
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
bencher.bench_local(move || {
let tx = conn.transaction().unwrap();
for name in &names {
let f = new_file(name);
let id: i64 = tx
.prepare_cached(
"UPDATE files
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
content_state = ?6
WHERE parent = ?7 AND name = ?8
RETURNING id",
)
.unwrap()
.query_row(
params![
f.size as i64,
f.mtime as i64,
f.hash,
f.mime,
f.ftype.bits() as i64,
repo::STATE_NA,
f.parent,
f.name,
],
|r| r.get(0),
)
.optional()
.unwrap()
.unwrap();
repo::remove_content_for_id(&tx, id).unwrap();
tx.prepare_cached("DELETE FROM failed_files WHERE file_id = ?1")
.unwrap()
.execute([id])
.unwrap();
}
tx.commit().unwrap();
});
}
#[divan::bench]
fn narrowed(bencher: Bencher) {
let (_dir, mut conn) = seeded_na("bench-update-new");
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
bencher.bench_local(move || {
let tx = conn.transaction().unwrap();
for name in &names {
repo::update_file_basic(&tx, &new_file(name)).unwrap().unwrap();
}
tx.commit().unwrap();
});
}
}

View file

@ -254,6 +254,16 @@ const CASES: &[Case] = &[
fuzzy: false, fuzzy: false,
passes: "regex name + content, no prefilter possible", passes: "regex name + content, no prefilter possible",
}, },
Case {
// The accept-predicate: a common term beside a regex whose path
// check misses everything, so every pass-A candidate fetches and
// decodes its stored body (or discovers its absence) through
// `Cx::regex_accepts` — per row, the shape `DocDecoder` exists for.
label: "term + regex",
query: r"content regex:zzznever\d",
fuzzy: false,
passes: "A hits many, regex accept-predicate decodes per candidate",
},
Case { Case {
label: "common (capped)", label: "common (capped)",
query: "content", query: "content",

View file

@ -12,9 +12,10 @@
//! What has to stay resident is the **`files` table**, not the FTS index. //! What has to stay resident is the **`files` table**, not the FTS index.
//! `search/cascade/passes.rs` answers filename queries (ranks 14, 910) with //! `search/cascade/passes.rs` answers filename queries (ranks 14, 910) with
//! `SELECT … FROM files f WHERE f.name LIKE '%…%'` — a full table scan, no FTS //! `SELECT … FROM files f WHERE f.name LIKE '%…%'` — a full table scan, no FTS
//! at all — and the fuzzy pass scans it again with `WHERE 1=1`. So the working //! at all — and the fuzzy pass scans it again (`LIKE`-narrowed for terms of
//! set scales with **file count**, not with document volume, and the corpus //! 9+ characters, unpredicated below that). So the working set scales with
//! dimension below is what makes that visible. //! **file count**, not with document volume, and the corpus dimension below
//! is what makes that visible.
//! //!
//! The keyed rows are where an undersized cache hurts first: a page-cache miss //! The keyed rows are where an undersized cache hurts first: a page-cache miss
//! costs an AES-CBC decrypt, not a `memcpy`. //! costs an AES-CBC decrypt, not a `memcpy`.
@ -155,20 +156,63 @@ fn mib(bytes: u64) -> f64 {
/// Run one query; hits are counted, not kept — holding 200k `SearchHit`s /// Run one query; hits are counted, not kept — holding 200k `SearchHit`s
/// would measure the allocator instead of the scan. /// would measure the allocator instead of the scan.
fn time_query(conn: &Connection, query: &str) -> (Duration, usize) { fn time_query(conn: &Connection, query: &str, options: &SearchOptions) -> (Duration, usize) {
let split = split_for_cascade(query).unwrap(); let split = split_for_cascade(query).unwrap();
let latest = std::sync::atomic::AtomicU64::new(1); let latest = std::sync::atomic::AtomicU64::new(1);
let mut count = 0usize; let mut count = 0usize;
let mut sink = |hits: Vec<SearchHit>| count += hits.len(); let mut sink = |hits: Vec<SearchHit>| count += hits.len();
let options = SearchOptions {
limit: 1000,
..SearchOptions::default()
};
let start = Instant::now(); let start = Instant::now();
cascade::run(conn, &split, &options, 1, &latest, &mut sink).unwrap(); cascade::run(conn, &split, options, 1, &latest, &mut sink).unwrap();
(start.elapsed(), count) (start.elapsed(), count)
} }
/// The options every measurement here runs under: the sweep's, with `fuzzy`
/// the variable. Both `SearchOptions::default()` and the GUI default
/// (`[search] fuzzy_default`) have fuzzy **off** — the fuzzy rows below are
/// what a user opts into, not what a default install pays per keystroke.
fn options(fuzzy: bool) -> SearchOptions {
SearchOptions {
limit: 1000,
fuzzy,
..SearchOptions::default()
}
}
/// The fuzzy pairing's keystroke sequence, per-term rather than averaged:
/// under the default `fuzzy_max_edits` of 2, `pigeonhole_chunks` needs
/// `3 × (k + 1)` characters, so terms under 9 characters keep the full scan
/// (`edit_budget` gives k=1 at 35 and k=2 from 6) and only the 9+ ones can
/// be prefiltered. Both shapes are in the sequence on purpose: the short
/// terms are the control a pass-C narrowing must not move, the long ones are
/// what it narrows. `quartzite` is `testutil::NEEDLE`, present in seeded
/// names, so the engaged rows return real hits.
const FUZZY_SEQUENCE: [&str; 4] = ["quar", "quartz", "quartzite", "quartzites"];
/// Warm per-term timings, fuzzy off against on, at the shipped cache — the
/// gate for the fuzzy filename pass's shape (`passes::pass_fuzzy_filename`).
/// The off column is the control: a pass-C change has no business moving it.
fn run_fuzzy_pairing(arm: &Arm) {
let conn = arm.open_search();
conn.execute_batch(&format!("PRAGMA cache_size = {};", SHIPPED_CACHE))
.unwrap();
// One settling pass per mode so every printed number is warm.
for fuzzy in [false, true] {
let opts = options(fuzzy);
for query in FUZZY_SEQUENCE {
time_query(&conn, query, &opts);
}
}
println!(
"{:>12} {:>10} {:>10} {:>8} (fuzzy pairing, shipped cache, warm)",
"term", "off", "on", "hits"
);
for query in FUZZY_SEQUENCE {
let (off, _) = time_query(&conn, query, &options(false));
let (on, hits) = time_query(&conn, query, &options(true));
println!("{:>12} {:>9.1?} {:>9.1?} {:>8}", query, off, on, hits);
}
}
/// One arm at one cache ceiling: the first keystroke on a fresh connection, /// One arm at one cache ceiling: the first keystroke on a fresh connection,
/// then the steady state after a priming pass. /// then the steady state after a priming pass.
/// ///
@ -182,13 +226,17 @@ fn measure(arm: &Arm, cache_size: i64) -> (Duration, Duration, usize) {
conn.execute_batch(&format!("PRAGMA cache_size = {};", cache_size)) conn.execute_batch(&format!("PRAGMA cache_size = {};", cache_size))
.unwrap(); .unwrap();
let (cold, hits) = time_query(&conn, SEQUENCE[0]); let opts = options(false);
let (cold, hits) = time_query(&conn, SEQUENCE[0], &opts);
// A priming pass, so "warm" is a settled session rather than the three // A priming pass, so "warm" is a settled session rather than the three
// keystrokes after the first. // keystrokes after the first.
for query in SEQUENCE { for query in SEQUENCE {
time_query(&conn, query); time_query(&conn, query, &opts);
} }
let total: Duration = SEQUENCE.iter().map(|q| time_query(&conn, q).0).sum(); let total: Duration = SEQUENCE
.iter()
.map(|q| time_query(&conn, q, &opts).0)
.sum();
(cold, total / SEQUENCE.len() as u32, hits) (cold, total / SEQUENCE.len() as u32, hits)
} }
@ -302,7 +350,21 @@ fn main() {
std::env::temp_dir().display() std::env::temp_dir().display()
); );
} }
for files in CORPORA { // `QSB_SEARCH_PERF=fuzzy` runs only the fuzzy pairing — the cache sweep
// is minutes per arm and a pass-C gate does not need it. `QSB_CORPORA`
// trims the corpus list the same way (comma-separated file counts).
let fuzzy_only = std::env::var("QSB_SEARCH_PERF").as_deref() == Ok("fuzzy");
let corpora: Vec<usize> = std::env::var("QSB_CORPORA")
.ok()
.map(|v| {
v.split(',')
.filter_map(|n| n.trim().parse().ok())
.collect()
})
.filter(|v: &Vec<usize>| !v.is_empty())
.unwrap_or_else(|| CORPORA.to_vec());
for files in corpora {
for keyed in [false, true] { for keyed in [false, true] {
let arm = Arm::seed( let arm = Arm::seed(
format!( format!(
@ -312,10 +374,31 @@ fn main() {
), ),
&format!("searchperf-{}-{}", files, keyed), &format!("searchperf-{}-{}", files, keyed),
keyed, keyed,
&spec(files), &if fuzzy_only {
light_spec(files)
} else {
spec(files)
},
); );
run_matrix(&arm, files); if fuzzy_only {
println!("\n=== {} === seeded in {:.1?}", arm.what, arm.seeded_in);
} else {
run_matrix(&arm, files);
}
run_fuzzy_pairing(&arm);
arm.discard(); arm.discard();
} }
} }
} }
/// The fuzzy-only corpus: `files` rows and nothing else. Pass C never reads a
/// document — its whole cost is the `files` scan plus the bitap — and 2 KB
/// bodies turn a minutes-long seed into hours on slow storage while adding
/// only pass-D noise to the number under test. One row in `usize::MAX` still
/// gets content, which keeps pass D exercised without costing anything.
fn light_spec(files: usize) -> SeedSpec {
SeedSpec {
content_every: usize::MAX,
..spec(files)
}
}

View file

@ -51,18 +51,28 @@
//! Every stage runs against a byte-identical copy of one seeded index rather //! Every stage runs against a byte-identical copy of one seeded index rather
//! than a fresh seed: FTS5 segment layout is most of what decides delete cost, //! than a fresh seed: FTS5 segment layout is most of what decides delete cost,
//! and reseeding would let it drift between the rows of the table. //! and reseeding would let it drift between the rows of the table.
//!
//! The final table prices the *other* bulk withdrawal, a completed run's stale
//! cleanup (`file_handling::cleanup_stale_index_entries`): the same doomed
//! rows, deleted by path the way that pass does. Its stages are spelled out in
//! probe code for the same reason as above — they are the fixed decomposition
//! — and its `live` row is the shipped function, which is the row that moves
//! when `file_handling::batch` is reshaped.
mod common; mod common;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use rusqlite::Connection; use rusqlite::{Connection, OptionalExtension};
use quicksearch_core::config::Config; use quicksearch_core::config::Config;
use quicksearch_core::db::{self, repo}; use quicksearch_core::db::{self, repo};
use quicksearch_core::extract::Registry; use quicksearch_core::extract::Registry;
use quicksearch_core::file_handling::ExtractCursor; use quicksearch_core::file_handling::{
cleanup_stale_index_entries, fts_begin_tombstone_burst, fts_end_tombstone_burst,
fts_finalize_after_text_indexing, split_db_path, ExtractCursor,
};
use quicksearch_core::scope::{self, Scope, WorkCursor}; use quicksearch_core::scope::{self, Scope, WorkCursor};
use quicksearch_core::testutil::{self, Arm, SeedSpec}; use quicksearch_core::testutil::{self, Arm, SeedSpec};
@ -397,6 +407,165 @@ fn delete_range(tx: &Connection, lo: &str, hi: &str) -> (usize, Duration, Durati
(removed, fts, at.elapsed()) (removed, fts, at.elapsed())
} }
// ---------------------------------------------------------------------------
// Stale cleanup stages
// ---------------------------------------------------------------------------
/// Cumulative slices of a completed run's stale cleanup, which deletes by
/// *path* rather than deciding rows from a page it already read.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum StaleShape {
/// Per-path deletes, three statements each, FTS handed every id — the
/// pass as originally shipped. This is the control row: it reproduces
/// that shape in probe code, so it must not move when
/// `cleanup_stale_index_entries` is reshaped.
PerRow,
/// ...resolve `(id, content_state)` per path instead, then chunked
/// `IN (...)` deletes with FTS narrowed to ids that can hold a posting.
Ids,
/// ...and hold FTS5's delete-merging off for the whole pass.
Burst,
}
impl StaleShape {
const ALL: [StaleShape; 3] = [StaleShape::PerRow, StaleShape::Ids, StaleShape::Burst];
fn label(self) -> &'static str {
match self {
StaleShape::PerRow => "per-row",
StaleShape::Ids => "+ids",
StaleShape::Burst => "+burst",
}
}
fn tag(self) -> &'static str {
match self {
StaleShape::PerRow => "row",
StaleShape::Ids => "ids",
StaleShape::Burst => "burst",
}
}
}
/// Delete `paths` the way stale cleanup does, one transaction per `PAGE`
/// chunk, timing each phase. Column mapping in the shared table: `cover` is
/// the id resolution, `files` the `files` deletes, `fts` the tombstones plus
/// the trailing consolidation.
fn run_stale(conn: &Connection, paths: &[String], shape: StaleShape) -> Timing {
let mut t = Timing::default();
let io_before = Io::read();
let (_, misses_before) = testutil::cache_stats(conn);
let started = Instant::now();
if shape == StaleShape::Burst {
fts_begin_tombstone_burst(conn);
}
for page in paths.chunks(PAGE as usize) {
let tx = conn.unchecked_transaction().expect("begin");
if shape == StaleShape::PerRow {
for path in page {
let Some((parent, name)) = split_db_path(path) else {
continue;
};
let at = Instant::now();
let id: Option<i64> = tx
.prepare_cached(
"DELETE FROM files WHERE parent = ?1 AND name = ?2 RETURNING id",
)
.expect("prepare")
.query_row(rusqlite::params![parent, name], |r| r.get(0))
.optional()
.expect("delete row");
t.files += at.elapsed();
let Some(id) = id else { continue };
t.deleted += 1;
let at = Instant::now();
tx.prepare_cached("DELETE FROM searchabletext WHERE rowid = ?1")
.expect("prepare")
.execute([id])
.expect("tombstone");
t.fts += at.elapsed();
let at = Instant::now();
tx.prepare_cached("DELETE FROM documents_text WHERE file_id = ?1")
.expect("prepare")
.execute([id])
.expect("clear body");
t.files += at.elapsed();
}
} else {
let at = Instant::now();
let mut ids: Vec<i64> = Vec::new();
let mut with_postings: Vec<i64> = Vec::new();
{
let mut sel = tx
.prepare_cached(
"SELECT id, content_state FROM files WHERE parent = ?1 AND name = ?2",
)
.expect("prepare");
for path in page {
let Some((parent, name)) = split_db_path(path) else {
continue;
};
let row: Option<(i64, i64)> = sel
.query_row(rusqlite::params![parent, name], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.optional()
.expect("resolve");
if let Some((id, state)) = row {
ids.push(id);
if state == repo::STATE_DONE {
with_postings.push(id);
}
}
}
}
t.cover += at.elapsed();
for chunk in with_postings.chunks(CHUNK) {
let at = Instant::now();
tx.execute(
&format!(
"DELETE FROM searchabletext WHERE rowid IN ({})",
placeholders(chunk.len())
),
rusqlite::params_from_iter(chunk.iter()),
)
.expect("tombstone");
t.fts += at.elapsed();
}
for chunk in ids.chunks(CHUNK) {
let at = Instant::now();
t.deleted += tx
.execute(
&format!(
"DELETE FROM files WHERE id IN ({})",
placeholders(chunk.len())
),
rusqlite::params_from_iter(chunk.iter()),
)
.expect("delete rows");
t.files += at.elapsed();
}
}
let at = Instant::now();
tx.commit().expect("commit");
t.commit += at.elapsed();
}
let at = Instant::now();
if shape == StaleShape::Burst {
fts_end_tombstone_burst(conn);
} else {
fts_finalize_after_text_indexing(conn);
}
t.fts += at.elapsed();
t.total = started.elapsed();
t.io = Io::read().since(&io_before);
let (_, misses_after) = testutil::cache_stats(conn);
t.misses = misses_after - misses_before;
t
}
/// How much data FTS5 is holding — the only quiescence signal that works. /// How much data FTS5 is holding — the only quiescence signal that works.
/// ///
/// `sqlite3_changes()` after a `'merge'` does **not** report whether the merge /// `sqlite3_changes()` after a `'merge'` does **not** report whether the merge
@ -875,6 +1044,91 @@ fn main() {
arm.discard(); arm.discard();
} }
// Stale cleanup, the pass a completed run ends with. Same doomed rows
// as the tables above, but deleted by *path* — the walk hands
// `cleanup_stale_index_entries` a list of paths it did not see, in
// directory order. Runs on `PRAGMAS_FAST` via `open_existing(_, true)`
// because that is the run's own writer connection, the one the real
// pass executes on — `open` here would measure the reconcile's 4 MiB
// cache instead.
{
let stale: Vec<String> = {
let conn = open(&master);
let mut stmt = conn
.prepare(
"SELECT parent || name FROM files WHERE parent LIKE ?1 \
ORDER BY parent, name",
)
.expect("prepare");
let rows = stmt
.query_map([format!("%/{}/%", PRUNE_PATTERN)], |r| r.get(0))
.expect("query stale paths");
rows.collect::<Result<Vec<_>, _>>().expect("read stale paths")
};
println!("\n stale cleanup, {} doomed paths:", stale.len());
header();
for shape in StaleShape::ALL {
let arm = clone_arm(&master, &format!("prune-{}-stale-{}", label, shape.tag()));
let conn = arm.with_key(|| {
db::open::open_existing(&arm.path.to_string_lossy(), true)
.expect("open the copy")
});
let t = run_stale(&conn, &stale, shape);
row(shape.label(), &t);
let (took, rounds, before, after) = merge_to_quiescence(&conn);
println!(
" {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows",
"",
took.as_secs_f64() * 1000.0,
rounds,
before,
after
);
drop(conn);
arm.discard();
}
// The shipped function, whole. The row that moves when
// `file_handling::batch` is reshaped; the stages above must not.
let arm = clone_arm(&master, &format!("prune-{}-stale-live", label));
let conn = arm.with_key(|| {
db::open::open_existing(&arm.path.to_string_lossy(), true).expect("open the copy")
});
let (_, misses_before) = testutil::cache_stats(&conn);
let conn_mutex = std::sync::Arc::new(std::sync::Mutex::new(conn));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Instant::now();
let deleted = cleanup_stale_index_entries(&conn_mutex, &stale, &stop, &config)
.expect("cleanup stale");
let total = started.elapsed();
let conn = std::sync::Arc::try_unwrap(conn_mutex)
.map_err(|_| ())
.expect("sole owner")
.into_inner()
.expect("unpoisoned");
let (_, misses_after) = testutil::cache_stats(&conn);
row(
"live",
&Timing {
total,
deleted,
misses: misses_after - misses_before,
..Timing::default()
},
);
let (took, rounds, before, after) = merge_to_quiescence(&conn);
println!(
" {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows <- cleanup_stale_index_entries",
"",
took.as_secs_f64() * 1000.0,
rounds,
before,
after
);
drop(conn);
arm.discard();
}
master.discard(); master.discard();
} }
} }

View file

@ -84,7 +84,6 @@ pub struct ProcessingConfig {
/// milliseconds — the bound on how long any one root can hold up the /// milliseconds — the bound on how long any one root can hold up the
/// others. `0` gives each turn one `batch_size` quantum and no more. /// others. `0` gives each turn one `batch_size` quantum and no more.
pub writer_turn_slice_ms: u64, pub writer_turn_slice_ms: u64,
pub fts_update_batch_size: usize,
/// How large the WAL may grow during a run before the indexer forces a /// How large the WAL may grow during a run before the indexer forces a
/// checkpoint, in bytes. `0` disables; else raised to [`MINIMUM_WAL_SIZE`]. /// checkpoint, in bytes. `0` disables; else raised to [`MINIMUM_WAL_SIZE`].
/// Needed because autocheckpoint can only *reset* the log when no reader /// Needed because autocheckpoint can only *reset* the log when no reader
@ -240,7 +239,6 @@ impl Default for ProcessingConfig {
maximum_text_file_size: 1024 * 1024 * 2, maximum_text_file_size: 1024 * 1024 * 2,
batch_size: 500, batch_size: 500,
writer_turn_slice_ms: 100, writer_turn_slice_ms: 100,
fts_update_batch_size: 1000,
maximum_wal_size: 1024 * 1024 * 1024 * 2, maximum_wal_size: 1024 * 1024 * 1024 * 2,
tokenize: "trigram".to_string(), tokenize: "trigram".to_string(),
store_text_for_snippets: true, store_text_for_snippets: true,
@ -251,7 +249,7 @@ impl Default for ProcessingConfig {
impl Default for SearchConfig { impl Default for SearchConfig {
fn default() -> Self { fn default() -> Self {
SearchConfig { SearchConfig {
fuzzy_default: true, fuzzy_default: false,
fuzzy_max_edits: 2, fuzzy_max_edits: 2,
display_limit: 1000, display_limit: 1000,
results_per_page: 100, results_per_page: 100,

View file

@ -103,14 +103,26 @@ fn initial_content_state(f: &NewFile<'_>) -> i64 {
/// Update a file's metadata in place and reset its content state, clearing /// Update a file's metadata in place and reset its content state, clearing
/// any extracted content. `None` if no row matches. /// any extracted content. `None` if no row matches.
///
/// The clearing is narrowed by the row's *stored* state, the way
/// `scope::PagePlan` narrows: content rows exist exactly for `STATE_DONE`
/// (`repo_tests::leaving_done_always_takes_the_posting_with_it`) and
/// `failed_files` rows exactly for `STATE_FAILED`, so every other state has
/// nothing to clear and pays one statement instead of four — most changed
/// rows on a text-free tree are NA→NA, measured 1.24x on that shape
/// (`benches/index.rs`, group `update_narrowing`; DONE→PENDING is unchanged
/// by construction, the clearing happens either way).
/// `content_state` is deliberately **not
/// in the SET list**: `RETURNING` reports the post-update row, so assigning
/// it there would return the new state and the narrowing would read its own
/// write.
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> { pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
let id: Option<i64> = tx let row: Option<(i64, i64)> = tx
.prepare_cached( .prepare_cached(
"UPDATE files "UPDATE files
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5, SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5
content_state = ?6 WHERE parent = ?6 AND name = ?7
WHERE parent = ?7 AND name = ?8 RETURNING id, content_state",
RETURNING id",
) )
.and_then(|mut stmt| { .and_then(|mut stmt| {
stmt.query_row( stmt.query_row(
@ -120,27 +132,42 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
f.hash, f.hash,
f.mime, f.mime,
f.ftype.bits() as i64, f.ftype.bits() as i64,
initial_content_state(f),
f.parent, f.parent,
f.name, f.name,
], ],
|r| r.get(0), |r| Ok((r.get(0)?, r.get(1)?)),
) )
.optional() .optional()
}) })
.map_err(|e| format!("update file {}: {}", f.path(), e))?; .map_err(|e| format!("update file {}: {}", f.path(), e))?;
let Some(id) = id else { let Some((id, old_state)) = row else {
return Ok(None); return Ok(None);
}; };
remove_content_for_id(tx, id)?; match old_state {
// Without this, a changed file that stops needing content keeps reading STATE_DONE => remove_content_for_id(tx, id)?,
// as "failed" in `list-failed` forever. // Without this, a changed file that stops needing content keeps
exec( // reading as "failed" in `list-failed` forever.
tx, STATE_FAILED => exec(
"DELETE FROM failed_files WHERE file_id = ?1", tx,
params![id], "DELETE FROM failed_files WHERE file_id = ?1",
|| format!("clear failed_files {}", id), params![id],
)?; || format!("clear failed_files {}", id),
)
.map(|_| ())?,
_ => {}
}
let new_state = initial_content_state(f);
if old_state != new_state {
// A plain assignment, not `set_state_clearing_failure`: the FAILED
// arm above already took the failure record, and no other state can
// hold one.
exec(
tx,
"UPDATE files SET content_state = ?1 WHERE id = ?2",
params![new_state, id],
|| format!("update reset content_state {}", id),
)?;
}
Ok(Some(id)) Ok(Some(id))
} }
@ -382,6 +409,24 @@ pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, Str
Ok(true) Ok(true)
} }
/// The stored id and `content_state` for one exact path, or `None` if the
/// path is not indexed.
///
/// Read it in the same transaction as the decision it feeds: the caller that
/// builds [`delete_ids`]'s `with_postings` from the state must see the state
/// *at delete time*, and only the transaction makes that simultaneous.
pub fn id_and_state_for_path(conn: &Connection, path: &str) -> Result<Option<(i64, i64)>, String> {
let Some((parent, name)) = crate::file_handling::split_db_path(path) else {
return Ok(None);
};
conn.prepare_cached("SELECT id, content_state FROM files WHERE parent = ?1 AND name = ?2")
.and_then(|mut stmt| {
stmt.query_row(params![parent, name], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()
})
.map_err(|e| format!("look up {}: {}", path, e))
}
/// Delete every row whose parent falls in `[lo, hi)`. Build the bounds with /// Delete every row whose parent falls in `[lo, hi)`. Build the bounds with
/// [`crate::file_handling::ExtractCursor::for_root`], which makes them /// [`crate::file_handling::ExtractCursor::for_root`], which makes them
/// separator-correct — the range covers the root's *own* files only because /// separator-correct — the range covers the root's *own* files only because

View file

@ -587,6 +587,166 @@ fn delete_ids_spans_chunk_boundaries() {
assert_eq!(left, keep); assert_eq!(left, keep);
} }
/// `update_file_basic` narrows its clearing by the stored state: only DONE
/// holds content, only FAILED holds a failure record, and everything else
/// must pay a single statement. The dangerous direction is under-clearing —
/// a DONE row keeping its posting surfaces a hit whose text no longer
/// matches — so every holding state is exercised.
#[test]
fn updating_clears_content_exactly_for_the_states_that_hold_it() {
let (_dir, p) = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let ids = seeded(&mut conn, &["/t/done.txt", "/t/failed.txt", "/t/keep.txt"]);
let na_id = {
let tx = conn.transaction().unwrap();
// Clears failed.txt's posting too — the invariant the narrowing rests on.
set_content_failed(&tx, ids["/t/failed.txt"], "bad parse").unwrap();
let id = insert_file(
&tx,
&NewFile {
name: "na.bin",
parent: "/t/",
size: 1,
mtime: 1,
mime: None,
ftype: crate::mime::FileType::EMPTY,
hash: None,
needs_content: false,
},
)
.unwrap()
.expect("unique path");
tx.commit().unwrap();
id
};
let update = |conn: &mut Connection, name: &str, needs_content: bool| {
let tx = conn.transaction().unwrap();
let id = update_file_basic(
&tx,
&NewFile {
name,
parent: "/t/",
size: 9,
mtime: 9,
mime: Some("text/plain"),
ftype: crate::mime::FileType::TEXT,
hash: None,
needs_content,
},
)
.unwrap();
tx.commit().unwrap();
id
};
let count = |conn: &Connection, sql: &str| -> i64 {
conn.query_row(sql, [], |r| r.get(0)).unwrap()
};
let state = |conn: &Connection, id: i64| -> i64 {
conn.query_row(
"SELECT content_state FROM files WHERE id = ?1",
[id],
|r| r.get(0),
)
.unwrap()
};
// DONE → PENDING: the posting and the body go, in the same transaction.
assert_eq!(
update(&mut conn, "done.txt", true),
Some(ids["/t/done.txt"])
);
assert_eq!(state(&conn, ids["/t/done.txt"]), STATE_PENDING);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM searchabletext WHERE rowid IN \
(SELECT id FROM files WHERE name = 'done.txt')"
),
0
);
// FAILED → PENDING: the failure record goes with the transition.
assert_eq!(
update(&mut conn, "failed.txt", true),
Some(ids["/t/failed.txt"])
);
assert_eq!(state(&conn, ids["/t/failed.txt"]), STATE_PENDING);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM failed_files"), 0);
// NA → NA: nothing to clear, and the metadata still lands.
assert_eq!(update(&mut conn, "na.bin", false), Some(na_id));
assert_eq!(state(&conn, na_id), STATE_NA);
assert_eq!(
conn.query_row("SELECT mtime FROM files WHERE id = ?1", [na_id], |r| r
.get::<_, i64>(0))
.unwrap(),
9
);
// PENDING → NA: the state transition alone.
assert_eq!(
update(&mut conn, "done.txt", false),
Some(ids["/t/done.txt"])
);
assert_eq!(state(&conn, ids["/t/done.txt"]), STATE_NA);
// keep.txt, never updated, keeps its posting — and it is the only one.
assert_eq!(count(&conn, "SELECT COUNT(*) FROM searchabletext"), 1);
assert_eq!(state(&conn, ids["/t/keep.txt"]), STATE_DONE);
// No row: `None`, not an invented insert.
assert_eq!(update(&mut conn, "vanished.txt", true), None);
}
/// The resolver behind stale cleanup's `with_postings` decision: the state it
/// returns is what decides whether FTS gets handed the id, so it must be the
/// stored state, and the unresolvable inputs must be `None`, not errors — a
/// stale list can legitimately name paths another writer already removed.
#[test]
fn id_and_state_for_path_reads_the_stored_state() {
let (_dir, p) = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
let ids = seeded(&mut conn, &["/t/done.txt"]);
let na_id = {
let tx = conn.transaction().unwrap();
let id = insert_file(
&tx,
&NewFile {
name: "na.bin",
parent: "/t/",
size: 1,
mtime: 1,
mime: None,
ftype: crate::mime::FileType::EMPTY,
hash: None,
needs_content: false,
},
)
.unwrap()
.expect("unique path");
tx.commit().unwrap();
id
};
assert_eq!(
id_and_state_for_path(&conn, "/t/done.txt").unwrap(),
Some((ids["/t/done.txt"], STATE_DONE))
);
assert_eq!(
id_and_state_for_path(&conn, "/t/na.bin").unwrap(),
Some((na_id, STATE_NA))
);
assert_eq!(id_and_state_for_path(&conn, "/t/vanished.txt").unwrap(), None);
assert_eq!(
id_and_state_for_path(&conn, "no-separator").unwrap(),
None,
"an unsplittable path resolves to nothing rather than erroring"
);
}
/// Dropping stored text must cost the file its snippets and nothing else. /// Dropping stored text must cost the file its snippets and nothing else.
#[test] #[test]
fn drop_stored_text_keeps_the_file_searchable() { fn drop_stored_text_keeps_the_file_searchable() {

View file

@ -84,9 +84,11 @@ fn write_prepared_records(
records: &[OwnedNewFile], records: &[OwnedNewFile],
stop_flag: &Arc<AtomicBool>, stop_flag: &Arc<AtomicBool>,
config: &Config, config: &Config,
chunk_size: usize,
write_row: impl Fn(&rusqlite::Transaction<'_>, &OwnedNewFile) -> Result<Option<i64>, String>, write_row: impl Fn(&rusqlite::Transaction<'_>, &OwnedNewFile) -> Result<Option<i64>, String>,
) -> Result<(), String> { ) -> Result<(), String> {
// `.max(1)`: `chunks(0)` panics on the indexing thread, so a hand-edited
// `batch_size = 0` would wedge indexing while the UI reads "Running".
let chunk_size = config.processing.batch_size.max(1);
// One set of buffers for every chunk this call writes. // One set of buffers for every chunk this call writes.
let mut bodies = Bodies::new()?; let mut bodies = Bodies::new()?;
for batch in records.chunks(chunk_size) { for batch in records.chunks(chunk_size) {
@ -134,17 +136,15 @@ pub fn process_batch_updates(
return Ok(()); return Ok(());
} }
let fts_batch = config.processing.fts_update_batch_size.max(1); // The row leaves this closure `_fresh`: `update_file_basic` cleared a
// DONE row's content in this same transaction — every other state
// The row leaves this closure `_fresh`: `update_file_basic` cleared its // provably holds none (`repo_tests::leaving_done_always_takes_the_posting_with_it`)
// content in this same transaction, and the insert fallback created the // — and the insert fallback created the row outright.
// row outright.
write_prepared_records( write_prepared_records(
conn_mutex, conn_mutex,
files_to_update, files_to_update,
stop_flag, stop_flag,
config, config,
fts_batch,
|tx, rec| { |tx, rec| {
let updated = repo::update_file_basic(tx, &rec.as_new_file()).map_err(|e| { let updated = repo::update_file_basic(tx, &rec.as_new_file()).map_err(|e| {
format!( format!(
@ -182,9 +182,6 @@ pub fn process_batch_inserts(
return Ok(()); return Ok(());
} }
// `.max(1)`: `chunks(0)` panics on the indexing thread, so a hand-edited
// `batch_size = 0` would wedge indexing while the UI reads "Running".
//
// The row leaves this closure `_fresh`: `insert_file` returned `Some` // The row leaves this closure `_fresh`: `insert_file` returned `Some`
// only by creating it, so it cannot carry content from anywhere. // only by creating it, so it cannot carry content from anywhere.
write_prepared_records( write_prepared_records(
@ -192,7 +189,6 @@ pub fn process_batch_inserts(
files_to_insert, files_to_insert,
stop_flag, stop_flag,
config, config,
config.processing.batch_size.max(1),
|tx, rec| { |tx, rec| {
repo::insert_file(tx, &rec.as_new_file()) repo::insert_file(tx, &rec.as_new_file())
.map_err(|e| format!("Failed to insert file record: {}", e)) .map_err(|e| format!("Failed to insert file record: {}", e))
@ -203,6 +199,26 @@ pub fn process_batch_inserts(
/// Delete the rows a completed run found no file behind. Returns how many /// Delete the rows a completed run found no file behind. Returns how many
/// went. A chunk either commits whole or is not begun, so a stop cannot /// went. A chunk either commits whole or is not begun, so a stop cannot
/// leave the index half-reconciled. /// leave the index half-reconciled.
///
/// FTS5's delete-merging is held off for the whole pass
/// ([`fts_begin_tombstone_burst`]) — this is a bulk withdrawal, the shape the
/// burst was measured on. A stop mid-pass returns without restoring it, which
/// is the established abandonment semantics: the next run's
/// [`fts_begin_bulk_write`] puts the threshold back.
///
/// `examples/pruneprobe.rs`'s stale table prices the shape (200k-row index,
/// 40k stale paths, 1 in 7 holding a posting): the per-path form this
/// replaced took 794 ms plain / 1,627 ms keyed; this takes 532 / 1,226 —
/// 1.5x and 1.3x, most of it the burst, the rest the batched deletes and the
/// `with_postings` narrowing. The gain is smaller than the reconcile's
/// because here only a seventh of the doomed rows hold a posting.
///
/// Each chunk resolves `(id, content_state)` **inside its own transaction**
/// and hands [`repo::delete_ids`] the `STATE_DONE` subset as `with_postings`.
/// The placement is load-bearing: extraction may still be running (its writes
/// serialize on this same connection mutex), so between chunks a doomed row
/// can flip PENDING→DONE — resolved any earlier, its posting would outlive
/// the row.
pub fn cleanup_stale_index_entries( pub fn cleanup_stale_index_entries(
conn_mutex: &Arc<Mutex<Connection>>, conn_mutex: &Arc<Mutex<Connection>>,
stale_paths: &[String], stale_paths: &[String],
@ -215,6 +231,13 @@ pub fn cleanup_stale_index_entries(
let chunk = config.processing.batch_size.max(1); let chunk = config.processing.batch_size.max(1);
let mut deleted_count = 0usize; let mut deleted_count = 0usize;
{
let conn = crate::lock_ok(conn_mutex);
fts_begin_tombstone_burst(&conn);
}
let mut ids: Vec<i64> = Vec::new();
let mut with_postings: Vec<i64> = Vec::new();
for batch in stale_paths.chunks(chunk) { for batch in stale_paths.chunks(chunk) {
if stop_flag.load(Ordering::Relaxed) { if stop_flag.load(Ordering::Relaxed) {
return Ok(deleted_count); return Ok(deleted_count);
@ -223,26 +246,26 @@ pub fn cleanup_stale_index_entries(
let tx = conn let tx = conn
.unchecked_transaction() .unchecked_transaction()
.map_err(|e| format!("Failed to begin stale cleanup transaction: {}", e))?; .map_err(|e| format!("Failed to begin stale cleanup transaction: {}", e))?;
ids.clear();
with_postings.clear();
for path in batch { for path in batch {
if stop_flag.load(Ordering::Relaxed) { if let Some((id, state)) = repo::id_and_state_for_path(&tx, path)
break; .map_err(|e| format!("Failed to resolve stale index entry: {}", e))?
}
if repo::delete_file_by_path(&tx, path)
.map_err(|e| format!("Failed to remove stale index entry for {}: {}", path, e))?
{ {
deleted_count += 1; ids.push(id);
if state == repo::STATE_DONE {
with_postings.push(id);
}
} }
} }
deleted_count += repo::delete_ids(&tx, &ids, &with_postings)?;
tx.commit() tx.commit()
.map_err(|e| format!("Failed to commit stale cleanup transaction: {}", e))?; .map_err(|e| format!("Failed to commit stale cleanup transaction: {}", e))?;
if stop_flag.load(Ordering::Relaxed) {
return Ok(deleted_count);
}
} }
if deleted_count > 0 && !stop_flag.load(Ordering::Relaxed) { if !stop_flag.load(Ordering::Relaxed) {
let conn = crate::lock_ok(conn_mutex); let conn = crate::lock_ok(conn_mutex);
fts_finalize_after_text_indexing(&conn); fts_end_tombstone_burst(&conn);
} }
Ok(deleted_count) Ok(deleted_count)

View file

@ -635,3 +635,159 @@ fn a_bulk_write_repairs_a_delete_merge_threshold_left_off() {
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
/// Seed one row per content state under `/t/`, plus a survivor. Returns
/// `name -> id`. States: the two `done-*` rows hold postings, the others do
/// not — the distinction stale cleanup's `with_postings` narrowing draws.
#[cfg(unix)]
fn seed_stale_corpus(
conn: &mut rusqlite::Connection,
) -> std::collections::HashMap<&'static str, i64> {
use crate::db::repo::{self, NewFile};
let tx = conn.transaction().unwrap();
let mut ids = std::collections::HashMap::new();
for (name, needs_content) in [
("done-stale.txt", true),
("done-kept.txt", true),
("na-stale.bin", false),
("pending-stale.txt", true),
("failed-stale.txt", true),
] {
let id = repo::insert_file(
&tx,
&NewFile {
name,
parent: "/t/",
size: 1,
mtime: 1,
mime: Some("text/plain"),
ftype: crate::mime::FileType::TEXT,
hash: None,
needs_content,
},
)
.unwrap()
.expect("unique path");
ids.insert(name, id);
}
repo::set_content_done(&tx, ids["done-stale.txt"], "body one", None).unwrap();
repo::set_content_done(&tx, ids["done-kept.txt"], "body two", None).unwrap();
repo::set_content_failed(&tx, ids["failed-stale.txt"], "bad parse").unwrap();
tx.commit().unwrap();
ids
}
/// The stale pass over rows in every content state: exactly the stale rows
/// go, a posting survives exactly for the surviving DONE row, and the
/// delete-merge threshold the pass held off is back afterwards. An orphan
/// posting would surface as a hit for a file no longer indexed, with no error
/// anywhere — the failure mode this pins.
#[test]
#[cfg(unix)]
fn stale_cleanup_deletes_exactly_the_stale_rows_and_their_postings() {
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
let dir = crate::testutil::scratch_dir("stale-cleanup");
let db = dir.join("index.sqlite");
let mut conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
let ids = seed_stale_corpus(&mut conn);
// `vanished.txt` was never indexed: a stale list can legitimately name a
// path another writer already removed, and it must not fail the pass.
let stale: Vec<String> = [
"done-stale.txt",
"na-stale.bin",
"pending-stale.txt",
"failed-stale.txt",
"vanished.txt",
]
.iter()
.map(|n| format!("/t/{}", n))
.collect();
let conn_mutex = Arc::new(Mutex::new(conn));
let stop = Arc::new(AtomicBool::new(false));
let deleted =
cleanup_stale_index_entries(&conn_mutex, &stale, &stop, &crate::config::Config::default())
.unwrap();
assert_eq!(deleted, 4);
let conn = Arc::try_unwrap(conn_mutex)
.map_err(|_| ())
.expect("sole owner")
.into_inner()
.unwrap();
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
assert_eq!(count("SELECT COUNT(*) FROM files"), 1);
assert_eq!(count("SELECT COUNT(*) FROM searchabletext"), 1);
assert_eq!(count("SELECT COUNT(*) FROM documents_text"), 0);
assert_eq!(count("SELECT COUNT(*) FROM failed_files"), 0);
let hit: i64 = conn
.query_row(
"SELECT rowid FROM searchabletext WHERE searchabletext MATCH 'body'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(hit, ids["done-kept.txt"], "the survivor is still findable");
assert_eq!(
conn.query_row(
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
[],
|r| r.get::<_, i64>(0),
)
.unwrap(),
i64::from(FTS_DELETEMERGE),
"the pass restored the threshold it held off"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A stop mid-pass returns without restoring `deletemerge` — the abandonment
/// semantics `a_bulk_write_repairs_a_delete_merge_threshold_left_off` exists
/// for. If this ever starts restoring it eagerly, that test loses its
/// subject; if it starts deleting under a stop, a stopped run reconciles.
#[test]
#[cfg(unix)]
fn a_stopped_stale_cleanup_deletes_nothing_and_leaves_the_burst() {
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
let dir = crate::testutil::scratch_dir("stale-cleanup-stop");
let db = dir.join("index.sqlite");
let mut conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
seed_stale_corpus(&mut conn);
let conn_mutex = Arc::new(Mutex::new(conn));
let stop = Arc::new(AtomicBool::new(true));
let deleted = cleanup_stale_index_entries(
&conn_mutex,
&["/t/done-stale.txt".to_string()],
&stop,
&crate::config::Config::default(),
)
.unwrap();
assert_eq!(deleted, 0);
let conn = Arc::try_unwrap(conn_mutex)
.map_err(|_| ())
.expect("sole owner")
.into_inner()
.unwrap();
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
assert_eq!(count("SELECT COUNT(*) FROM files"), 5, "a stop deletes nothing");
assert_eq!(
conn.query_row(
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
[],
|r| r.get::<_, i64>(0),
)
.unwrap(),
0,
"the burst is left for the next run's bulk write to repair"
);
std::fs::remove_dir_all(&dir).ok();
}

View file

@ -27,8 +27,13 @@ use super::*;
/// wholesale — or newly excluded — is never read, so per-directory /// wholesale — or newly excluded — is never read, so per-directory
/// reconciliation cannot see its rows. Not deletions: parents under an /// reconciliation cannot see its rows. Not deletions: parents under an
/// unreadable directory, and paths reached via symlink (`aliased`). /// unreadable directory, and paths reached via symlink (`aliased`).
///
/// Takes a read connection, not the writer's: this scans every stored parent
/// under the root, and roots still `Extracting` need the writer while it
/// runs. Read-your-writes is safe — every walk batch was committed before
/// the sweep starts. Same reasoning as `count_extract_scope`.
fn sweep_unvisited_parents( fn sweep_unvisited_parents(
conn_mutex: &Arc<Mutex<Connection>>, conn: &Connection,
root: &str, root: &str,
seen_dirs: &HashSet<String>, seen_dirs: &HashSet<String>,
unreadable: &crate::file_handling::UnreadableDirs, unreadable: &crate::file_handling::UnreadableDirs,
@ -37,17 +42,16 @@ fn sweep_unvisited_parents(
) -> Result<(), String> { ) -> Result<(), String> {
// Same keyset range the extraction cursor uses: `[root + "/", root + "0")`. // Same keyset range the extraction cursor uses: `[root + "/", root + "0")`.
let range = ExtractCursor::for_root(root); let range = ExtractCursor::for_root(root);
let conn = crate::lock_ok(conn_mutex);
let mut unvisited: Vec<String> = Vec::new(); let mut unvisited: Vec<String> = Vec::new();
repo::for_each_parent_in_range(&conn, &range.lo, &range.hi, |parent| { repo::for_each_parent_in_range(conn, &range.lo, &range.hi, |parent| {
if !seen_dirs.contains(&parent) && !unreadable.covers(&parent) { if !seen_dirs.contains(&parent) && !unreadable.covers(&parent) {
unvisited.push(parent); unvisited.push(parent);
} }
})?; })?;
for parent in unvisited { for parent in unvisited {
for path in repo::paths_in_dir(&conn, &parent)? { for path in repo::paths_in_dir(conn, &parent)? {
if !aliased.contains(&path) { if !aliased.contains(&path) {
out.push(path); out.push(path);
} }
@ -742,9 +746,12 @@ fn cleanup_stale(pipelines: &[RootPipeline], cx: &mut RunCx<'_>) -> Result<(), S
// stored parent under every root, and the merge that ends the deletion is // stored parent under every root, and the merge that ends the deletion is
// minutes of writer time on a big index. // minutes of writer time on a big index.
let _maintaining = cx.maintaining(MaintenanceStep::RemovingStale); let _maintaining = cx.maintaining(MaintenanceStep::RemovingStale);
// One read connection for the whole sweep, so the writer stays free for
// any root still extracting; see `sweep_unvisited_parents`.
let sweep_conn = crate::db::open::open_walk_reader(cx.db_path)?;
for p in pipelines.iter() { for p in pipelines.iter() {
sweep_unvisited_parents( sweep_unvisited_parents(
&cx.conn_mutex, &sweep_conn,
&p.root, &p.root,
&p.walk.seen_dirs(), &p.walk.seen_dirs(),
p.walk.unreadable(), p.walk.unreadable(),
@ -752,6 +759,7 @@ fn cleanup_stale(pipelines: &[RootPipeline], cx: &mut RunCx<'_>) -> Result<(), S
&mut cx.stale_candidates, &mut cx.stale_candidates,
)?; )?;
} }
drop(sweep_conn);
// The aliased filter applies to both sources: per-directory // The aliased filter applies to both sources: per-directory
// reconciliation can flag a symlink target as stale while the alias route // reconciliation can flag a symlink target as stale while the alias route
// inserted it — the row would be written and deleted on every run. // inserted it — the row would be written and deleted on every run.

View file

@ -39,7 +39,7 @@ use crate::query::translator::{escape_like, quote_phrase};
use crate::snippet; use crate::snippet;
use super::fuzzy::{edit_budget, pigeonhole_chunks, Bitap}; use super::fuzzy::{edit_budget, pigeonhole_chunks, Bitap};
use super::{SearchHit, SearchOptions}; use super::{prefilter, SearchHit, SearchOptions};
mod passes; mod passes;
@ -137,6 +137,11 @@ pub fn run(
emitted: IdSet::default(), emitted: IdSet::default(),
deferred_path: Deferred::default(), deferred_path: Deferred::default(),
deferred_fuzzy_path: Deferred::default(), deferred_fuzzy_path: Deferred::default(),
regex_doc: query
.regex
.as_ref()
.map(|_| crate::db::repo::DocDecoder::new())
.transpose()?,
total: 0, total: 0,
limited: false, limited: false,
sink, sink,
@ -310,6 +315,11 @@ struct Cx<'a> {
deferred_path: Deferred, deferred_path: Deferred,
/// Rank 11, filled by pass C. /// Rank 11, filled by pass C.
deferred_fuzzy_path: Deferred, deferred_fuzzy_path: Deferred,
/// Decoder for [`Cx::regex_accepts`]'s content fetches, built once per
/// search and only when the query carries a regex — the predicate runs
/// per candidate row, exactly the shape [`crate::db::repo::DocDecoder`]
/// exists for.
regex_doc: Option<crate::db::repo::DocDecoder>,
total: usize, total: usize,
limited: bool, limited: bool,
sink: &'a mut dyn FnMut(Vec<SearchHit>), sink: &'a mut dyn FnMut(Vec<SearchHit>),
@ -370,9 +380,25 @@ impl<'a> Cx<'a> {
/// The `regex:` accept-predicate when a regex accompanies a term. The /// The `regex:` accept-predicate when a regex accompanies a term. The
/// path contains the name, so one path check covers both; content is /// path contains the name, so one path check covers both; content is
/// fetched only for rows whose path missed. /// fetched only for rows whose path missed — per candidate row, which is
fn regex_accepts(&self, file_id: i64, path: &str, text: Option<&str>) -> Result<bool, String> { /// why the statement is cached and the decoder ([`Cx::regex_doc`]) is
let Some(re) = &self.query.regex else { /// reused, and the blob is decoded borrowed rather than copied out.
/// Measured (`benches/search_alloc.rs`, case "term + regex"): 42.6 MiB
/// churned per keystroke → 4.3, and ~1.3x on the case's time, over the
/// `query_row` + `decode_all` shape this replaced.
fn regex_accepts(
&mut self,
file_id: i64,
path: &str,
text: Option<&str>,
) -> Result<bool, String> {
let Cx {
conn,
query,
regex_doc,
..
} = self;
let Some(re) = &query.regex else {
return Ok(true); return Ok(true);
}; };
if re.is_match(path) { if re.is_match(path) {
@ -381,19 +407,23 @@ impl<'a> Cx<'a> {
if let Some(text) = text { if let Some(text) = text {
return Ok(re.is_match(text)); return Ok(re.is_match(text));
} }
let blob: Option<Vec<u8>> = self let doc = regex_doc
.conn .as_mut()
.query_row( .expect("built in run() whenever the query carries a regex");
"SELECT text_zstd FROM documents_text WHERE file_id = ?1", let accepted = conn
[file_id], .prepare_cached("SELECT text_zstd FROM documents_text WHERE file_id = ?1")
|r| r.get(0), .and_then(|mut stmt| {
) stmt.query_row([file_id], |r| {
.optional() let blob = r.get_ref(0)?.as_blob()?;
// Strict UTF-8 via `decode` is not a behaviour change:
// bodies are written from `&str` (`repo::set_content_done`),
// and `stored_text` already treats non-UTF-8 as absent.
Ok(doc.decode(blob).is_some_and(|t| re.is_match(t)))
})
.optional()
})
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let Some(raw) = blob.and_then(|b| zstd::decode_all(b.as_slice()).ok()) else { Ok(accepted.unwrap_or(false))
return Ok(false);
};
Ok(re.is_match(&String::from_utf8_lossy(&raw)))
} }
/// Hand `buf` over mid-scan if it is due, leaving it empty when it goes. /// Hand `buf` over mid-scan if it is due, leaving it empty when it goes.

View file

@ -408,20 +408,42 @@ impl<'a> Cx<'a> {
Bitap::new(self.query.term.as_bytes(), k).map(|bitap| (k, bitap)) Bitap::new(self.query.term.as_bytes(), k).map(|bitap| (k, bitap))
} }
/// Pass C — rank 7 now, rank 11 deferred: one bitap sweep over every /// Pass C — rank 7 now, rank 11 deferred: one bitap sweep over the
/// filename, falling back to the full path where the name misses. /// filenames, falling back to the full path where the name misses.
///
/// Narrowed by the same pigeonhole split pass D uses: at least one of the
/// `k+1` chunks survives `≤k` edits verbatim ([`pigeonhole_chunks`]), and
/// a separator-free chunk cannot span the `parent‖name` join, so
/// `(name LIKE OR parent LIKE)` per chunk covers the name tier and the
/// path tier both ([`prefilter::Required::like_predicate`]). `LIKE` folds
/// ASCII case, a superset of the matcher's own folding — the direction
/// the one rule in `search/prefilter.rs` allows. Any `None` along the way
/// keeps the full scan — which, under the default edit cap of 2, is every
/// term shorter than 9 characters (`3 × (k + 1)` with k already 2 at 6).
///
/// Measured (`benches/search_perf.rs`, fuzzy pairing, 200k rows, warm):
/// `quartzite` 113 ms → 62 plain and 113 → 59 keyed; `quartzites`
/// 112 → 97 and 110 → 97 (its split ends in a weak 3-char chunk); the
/// short-term fallback rows did not move.
pub(super) fn pass_fuzzy_filename(&mut self) -> Result<bool, String> { pub(super) fn pass_fuzzy_filename(&mut self) -> Result<bool, String> {
let Some((_, bitap)) = self.fuzzy_matcher() else { let Some((k, bitap)) = self.fuzzy_matcher() else {
return Ok(true); return Ok(true);
}; };
let query = self.query; let query = self.query;
let with_paths = path_tiers_enabled(&query.pattern); let with_paths = path_tiers_enabled(&query.pattern);
let (predicate, terms) = pigeonhole_chunks(&query.term, k)
.and_then(|chunks| {
prefilter::Required::new(chunks.iter().map(|c| c.to_string()).collect())
})
.and_then(|req| req.like_predicate())
.unwrap_or_else(|| ("1=1".to_string(), Vec::new()));
let sql = format!( let sql = format!(
"SELECT {} FROM files f WHERE 1=1{}", "SELECT {} FROM files f WHERE {}{}",
HIT_COLUMNS, query.filter_sql HIT_COLUMNS, predicate, query.filter_sql
); );
let params = self.params_with_filters(Vec::new()); let params = self.params_with_filters(terms);
self.scan_pass( self.scan_pass(
&sql, &sql,
params, params,

View file

@ -811,6 +811,93 @@ fn a_wildcard_prefilter_never_loses_a_hit_the_full_scan_finds() {
} }
} }
/// The pigeonhole `LIKE` prefilter the fuzzy filename pass gained must be a
/// superset of what the bitap accepts over name and path, or fuzzy hits
/// vanish silently. The reference set is computed with the same matcher the
/// pass uses, so the assertion is against the semantics, not a copy of the
/// SQL. `fuzzy_max_edits: 1` keeps `k` at 1, which "report" is long enough
/// to split for — at the default 2 the term is too short and the pass would
/// scan, exercising nothing.
#[test]
fn a_fuzzy_prefilter_never_loses_a_hit_the_full_scan_finds() {
use quicksearch_core::search::fuzzy::{edit_budget, Bitap};
let (_dir, p) = Scratch::db("fuzzyprefilter");
let mut s = Seeder::new(&p, true);
let rows = [
("report.txt", "/data"), // exact
("REPORT.TXT", "/upper"), // ASCII case is free for LIKE and bitap both
("xeport.txt", "/data"), // first chunk broken; the OR reaches the second
("repXrt.txt", "/data"), // second chunk broken; the first carries it
("ort.txt", "/rep"), // name over budget, path-tier hit across the join
("summary.txt", "/data"), // miss
("rep\u{f6}rt.txt", "/data"), // non-ASCII: two byte edits, over a k of 1
];
let seeded: Vec<(i64, String)> = rows
.iter()
.enumerate()
.map(|(i, (name, dir))| {
let id = s.add(name, dir, i as u64 + 1, None);
(id, format!("{}/{}", dir, name))
})
.collect();
let conn = s.done();
let opts = SearchOptions {
fuzzy: true,
fuzzy_max_edits: 1,
..SearchOptions::default()
};
let term = "report";
let (hits, _) = run_collect(&conn, term, &opts);
let mut got: Vec<i64> = hits.iter().map(|h| h.file_id).collect();
got.sort();
let k = edit_budget(term.len(), opts.fuzzy_max_edits).unwrap();
let bitap = Bitap::new(term.as_bytes(), k).unwrap();
let mut want: Vec<i64> = seeded
.iter()
.filter(|(_, path)| {
let name = path.rsplit('/').next().unwrap();
bitap.best_distance_and_first(name.as_bytes()).is_some()
|| bitap.best_distance_and_first(path.as_bytes()).is_some()
})
.map(|(id, _)| *id)
.collect();
want.sort();
assert_eq!(got, want);
assert!(
(5..seeded.len()).contains(&got.len()),
"the corpus must exercise both hits and misses: {:?}",
got
);
}
/// A term carrying a separator disqualifies every chunk set
/// (`prefilter::Required::like_predicate`), so the fuzzy pass falls back to
/// the full scan — and must still find a hit whose only match spans the
/// `parent‖name` join.
#[test]
fn a_fuzzy_term_with_a_separator_still_scans_and_matches() {
let (_dir, p) = Scratch::db("fuzzysep");
let mut s = Seeder::new(&p, true);
let hit = s.add("orts.txt", "/a/rep", 1, None);
let _miss = s.add("plans.txt", "/a/sum", 2, None);
let conn = s.done();
let opts = SearchOptions {
fuzzy: true,
fuzzy_max_edits: 1,
..SearchOptions::default()
};
// Within one edit of "/a/rep/orts" read across the join.
let (hits, _) = run_collect(&conn, "rep/orts", &opts);
assert_eq!(
hits.iter().map(|h| h.file_id).collect::<Vec<_>>(),
vec![hit]
);
}
/// A pattern every segment of which carries a separator has nothing to anchor /// A pattern every segment of which carries a separator has nothing to anchor
/// on, so the pass falls back to scanning — and must still find its hits. /// on, so the pass falls back to scanning — and must still find its hits.
#[test] #[test]

View file

@ -536,6 +536,191 @@ fn every_document_within_the_budget_is_found_and_nothing_outside_it_is() {
assert!(compared > 50, "only {} queries compared", compared); assert!(compared > 50, "only {} queries compared", compared);
} }
// ---------------------------------------------------------------------------
// The filename pass
// ---------------------------------------------------------------------------
/// Names and parents from the same hostile vocabulary as the bodies, plus
/// `LIKE`'s own metacharacters: a `%` or `_` cut into a term must reach the
/// filename prefilter escaped (`translator::escape_like`), or the pattern
/// stops meaning the literal it was cut from. Unique suffix per row.
fn file_paths() -> Vec<(String, String)> {
let words = [
"quartzite",
"Report",
"SUMMARY",
"café",
"naïve",
"Ünicode",
"日本語テキスト",
"emoji🙂here",
"wild*card",
"100%_done",
"under_score",
"dash-joined",
"mixedCaseWord",
"budget",
"revenue",
"planning",
"aaaaaaaaaa",
];
let mut lcg = Lcg::new(0xf11e);
let mut out = Vec::new();
for i in 0..DOCS {
let word = |lcg: &mut Lcg| words[lcg.next_u64() as usize % words.len()];
let depth = 1 + (lcg.next_u64() as usize % 3);
let mut parent = String::from("/fz");
for _ in 0..depth {
parent.push('/');
parent.push_str(word(&mut lcg));
}
parent.push('/');
let name = format!("{}-{}-{:03}.txt", word(&mut lcg), word(&mut lcg), i);
out.push((parent, name));
}
out
}
fn seed_files(path: &std::path::Path, rows: &[(String, String)]) -> Vec<i64> {
use quicksearch_core::db::repo::{insert_file, NewFile};
use quicksearch_core::mime::FileType;
let mut conn =
quicksearch_core::db::open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
let tx = conn.transaction().unwrap();
let mut ids = Vec::with_capacity(rows.len());
for (parent, name) in rows {
let id = insert_file(
&tx,
&NewFile {
name,
parent,
size: 1,
mtime: 1_700_000_000,
mime: None,
ftype: FileType::EMPTY,
hash: None,
// No content anywhere: a hit can only come from a filename
// or path tier, which is the pass under test.
needs_content: false,
},
)
.unwrap()
.expect("unique path");
ids.push(id);
}
tx.commit().unwrap();
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
ids
}
/// The filename analog of [`a_corrupted_substring_still_finds_the_document_it_came_from`]:
/// cut a substring from a stored name or full path, corrupt it within the
/// byte budget, and the row must still be found — the recall direction pass
/// C's pigeonhole `LIKE` prefilter can break with no symptom. Terms cut from
/// a path can carry a separator, which disqualifies the chunk set and
/// exercises the full-scan fallback in the same sweep.
#[test]
fn a_corrupted_path_substring_still_finds_the_file_it_came_from() {
let rows = file_paths();
let (_dir, db) = Scratch::db("fuzzprefilter-name");
let ids = seed_files(&db, &rows);
let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy())
.expect("open the seeded index");
let mut lcg = Lcg::new(0xf00d);
let mut checked = 0usize;
let mut via_path = 0usize;
for &cap in caps() {
for len in sweep_lengths() {
for _ in 0..iters_per_len() {
let at = lcg.next_u64() as usize % rows.len();
let (parent, name) = &rows[at];
let full = format!("{}{}", parent, name);
let source: &str = if lcg.next_u64() % 2 == 0 { name } else { &full };
let Some(original) = substring_of(source, len, &mut lcg) else {
continue;
};
let mut chars: Vec<char> = original.chars().collect();
let planned = edit_budget(original.len(), cap).unwrap_or(0);
let edits = if planned == 0 {
0
} else {
lcg.next_u64() as usize % (planned + 1)
};
for _ in 0..edits {
corrupt(&mut chars, &mut lcg);
}
let term: String = chars.into_iter().collect();
if term.trim().is_empty() {
continue;
}
let Ok(split) = split_for_cascade(&term) else {
continue;
};
if split.pattern.is_wildcard() || split.regex.is_some() {
continue;
}
let effective = split.term.as_str();
let Some(k) = edit_budget(effective.len(), cap) else {
let _ = search_ids(&conn, &term, cap);
continue;
};
if Bitap::new(effective.as_bytes(), k).is_none() {
continue;
}
// The pass verifies the name always, the path only at three
// *characters* of pattern (`cascade::path_tiers_enabled`) —
// a three-byte term can be one CJK character, under the tier
// floor while over the fuzzy one.
let name_hit = oracle_distance(effective.as_bytes(), name.as_bytes()) <= k;
let path_hit = effective.chars().count() >= 3
&& oracle_distance(effective.as_bytes(), full.as_bytes()) <= k;
if !name_hit && !path_hit {
continue;
}
let found = search_ids(&conn, &term, cap);
assert!(
found.contains(&ids[at]),
"lost the file the term was cut from\n typed {:?}\n \
parsed {:?}\n original {:?}\n cap {} k {} edits {}\n \
chunks {:?}\n path {:?}",
term,
effective,
original,
cap,
k,
edits,
pigeonhole_chunks(effective, k),
full,
);
checked += 1;
via_path += usize::from(!name_hit);
}
}
}
eprintln!(
"filename prefilter fuzz: {} recall assertions, {} of them path-tier",
checked, via_path
);
assert!(
checked > 200,
"only {} recall assertions ran; the generator is not producing \
in-budget terms",
checked
);
assert!(
via_path > 10,
"only {} path-tier assertions; the path arm is not being exercised",
via_path
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The regex prefilter // The regex prefilter
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -75,18 +75,6 @@ eframe = { version = "0.32", default-features = false, features = [
"x11", "x11",
] } ] }
# The Wayland half of the in-application shortcut:
# `org.freedesktop.portal.GlobalShortcuts`, the only way a Wayland application
# can be told about a key it does not own. `rfd` already pulls all four in (it
# uses the file-chooser portal), so the versions here are the ones already
# resolved and nothing extra is compiled. `default-features = false` matters:
# ashpd defaults to Tokio, which would add a second async runtime and switch
# `zbus` over to it underneath `rfd`.
ashpd = { version = "0.11", default-features = false, features = ["async-std"] }
futures-channel = "0.3"
futures-util = "0.3"
pollster = "0.4"
# Raising the window from either shortcut on X11, which winit cannot # Raising the window from either shortcut on X11, which winit cannot
# do: it asks with a source indication of "application", and every mainstream # do: it asks with a source indication of "application", and every mainstream
# window manager refuses that from a window that is not already focused. See # window manager refuses that from a window that is not already focused. See
@ -94,6 +82,16 @@ pollster = "0.4"
# adds no crate. # adds no crate.
x11rb = "0.13" x11rb = "0.13"
# The Wayland half of activate::raise: a guest connection over winit's own
# wl_display (wayland-backend's from_foreign_display), used to hand the
# compositor an xdg-activation token for our live surface. All three are
# already in the tree with these exact features — winit enables
# wayland-backend/client_system and wayland-protocols/staging, sctk enables
# wayland-protocols/client — so this compiles nothing new.
wayland-client = "0.31"
wayland-backend = { version = "0.3", features = ["client_system"] }
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
# XTEST, for `examples/raiseprobe.rs` only: it synthesises the global key # XTEST, for `examples/raiseprobe.rs` only: it synthesises the global key
# press that proves the shortcut path end to end, which no command-line tool # press that proves the shortcut path end to end, which no command-line tool
# here can do. A dev-dependency feature, so the shipped binary never gets it — # here can do. A dev-dependency feature, so the shipped binary never gets it —

View file

@ -7,17 +7,15 @@
//! box whether or not the app was started", because a shortcut an application //! box whether or not the app was started", because a shortcut an application
//! registers for itself cannot fire while the application is not there. //! registers for itself cannot fire while the application is not there.
//! //!
//! The message carries nothing: "come forward" is the whole protocol. On //! "Come forward" is nearly the whole protocol; what little each side says
//! unix the reply exists only so the sender can tell a live instance from a //! is the permission the other needs. On unix the request line carries the
//! leftover socket; on Windows it instead carries the server's PID, which //! sender's `XDG_ACTIVATION_TOKEN` (usually empty) — on Wayland that token
//! the sender feeds to `AllowSetForegroundWindow` so the running window may //! is the compositor's leave for the running window to take focus, spent in
//! actually take the foreground — see the `#[cfg(windows)]` module below. //! [`raise`]'s `xdg_activation_v1` path — and the reply exists so the
//! An xdg-activation token would be the natural thing to //! sender can tell a live instance from a leftover socket. On Windows the
//! carry — it is what a compositor wants before letting a background client //! reply instead carries the server's PID, which the sender feeds to
//! take focus — but nothing downstream can consume one: winit 0.30 applies a //! `AllowSetForegroundWindow` so the running window may take the foreground
//! token only in `WindowAttributes`, and egui's `ViewportBuilder` has no //! — see the `#[cfg(windows)]` module below.
//! field for it, so eframe never plumbs one either. See [`raise`] for what
//! that costs on Wayland.
pub mod raise; pub mod raise;
@ -30,6 +28,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// the same thing as one. /// the same thing as one.
static PENDING: AtomicBool = AtomicBool::new(false); static PENDING: AtomicBool = AtomicBool::new(false);
/// The xdg-activation token that came with the pending press, when a
/// `--toggle` sender forwarded one (see the unix module). Read together with
/// [`take_pending`]; a later press's token simply replaces an unread one,
/// matching the flag's "two presses mean one" rule.
static TOKEN: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// The most a PID reply can be: a 32-bit PID is at most ten digits, and the /// The most a PID reply can be: a 32-bit PID is at most ten digits, and the
/// newline ends it. Also the pipe's buffer size, so the server's write never /// newline ends it. Also the pipe's buffer size, so the server's write never
/// blocks on a client that reads nothing. /// blocks on a client that reads nothing.
@ -97,6 +101,12 @@ pub fn take_pending() -> bool {
PENDING.swap(false, Ordering::SeqCst) PENDING.swap(false, Ordering::SeqCst)
} }
/// The token belonging to the press [`take_pending`] just returned; call
/// right after it, on the same thread's handling of the same frame.
pub fn take_token() -> Option<String> {
TOKEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
}
/// What to tell the user to bind, as they would type it. The installed name /// What to tell the user to bind, as they would type it. The installed name
/// when we are on the path under it, and the full path otherwise — a build /// when we are on the path under it, and the full path otherwise — a build
/// run out of `target/` is the common case, and "quicksearch" would be wrong /// run out of `target/` is the common case, and "quicksearch" would be wrong
@ -125,6 +135,13 @@ pub fn command_name() -> String {
/// application's own registration fires, so the two paths are identical from /// application's own registration fires, so the two paths are identical from
/// the UI's point of view. /// the UI's point of view.
pub(crate) fn fire(ctx: &egui::Context) { pub(crate) fn fire(ctx: &egui::Context) {
fire_with_token(ctx, None);
}
/// [`fire`], carrying the activation token a `--toggle` sender forwarded —
/// on Wayland it is the compositor's permission to take focus.
pub(crate) fn fire_with_token(ctx: &egui::Context, token: Option<String>) {
*TOKEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = token;
PENDING.store(true, Ordering::SeqCst); PENDING.store(true, Ordering::SeqCst);
ctx.request_repaint(); ctx.request_repaint();
} }
@ -137,6 +154,11 @@ mod imp {
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
/// The most a request line may be. Real xdg-activation tokens run some
/// tens of characters; the cap is what keeps a hostile peer from feeding
/// the listener forever.
const REQUEST_CAP: usize = 1024;
/// Ask the instance configured by `config_path` to come forward. /// Ask the instance configured by `config_path` to come forward.
/// ///
/// `true` means a live instance accepted it. `false` means there is none /// `true` means a live instance accepted it. `false` means there is none
@ -144,7 +166,18 @@ mod imp {
/// start the GUI itself. **A leftover socket file never counts as an /// start the GUI itself. **A leftover socket file never counts as an
/// instance**: the same rule `IndexLock` follows, so a crash cannot /// instance**: the same rule `IndexLock` follows, so a crash cannot
/// strand the user behind a file nobody is listening on. /// strand the user behind a file nobody is listening on.
///
/// The request carries this process's `XDG_ACTIVATION_TOKEN`, when the
/// launcher behind the user's keypress minted one: on Wayland that token
/// is the compositor's permission to take focus, and the running
/// instance spends it on its own window (`raise`). Launched by hand
/// there is none, and the request is the empty line it always was.
pub fn signal(config_path: &Path) -> bool { pub fn signal(config_path: &Path) -> bool {
let token = std::env::var("XDG_ACTIVATION_TOKEN").unwrap_or_default();
signal_with_token(config_path, &token)
}
pub(super) fn signal_with_token(config_path: &Path, token: &str) -> bool {
let Ok(mut stream) = UnixStream::connect(path_for(config_path)) else { let Ok(mut stream) = UnixStream::connect(path_for(config_path)) else {
return false; return false;
}; };
@ -156,7 +189,18 @@ mod imp {
{ {
return false; return false;
} }
if stream.write_all(b"\n").is_err() || stream.flush().is_err() { // One line is the whole request. A token that could not cross intact
// (absurd length, a newline of its own) is dropped rather than sent
// mangled: the press still lands, the raise just loses its token.
let token = if token.len() < REQUEST_CAP && !token.contains('\n') {
token
} else {
""
};
if stream.write_all(token.as_bytes()).is_err()
|| stream.write_all(b"\n").is_err()
|| stream.flush().is_err()
{
return false; return false;
} }
// The reply is what distinguishes "delivered" from "wrote into a // The reply is what distinguishes "delivered" from "wrote into a
@ -215,8 +259,8 @@ mod imp {
// Fire *before* the ack: a client that saw the reply may act // Fire *before* the ack: a client that saw the reply may act
// on "delivered", and delivered means the window was already // on "delivered", and delivered means the window was already
// asked to come forward. // asked to come forward.
Ok(()) => { Ok(token) => {
fire(ctx); fire_with_token(ctx, (!token.is_empty()).then_some(token));
let _ = acknowledge(&mut stream); let _ = acknowledge(&mut stream);
} }
// One stalled or truncated peer must not stop the loop, and // One stalled or truncated peer must not stop the loop, and
@ -226,21 +270,40 @@ mod imp {
} }
} }
/// Read the request. /// Read the request: one newline-terminated line carrying the sender's
/// activation token, usually empty.
/// ///
/// Hostile input is the norm rather than the exception: any process of /// Hostile input is the norm rather than the exception: any process of
/// this user can connect. The read is bounded in both bytes and time, so /// this user can connect. The read is bounded in bytes ([`REQUEST_CAP`])
/// a peer that connects and stalls cannot wedge the one thread that /// and time, so a peer that connects and stalls, or pours data without a
/// answers every activation. /// newline, cannot wedge the one thread that answers every activation.
pub(super) fn request(stream: &mut UnixStream) -> std::io::Result<()> { pub(super) fn request(stream: &mut UnixStream) -> std::io::Result<String> {
use std::io::{Error, ErrorKind};
let timeout = std::time::Duration::from_secs(5); let timeout = std::time::Duration::from_secs(5);
stream.set_read_timeout(Some(timeout))?; stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?; stream.set_write_timeout(Some(timeout))?;
// One byte is the whole request; the cap is what keeps a peer from let mut buf = [0u8; REQUEST_CAP];
// holding this thread for as long as it cares to send. let mut len = 0;
let mut scratch = [0u8; 1]; loop {
stream.read_exact(&mut scratch) let n = stream.read(&mut buf[len..])?;
if n == 0 {
return Err(ErrorKind::UnexpectedEof.into());
}
len += n;
if let Some(newline) = buf[..len].iter().position(|&b| b == b'\n') {
let token = std::str::from_utf8(&buf[..newline])
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
return Ok(token.to_string());
}
if len == buf.len() {
return Err(Error::new(
ErrorKind::InvalidData,
"no newline within the request cap",
));
}
}
} }
/// The reply that lets the sender tell a live instance from a leftover /// The reply that lets the sender tell a live instance from a leftover
@ -548,6 +611,23 @@ mod tests {
assert!(!take_pending(), "the flag is consumed"); assert!(!take_pending(), "the flag is consumed");
} }
/// The token rides the flag: consumed with it, and a fresh press without
/// one clears a stale token rather than resurrecting it.
#[test]
fn the_token_is_consumed_with_the_press() {
let _serial = pending_guard();
let ctx = egui::Context::default();
fire_with_token(&ctx, Some("tok".to_string()));
assert!(take_pending());
assert_eq!(take_token(), Some("tok".to_string()));
assert_eq!(take_token(), None, "the token is consumed");
fire_with_token(&ctx, Some("stale".to_string()));
fire(&ctx);
assert!(take_pending());
assert_eq!(take_token(), None, "a tokenless press cleared the older token");
}
/// A config path unique to this test, so the sockets these bind never /// A config path unique to this test, so the sockets these bind never
/// collide: they run on one process, in parallel. /// collide: they run on one process, in parallel.
#[cfg(unix)] #[cfg(unix)]
@ -575,7 +655,8 @@ mod tests {
} }
/// The whole point, end to end: a signal to a live listener is accepted, /// The whole point, end to end: a signal to a live listener is accepted,
/// and the listener sees a well-formed request. /// the listener sees a well-formed request, and the activation token it
/// carries crosses intact.
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn a_signal_crosses_the_socket() { fn a_signal_crosses_the_socket() {
@ -584,19 +665,71 @@ mod tests {
let db = scratch("roundtrip"); let db = scratch("roundtrip");
let listener = UnixListener::bind(path_for(&db)).expect("bind"); let listener = UnixListener::bind(path_for(&db)).expect("bind");
let sender = std::thread::spawn(move || signal(&db)); let sender =
std::thread::spawn(move || imp::signal_with_token(&db, "wayland-token-123"));
let mut stream = listener let mut stream = listener
.incoming() .incoming()
.next() .next()
.expect("a connection") .expect("a connection")
.expect("accepted"); .expect("accepted");
imp::request(&mut stream).expect("a well-formed request"); let token = imp::request(&mut stream).expect("a well-formed request");
assert_eq!(token, "wayland-token-123");
imp::acknowledge(&mut stream).expect("acknowledged"); imp::acknowledge(&mut stream).expect("acknowledged");
assert!(sender.join().expect("sender"), "the client saw the reply"); assert!(sender.join().expect("sender"), "the client saw the reply");
} }
/// A token that cannot cross as one line is dropped, not sent mangled:
/// the press still lands and only the raise loses its token.
#[cfg(unix)]
#[test]
fn an_unsendable_token_degrades_to_an_empty_request() {
use std::os::unix::net::UnixListener;
for bad in ["with\nnewline".to_string(), "x".repeat(5000)] {
let db = scratch("badtoken");
let listener = UnixListener::bind(path_for(&db)).expect("bind");
let sender = std::thread::spawn(move || imp::signal_with_token(&db, &bad));
let mut stream = listener
.incoming()
.next()
.expect("a connection")
.expect("accepted");
assert_eq!(imp::request(&mut stream).expect("well-formed"), "");
imp::acknowledge(&mut stream).expect("acknowledged");
assert!(sender.join().expect("sender"));
}
}
/// A peer pouring bytes with no newline is cut off at the cap rather
/// than fed forever.
#[cfg(unix)]
#[test]
fn a_newline_less_flood_is_refused_at_the_cap() {
use std::io::Write;
use std::os::unix::net::{UnixListener, UnixStream};
let db = scratch("flood");
let path = path_for(&db);
let listener = UnixListener::bind(&path).expect("bind");
let mut peer = UnixStream::connect(&path).expect("connect");
std::thread::spawn(move || {
// More than the cap, never a newline; ignore the write error the
// refusal causes.
let _ = peer.write_all(&[b'x'; 4096]);
});
let mut stream = listener
.incoming()
.next()
.expect("a connection")
.expect("accepted");
assert!(
imp::request(&mut stream).is_err(),
"a capless request was accepted"
);
}
/// `listen` must replace a crashed predecessor's socket rather than /// `listen` must replace a crashed predecessor's socket rather than
/// giving up on it. /// giving up on it.
#[cfg(unix)] #[cfg(unix)]

View file

@ -10,34 +10,33 @@
//! Hence [`x11_activate`], which sends source indication 2 (EWMH's "direct //! Hence [`x11_activate`], which sends source indication 2 (EWMH's "direct
//! user action"). Do not replace it with winit's version. //! user action"). Do not replace it with winit's version.
//! * **Wayland**: a client cannot raise itself; the compositor only honours //! * **Wayland**: a client cannot raise itself; the compositor only honours
//! an xdg-activation token, and winit 0.30 applies one in exactly one //! an xdg-activation token. winit 0.30 applies one in exactly one place —
//! place — `WindowAttributes::with_activation_token`, at window creation — //! window creation — and its `focus_window` is an empty body, so for a
//! which egui's `ViewportBuilder` has no field for and eframe therefore //! *live* window we speak `xdg_activation_v1` ourselves ([`wayland`]): the
//! never sets. `focus_window` on Wayland is an empty function body. So a //! `--toggle` sender forwards the `XDG_ACTIVATION_TOKEN` its launcher gave
//! `--toggle` that *starts* the app gets whatever focus the compositor //! it over the socket, and this process hands it to the compositor for its
//! gives a newly mapped window, and one that finds it already running //! own surface. Without a token (a manual binding whose launcher minted
//! cannot raise it at all: the most this path can do is ask for attention, //! none) the fallback is a request for attention — a highlighted task
//! which shows up as a highlighted task entry. Closing that gap means //! entry.
//! patching both winit (to activate a live surface) and eframe (to carry
//! the token), the way `vendor/` already patches two other crates.
//! * **Windows**: `SetForegroundWindow` is refused to background processes — //! * **Windows**: `SetForegroundWindow` is refused to background processes —
//! but the `--toggle` sender was launched by the user's keypress and //! but the `--toggle` sender was launched by the user's keypress and
//! donates its right over the pipe with `AllowSetForegroundWindow` (see //! donates its right over the pipe with `AllowSetForegroundWindow` (see
//! `crate::activate`'s Windows module), after which [`win32_activate`]'s //! `crate::activate`'s Windows module), after which [`win32_activate`]'s
//! `SetForegroundWindow` is honoured. The in-app hotkey path needs no //! `SetForegroundWindow` is honoured. The in-app hotkey path needs no
//! grant: the press was delivered to this process. Only the portal-less //! grant: the press was delivered to this process. Only a request with no
//! case with no grant — e.g. some other process poking the pipe — degrades //! grant — e.g. some other process poking the pipe — degrades to a
//! to a taskbar flash. //! taskbar flash.
/// Whether this is a Wayland session, where an already-open window cannot be /// Whether this is a Wayland session, where raising needs an activation
/// raised. The Settings tab says so rather than letting the shortcut look /// token; the Settings tab words its note accordingly. See the module docs.
/// broken; see the module docs.
pub fn is_wayland() -> bool { pub fn is_wayland() -> bool {
cfg!(all(unix, not(target_os = "macos"))) && std::env::var_os("WAYLAND_DISPLAY").is_some() cfg!(all(unix, not(target_os = "macos"))) && std::env::var_os("WAYLAND_DISPLAY").is_some()
} }
/// Bring the window to the front, restoring it if it was minimised. /// Bring the window to the front, restoring it if it was minimised.
pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) { /// `token` is an xdg-activation token relayed by a `--toggle` sender, the
/// compositor's permission to take focus; only Wayland consumes it.
pub fn raise(ctx: &egui::Context, frame: &eframe::Frame, token: Option<&str>) {
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
{ {
if x11_activate(frame) { if x11_activate(frame) {
@ -47,9 +46,17 @@ pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
return; return;
} }
// Wayland: nothing below will raise the window, so make the one
// request the compositor does honour from a background client.
if std::env::var_os("WAYLAND_DISPLAY").is_some() { if std::env::var_os("WAYLAND_DISPLAY").is_some() {
// With a token the compositor will move focus itself; the
// de-iconify still has to be asked for separately.
if let Some(token) = token.filter(|t| !t.is_empty()) {
if wayland::activate(frame, token) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
return;
}
}
// No token, or the compositor refused: the one request honoured
// from a background client is a bid for attention.
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention( ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(
egui::UserAttentionType::Informational, egui::UserAttentionType::Informational,
)); ));
@ -58,13 +65,14 @@ pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let _ = token;
if win32_activate(frame) { if win32_activate(frame) {
return; return;
} }
} }
#[cfg(not(any(all(unix, not(target_os = "macos")), windows)))] #[cfg(not(any(all(unix, not(target_os = "macos")), windows)))]
{ {
let _ = frame; let _ = (frame, token);
} }
// A window still minimised cannot take focus. // A window still minimised cannot take focus.
@ -289,3 +297,179 @@ fn x11_activate(frame: &eframe::Frame) -> bool {
} }
} }
} }
/// The Wayland activation path. winit applies an activation token in exactly
/// one place — window creation — so for a live window we speak the protocol
/// ourselves, over winit's own connection: `from_foreign_display` wraps its
/// `wl_display` as a "guest" backend with a private libwayland event queue.
/// Roundtrips here dispatch only that queue; events for winit's objects stay
/// queued for winit, and dropping the guest never disconnects the display
/// (`owns_display: false`).
#[cfg(all(unix, not(target_os = "macos")))]
mod wayland {
use std::cell::RefCell;
use std::error::Error;
use std::ffi::c_void;
use raw_window_handle::{
HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle,
};
use wayland_client::backend::{Backend, ObjectId};
use wayland_client::protocol::{wl_registry, wl_surface::WlSurface};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
use wayland_protocols::xdg::activation::v1::client::xdg_activation_v1::XdgActivationV1;
// Kept open across presses like the X11 state above, and for one more
// reason: wl_registry has no destructor request, so a fresh connection
// per press would leak a server-side registry every time.
thread_local! {
static WAYLAND: RefCell<Option<WaylandState>> = const { RefCell::new(None) };
}
struct WaylandState {
/// The wl_display this guest connection wraps — winit's. Compared on
/// every press so a different display gets a fresh connection.
display_ptr: *mut c_void,
conn: Connection,
queue: EventQueue<ActivationGlobals>,
globals: ActivationGlobals,
}
struct ActivationGlobals {
activation: Option<XdgActivationV1>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for ActivationGlobals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version: _,
} = event
{
if interface == "xdg_activation_v1" && state.activation.is_none() {
// We speak version 1; a compositor that advertises the
// global supports at least that.
state.activation =
Some(registry.bind::<XdgActivationV1, _, _>(name, 1, qh, ()));
}
}
}
}
// xdg_activation_v1 has no events; `unreachable!()` if that ever changes.
wayland_client::delegate_noop!(ActivationGlobals: XdgActivationV1);
impl WaylandState {
fn connect(display_ptr: *mut c_void) -> Result<Self, Box<dyn Error>> {
// SAFETY: `display_ptr` is the wl_display of winit's live
// connection, which outlives every `Frame` we are handed. Guest
// mode: on drop only our private queue and our own proxies are
// destroyed, never the display itself.
let backend = unsafe { Backend::from_foreign_display(display_ptr.cast()) };
let conn = Connection::from_backend(backend);
let mut queue = conn.new_event_queue::<ActivationGlobals>();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut globals = ActivationGlobals { activation: None };
// Blocks for one compositor round trip (local socket, well under
// a frame) and dispatches only this queue.
queue.roundtrip(&mut globals)?;
if globals.activation.is_none() {
return Err("the compositor does not support xdg_activation_v1".into());
}
Ok(WaylandState {
display_ptr,
conn,
queue,
globals,
})
}
fn activate(&mut self, surface_ptr: *mut c_void, token: &str) -> Result<(), Box<dyn Error>> {
// Drain registry chatter (global add/remove) accumulated since
// the last press so the queue buffer cannot grow over a session.
self.queue.dispatch_pending(&mut self.globals)?;
let activation = self
.globals
.activation
.as_ref()
.ok_or("xdg_activation_v1 disappeared from the registry")?;
// SAFETY: `surface_ptr` is winit's live wl_surface for the very
// window we are raising; it outlives this call. `from_ptr`
// verifies the interface really is wl_surface.
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface_ptr.cast()) }?;
let surface = WlSurface::from_id(&self.conn, id)?;
activation.activate(token.to_owned(), &surface);
// A roundtrip rather than a bare flush: it proves the compositor
// consumed the request, so a connection that died since the last
// press surfaces as an error here (and the cache is dropped)
// instead of "succeeding" into a closed socket forever.
self.queue.roundtrip(&mut self.globals)?;
Ok(())
}
}
/// Activate our already-mapped window with an xdg-activation token minted
/// by the compositor for a real user action. `false` when this is not a
/// Wayland window, the compositor lacks `xdg_activation_v1`, or anything
/// on the way failed — the caller then falls back to a request for
/// attention. Note the compositor may still quietly downgrade a stale
/// token to that same attention hint; v1 has no error event to say so.
pub(super) fn activate(frame: &eframe::Frame, token: &str) -> bool {
let display_ptr = match frame.display_handle().map(|h| h.as_raw()) {
Ok(RawDisplayHandle::Wayland(w)) => w.display.as_ptr(),
Ok(other) => {
quicksearch_core::log_warn!("raising the window: not a Wayland display: {:?}", other);
return false;
}
Err(e) => {
quicksearch_core::log_warn!("raising the window: no display handle: {}", e);
return false;
}
};
let surface_ptr = match frame.window_handle().map(|h| h.as_raw()) {
Ok(RawWindowHandle::Wayland(w)) => w.surface.as_ptr(),
Ok(other) => {
quicksearch_core::log_warn!("raising the window: not a Wayland window: {:?}", other);
return false;
}
Err(e) => {
quicksearch_core::log_warn!("raising the window: no window handle: {}", e);
return false;
}
};
let sent = WAYLAND.with(|slot| -> Result<(), Box<dyn Error>> {
let mut slot = slot.borrow_mut();
// A cached connection is only good for the display it wraps.
if slot.as_ref().is_some_and(|s| s.display_ptr != display_ptr) {
*slot = None;
}
let state = match slot.as_mut() {
Some(state) => state,
None => slot.insert(WaylandState::connect(display_ptr)?),
};
state.activate(surface_ptr, token)
});
match sent {
Ok(()) => true,
Err(e) => {
quicksearch_core::log_warn!("raising the window: {}", e);
// Do not reuse a connection that failed mid-way; the next
// press reconnects.
WAYLAND.with(|slot| slot.borrow_mut().take());
false
}
}
}
}

View file

@ -388,8 +388,8 @@ impl QuickSearchApp {
ctx.set_zoom_factor(clamp_scale(new.ui.scale)); ctx.set_zoom_factor(clamp_scale(new.ui.scale));
} }
if new.ui.search_hotkey != self.cfg.ui.search_hotkey { if new.ui.search_hotkey != self.cfg.ui.search_hotkey {
// Only when moved: on Wayland re-registering opens a new portal // Only when moved: re-registering an unchanged key would still
// session, which some desktops confirm with the user. // release and re-grab it, a window in which a press is lost.
crate::hotkey::apply(&new.ui.search_hotkey); crate::hotkey::apply(&new.ui.search_hotkey);
// And the system-wide binding follows, where one is written. // And the system-wide binding follows, where one is written.
crate::shortcut_setup::hotkey_changed(&new.ui.search_hotkey); crate::shortcut_setup::hotkey_changed(&new.ui.search_hotkey);

View file

@ -518,7 +518,7 @@ const KEY_COL_WIDTH: f32 = 140.0;
const CELL_SPACING: f32 = 14.0; const CELL_SPACING: f32 = 14.0;
/// A prose cell in one of this tab's tables, laid out in a child `Ui` of /// A prose cell in one of this tab's tables, laid out in a child `Ui` of
/// exactly `width`. /// exactly `width` and exactly as tall as the wrapped text came out.
/// ///
/// Two things force the explicit width. Grid cells default to /// Two things force the explicit width. Grid cells default to
/// `TextWrapMode::Extend`, which lays a long cell out past the panel and, far /// `TextWrapMode::Extend`, which lays a long cell out past the panel and, far
@ -529,9 +529,23 @@ const CELL_SPACING: f32 = 14.0;
/// the column to that, squeezing prose into a two-word ribbon. A child `Ui` /// the column to that, squeezing prose into a two-word ribbon. A child `Ui`
/// of a width we chose settles both. Pinned by /// of a width we chose settles both. Pinned by
/// `tests::a_window_narrower_than_the_column_reflows_rather_than_clipping`. /// `tests::a_window_narrower_than_the_column_reflows_rather_than_clipping`.
///
/// The height has to be measured rather than left at zero, which is why the
/// galley is laid out here instead of by the `Label`. `Grid` aligns a cell
/// `LEFT_CENTER` within its row, and centring a *zero-height* box lands it on
/// the row's midpoint; the text then runs downward from there, half a row
/// below the striped background painted behind it. Handing the measured
/// height in makes that centring a no-op. Pinned by
/// `tests::table_rows_line_up_with_their_stripes`.
fn cell(ui: &mut egui::Ui, width: f32, text: impl Into<egui::WidgetText>) { fn cell(ui: &mut egui::Ui, width: f32, text: impl Into<egui::WidgetText>) {
ui.allocate_ui(egui::vec2(width, 0.0), |ui| { let galley = text.into().into_galley(
ui.add(egui::Label::new(text).wrap()); ui,
Some(egui::TextWrapMode::Wrap),
width,
egui::TextStyle::Body,
);
ui.allocate_ui(egui::vec2(width, galley.size().y), |ui| {
ui.add(egui::Label::new(galley));
}); });
} }
@ -674,6 +688,97 @@ mod tests {
} }
} }
/// A table cell allocated with no height of its own is centred on its
/// row's midpoint by `Grid` and then laid out downward from there, which
/// leaves every prose column half a row below the striped background
/// painted behind it. Both halves of a row have to share a centre line,
/// and the text has to sit inside its own stripe.
#[test]
fn table_rows_line_up_with_their_stripes() {
let ctx = crate::test_ui::ctx();
let input = crate::test_ui::raw_input(egui::vec2(1000.0, 4000.0), vec![]);
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
super::ui(ui);
});
});
let painted = crate::test_ui::painted(&out);
let find = |needle: &str| {
painted
.iter()
.find(|(text, _)| text == needle)
.unwrap_or_else(|| panic!("nothing painted for {:?}", needle))
.1
};
// One single-line and one wrapping row from each striped table: the
// offset is half the row's height, so a wrapping row misses by twice
// as much as a single-line one.
for (key, prose) in [
(" Exact name ", "the file is called exactly what you typed"),
(
" Close spelling ",
"a name or some text within a typo or two of what you typed, \
only while Fuzzy is ticked",
),
("*.jpg", "holiday.jpg, 1.jpg"),
(
"node_modules",
"a file or folder named exactly that, and all it holds",
),
] {
let (key_rect, prose_rect) = (find(key), find(prose));
assert!(
(key_rect.center().y - prose_rect.center().y).abs() < 1.0,
"{:?} is centred at {} but {:?} at {}",
key,
key_rect.center().y,
prose,
prose_rect.center().y
);
}
// The stripes themselves, so the rows are checked against what the
// user actually sees behind them rather than only against each other.
// Only every other row carries one, so both of these are rows egui
// paints: the ranking table's fourth, the examples' second.
let faint = ctx.style().visuals.faint_bg_color;
fn walk(shape: &egui::epaint::Shape, faint: egui::Color32, into: &mut Vec<egui::Rect>) {
match shape {
egui::epaint::Shape::Rect(r) if r.fill == faint => into.push(r.rect),
egui::epaint::Shape::Vec(shapes) => {
for s in shapes {
walk(s, faint, into);
}
}
_ => {}
}
}
let mut stripes = Vec::new();
for clipped in &out.shapes {
walk(&clipped.shape, faint, &mut stripes);
}
assert!(!stripes.is_empty(), "no striped rows painted");
for prose in [
"a name or some text within a typo or two of what you typed, \
only while Fuzzy is ticked",
"a file or folder named exactly that, and all it holds",
] {
let rect = find(prose);
let stripe = stripes
.iter()
.find(|s| s.contains(rect.center()))
.unwrap_or_else(|| panic!("{:?} sits on no stripe: {:#?}", prose, stripes));
assert!(
stripe.y_range().contains(rect.top()) && stripe.y_range().contains(rect.bottom()),
"{:?} spans {:?} but its stripe only {:?}",
prose,
rect.y_range(),
stripe.y_range()
);
}
}
/// The near-miss column is half of what the examples teach, so the /// The near-miss column is half of what the examples teach, so the
/// narrow layout has to keep it rather than dropping to pattern-only. /// narrow layout has to keep it rather than dropping to pattern-only.
#[test] #[test]

View file

@ -1,8 +1,8 @@
//! The one representation of a shortcut and its three spellings: the config //! The one representation of a shortcut and its spellings: the config text
//! text (which is also the `global-hotkey` token), and the xkbcommon keysym //! (which is also the `global-hotkey` token), the X11 keysym name (which
//! the XDG shortcuts spec wants for the Wayland portal. All three come out //! doubles as the GTK keyval for GNOME bindings), and the Qt key code KDE's
//! of [`KEYS`], so a key cannot be spelled correctly for one backend and //! KGlobalAccel takes. All of them come out of [`KEYS`] and [`qt_key`], so a
//! wrongly for the other. //! key cannot be spelled correctly for one backend and wrongly for another.
use std::fmt; use std::fmt;
use std::str::FromStr; use std::str::FromStr;
@ -161,9 +161,9 @@ impl Binding {
/// `tests::every_key_has_a_qt_code` proves the mapping total over /// `tests::every_key_has_a_qt_code` proves the mapping total over
/// [`KEYS`], so a new row cannot reach KDE as a panic. /// [`KEYS`], so a new row cannot reach KDE as a panic.
/// ///
/// This and the two spellings below exist for the desktops /// This and the GTK spelling below exist for the desktops
/// `crate::shortcut_setup` and [`super::portal`] write to, all of which /// `crate::shortcut_setup` writes to, all of which are unix; off unix
/// are unix; off unix nothing calls them and the lint would say so. /// nothing calls them and the lint would say so.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub fn qt_key_code(&self) -> u32 { pub fn qt_key_code(&self) -> u32 {
let mut code = qt_key(self.key); let mut code = qt_key(self.key);
@ -197,25 +197,6 @@ impl Binding {
out.push_str(self.row().1); out.push_str(self.row().1);
out out
} }
/// The trigger in the XDG shortcuts spec's syntax: uppercase modifiers
/// and an xkbcommon keysym, joined with `+`.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub fn portal_trigger(&self) -> String {
let mut out = String::new();
for (held, name) in [
(self.ctrl, "CTRL"),
(self.alt, "ALT"),
(self.shift, "SHIFT"),
] {
if held {
out.push_str(name);
out.push('+');
}
}
out.push_str(self.row().1);
out
}
} }
impl fmt::Display for Binding { impl fmt::Display for Binding {
@ -376,7 +357,6 @@ mod tests {
let cfg = quicksearch_core::config::UiConfig::default(); let cfg = quicksearch_core::config::UiConfig::default();
let binding: Binding = cfg.search_hotkey.parse().expect("the default is valid"); let binding: Binding = cfg.search_hotkey.parse().expect("the default is valid");
assert_eq!(binding.to_string(), "Ctrl+Shift+F"); assert_eq!(binding.to_string(), "Ctrl+Shift+F");
assert_eq!(binding.portal_trigger(), "CTRL+SHIFT+f");
assert_eq!(binding.gtk_accelerator(), "<Ctrl><Shift>f"); assert_eq!(binding.gtk_accelerator(), "<Ctrl><Shift>f");
} }
@ -456,7 +436,7 @@ mod tests {
fn modifiers_are_ordered_and_case_insensitive() { fn modifiers_are_ordered_and_case_insensitive() {
let binding: Binding = "shift+ALT+ctrl+f".parse().unwrap(); let binding: Binding = "shift+ALT+ctrl+f".parse().unwrap();
assert_eq!(binding.to_string(), "Ctrl+Alt+Shift+F"); assert_eq!(binding.to_string(), "Ctrl+Alt+Shift+F");
assert_eq!(binding.portal_trigger(), "CTRL+ALT+SHIFT+f"); assert_eq!(binding.gtk_accelerator(), "<Ctrl><Alt><Shift>f");
assert_eq!( assert_eq!(
" Ctrl + Shift + F ".parse::<Binding>(), " Ctrl + Shift + F ".parse::<Binding>(),
Ok(binding_of("Ctrl+Shift+F")) Ok(binding_of("Ctrl+Shift+F"))

View file

@ -1,7 +1,12 @@
//! The in-application half of the search shortcut: the key QuickSearch //! The in-application half of the search shortcut: the key QuickSearch
//! claims for itself while it is running. Windows and X11 grant that via //! claims for itself while it is running. Windows and X11 grant that via
//! `global-hotkey` (`RegisterHotKey`/`XGrabKey`); Wayland refuses grabs by //! `global-hotkey` (`RegisterHotKey`/`XGrabKey`). Wayland refuses grabs by
//! design, so it goes through the XDG portal and the *desktop* picks the key. //! design and gets no in-app half at all ([`Status::DesktopOnly`]): the XDG
//! GlobalShortcuts portal was tried and removed — it prompted a dialog on
//! every launch, bound an action that only worked while the app ran, and
//! its persisted claim on the key blocked the desktop's own launch binding.
//! On Wayland the desktop's binding (`crate::shortcut_setup`, `--toggle`)
//! is the whole story.
//! //!
//! This is the path that needs no setup at all, and it is why the Settings //! This is the path that needs no setup at all, and it is why the Settings
//! tab can offer an arbitrary combination on every platform. It cannot fire //! tab can offer an arbitrary combination on every platform. It cannot fire
@ -10,14 +15,17 @@
//! Both funnel into the same pending flag, so the window comes forward the //! Both funnel into the same pending flag, so the window comes forward the
//! same way whichever one fired. //! same way whichever one fired.
//! //!
//! When a [`crate::shortcut_setup`] system binding is installed, this half
//! stands down entirely ([`Status::SystemOwned`]): the desktop delivers the
//! key whether QuickSearch is running or not, and holding our own claim as
//! well would fight it for the key.
//!
//! Held in a thread-local global rather than a field: the registration is //! Held in a thread-local global rather than a field: the registration is
//! process-wide, the event handler is set-once, and on Windows //! process-wide, the event handler is set-once, and on Windows
//! `GlobalHotKeyManager` is not `Send`. Every entry point is inert until //! `GlobalHotKeyManager` is not `Send`. Every entry point is inert until
//! [`init`] runs, so headless UI tests never touch an OS registration. //! [`init`] runs, so headless UI tests never touch an OS registration.
mod binding; mod binding;
#[cfg(all(unix, not(target_os = "macos")))]
mod portal;
pub use binding::{parse_setting, Binding}; pub use binding::{parse_setting, Binding};
@ -35,30 +43,31 @@ thread_local! {
pub enum Status { pub enum Status {
Disabled, Disabled,
Active, Active,
/// Asked for; the desktop has not answered yet. /// Wayland without a system binding: the compositor refuses in-app
Pending, /// grabs, so only the desktop's own binding can deliver the key.
/// Wayland: described in the desktop's own words, because the desktop, DesktopOnly,
/// not the setting, decides the key. /// A `crate::shortcut_setup` binding is installed, so the desktop owns
PortalBound(String), /// the key and nothing is registered in-app: every press arrives through
/// `--toggle`, which also works while QuickSearch is closed. Registering
/// here as well would fight the desktop over the key.
SystemOwned,
Error(String), Error(String),
} }
struct Registry { struct Registry {
backend: Backend, backend: Backend,
/// Everything except the portal, which reports its own asynchronously.
status: Status, status: Status,
} }
enum Backend { enum Backend {
/// Nothing registered: no shortcut set, or the backend never started. /// Nothing registered: no shortcut set, a Wayland session (see the
/// module docs), or the backend never started.
Idle, Idle,
/// Windows and X11. /// Windows and X11.
Grab { Grab {
manager: GlobalHotKeyManager, manager: GlobalHotKeyManager,
registered: Option<HotKey>, registered: Option<HotKey>,
}, },
#[cfg(all(unix, not(target_os = "macos")))]
Portal(portal::Portal),
} }
/// Start the shortcut and register `setting`. Must be called on the /// Start the shortcut and register `setting`. Must be called on the
@ -73,7 +82,7 @@ pub fn init(ctx: &egui::Context, setting: &str) {
} }
})); }));
let backend = match choose_backend(ctx) { let backend = match choose_backend() {
Ok(backend) => backend, Ok(backend) => backend,
Err(message) => { Err(message) => {
quicksearch_core::log_warn!("global shortcut: {}", message); quicksearch_core::log_warn!("global shortcut: {}", message);
@ -103,6 +112,22 @@ pub fn apply(setting: &str) {
let Some(registry) = slot.as_mut() else { let Some(registry) = slot.as_mut() else {
return; return;
}; };
// The desktop's own binding outranks ours: release whatever is held
// and stand down (see `Status::SystemOwned`). Checked here rather
// than by the callers so init and every save agree; the probe is a
// file check on KDE and a one-off subprocess on GNOME, and `apply`
// only runs on saves.
if crate::shortcut_setup::installed() {
let _ = registry.backend.register(None);
registry.status = Status::SystemOwned;
return;
}
// Wayland with nothing installed: there is nothing to register —
// the compositor refuses grabs — so say where the key has to live.
if crate::activate::raise::is_wayland() {
registry.status = Status::DesktopOnly;
return;
}
let wanted = match parse_setting(setting) { let wanted = match parse_setting(setting) {
Ok(binding) => binding, Ok(binding) => binding,
Err(e) => { Err(e) => {
@ -131,12 +156,6 @@ pub fn apply(setting: &str) {
pub fn status() -> Status { pub fn status() -> Status {
REGISTRY.with_borrow(|slot| match slot.as_ref() { REGISTRY.with_borrow(|slot| match slot.as_ref() {
None => Status::Disabled, None => Status::Disabled,
// The portal answers on its own schedule and keeps its own status.
#[cfg(all(unix, not(target_os = "macos")))]
Some(Registry {
backend: Backend::Portal(portal),
..
}) => portal.status(),
Some(registry) => registry.status.clone(), Some(registry) => registry.status.clone(),
}) })
} }
@ -179,31 +198,21 @@ impl Backend {
*registered = Some(hotkey); *registered = Some(hotkey);
Ok(()) Ok(())
} }
#[cfg(all(unix, not(target_os = "macos")))]
Backend::Portal(portal) => {
portal.bind(wanted.map(|b| b.portal_trigger()));
Ok(())
}
} }
} }
} }
/// A Wayland session gets the portal, everything else a grab. No falling /// A Wayland session gets no backend at all — the desktop's binding is the
/// back: an X11 grab inside a Wayland session succeeds and then only fires /// Wayland path; see the module docs. No X11 grab as a fallback either: it
/// while an XWayland window has focus — a broken-looking shortcut. /// succeeds inside a Wayland session and then only fires while an XWayland
#[cfg(all(unix, not(target_os = "macos")))] /// window has focus — a broken-looking shortcut.
fn choose_backend(ctx: &egui::Context) -> Result<Backend, String> { fn choose_backend() -> Result<Backend, String> {
if std::env::var_os("WAYLAND_DISPLAY").is_some() { if crate::activate::raise::is_wayland() {
return Ok(Backend::Portal(portal::Portal::new(ctx))); return Ok(Backend::Idle);
} }
grab_backend() grab_backend()
} }
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn choose_backend(_ctx: &egui::Context) -> Result<Backend, String> {
grab_backend()
}
fn grab_backend() -> Result<Backend, String> { fn grab_backend() -> Result<Backend, String> {
GlobalHotKeyManager::new() GlobalHotKeyManager::new()
.map(|manager| Backend::Grab { .map(|manager| Backend::Grab {

View file

@ -1,183 +0,0 @@
//! The Wayland half of the shortcut: `org.freedesktop.portal.GlobalShortcuts`.
//!
//! **The desktop owns the binding**: what we send is a `preferred_trigger`,
//! and the compositor may bind something else or ask the user; what it bound
//! comes back as a `trigger_description`, which the Settings tab shows.
//!
//! All of this lives on its own thread — a portal call is a D-Bus round trip
//! that can block as long as a dialog stays up. The session must stay open
//! for activations to keep arriving; dropping it is how a rebind starts over.
use std::sync::{Arc, Mutex};
use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut};
use ashpd::desktop::Session;
use futures_channel::mpsc;
use futures_util::future::{select, Either};
use futures_util::StreamExt;
use super::Status;
/// The portal keys activations by this id; the desktop lists the entry by it.
const SHORTCUT_ID: &str = "search";
/// Shown next to the key in the desktop's shortcut settings.
const SHORTCUT_DESCRIPTION: &str = "Focus the QuickSearch search box";
pub(super) struct Portal {
/// `Some(trigger)` binds, `None` unbinds. Unbounded: sends happen on
/// the UI thread and must never block it.
tx: mpsc::UnboundedSender<Option<String>>,
status: Arc<Mutex<Status>>,
}
impl Portal {
/// The thread runs until the process exits; nothing to shut down.
pub(super) fn new(ctx: &egui::Context) -> Portal {
let (tx, rx) = mpsc::unbounded();
let status = Arc::new(Mutex::new(Status::Pending));
let portal = Portal {
tx,
status: Arc::clone(&status),
};
let ctx = ctx.clone();
if let Err(e) = std::thread::Builder::new()
.name("quicksearch-hotkey-portal".to_string())
.spawn(move || pollster::block_on(run(ctx, status, rx)))
{
// The status must say so, or the Settings tab shows
// "Waiting for your desktop…" forever.
quicksearch_core::log_warn!("global shortcut portal thread: {}", e);
*lock_ok(&portal.status) =
Status::Error(format!("the shortcut thread could not be started: {}", e));
}
portal
}
/// Returns immediately; the answer lands in [`Portal::status`]
/// whenever the desktop gets to it.
pub(super) fn bind(&self, trigger: Option<String>) {
*lock_ok(&self.status) = match trigger {
Some(_) => Status::Pending,
None => Status::Disabled,
};
let _ = self.tx.unbounded_send(trigger);
}
pub(super) fn status(&self) -> Status {
lock_ok(&self.status).clone()
}
}
/// Ignore poisoning: a portal-thread panic must not take the UI thread too.
fn lock_ok<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
async fn run(
ctx: egui::Context,
status: Arc<Mutex<Status>>,
mut commands: mpsc::UnboundedReceiver<Option<String>>,
) {
let shortcuts: GlobalShortcuts<'static> = match GlobalShortcuts::new().await {
Ok(s) => s,
Err(e) => return fail(&ctx, &status, unavailable(&e)),
};
// A signal match on the interface, not a session: survives rebinds.
let activated = match shortcuts.receive_activated().await {
Ok(s) => s,
Err(e) => return fail(&ctx, &status, unavailable(&e)),
};
futures_util::pin_mut!(activated);
let mut session: Option<Session<'static, GlobalShortcuts<'static>>> = None;
loop {
match select(activated.next(), commands.next()).await {
Either::Left((Some(_), _)) => {
// No id check needed: this session has exactly one shortcut.
super::fire(&ctx);
}
// The portal went away; the session is already dead.
Either::Left((None, _)) => {
return fail(
&ctx,
&status,
"the desktop's global shortcuts service stopped".to_string(),
)
}
Either::Right((Some(trigger), _)) => {
// A rebind is a new session: the portal treats a session's
// shortcuts as fixed once bound.
if let Some(old) = session.take() {
let _ = old.close().await;
}
let next = match &trigger {
None => {
set(&ctx, &status, Status::Disabled);
None
}
Some(trigger) => match bind(&shortcuts, trigger).await {
Ok((session, description)) => {
set(&ctx, &status, Status::PortalBound(description));
Some(session)
}
Err(e) => {
fail(&ctx, &status, unavailable(&e));
None
}
},
};
session = next;
}
// The registry dropped the sender: we are on the way out.
Either::Right((None, _)) => return,
}
}
}
/// Bind the trigger; returns the desktop's own wording for what it settled on.
async fn bind(
shortcuts: &GlobalShortcuts<'static>,
trigger: &str,
) -> Result<(Session<'static, GlobalShortcuts<'static>>, String), ashpd::Error> {
let session = shortcuts.create_session().await?;
let shortcut =
NewShortcut::new(SHORTCUT_ID, SHORTCUT_DESCRIPTION).preferred_trigger(Some(trigger));
let request = shortcuts
.bind_shortcuts(&session, &[shortcut], None)
.await?;
let bound = request.response()?;
// A blank description falls back to the preferred trigger.
let description = bound
.shortcuts()
.iter()
.find(|s| s.id() == SHORTCUT_ID)
.map(|s| s.trigger_description().to_string())
.filter(|d| !d.trim().is_empty())
.unwrap_or_else(|| trigger.to_string());
Ok((session, description))
}
fn unavailable(e: &ashpd::Error) -> String {
match e {
ashpd::Error::PortalNotFound(_) => {
"this desktop does not offer the global shortcuts portal".to_string()
}
ashpd::Error::RequiresVersion(required, found) => format!(
"this desktop's global shortcuts portal is version {}, and {} is needed",
found, required
),
ashpd::Error::Response(_) => "the desktop declined the shortcut".to_string(),
other => other.to_string(),
}
}
fn set(ctx: &egui::Context, status: &Mutex<Status>, next: Status) {
*lock_ok(status) = next;
ctx.request_repaint();
}
fn fail(ctx: &egui::Context, status: &Mutex<Status>, message: String) {
quicksearch_core::log_warn!("global shortcut: {}", message);
set(ctx, status, Status::Error(message));
}

View file

@ -446,47 +446,12 @@ fn read_capture(ui: &egui::Ui) -> Option<Option<crate::hotkey::Binding>> {
/// Silent while registered and working; a line appears only when what is on /// Silent while registered and working; a line appears only when what is on
/// the button is not what is in force. /// the button is not what is in force.
fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) { fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
use crate::hotkey::Status; let (text, is_error) = if draft.trim() != live.trim() {
let (text, color) = if draft.trim() != live.trim() { ("Not registered until Apply and Save.".to_string(), false)
("Not registered until Apply and Save.".to_string(), None)
} else { } else {
match crate::hotkey::status() { hotkey_status_line(crate::hotkey::status())
Status::Disabled | Status::Active => (String::new(), None),
Status::Pending => (
"Waiting for your desktop to accept the shortcut.".to_string(),
None,
),
Status::PortalBound(trigger) => (
format!(
"Your desktop registered this as {}. It has the final say; \
change it in its own keyboard settings. On Wayland it also \
decides whether the window comes forward, so a minimised \
window may stay minimised.",
trigger
),
None,
),
// On Windows a failed registration is the *expected* state
// whenever the Start-menu shortcut owns the same key — Explorer
// registers it at logon, wins, and every press then reaches us
// through the `--toggle` relay anyway. An error color would cry
// wolf on every installed copy.
Status::Error(why) if cfg!(windows) => (
format!(
"Another program holds this key ({}) — usually the Start \
menu shortcut that starts QuickSearch, which also brings \
it forward while it is running. If the key does nothing, \
pick a different combination.",
why
),
None,
),
Status::Error(why) => (
format!("The shortcut is not active: {}.", why),
Some(crate::color::palette(ui.visuals().dark_mode).orange),
),
}
}; };
let color = is_error.then(|| crate::color::palette(ui.visuals().dark_mode).orange);
crate::ui_util::stable_section(ui, |ui| { crate::ui_util::stable_section(ui, |ui| {
if text.is_empty() { if text.is_empty() {
return; return;
@ -499,6 +464,44 @@ fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
}); });
} }
/// The sentence for a registration state, and whether it is an error worth
/// the warning colour. Split from the rendering so the states are testable.
fn hotkey_status_line(status: crate::hotkey::Status) -> (String, bool) {
use crate::hotkey::Status;
match status {
Status::Disabled | Status::Active => (String::new(), false),
Status::SystemOwned => (
"Your desktop delivers this key: it starts QuickSearch when \
closed and brings it forward when running."
.to_string(),
false,
),
Status::DesktopOnly => (
"On Wayland only your desktop can hold a global key. Use \
\"Set up system shortcut\" below, or bind the command shown \
there by hand."
.to_string(),
false,
),
// On Windows a failed registration is the *expected* state whenever
// the Start-menu shortcut owns the same key — Explorer registers it
// at logon, wins, and every press then reaches us through the
// `--toggle` relay anyway. An error color would cry wolf on every
// installed copy.
Status::Error(why) if cfg!(windows) => (
format!(
"Another program holds this key ({}) — usually the Start \
menu shortcut that starts QuickSearch, which also brings \
it forward while it is running. If the key does nothing, \
pick a different combination.",
why
),
false,
),
Status::Error(why) => (format!("The shortcut is not active: {}.", why), true),
}
}
/// How to get a shortcut that also *starts* QuickSearch. /// How to get a shortcut that also *starts* QuickSearch.
/// ///
/// The shortcut above is ours and needs no setup, but it cannot fire while /// The shortcut above is ours and needs no setup, but it cannot fire while
@ -572,6 +575,9 @@ fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::sh
state.installed = false; state.installed = false;
state.feedback = state.feedback =
Some((true, "System shortcut removed.".to_string())); Some((true, "System shortcut removed.".to_string()));
// Take the key back in-app now, not at the
// next save.
crate::hotkey::apply(hotkey_setting);
} }
Err(e) => state.feedback = Some((false, e)), Err(e) => state.feedback = Some((false, e)),
} }
@ -581,12 +587,26 @@ fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::sh
.clicked() .clicked()
{ {
match crate::shortcut_setup::install(&binding) { match crate::shortcut_setup::install(&binding) {
Ok(()) => { Ok(when) => {
state.installed = true; state.installed = true;
state.feedback = Some(( state.feedback = Some((
true, true,
"Added to your desktop's keyboard shortcuts.".to_string(), match when {
crate::shortcut_setup::Installed::Immediately => {
"Added to your desktop's keyboard shortcuts."
.to_string()
}
crate::shortcut_setup::Installed::AfterRelogin => {
"Added to your desktop's keyboard shortcuts; \
the key starts answering after you next log in."
.to_string()
}
},
)); ));
// Stand the in-app registration down at once so
// the desktop's binding is not fought for the key
// (see `hotkey::Status::SystemOwned`).
crate::hotkey::apply(hotkey_setting);
} }
Err(e) => state.feedback = Some((false, e)), Err(e) => state.feedback = Some((false, e)),
} }
@ -631,9 +651,11 @@ fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::sh
if crate::activate::raise::is_wayland() { if crate::activate::raise::is_wayland() {
ui.label( ui.label(
egui::RichText::new( egui::RichText::new(
"On Wayland a window that is already open cannot be raised by \ "On Wayland, focus follows the shortcut's activation token: \
another process, so the shortcut will highlight QuickSearch in \ the binding set up above passes one along, so QuickSearch \
the task bar rather than bring it to the front.", comes to the front. A binding made by hand whose launcher \
provides no token can only highlight QuickSearch in the \
task bar.",
) )
.small() .small()
.weak(), .weak(),

View file

@ -729,6 +729,27 @@ fn showing_advanced_settings_is_not_an_unsaved_edit() {
); );
} }
/// Each registration state gets its sentence; only a real failure (and not
/// Windows' expected .lnk contention) earns the warning colour.
#[test]
fn the_hotkey_status_lines_match_their_states() {
use crate::hotkey::Status;
let (none, err) = super::hotkey_status_line(Status::Active);
assert!(none.is_empty() && !err);
let (owned, err) = super::hotkey_status_line(Status::SystemOwned);
assert!(
owned.contains("starts QuickSearch when closed"),
"{owned}"
);
assert!(!err, "SystemOwned is the working state, not a warning");
let (desktop, err) = super::hotkey_status_line(Status::DesktopOnly);
assert!(desktop.contains("Set up system shortcut"), "{desktop}");
assert!(!err, "DesktopOnly is a pointer, not a failure");
let (error, err) = super::hotkey_status_line(Status::Error("taken".to_string()));
assert!(error.contains("taken"));
assert_eq!(err, !cfg!(windows), "only non-Windows colours the failure");
}
/// The panel that tells a user how to get a shortcut that also starts /// The panel that tells a user how to get a shortcut that also starts
/// QuickSearch has to actually show the command they must bind — with or /// QuickSearch has to actually show the command they must bind — with or
/// without a one-click desktop to lean on. /// without a one-click desktop to lean on.

View file

@ -76,14 +76,27 @@ pub fn installed() -> bool {
} }
} }
/// A successful install, and when the key starts answering.
///
/// Off unix nothing installs, so nothing constructs these; the match arms
/// in the Settings tab still name them on every platform.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Installed {
Immediately,
/// Durably written, but the desktop only picks it up at the next login
/// (KDE with no `kbuildsycoca` on the PATH).
AfterRelogin,
}
/// Write `binding` → `quicksearch --toggle` into the desktop's keyboard /// Write `binding` → `quicksearch --toggle` into the desktop's keyboard
/// configuration. Idempotent: a second install rewrites the same entry. /// configuration. Idempotent: a second install rewrites the same entry.
pub fn install(binding: &Binding) -> Result<(), String> { pub fn install(binding: &Binding) -> Result<Installed, String> {
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
{ {
let command = toggle_command(); let command = toggle_command();
match detect() { match detect() {
Desktop::Gnome => gnome::install(binding, &command), Desktop::Gnome => gnome::install(binding, &command).map(|()| Installed::Immediately),
Desktop::Kde => kde::install(binding, &command), Desktop::Kde => kde::install(binding, &command),
Desktop::Unsupported => Err(UNSUPPORTED.to_string()), Desktop::Unsupported => Err(UNSUPPORTED.to_string()),
} }
@ -271,40 +284,40 @@ fn format_string_list(paths: &[String]) -> String {
format!("[{}]", quoted.join(", ")) format!("[{}]", quoted.join(", "))
} }
/// KDE: registration with the kglobalaccel daemon over DBus, which is the /// KDE: a desktop file carrying its own key, discovered through the service
/// only writer `kglobalshortcutsrc` has — the daemon rewrites that file at /// database. Grounded in the kglobalacceld source (v6.6.5,
/// will and *drops* groups it did not create, so editing it directly (the /// `globalshortcutsregistry.cpp`) after three approaches failed in the field:
/// first version of this module) produced an entry that neither fired nor
/// survived. `setShortcut` takes effect immediately and the daemon does its
/// own persisting.
/// ///
/// What the key *runs* is a small desktop file in /// * DBus `doRegister`+`setShortcut` records a mapping but can never *arm*
/// `~/.local/share/kglobalaccel/`, the directory Plasma itself uses for /// it: `getOrCreateComponent` deliberately skips `loadSettings`, and only
/// custom command shortcuts: a component whose name ends in `.desktop` /// that path reaches `registerKey` (the actual grab). Worse, the claim is
/// resolves to that file, and its `_launch` action runs the `Exec` line. /// bound to the registering connection and dropped when it exits.
/// The file's presence is also this module's "installed" marker — written /// * Editing `kglobalshortcutsrc` around a daemon restart loses a race on
/// last on install, deleted on remove, and free of a per-frame DBus call. /// any live desktop: KDE clients DBus-activate the daemon back within
/// milliseconds of `stop`, before the edit lands, and its debounced
/// `writeSettings` then erases the entry.
/// * The daemon's startup scan of `~/.local/share/kglobalaccel/` skips
/// `NoDisplay=true` files outright.
/// ///
/// Verified live against Plasma 6.6: register → the key launches a closed /// What does work, restart-free: `detectAppsWithShortcuts()` queries the
/// QuickSearch; `unregister` → the daemon drops the entry from its config. /// service database for applications whose desktop file carries
/// `X-KDE-Shortcuts=`, arming their `_launch` — and it runs both at daemon
/// startup and at runtime on every `KSycoca::databaseChanged`. So install
/// writes a hidden desktop entry with the key inside it into
/// `~/.local/share/applications/` and pokes `kbuildsycoca6`; the daemon
/// arms it live, and every later login re-arms it from the same detection.
/// The file is also this module's "installed" marker. `unregister` over
/// DBus disarms live (verified by synthesized keypress), so removal needs
/// no database round trip to take effect.
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
mod kde { mod kde {
use super::*; use super::*;
use std::path::PathBuf; use std::path::PathBuf;
/// Ends in `.desktop`: what makes the daemon treat the component as a /// The service's storage id — its filename — which is also the
/// service it can launch rather than an app that must be running. /// component name every DBus query answers with.
const COMPONENT: &str = "quicksearch-search.desktop"; const COMPONENT: &str = "quicksearch-search.desktop";
const ACTION: &str = "_launch"; const ACTION: &str = "_launch";
/// KGlobalAccel's NoAutoloading flag: set the key now, not merely as a
/// default for the next load.
const SET_NOW: &str = "4";
/// The action id every `org.kde.KGlobalAccel` call takes, in GVariant
/// text: `[component, action, component friendly, action friendly]`.
pub(super) fn action_id() -> String {
format!("['{}', '{}', 'QuickSearch', 'QuickSearch']", COMPONENT, ACTION)
}
/// One `gdbus call` against the daemon. gdbus over qdbus because it /// One `gdbus call` against the daemon. gdbus over qdbus because it
/// takes GVariant text for the list arguments, which qdbus cannot spell. /// takes GVariant text for the list arguments, which qdbus cannot spell.
@ -324,81 +337,187 @@ mod kde {
run("gdbus", &argv) run("gdbus", &argv)
} }
/// Where the launch target lives; the daemon looks here by name. fn data_dir() -> PathBuf {
pub(super) fn desktop_file() -> PathBuf { std::env::var_os("XDG_DATA_HOME")
let data = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)
.filter(|p| p.is_absolute()) .filter(|p| p.is_absolute())
.unwrap_or_else(|| { .unwrap_or_else(|| {
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()) PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
.join(".local/share") .join(".local/share")
}); })
data.join("kglobalaccel").join(COMPONENT)
} }
/// `NoDisplay`: the entry exists to be launched by a key, not to appear /// In the applications directory — the one place the service database
/// in menus next to the real QuickSearch entry. /// indexes, which is what `detectAppsWithShortcuts` queries.
pub(super) fn desktop_entry(command: &str) -> String { pub(super) fn desktop_file() -> PathBuf {
data_dir().join("applications").join(COMPONENT)
}
/// Where an earlier build put the file; deleted on sight so upgrades
/// leave one binding, not two.
fn legacy_desktop_file() -> PathBuf {
data_dir().join("kglobalaccel").join(COMPONENT)
}
/// `NoDisplay` keeps it out of menus next to the real QuickSearch entry
/// (the shortcut detection reads `X-KDE-Shortcuts` regardless);
/// `X-KDE-Shortcuts` is the key itself, in QKeySequence text — the
/// daemon arms `_launch` with it wherever the service turns up.
pub(super) fn desktop_entry(command: &str, binding: &Binding) -> String {
format!( format!(
"[Desktop Entry]\nType=Application\nName=QuickSearch\nNoDisplay=true\nExec={}\n", "[Desktop Entry]\nType=Application\nName=QuickSearch\nNoDisplay=true\n\
command Exec={}\nX-KDE-Shortcuts={}\n",
command, binding
) )
} }
pub(super) fn installed() -> bool { pub(super) fn installed() -> bool {
desktop_file().is_file() desktop_file().is_file() || legacy_desktop_file().is_file()
} }
pub(super) fn install(binding: &Binding, command: &str) -> Result<(), String> { pub(super) fn install(binding: &Binding, command: &str) -> Result<super::Installed, String> {
let id = action_id(); let code = binding.qt_key_code();
call("doRegister", &[&id])?; // Evict any claim of *ours* on the key first — above all the
let keys = format!("[{}]", binding.qt_key_code()); // popup-bound portal shortcut ("Focus the QuickSearch search box")
let reply = call("setShortcut", &[&id, &keys, SET_NOW])?; // that builds which still registered in-app on Wayland left behind:
// The daemon answers with the keys now in force; ours missing means // its persisted claim keeps the launch binding from ever firing.
// it kept something else — the key is taken. // Looked up rather than guessed — the daemon says who holds the key
if !reply_ints(&reply).contains(&binding.qt_key_code()) { // — and a foreign application's claim is never touched.
let _ = call("unregister", &[COMPONENT, ACTION]); for holder in holders_of(code) {
if holder.is_ours() && holder.component != COMPONENT {
let _ = call("unregister", &[&holder.component, &holder.action]);
}
}
// A key another application still claims is reported before anything
// is registered: the daemon happily records two claims and then
// delivers the press to neither, which read as "set up" and did
// nothing (the user's "View Full Screen Mode" collision).
if let Some(holder) = holders_of(code).iter().find(|h| !h.is_ours()) {
return Err(format!( return Err(format!(
"your desktop refused {} — probably already in use", "{} is taken by \"{}\" ({}); free it under System Settings → \
binding Keyboard Shortcuts, or pick a different combination",
binding, holder.action_friendly, holder.component_friendly,
)); ));
} }
// The file last: it is the installed marker, so nothing marks this // An earlier build's file in the old location would leave a second
// installed until the key is actually in force. // component claiming a key; gone before the new one appears.
let _ = std::fs::remove_file(legacy_desktop_file());
// The file is the whole registration — key included — and the
// installed marker, so a failure below deletes it again.
let path = desktop_file(); let path = desktop_file();
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {}", dir.display(), e))?; std::fs::create_dir_all(dir)
.map_err(|e| format!("creating {}: {}", dir.display(), e))?;
} }
std::fs::write(&path, desktop_entry(command)) std::fs::write(&path, desktop_entry(command, binding))
.map_err(|e| format!("writing {}: {}", path.display(), e))?; .map_err(|e| format!("writing {}: {}", path.display(), e))?;
// Executable, or KConfig refuses to trust the file's Exec line // Executable, matching the trust rules KIO applies to desktop files
// ("not owned by root and executable flag not set") — the same bit // before launching what they name.
// Plasma's own Shortcuts page sets on the files it creates here.
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("marking {} executable: {}", path.display(), e))?; .map_err(|e| format!("marking {} executable: {}", path.display(), e))?;
Ok(())
// The service database rebuild is what tells the running daemon: it
// arms new X-KDE-Shortcuts services on every database-changed
// signal (see the module docs). Without the tool the file still
// arms at the next login, when the daemon re-detects on startup.
let rebuilt = run("kbuildsycoca6", &[])
.or_else(|_| run("kbuildsycoca5", &[]))
.is_ok();
if !rebuilt {
return Ok(super::Installed::AfterRelogin);
}
// The daemon's own routing table is the proof the key is armed;
// three earlier versions of this function reported success for keys
// that could never fire, and this check is what closes that class.
// Retried: the database-changed signal reaches the daemon
// asynchronously.
let mut armed = false;
for _ in 0..20 {
armed = call("action", &[&code.to_string()])
.map(|reply| parse_string_list(&reply).iter().any(|s| s == COMPONENT))
.unwrap_or(false);
if armed {
break;
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
if !armed {
let _ = std::fs::remove_file(&path);
let _ = run("kbuildsycoca6", &[]).or_else(|_| run("kbuildsycoca5", &[]));
return Err(format!(
"your desktop did not arm {}; if this keeps happening, add a \
shortcut for the command by hand in System Settings",
binding
));
}
Ok(super::Installed::Immediately)
} }
pub(super) fn remove() -> Result<(), String> { pub(super) fn remove() -> Result<(), String> {
call("unregister", &[COMPONENT, ACTION])?; // Disarms live (verified by synthesized keypress); the file deletion
let path = desktop_file(); // plus database rebuild below is what keeps the next login from
match std::fs::remove_file(&path) { // re-detecting it.
Ok(()) => Ok(()), let _ = call("unregister", &[COMPONENT, ACTION]);
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), for path in [desktop_file(), legacy_desktop_file()] {
Err(e) => Err(format!("deleting {}: {}", path.display(), e)), match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("deleting {}: {}", path.display(), e)),
}
}
let _ = run("kbuildsycoca6", &[]).or_else(|_| run("kbuildsycoca5", &[]));
Ok(())
}
/// One current owner of a key, out of `getGlobalShortcutsByKey`.
pub(super) struct Holder {
pub(super) action: String,
pub(super) action_friendly: String,
pub(super) component: String,
pub(super) component_friendly: String,
}
impl Holder {
/// Whether this claim is QuickSearch's own — under any of the names
/// a portal or an older build may have registered it as.
pub(super) fn is_ours(&self) -> bool {
[
&self.action,
&self.action_friendly,
&self.component,
&self.component_friendly,
]
.iter()
.any(|name| name.to_ascii_lowercase().contains("quicksearch"))
} }
} }
/// The integers out of a gdbus reply like `([100663366],)`. Wrong or fn holders_of(code: u32) -> Vec<Holder> {
/// hostile shapes yield fewer integers, never a panic. call("getGlobalShortcutsByKey", &[&code.to_string()])
pub(super) fn reply_ints(reply: &str) -> Vec<u32> { .map(|reply| parse_holders(&reply))
reply .unwrap_or_default()
.split(|c: char| !c.is_ascii_digit()) }
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok()) /// The holders out of a reply like
/// `([('action', 'Action', 'comp', 'Component', 'default',
/// 'Default Context', [100663366], @ai [])],)` — six strings then two
/// int lists per tuple, an order taken from the daemon itself (probed
/// live against Plasma 6.6). Short or hostile shapes yield fewer
/// holders, never a panic.
pub(super) fn parse_holders(reply: &str) -> Vec<Holder> {
super::parse_string_list(reply)
.chunks_exact(6)
.map(|names| Holder {
action: names[0].clone(),
action_friendly: names[1].clone(),
component: names[2].clone(),
component_friendly: names[3].clone(),
})
.collect() .collect()
} }
} }
/// Windows: the hotkey field of the Start-menu `.lnk` is the closed-app /// Windows: the hotkey field of the Start-menu `.lnk` is the closed-app
@ -500,37 +619,48 @@ mod tests {
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
#[test] #[test]
fn the_kde_desktop_entry_launches_the_toggle() { fn the_kde_desktop_entry_launches_the_toggle() {
let entry = kde::desktop_entry("/opt/qs/quicksearch --toggle"); let binding: Binding = "Ctrl+Shift+F".parse().unwrap();
let entry = kde::desktop_entry("/opt/qs/quicksearch --toggle", &binding);
assert!(entry.starts_with("[Desktop Entry]\n")); assert!(entry.starts_with("[Desktop Entry]\n"));
assert!(entry.contains("Exec=/opt/qs/quicksearch --toggle\n")); assert!(entry.contains("Exec=/opt/qs/quicksearch --toggle\n"));
assert!(entry.contains("NoDisplay=true\n")); assert!(entry.contains("NoDisplay=true\n"));
assert!(kde::desktop_file().ends_with("kglobalaccel/quicksearch-search.desktop")); // The key rides inside the file: it is what `detectAppsWithShortcuts`
// arms, with no config entry and no daemon restart.
assert!(entry.contains("X-KDE-Shortcuts=Ctrl+Shift+F\n"));
// The applications dir, because that is the one the service database
// indexes and the shortcut detection queries.
assert!(kde::desktop_file().ends_with("applications/quicksearch-search.desktop"));
} }
/// The GVariant action id every KGlobalAccel call names; `_launch` is
/// the action that runs a `.desktop` component's Exec. /// The holder tuples come out in the daemon's own field order (captured
/// live from Plasma 6.6), and it is the portal's leftover entry that the
/// ours-test must recognise.
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
#[test] #[test]
fn the_kde_action_id_names_the_launch_action() { fn key_holders_parse_and_ours_is_recognised() {
assert_eq!( let reply = "([('search', 'Focus the QuickSearch search box', \
kde::action_id(), 'some-portal-id', 'Portal App', 'default', \
"['quicksearch-search.desktop', '_launch', 'QuickSearch', 'QuickSearch']" 'Default Context', [100663366], @ai [])],)";
let holders = kde::parse_holders(reply);
assert_eq!(holders.len(), 1);
assert_eq!(holders[0].action, "search");
assert_eq!(holders[0].component, "some-portal-id");
assert!(holders[0].is_ours(), "the portal leftover was not recognised");
let foreign = kde::parse_holders(
"([('copy', 'Copy Screenshot', 'org.kde.spectacle.desktop', \
'Spectacle', 'default', 'Default Context', [1], @ai [])],)",
); );
} assert!(!foreign[0].is_ours(), "a foreign claim must never be evicted");
/// gdbus replies, including hostile ones, must parse to integers or to assert!(kde::parse_holders("(@a(ssssssaiai) [],)").is_empty());
/// nothing — never panic. for garbage in ["", "([('a', 'b')],)", "no quotes at all"] {
#[cfg(all(unix, not(target_os = "macos")))] let _ = kde::parse_holders(garbage);
#[test]
fn gdbus_replies_parse_to_integers_or_nothing() {
assert_eq!(kde::reply_ints("([100663366],)"), [100663366]);
assert_eq!(kde::reply_ints("([1, 2],)"), [1, 2]);
assert_eq!(kde::reply_ints("([],)"), Vec::<u32>::new());
for garbage in ["", "(true,)", "nonsense", "([99999999999999999999],)"] {
let _ = kde::reply_ints(garbage);
} }
} }
/// A path with a space would otherwise split into a broken Exec line. /// A path with a space would otherwise split into a broken Exec line.
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
#[test] #[test]

View file

@ -422,13 +422,11 @@ pub static SEARCH_HOTKEY: Tip = Tip {
start typing.\n\n\ start typing.\n\n\
Click the button and press the keys you want. Combine Ctrl, Alt \ Click the button and press the keys you want. Combine Ctrl, Alt \
and Shift with one other key. Clear switches the shortcut off.\n\n\ and Shift with one other key. Clear switches the shortcut off.\n\n\
This works whenever QuickSearch is running and needs no setting \ While QuickSearch is running it holds this key itself where the \
up, but it cannot start QuickSearch. For a shortcut that opens it \ system allows that (Windows and X11), with no setting up. A key \
too, bind the command shown below in your desktop's own keyboard \ that also starts QuickSearch has to be bound by the desktop: the \
settings.\n\n\ set-up button below does that where it can, and on Wayland the \
On Wayland your desktop registers the shortcut, so it may pick a \ desktop binding is the only kind there is.",
different key and owns it afterwards, and it decides whether the \
window comes forward.",
examples: &[ examples: &[
"Ctrl+Shift+F, the default, which few other programs use." "Ctrl+Shift+F, the default, which few other programs use."
], ],
@ -567,7 +565,7 @@ pub static REBUILDING_FTS: Tip = Tip {
body: "A setting changed that affects which text is searchable, so \ body: "A setting changed that affects which text is searchable, so \
QuickSearch is modifying the search index. On a \ QuickSearch is modifying the search index. On a \
large index this can take several minutes, and the progress may look \ large index this can take several minutes, and the progress may look \
frozen while it runs. You can still search during this time!", frozen while it runs. You can still search during this time.",
examples: &[], examples: &[],
caution: None, caution: None,
}; };
@ -603,15 +601,12 @@ pub static REMOVE_ROOT: Tip = Tip {
pub static ROOT_WORKERS: Tip = Tip { pub static ROOT_WORKERS: Tip = Tip {
title: "Workers", title: "Workers",
body: "How many folders QuickSearch explores at once inside this indexed \ body: "How many folders QuickSearch explores at once inside this indexed \
folder. More of them finish sooner on storage that answers many \ folder. More workers finish sooner on faster storage or network shares, \
requests at a time, which network drives do especially well, but \ but they compete for the same disk and could slow your system.\n\n\
they compete for the same disk.\n\n\
auto reads 4 on local storage and 16 on a network mount. Takes \ auto reads 4 on local storage and 16 on a network mount. Takes \
effect on the next indexing run.", effect on the next indexing run.",
examples: &[ examples: &[
"auto unless indexing is slower than you would expect.", "You can override to 1-2 to keep indexing low-priority.",
"16 or more for a network share that is slow to answer each request.",
"2 to keep indexing out of the way on an older machine.",
], ],
caution: None, caution: None,
}; };
@ -624,8 +619,7 @@ pub static ROOT_COUNTS: Tip = Tip {
files nothing could read text from (images, videos, archives, \ files nothing could read text from (images, videos, archives, \
program binaries) plus anything the extension whitelist \ program binaries) plus anything the extension whitelist \
excludes.\n\n\ excludes.\n\n\
Both are counted when an indexing run finishes, so they do not \ Both are updated when an indexing run finishes.",
move as live updates apply single changes in between.",
examples: &[], examples: &[],
caution: None, caution: None,
}; };
@ -634,17 +628,15 @@ pub static ROOT_COUNTS: Tip = Tip {
pub static EXT_WHITELIST: Tip = Tip { pub static EXT_WHITELIST: Tip = Tip {
title: "Full-text extensions whitelist", title: "Full-text extensions whitelist",
body: "Which kinds of file QuickSearch is allowed to read the text out \ body: "Which kinds of files QuickSearch is allowed to read the text out \
of. It limits contents only: every file is still indexed and still \ of. It limits contents only: every file is still indexed and still \
found by its name and its path, whatever you put here. A file left \ found by its name and its path, whatever you put here. A file left \
off the list simply cannot be found by the words inside it.\n\n\ off the list cannot be found by the text inside of it.\n\n\
Empty, the default, means every kind QuickSearch understands. To \ Empty, the default, means every kind of file QuickSearch understands. To \
narrow it, enter one extension per line, the leading dot optional. \ narrow it, enter one extension per line, the leading dot optional. \
A non-empty list also leaves out files with no extension at all, \ A non-empty list also leaves out files with no extension at all, \
such as Makefile or README, unless you add the line (none). \ such as Makefile or README, unless you add the line (none). \
Anything after a # is a comment.\n\n\ Anything after a # is a comment.",
Narrowing the list discards the text it now excludes; widening it \
reads those files again.",
examples: &[ examples: &[
"txt, md and pdf to keep the stored text small and focused on documents, \ "txt, md and pdf to keep the stored text small and focused on documents, \
with everything else still findable by name.", with everything else still findable by name.",

View file

@ -67,10 +67,13 @@ impl Gate {
if !crate::activate::take_pending() { if !crate::activate::take_pending() {
return; return;
} }
// The token travels with the press: on Wayland it is the
// compositor's permission for the raise below to take focus.
let token = crate::activate::take_token();
if let Gate::Running(app) = self { if let Gate::Running(app) = self {
app.activate_search(ctx); app.activate_search(ctx);
} }
crate::activate::raise(ctx, frame); crate::activate::raise(ctx, frame, token.as_deref());
} }
} }

View file

@ -3,7 +3,12 @@
# duplicates.png, query-highlight.png — all landing in packaging/captures/ # duplicates.png, query-highlight.png — all landing in packaging/captures/
# (gitignored). Needs a graphical session (X11 or Wayland — screenshots and # (gitignored). Needs a graphical session (X11 or Wayland — screenshots and
# video frames are read back from the app's own framebuffer, so the display # video frames are read back from the app's own framebuffer, so the display
# server does not matter) and ffmpeg with libx264rgb and libvpx-vp9. # server does not matter) and ffmpeg with libx264rgb, libvpx-vp9 and libwebp.
#
# Alongside those masters it emits the derivatives the website actually ships:
# -700.webp thumbnails, -1400.webp for the lightbox, and a -poster.webp first
# frame per clip. The website only needs the .webm and .webp files; the PNG
# masters stay here.
# #
# The app is built with the `capture` feature and drives itself through # The app is built with the `capture` feature and drives itself through
# packaging/capture-scenario.txt (see crates/quicksearch-gui/src/capture.rs # packaging/capture-scenario.txt (see crates/quicksearch-gui/src/capture.rs
@ -33,7 +38,7 @@ done
# The encoder list is captured first: `grep -q` closing the pipe early would # The encoder list is captured first: `grep -q` closing the pipe early would
# make ffmpeg exit on SIGPIPE, which pipefail reports as failure. # make ffmpeg exit on SIGPIPE, which pipefail reports as failure.
encoders="$(ffmpeg -hide_banner -encoders 2>/dev/null)" encoders="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
for enc in libx264rgb libvpx-vp9; do for enc in libx264rgb libvpx-vp9 libwebp; do
grep -q "$enc" <<< "$encoders" \ grep -q "$enc" <<< "$encoders" \
|| { echo "ffmpeg lacks the $enc encoder" >&2; exit 1; } || { echo "ffmpeg lacks the $enc encoder" >&2; exit 1; }
done done
@ -132,6 +137,41 @@ for clip in manage-indexing search; do
done done
mv "$work/tmp/query-highlight.png" "$work/tmp/duplicates.png" "$out/" mv "$work/tmp/query-highlight.png" "$work/tmp/duplicates.png" "$out/"
# --- web derivatives --------------------------------------------------------
# The PNG masters are ~1400x980 and 350-700 KB each, but the website renders
# them in a ~320 CSS px column (~350 px full-width on a phone). Shipping the
# masters was costing about a megabyte for two thumbnails, so the site loads
# -700.webp inline and only fetches -1400.webp when the lightbox opens.
# QS_WEBP_Q is the inline-thumbnail quality; the lightbox copy is encoded
# higher because it is the one people zoom into to read UI text.
webp_q="${QS_WEBP_Q:-82}"
webp_q_full="${QS_WEBP_Q_FULL:-90}"
for shot in duplicates query-highlight; do
ffmpeg -y -hide_banner -loglevel warning -i "$out/$shot.png" \
-vf "scale=700:-2:flags=lanczos" \
-c:v libwebp -quality "$webp_q" -compression_level 6 \
"$out/$shot-700.webp"
ffmpeg -y -hide_banner -loglevel warning -i "$out/$shot.png" \
-c:v libwebp -quality "$webp_q_full" -compression_level 6 \
"$out/$shot-1400.webp"
done
# Poster frames: with the videos deferred behind an IntersectionObserver these
# are what the page paints on first load, so each is sized to the slot it
# actually renders in — search is the full-width hero, manage-indexing is one
# column of a three-up grid. The frame is taken from 80% through rather than
# frame 0: every clip opens on an empty results list, which is the worst
# possible still to hold on the hero while the video is still downloading.
for spec in "search:1200" "manage-indexing:700"; do
clip="${spec%:*}"; pw="${spec#*:}"
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 \
"$out/$clip.webm")
at=$(awk -v d="$dur" 'BEGIN{printf "%.2f", d*0.8}')
ffmpeg -y -hide_banner -loglevel warning -ss "$at" -i "$out/$clip.webm" \
-frames:v 1 -vf "scale=$pw:-2:flags=lanczos" \
-c:v libwebp -quality "$webp_q" -compression_level 6 \
"$out/$clip-poster.webp"
done
# --- verify ----------------------------------------------------------------- # --- verify -----------------------------------------------------------------
fail=0 fail=0
for f in "$out/search.webm" "$out/manage-indexing.webm"; do for f in "$out/search.webm" "$out/manage-indexing.webm"; do
@ -148,10 +188,20 @@ for f in "$out/duplicates.png" "$out/query-highlight.png"; do
-show_entries stream=width,height -of csv=p=0 "$f") -show_entries stream=width,height -of csv=p=0 "$f")
echo "OK: $f (${dims})" echo "OK: $f (${dims})"
done done
# The derivatives are what the website ships, so a silent encode failure here
# would ship broken <img> tags rather than merely large ones.
for f in "$out/duplicates-700.webp" "$out/duplicates-1400.webp" \
"$out/query-highlight-700.webp" "$out/query-highlight-1400.webp" \
"$out/search-poster.webp" "$out/manage-indexing-poster.webp"; do
[ -s "$f" ] || { echo "FAIL: missing or empty $f" >&2; fail=1; continue; }
dims=$(ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height -of csv=p=0 "$f")
echo "OK: $f (${dims}, $(stat -c%s "$f") bytes)"
done
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]
# $work is kept for post-mortems (app stderr is on this terminal; the scratch # $work is kept for post-mortems (app stderr is on this terminal; the scratch
# index and raw .cap.mkv files live there) and recreated fresh next run. # index and raw .cap.mkv files live there) and recreated fresh next run.
echo echo
echo "Assets:" echo "Assets (the website needs the .webm and .webp files; PNGs stay here):"
ls -l "$out"/*.webm "$out"/*.png ls -l "$out"/*.webm "$out"/*.png "$out"/*.webp

View file

@ -59,8 +59,10 @@ binding runs so that a key can start it as well.
The activation reaches the running instance over a unix socket in The activation reaches the running instance over a unix socket in
.IR $XDG_RUNTIME_DIR , .IR $XDG_RUNTIME_DIR ,
named after the configuration file; when nothing answers, this process becomes named after the configuration file; when nothing answers, this process becomes
the application. On Wayland an already\-open window cannot be raised by another the application. On Wayland the window comes forward when the desktop supplied
process, so it is highlighted in the task bar instead of coming to the front. an activation token for the launch (a binding made with the Settings tab's
set\-up button does); run by hand with no token, the window is highlighted in
the task bar instead.
.TP .TP
.B \-\-fuzzy .B \-\-fuzzy
Also run the fuzzy filename and full\-text passes, which tolerate spelling Also run the fuzzy filename and full\-text passes, which tolerate spelling