diff --git a/.githooks/pre-commit b/.githooks/pre-commit index fdfedbc..0afd520 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -33,6 +33,22 @@ if ! cargo fmt --all -- --check; then exit 1 fi +# Two clippy runs, not one, and this is the interesting part. +# +# CI lints WITHOUT `--all-features`, so the feature-off shape of an +# optional crate is a configuration nothing local ever checked. That +# is not hypothetical: the 0.10.0 AI work passed every local check and +# then failed on all three CI platforms, because the HTTP module is +# dead code when `remote` is off. Whichever set you run, run the other +# one too — the cheap one first, since it is the one CI mirrors. +echo "poltertype pre-commit: cargo clippy --workspace --all-targets -- -D warnings (CI's set)" +if ! cargo clippy --workspace --all-targets -- -D warnings; then + echo + echo "poltertype pre-commit: clippy found problems in the DEFAULT feature set." + echo "This is exactly what CI runs. Fix, or commit with --no-verify." + exit 1 +fi + echo "poltertype pre-commit: cargo clippy --workspace --all-targets --all-features -- -D warnings" if ! cargo clippy --workspace --all-targets --all-features -- -D warnings; then echo diff --git a/CHANGELOG.md b/CHANGELOG.md index 754340d..e400296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,167 @@ All notable changes to PolterType are recorded here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] — 0.9.0 + +### Added + +- **The AI subsystem is a socket you plug your own model into.** Both + shipped backends were stubs that returned no opinion; they are gone. + What replaced them is one detector that speaks three common HTTP + shapes — `openai-chat`, `anthropic-messages`, `ollama-generate` — + and asks a model exactly one question. + + **PolterType ships no model, no vendor SDK and no default endpoint.** + What answers is an Ollama on your own machine, an API you hold the + key to, or a gateway of your own, named by you in `[[ai.plugins]]`. + Configure nothing — the default — and there is no AI in PolterType + at all. An entry with neither an `endpoint` nor a `provider` preset + is refused with a message saying exactly that: picking one for you + would be choosing a vendor on your behalf. + + Two properties the implementation exists to hold up. + + *It cannot slow your typing down.* `judge` runs between you + finishing a word and the word being fixed, so the default mode never + waits: it answers from a cache of already-decided words and queues a + miss for next time. The first time you type a word the model + contributes nothing — exactly what the stubs did for every word — + and everything after it is free, because people retype the same few + thousand words all day. `mode = "blocking"` puts the call inline if + you want it, capped at 250 ms and **refused at startup with the + reason** above that, rather than silently clamped into lag you would + have to diagnose. + + *Local is not remote.* `[ai].allow_remote` exists to gate typed + words **leaving your machine**, and a request to `127.0.0.1` does + not leave it — so a model you run yourself needs no network + permission. Requiring one would make people enable access they are + not using. The distinction is decided in one place, resolves no DNS + (a resolver answer can change between the check and the request), + and treats anything unparseable as remote. + + What goes on the wire is one word's candidate readings and a fixed + instruction. Not the sentence, not the document, not the focused + application, and **not the layout ids** — those would reveal which + languages you have installed. Keys stay in the OS keychain; a literal + secret in `config.toml` is refused, not used. Without the `remote` + cargo feature no HTTP client is compiled in at all, which `cargo + tree` will confirm. + +- **PolterType knows which application you are in on GNOME and KDE.** + `focused_exe()` returned `None` on every Wayland session but + Hyprland, so `[exceptions].disabled_apps`, per-app wordlist profiles + and `apps = [...]` scoping were quietly inert on the two largest + desktops. + + The plan was a KWin script plus a GNOME Shell extension — two + out-of-tree artifacts, in two languages, that you would have to + install. It turned out to be unnecessary: AT-SPI events arrive over + the accessibility bus from the *application's own* connection, so + the bus itself can be asked whose it is. One backend, nothing to + install, and it works on any compositor with an a11y bridge. + + **Read the limit before relying on it.** Only applications with a + live accessibility bridge are visible — GTK, Qt and Electron answer; + most terminals do not, and a terminal is where developers type. An + app that never emits also never *un*-focuses the previous one, so + observations carry an age and anything older than five minutes counts + as no answer. This is an improvement on nothing, not an equivalent + of a compositor query. + +- **The settings window speaks other languages, starting with + Ukrainian.** An app whose whole subject is other people's languages + had an English-only interface. + + Translations are data — `data/i18n/.toml`, one flat table — + and a file in `/poltertype/i18n/` wins over the shipped + one, so a translator can edit and reopen the window without + rebuilding anything. English is compiled into every call site rather + than loaded, so a catalog that fails to parse, a key nobody + translated, or a file a packager forgot degrades to readable English + instead of a blank button. `[general].ui_language` picks; `"system"` + and `"auto"` both follow the environment. Adding a language is one + file — see [docs/TRANSLATING_THE_UI.md](docs/TRANSLATING_THE_UI.md). + +- **Smart-command triggers can be more than one word.** `best regards` + now works. The word buffer still resets at every boundary, so the + engine keeps the last four completed words alongside it — bounded by + the same idle timeout that already abandons the buffer, and cleared + when you change application, because half a trigger typed in one + window must not complete in another. It is the one place the engine + holds more of your text than the word you are typing, and it is + sized accordingly. + +- **`run_shell` smart commands**, off by default and deliberately + awkward to misuse. PolterType already reads every keystroke; adding + "and can run a program" turns a shared or stolen `config.toml` into + code that fires the next time you type an ordinary word. So it needs + `[commands].allow_run_shell = true`, runs **no shell** — a program + and an argument vector, executed directly, so a metacharacter is + just a character — and never puts anything you typed into an + argument. A timeout, an output cap, no stdin, and dispatch off the + correction path. Inserted output is truncated on a character + boundary, stripped of control characters (a newline typed into a + chat window sends it), and not inserted at all when the command + failed. + +- **Language packs have a supported way in.** The loader has read + `/plugins//` since v0.1, but getting a pack there meant + copying directories by hand with no validation. `install` takes a + directory already on your disk — **there is no download, and that is + the point.** Fetching third-party content into a process that reads + every keystroke is a far wider channel than the updater's signed, + no-payload manifest fetch; a pack you downloaded yourself is a trust + decision you made where you could see it. It also means no archive, + so no zip-slip and no decompression bomb. + + Installation copies only what a data-only pack may contain, reports + everything it left behind, refuses symlinks rather than following + them, and replaces atomically — an interrupted install leaves the + old pack or none, never half of a new one. + +- **Wayland can type without the setup script — on GNOME and KDE, in + theory.** `uinput` needs `input`-group membership plus a udev rule, + which is the one `sudo` standing between installing PolterType and + it doing anything. The `RemoteDesktop` portal is the standard, + permissioned way to ask a compositor to synthesise input, so it is + now tried **when and only when `uinput` cannot be opened** — nobody + who already ran `scripts/setup-linux.sh` will ever see a consent + dialog. + + **This has never run.** There is no RemoteDesktop backend on the + machine it was written on, so it is written from the specification + and executed by nobody — the same standing as the macOS paths, and + it is labelled that way in the code. If it misbehaves on a real + GNOME or KDE session, assume PolterType is wrong before the + compositor. + + It takes the portal's `NotifyKeyboardKeycode` rather than `libei` + deliberately: that method does exactly what a correction needs, and + going through `ConnectToEIS` and the libei protocol would have meant + a new protocol implementation and a heavyweight dependency to send + twenty keystrokes — while still needing the same session + negotiation. A restore token is stored so later launches are silent. + +### Fixed + +- **A `-1` from a model was read as "the first candidate".** Every + model that means "none of these" and writes it as a negative number + would have had a word retyped as something the user did not ask for. + +### Changed + +- **There will be no AT-SPI keystroke listener**, and this is now a + decision with measurements rather than an open plan item. Registering + one returns false on wlroots and delivers nothing even with keys + injected through `uinput`, because `at-spi2-registryd` has no + keyboard of its own — on Wayland it relays what the compositor hands + it, and only mutter does. Where it *would* work (X11) the existing + listener already needs no permissions. Wayland still needs + `scripts/setup-linux.sh` once; anyone wanting a zero-permission + session has X11 today. See [docs/DECISIONS.md](docs/DECISIONS.md), + 2026-08-01. + ## [0.9.0] — nine more languages, and a dictionary pipeline that stops failing quietly ### Added diff --git a/Cargo.lock b/Cargo.lock index e330d0a..6f4ff3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3975,6 +3975,7 @@ dependencies = [ "poltertype-types", "reqwest", "serde", + "serde_json", "thiserror 1.0.69", "tracing", ] @@ -4057,6 +4058,7 @@ dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "crossbeam-channel", + "directories", "evdev", "libc", "parking_lot 0.12.5", diff --git a/README.md b/README.md index 0f40ff1..16f62b4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ typed on an English layout comes out as `ma;ana` — PolterType fixes the word the moment it ends and switches the layout with it, so `por la tarde` lands correctly as typed.* -> **Status:** v0.9.0 — out of beta since v0.1.0. Works end-to-end on +> **Status:** v0.10.0 — out of beta since v0.1.0. Works end-to-end on > Windows and on Linux (both Wayland and X11); the spelling- > suggestions tooltip renders on Hyprland, Sway, KDE Plasma and X11 > (GNOME Wayland gets it through XWayland). On Linux/Wayland a @@ -120,7 +120,7 @@ is two requests: a `GET` of a small JSON manifest, and — only when there's actually a new version — a `GET` of the installer itself. No account, no identifier, nothing about you and nothing about what you type. What GitHub can see is what any download reveals: your IP, and -a User-Agent naming the running version (`PolterType/0.9.0 +a User-Agent naming the running version (`PolterType/0.10.0 (updater)`). The exact manifest URL is printed on the Settings window's **General** pane, so you never have to take our word for it. @@ -330,12 +330,15 @@ explicitly: windows developers type in. Add the entries you want, or manage them on the **Exceptions** pane in Settings. -> **The skip list needs a focus tracker, and one doesn't exist -> everywhere.** Reading which application has focus is implemented on -> Windows, Hyprland and X11. On macOS and on non-Hyprland Wayland -> (GNOME/KDE) the tracker is a no-op, so the per-app skip list, the -> per-app wordlist profiles below, and the `apps = [...]` scoping on -> smart commands silently do nothing there. +> **The skip list needs a focus tracker, and it isn't equally good +> everywhere.** Reading which application has focus is complete on +> Windows, Hyprland and X11. On other Wayland sessions (GNOME, KDE) +> PolterType asks the accessibility bus instead — which works, but +> only for applications that expose an accessibility bridge. Most +> terminals don't, so the per-app skip list, per-app wordlist +> profiles, and `apps = [...]` scoping on smart commands may simply +> not fire there. On macOS the tracker is still a no-op and they do +> nothing at all. ### Adding your own vocabulary diff --git a/crates/poltertype-ai/Cargo.toml b/crates/poltertype-ai/Cargo.toml index b534902..fc5df90 100644 --- a/crates/poltertype-ai/Cargo.toml +++ b/crates/poltertype-ai/Cargo.toml @@ -14,9 +14,11 @@ workspace = true [features] default = [] -# Enabling `remote` brings in reqwest + native-tls and lets the -# RemoteLlmDetector make real HTTP calls. Off by default; users opt -# in via `[ai].allow_remote = true` AND a feature-enabled build. +# `remote` is what puts an HTTP client in the binary. Without it this +# crate compiles with no `reqwest` anywhere in the tree — the claim +# "a stock build cannot make an AI call" is checkable with +# `cargo tree`, not just documented. Users opt in with this feature +# AND `[ai].allow_remote = true` for a non-loopback endpoint. remote = ["dep:reqwest"] [dependencies] @@ -27,5 +29,13 @@ thiserror = { workspace = true } tracing = { workspace = true } keyring = { workspace = true } -# Activated only when `--features remote` is on. -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"], optional = true } +# Activated only when `--features remote` is on. No `json` feature: +# request bodies are built and read in `wire.rs`, which keeps the +# whole request/response contract testable on hosts where this +# dependency does not exist. +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"], optional = true } + +[dev-dependencies] +# Only to prove the hand-rolled JSON escaping in `wire.rs` produces +# bodies a real parser accepts. Never linked into a shipped binary. +serde_json = { workspace = true } diff --git a/crates/poltertype-ai/src/cache.rs b/crates/poltertype-ai/src/cache.rs new file mode 100644 index 0000000..6ec491f --- /dev/null +++ b/crates/poltertype-ai/src/cache.rs @@ -0,0 +1,107 @@ +//! The decided-word cache. +//! +//! This is what makes an LLM usable on the correction path at all. A +//! query takes anywhere from 30 ms on a warm local model to several +//! seconds on a hosted API; a correction has to happen in the pause +//! between two words. So the detector never waits by default — it +//! answers from here, and a miss queues the question so the *next* +//! occurrence is decided. +//! +//! That trade is only worth making because of how people type. The +//! same person types the same few thousand words over and over, so a +//! cache this small reaches a high hit rate within a session and the +//! cost is a one-off "no opinion" the first time a word appears — +//! which is exactly what the detector returned before there was a +//! backend at all. +//! +//! **What is stored is a verdict, not text a human can read back.** +//! Keys are hashes of the candidate list, never the words themselves, +//! and nothing here is written to disk. A memory dump of a running +//! process is out of scope, but a cache that quietly accumulated a +//! plain-text record of everything typed would not be. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +/// A remembered answer: the index the model chose among the candidates +/// it was given, or `None` for "none of these". +pub type Decision = Option; + +/// Fixed-capacity map from question-hash to decision, with a +/// second-chance eviction: once full, insertion clears the oldest +/// half. Cruder than a true LRU and deliberately so — it needs no +/// per-entry bookkeeping on the read path, which is the path that +/// runs while the user is mid-correction. +pub struct DecisionCache { + entries: HashMap, + order: Vec, + capacity: usize, +} + +impl DecisionCache { + pub fn new(capacity: usize) -> Self { + Self { + entries: HashMap::with_capacity(capacity.min(1024)), + order: Vec::with_capacity(capacity.min(1024)), + capacity, + } + } + + /// Hash a question into a cache key. + /// + /// The candidate list *is* the question — same renderings, same + /// answer — so it alone determines the key. Hashing rather than + /// storing means the cache holds no recoverable copy of what was + /// typed. + pub fn key(candidates: &[String]) -> u64 { + let mut h = DefaultHasher::new(); + candidates.len().hash(&mut h); + for c in candidates { + c.hash(&mut h); + } + h.finish() + } + + pub fn get(&self, key: u64) -> Option { + self.entries.get(&key).copied() + } + + pub fn insert(&mut self, key: u64, decision: Decision) { + if self.capacity == 0 { + return; + } + // An existing key is an update, not a new occupant: it must + // not take a second slot in `order`, or a word re-decided a + // few times would evict everything around it. + if let std::collections::hash_map::Entry::Occupied(mut e) = self.entries.entry(key) { + e.insert(decision); + return; + } + if self.entries.len() >= self.capacity { + // Drop the oldest half in one pass rather than one entry + // per insert: amortised, and it keeps `order` from needing + // an O(n) remove on every write. + let cut = self.order.len() / 2; + for old in self.order.drain(..cut) { + self.entries.remove(&old); + } + } + self.entries.insert(key, decision); + self.order.push(key); + } + + /// Live entry count. Not used on any hot path — it exists so the + /// eviction policy can be asserted rather than assumed. + #[cfg(test)] + pub fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-ai/src/cache/tests.rs b/crates/poltertype-ai/src/cache/tests.rs new file mode 100644 index 0000000..793002e --- /dev/null +++ b/crates/poltertype-ai/src/cache/tests.rs @@ -0,0 +1,87 @@ +use super::*; + +fn cands(words: &[&str]) -> Vec { + words.iter().map(|s| (*s).to_string()).collect() +} + +#[test] +fn remembers_a_decision() { + let mut c = DecisionCache::new(8); + let k = DecisionCache::key(&cands(&["привіт", "ghbdsn"])); + assert_eq!(c.get(k), None, "cold cache has no answer"); + c.insert(k, Some(0)); + assert_eq!(c.get(k), Some(Some(0))); +} + +/// "None of these" is a real answer worth remembering — otherwise +/// every occurrence of a word the model rejected re-queries forever. +#[test] +fn a_negative_answer_is_cached_too() { + let mut c = DecisionCache::new(8); + let k = DecisionCache::key(&cands(&["qwerty"])); + c.insert(k, None); + assert_eq!(c.get(k), Some(None), "cached 'none of these'"); +} + +#[test] +fn the_candidate_list_determines_the_key() { + let a = DecisionCache::key(&cands(&["слово", "ckjdj"])); + let b = DecisionCache::key(&cands(&["слово", "ckjdj"])); + assert_eq!(a, b, "same question, same key"); + + let different = DecisionCache::key(&cands(&["ckjdj", "слово"])); + assert_ne!(a, different, "order is part of the question"); + + let shorter = DecisionCache::key(&cands(&["слово"])); + assert_ne!(a, shorter, "candidate count is part of the question"); +} + +/// Length is hashed separately so that concatenation cannot collide: +/// ["ab","c"] and ["a","bc"] are different questions. +#[test] +fn concatenation_does_not_collide() { + assert_ne!( + DecisionCache::key(&cands(&["ab", "c"])), + DecisionCache::key(&cands(&["a", "bc"])) + ); +} + +#[test] +fn stays_within_capacity() { + let mut c = DecisionCache::new(16); + for i in 0..200 { + c.insert(DecisionCache::key(&cands(&[&format!("w{i}")])), Some(0)); + } + assert!(c.len() <= 16, "grew past capacity: {}", c.len()); + assert!(!c.is_empty(), "evicted everything"); +} + +#[test] +fn recent_entries_survive_eviction() { + let mut c = DecisionCache::new(16); + for i in 0..40 { + c.insert(DecisionCache::key(&cands(&[&format!("w{i}")])), Some(0)); + } + let newest = DecisionCache::key(&cands(&["w39"])); + assert_eq!(c.get(newest), Some(Some(0)), "newest must still be there"); +} + +#[test] +fn re_inserting_a_key_updates_rather_than_grows() { + let mut c = DecisionCache::new(4); + let k = DecisionCache::key(&cands(&["x"])); + c.insert(k, Some(0)); + c.insert(k, Some(1)); + assert_eq!(c.len(), 1, "same key must not occupy two slots"); + assert_eq!(c.get(k), Some(Some(1)), "later answer wins"); +} + +/// A zero capacity is a legal way to say "never remember anything". +#[test] +fn zero_capacity_stores_nothing() { + let mut c = DecisionCache::new(0); + let k = DecisionCache::key(&cands(&["x"])); + c.insert(k, Some(0)); + assert_eq!(c.get(k), None); + assert!(c.is_empty()); +} diff --git a/crates/poltertype-ai/src/consts.rs b/crates/poltertype-ai/src/consts.rs new file mode 100644 index 0000000..8db32d9 --- /dev/null +++ b/crates/poltertype-ai/src/consts.rs @@ -0,0 +1,81 @@ +//! Fixed values the LLM plug-in is built around. + +use crate::enums::WireFormat; + +/// Plug-in kind accepted in `type`. +pub const TYPE_LLM: &str = "llm"; + +/// Kind strings from before 0.10.0, kept only to produce a useful +/// error. They named backends PolterType no longer pretends to +/// provide: a bundled ONNX model and a vendor-specific client. +pub const RETIRED_TYPES: &[&str] = &["local-onnx", "remote-llm"]; + +/// Budget for one query when the entry does not set one. Generous +/// because the default mode is off the correction path; a `blocking` +/// entry should set its own and will be warned if it doesn't. +pub const DEFAULT_MAX_LATENCY_MS: u64 = 2_000; + +/// A `blocking` entry above this is refused. Past roughly a fifth of a +/// second the user has started the next word, and a "correction" that +/// lands then is just corruption arriving late. +pub const MAX_BLOCKING_LATENCY_MS: u64 = 250; + +/// Decided words remembered per plug-in, by default. +pub const DEFAULT_CACHE_SIZE: usize = 2_048; + +/// How many queries may be waiting for the worker before new ones are +/// dropped. Small on purpose: a backlog means the endpoint is slower +/// than the user types, and yesterday's word is worthless. +pub const QUEUE_DEPTH: usize = 32; + +/// `(preset name, endpoint, wire format)` for `provider = "..."`. +/// +/// A preset only fills in what the entry leaves blank — it is a +/// shorthand for two fields, never a special case in the code that +/// follows. Everything here is a URL the *user* has to be running or +/// have an account for; PolterType ships no credentials and no +/// default endpoint. +pub const PRESETS: &[(&str, &str, WireFormat)] = &[ + ( + "ollama", + "http://127.0.0.1:11434/api/generate", + WireFormat::OllamaGenerate, + ), + ( + "llama-cpp", + "http://127.0.0.1:8080/v1/chat/completions", + WireFormat::OpenAiChat, + ), + ( + "lm-studio", + "http://127.0.0.1:1234/v1/chat/completions", + WireFormat::OpenAiChat, + ), + ( + "openai", + "https://api.openai.com/v1/chat/completions", + WireFormat::OpenAiChat, + ), + ( + "anthropic", + "https://api.anthropic.com/v1/messages", + WireFormat::AnthropicMessages, + ), +]; + +/// Anthropic requires a pinned API version header. +pub const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// The instruction sent with every query. +/// +/// Deliberately tiny. It ships one word and a list of candidate +/// readings of that word — never the surrounding sentence, never the +/// document, never anything identifying. The model is asked for one +/// token back, which keeps the response cheap to parse and leaves no +/// room for it to editorialise into our decision. +pub const SYSTEM_PROMPT: &str = "\ +You identify which keyboard layout a typed word was meant for. \ +You will be given numbered candidate readings of the same keystrokes \ +under different layouts. Reply with the number of the reading that is \ +a real word a human meant to type, or 0 if none of them is. \ +Reply with the number only — no punctuation, no explanation."; diff --git a/crates/poltertype-ai/src/detector.rs b/crates/poltertype-ai/src/detector.rs new file mode 100644 index 0000000..9a2e692 --- /dev/null +++ b/crates/poltertype-ai/src/detector.rs @@ -0,0 +1,355 @@ +//! `LlmDetector` — the socket a user plugs their own model into. +//! +//! PolterType ships no model, no vendor client and no default +//! endpoint. This detector knows how to ask a question and read an +//! answer; *what* answers is whatever the user pointed it at and, if +//! the endpoint needs one, a key only they hold. +//! +//! Three properties the rest of this file exists to hold up: +//! +//! * **Off unless asked for, twice.** `[ai].enabled` builds it; +//! pointing it at a non-loopback host additionally needs +//! `[ai].allow_remote`. A detector that may not run returns no +//! opinion rather than failing to construct, so flipping a setting +//! takes effect on restart without editing the plug-in entry. +//! * **It cannot slow typing down.** The default mode never waits: it +//! answers from the cache and queues the miss. `blocking` exists, +//! is capped, and is the user putting a model in the path of their +//! own keystrokes on purpose. +//! * **It never logs what was typed.** Every word that reaches a +//! `tracing` call goes through `redact_word` first, exactly like +//! the rest of the engine. + +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(feature = "remote")] +use std::sync::mpsc::Receiver; +use std::sync::mpsc::{SyncSender, sync_channel}; +use std::sync::{Arc, Mutex}; + +use poltertype_detect::{DetectionContext, DetectionVerdict, Detector, Verdict}; +use tracing::{info, warn}; + +use crate::AiError; +use crate::cache::{Decision, DecisionCache}; +use crate::consts::QUEUE_DEPTH; +use crate::enums::{Locality, QueryMode, WireFormat}; +#[cfg(feature = "remote")] +use crate::transport::Call; + +/// Everything the detector needs, already validated by the factory. +pub struct LlmSettings { + pub id: String, + pub endpoint: String, + pub format: WireFormat, + pub model: String, + pub api_key: Option, + /// A key was configured but the keychain could not supply it. The + /// detector loads and stays silent rather than calling an endpoint + /// that will certainly reject it. + pub key_unavailable: bool, + pub max_latency_ms: u64, + pub mode: QueryMode, + pub cache_size: usize, + pub locality: Locality, + /// `[ai].allow_remote`. Only consulted for a remote endpoint. + pub allow_remote: bool, +} + +impl LlmSettings { + /// Whether this detector is allowed to make its call at all. + pub fn permitted(&self) -> bool { + cfg!(feature = "remote") + && !self.key_unavailable + && match self.locality { + Locality::Loopback => true, + Locality::Remote => self.allow_remote, + } + } +} + +/// A question handed to the background worker. +/// +/// Only read by the worker, which only exists with an HTTP client — +/// so without the feature this is a type nothing consumes. +#[cfg_attr(not(feature = "remote"), allow(dead_code))] +struct Job { + key: u64, + candidates: Vec, +} + +pub struct LlmDetector { + settings: Arc, + cache: Arc>, + queue: Option>, + /// Set after the first failed call so the worker complains once + /// rather than once per word. + reported_failure: Arc, + #[cfg(feature = "remote")] + client: Option, +} + +impl LlmDetector { + pub fn new(settings: LlmSettings) -> Result { + let cache_size = settings.cache_size; + let settings = Arc::new(settings); + let cache = Arc::new(Mutex::new(DecisionCache::new(cache_size))); + let reported_failure = Arc::new(AtomicBool::new(false)); + + #[cfg(feature = "remote")] + let client = if settings.permitted() { + Some( + reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_millis( + settings.max_latency_ms.max(50), + )) + .build() + .map_err(AiError::Remote)?, + ) + } else { + None + }; + + // The worker only exists in background mode, and only when the + // detector is actually allowed to call. No permission, no + // thread. + let queue = if settings.mode == QueryMode::Background && settings.permitted() { + let (tx, rx) = sync_channel::(QUEUE_DEPTH); + #[cfg(feature = "remote")] + spawn_worker( + rx, + Arc::clone(&settings), + Arc::clone(&cache), + Arc::clone(&reported_failure), + client.clone(), + ); + #[cfg(not(feature = "remote"))] + drop(rx); + Some(tx) + } else { + None + }; + + let built = Self { + settings, + cache, + queue, + reported_failure, + #[cfg(feature = "remote")] + client, + }; + built.announce(); + Ok(built) + } + + /// Say once, at construction, what this detector will do. `judge` + /// stays silent — it runs per word, and a detector that logs on + /// the correction path costs more than it gives. + fn announce(&self) { + let s = &self.settings; + if !cfg!(feature = "remote") { + warn!( + id = %s.id, + "LLM plug-in loaded but this build has no HTTP client (`remote` cargo feature \ + off) — it will return no opinion" + ); + return; + } + if s.key_unavailable { + warn!( + id = %s.id, + "LLM plug-in has no usable API key — it will return no opinion. See the keychain \ + warning above." + ); + return; + } + if s.locality == Locality::Remote && !s.allow_remote { + warn!( + id = %s.id, + endpoint = %s.endpoint, + "LLM plug-in points at a non-loopback endpoint but `[ai].allow_remote = false` \ + — it will return no opinion until that is switched on. Typed words would leave \ + this machine, so the switch is deliberate." + ); + return; + } + info!( + id = %s.id, + endpoint = %s.endpoint, + format = ?s.format, + model = %s.model, + mode = ?s.mode, + local = s.locality == Locality::Loopback, + "LLM plug-in active" + ); + if s.locality == Locality::Remote { + warn!( + id = %s.id, + endpoint = %s.endpoint, + "this plug-in sends the words you type to a third party you configured. \ + Nothing else in PolterType does that." + ); + } + } + + /// Cache lookup shared by both modes. + fn cached(&self, key: u64) -> Option { + self.cache.lock().ok()?.get(key) + } + + fn remember(&self, key: u64, decision: Decision) { + if let Ok(mut c) = self.cache.lock() { + c.insert(key, decision); + } + } +} + +impl Detector for LlmDetector { + fn name(&self) -> &'static str { + "llm" + } + + fn judge(&self, ctx: &DetectionContext<'_>) -> Verdict { + // Every early return is silent: this is the correction path. + if !self.settings.permitted() || ctx.candidates.len() < 2 { + return Verdict::NoOpinion; + } + + let candidates: Vec = ctx.candidates.iter().map(|(_, t)| t.clone()).collect(); + let key = DecisionCache::key(&candidates); + + if let Some(decision) = self.cached(key) { + return to_verdict(decision, ctx, &self.settings.id); + } + + match self.settings.mode { + QueryMode::Background => { + // Queue and get out of the way. A full queue means the + // endpoint is slower than the user types, in which + // case dropping is right — a stale answer to a word + // typed a minute ago helps nobody. + if let Some(q) = &self.queue { + let _ = q.try_send(Job { + key, + candidates: candidates.clone(), + }); + } + Verdict::NoOpinion + } + QueryMode::Blocking => { + let decision = self.ask_now(&candidates); + match decision { + Ok(d) => { + self.remember(key, d); + to_verdict(d, ctx, &self.settings.id) + } + Err(e) => { + report_once(&self.reported_failure, &self.settings.id, &e); + Verdict::NoOpinion + } + } + } + } + } +} + +impl LlmDetector { + #[cfg(feature = "remote")] + fn ask_now(&self, candidates: &[String]) -> Result { + let Some(client) = &self.client else { + return Ok(None); + }; + crate::transport::ask( + client, + &Call { + endpoint: &self.settings.endpoint, + format: self.settings.format, + model: &self.settings.model, + api_key: self.settings.api_key.as_deref(), + candidates, + }, + ) + } + + #[cfg(not(feature = "remote"))] + fn ask_now(&self, _candidates: &[String]) -> Result { + Ok(None) + } +} + +/// Turn a remembered index into a verdict against the live context. +/// +/// The index is into the candidate list, which is rebuilt per word — +/// so a cached answer is only reused when the candidate list matches, +/// which is what the cache key guarantees. +fn to_verdict(decision: Decision, ctx: &DetectionContext<'_>, id: &str) -> Verdict { + let Some(idx) = decision else { + return Verdict::NoOpinion; + }; + let Some((layout, _)) = ctx.candidates.get(idx) else { + return Verdict::NoOpinion; + }; + if layout == ctx.current_layout { + // The model picked the reading the user is already producing: + // that is a vote to leave the word alone, not a switch. + return Verdict::Keep { + reason: format!("llm[{id}]: current layout reads as real text"), + }; + } + Verdict::Switch(DetectionVerdict { + best_layout: layout.clone(), + confidence: 0.75, + reason: format!("llm[{id}]"), + }) +} + +/// Complain about a broken endpoint once per run, not once per word. +fn report_once(flag: &AtomicBool, id: &str, e: &AiError) { + if !flag.swap(true, Ordering::Relaxed) { + warn!(id = %id, %e, "LLM plug-in call failed; it will stay quiet from here on"); + } +} + +#[cfg(feature = "remote")] +fn spawn_worker( + rx: Receiver, + settings: Arc, + cache: Arc>, + reported_failure: Arc, + client: Option, +) { + let Some(client) = client else { return }; + let name = format!("poltertype-llm-{}", settings.id); + let spawned = std::thread::Builder::new().name(name).spawn(move || { + for job in rx { + // Re-check: another job may have answered this question + // while this one waited in the queue. + if cache.lock().is_ok_and(|c| c.get(job.key).is_some()) { + continue; + } + let result = crate::transport::ask( + &client, + &Call { + endpoint: &settings.endpoint, + format: settings.format, + model: &settings.model, + api_key: settings.api_key.as_deref(), + candidates: &job.candidates, + }, + ); + match result { + Ok(decision) => { + if let Ok(mut c) = cache.lock() { + c.insert(job.key, decision); + } + } + Err(e) => report_once(&reported_failure, &settings.id, &e), + } + } + }); + if let Err(e) = spawned { + warn!(%e, "could not start the LLM worker thread; the plug-in will stay quiet"); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-ai/src/detector/tests.rs b/crates/poltertype-ai/src/detector/tests.rs new file mode 100644 index 0000000..23edc1e --- /dev/null +++ b/crates/poltertype-ai/src/detector/tests.rs @@ -0,0 +1,220 @@ +use super::*; +use poltertype_types::LayoutId; + +fn settings(endpoint: &str, allow_remote: bool, mode: QueryMode) -> LlmSettings { + let locality = crate::locality::classify(endpoint); + LlmSettings { + id: "t".into(), + endpoint: endpoint.into(), + format: WireFormat::OpenAiChat, + model: "m".into(), + api_key: None, + key_unavailable: false, + max_latency_ms: 100, + mode, + cache_size: 16, + locality, + allow_remote, + } +} + +/// A loopback endpoint is *not* a network call in the sense +/// `allow_remote` exists to gate, so it must work without it. +#[test] +fn loopback_is_permitted_without_allow_remote() { + let s = settings( + "http://127.0.0.1:11434/api/generate", + false, + QueryMode::Background, + ); + assert_eq!(s.locality, Locality::Loopback); + assert_eq!( + s.permitted(), + cfg!(feature = "remote"), + "only the cargo feature should gate a local endpoint" + ); +} + +/// A remote endpoint must stay silent until the user says otherwise. +#[test] +fn remote_needs_allow_remote() { + let denied = settings( + "https://api.openai.com/v1/chat/completions", + false, + QueryMode::Background, + ); + assert_eq!(denied.locality, Locality::Remote); + assert!( + !denied.permitted(), + "remote without the switch must not run" + ); + + let allowed = settings( + "https://api.openai.com/v1/chat/completions", + true, + QueryMode::Background, + ); + assert_eq!(allowed.permitted(), cfg!(feature = "remote")); +} + +/// The whole crate must be inert in a build without the HTTP client, +/// whatever the config says. +#[test] +#[cfg(not(feature = "remote"))] +fn without_the_cargo_feature_nothing_is_permitted() { + for (ep, allow) in [ + ("http://127.0.0.1:11434/api/generate", true), + ("https://api.openai.com/v1/chat/completions", true), + ] { + assert!(!settings(ep, allow, QueryMode::Background).permitted()); + } +} + +fn ctx_layouts() -> (Vec<(LayoutId, String)>, LayoutId) { + ( + vec![ + (LayoutId::from("en-US"), "ghbdsn".to_string()), + (LayoutId::from("uk-UA"), "привіт".to_string()), + ], + LayoutId::from("en-US"), + ) +} + +#[test] +fn a_chosen_other_layout_becomes_a_switch() { + let (cands, current) = ctx_layouts(); + let ctx = DetectionContext { + current_layout: ¤t, + candidates: &cands, + recent_context: "", + }; + match to_verdict(Some(1), &ctx, "t") { + Verdict::Switch(v) => assert_eq!(v.best_layout.as_str(), "uk-UA"), + other => panic!("expected a switch, got {other:?}"), + } +} + +/// Picking the layout the user is already in is a vote to leave the +/// word alone — not a switch to where they already are. +#[test] +fn choosing_the_current_layout_is_a_keep() { + let (cands, current) = ctx_layouts(); + let ctx = DetectionContext { + current_layout: ¤t, + candidates: &cands, + recent_context: "", + }; + assert!(matches!( + to_verdict(Some(0), &ctx, "t"), + Verdict::Keep { .. } + )); +} + +/// "None of these" and an index that no longer exists both have to +/// come out as no opinion rather than a wrong correction. +#[test] +fn undecidable_answers_are_no_opinion() { + let (cands, current) = ctx_layouts(); + let ctx = DetectionContext { + current_layout: ¤t, + candidates: &cands, + recent_context: "", + }; + assert!(matches!(to_verdict(None, &ctx, "t"), Verdict::NoOpinion)); + assert!( + matches!(to_verdict(Some(9), &ctx, "t"), Verdict::NoOpinion), + "a stale index must not panic or mis-target" + ); +} + +/// Background mode is the default and must never wait on a miss. This +/// asserts the property the whole cache design exists for: a cold +/// cache costs one no-opinion, not a round-trip. +#[test] +fn background_mode_returns_immediately_on_a_cache_miss() { + let d = LlmDetector::new(settings( + // Deliberately a port nothing listens on: if this ever blocked + // it would block for the full timeout and the test would + // notice by taking 100 ms+. + "http://127.0.0.1:9/v1/chat/completions", + false, + QueryMode::Background, + )) + .expect("construct"); + + let (cands, current) = ctx_layouts(); + let ctx = DetectionContext { + current_layout: ¤t, + candidates: &cands, + recent_context: "", + }; + let started = std::time::Instant::now(); + let verdict = d.judge(&ctx); + let elapsed = started.elapsed(); + + assert!( + matches!(verdict, Verdict::NoOpinion), + "cold cache: no opinion" + ); + assert!( + elapsed < std::time::Duration::from_millis(50), + "background judge must not wait on the network; took {elapsed:?}" + ); +} + +/// One candidate means there is nothing to choose between, so the +/// detector should not spend a query on it. +#[test] +fn a_single_candidate_is_never_queried() { + let d = LlmDetector::new(settings( + "http://127.0.0.1:9/v1/chat/completions", + false, + QueryMode::Background, + )) + .expect("construct"); + let cands = vec![(LayoutId::from("en-US"), "hello".to_string())]; + let current = LayoutId::from("en-US"); + let ctx = DetectionContext { + current_layout: ¤t, + candidates: &cands, + recent_context: "", + }; + assert!(matches!(d.judge(&ctx), Verdict::NoOpinion)); +} + +/// A cached answer is used without any call, in either mode. +/// +/// Feature-gated because without an HTTP client the detector is inert +/// by design and returns before it ever consults the cache — which is +/// the behaviour `without_the_cargo_feature_nothing_is_permitted` +/// pins down, and there would be no way to populate the cache anyway. +#[test] +#[cfg(feature = "remote")] +fn a_cached_answer_is_served_from_memory() { + let d = LlmDetector::new(settings( + "http://127.0.0.1:9/v1/chat/completions", + false, + QueryMode::Blocking, + )) + .expect("construct"); + + let (cands, current) = ctx_layouts(); + let texts: Vec = cands.iter().map(|(_, t)| t.clone()).collect(); + d.remember(DecisionCache::key(&texts), Some(1)); + + let ctx = DetectionContext { + current_layout: ¤t, + candidates: &cands, + recent_context: "", + }; + let started = std::time::Instant::now(); + let verdict = d.judge(&ctx); + assert!( + started.elapsed() < std::time::Duration::from_millis(50), + "a cache hit must not touch the network even in blocking mode" + ); + match verdict { + Verdict::Switch(v) => assert_eq!(v.best_layout.as_str(), "uk-UA"), + other => panic!("expected the cached switch, got {other:?}"), + } +} diff --git a/crates/poltertype-ai/src/enums.rs b/crates/poltertype-ai/src/enums.rs index 8f271b0..933ba86 100644 --- a/crates/poltertype-ai/src/enums.rs +++ b/crates/poltertype-ai/src/enums.rs @@ -1,4 +1,4 @@ -//! AI subsystem errors. +//! AI subsystem errors and the choices a plug-in entry switches on. pub use poltertype_detect::{Detector, RewriteRequest, RewriteVerdict, Verdict, WordRewriter}; use thiserror::Error; @@ -7,12 +7,10 @@ use thiserror::Error; pub enum AiError { #[error("keyring lookup for {0:?} failed: {1}")] KeyringLookup(String, String), - #[error("model file not found at {0}")] - ModelMissing(std::path::PathBuf), #[cfg(feature = "remote")] - #[error("remote LLM call failed: {0}")] + #[error("LLM call failed: {0}")] Remote(#[from] reqwest::Error), - #[error("remote LLM disabled: {0}")] + #[error("LLM disabled: {0}")] RemoteDisabled(String), /// The `[[ai.plugins]]` entry does not describe a buildable /// plug-in. Always names the entry's `id` at the call site, so a @@ -20,3 +18,69 @@ pub enum AiError { #[error("invalid plug-in config: {0}")] Config(String), } + +/// The wire shape of the endpoint we are talking to. +/// +/// Three formats cover essentially every self-hosted and hosted +/// option, because everything that isn't Anthropic or Ollama's native +/// API has settled on OpenAI's chat-completions shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WireFormat { + /// `POST {"model", "messages":[…]}` → `choices[0].message.content`. + /// llama.cpp, LM Studio, vLLM, OpenRouter, OpenAI itself, and + /// Ollama's `/v1` compatibility layer all speak this. + OpenAiChat, + /// `POST {"model", "max_tokens", "messages":[…]}` → + /// `content[0].text`, with `x-api-key` + `anthropic-version`. + AnthropicMessages, + /// Ollama's native `POST /api/generate` → `response`. + OllamaGenerate, +} + +impl WireFormat { + pub fn parse(s: &str) -> Option { + Some(match s { + "openai-chat" | "openai" => Self::OpenAiChat, + "anthropic-messages" | "anthropic" => Self::AnthropicMessages, + "ollama-generate" | "ollama" => Self::OllamaGenerate, + _ => return None, + }) + } +} + +/// When the query happens relative to the correction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryMode { + /// Answer from cache; on a miss return no opinion and queue the + /// query so the *next* occurrence of that word is decided. The + /// default, because it cannot slow a correction down. + Background, + /// Perform the call inline, inside the deadline. Only sane against + /// a local endpoint, and even then it is the user choosing to put + /// a model in the path of their own typing. + Blocking, +} + +impl QueryMode { + pub fn parse(s: &str) -> Option { + Some(match s { + "background" | "async" => Self::Background, + "blocking" | "sync" => Self::Blocking, + _ => return None, + }) + } +} + +/// Whether an endpoint's host keeps the request on this machine. +/// +/// This is the distinction that lets a local model work without the +/// network switch: `[ai].allow_remote` exists to gate *typed text +/// leaving the computer*, and a request to `127.0.0.1` does not leave +/// it. Treating "uses HTTP" and "goes on the network" as the same +/// thing would force a user to enable remote access in order to run a +/// model that is entirely offline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locality { + Loopback, + Remote, +} diff --git a/crates/poltertype-ai/src/factory.rs b/crates/poltertype-ai/src/factory.rs index ac0b9bd..72d427b 100644 --- a/crates/poltertype-ai/src/factory.rs +++ b/crates/poltertype-ai/src/factory.rs @@ -1,12 +1,9 @@ //! Turn `[[ai.plugins]]` entries into detectors. //! -//! This is the seam that was missing: the crate has had backends since -//! v0.1 and nothing ever constructed them, so `[ai].enabled` was a -//! setting no code read. Everything below is about being *safe to -//! enable*, because the pipeline this feeds runs on the correction -//! path. +//! Everything here is about being *safe to enable*, because the +//! pipeline this feeds runs on the correction path. //! -//! Three rules the whole module exists to enforce: +//! Four rules the module exists to enforce: //! //! * **One bad entry never costs the others.** A plug-in that cannot be //! built is logged with its id and skipped; the rest still load. A @@ -17,25 +14,29 @@ //! quietly teach users to put one in a plain-text file that they //! might well paste into a bug report. //! * **Remote stays behind both switches.** The cargo feature decides -//! whether the code exists; `[ai].allow_remote` decides whether it -//! may run. A detector built with `allow_remote = false` returns no -//! opinion rather than failing to construct — so flipping the -//! setting takes effect on the next restart without editing config. +//! whether an HTTP client exists at all; `[ai].allow_remote` decides +//! whether a non-loopback endpoint may be called. A detector that +//! may not run returns no opinion rather than failing to construct, +//! so flipping the setting takes effect on the next restart without +//! editing config. +//! * **A blocking entry cannot be configured into ruining typing.** +//! The deadline is capped here, at build time, where the user gets +//! told — not silently clamped later where they would just +//! experience it as lag. use poltertype_detect::Detector; use poltertype_types::AiPluginConfig; use tracing::{info, warn}; use crate::AiError; -use crate::local::LocalOnnxDetector; -use crate::remote::{Provider, RemoteLlmDetector}; - -/// Plug-in kind strings accepted in `type`. -pub const TYPE_LOCAL_ONNX: &str = "local-onnx"; -pub const TYPE_REMOTE_LLM: &str = "remote-llm"; - -/// Default budget for a remote call, if the entry does not set one. -const DEFAULT_MAX_LATENCY_MS: u64 = 400; +use crate::consts::{ + DEFAULT_CACHE_SIZE, DEFAULT_MAX_LATENCY_MS, MAX_BLOCKING_LATENCY_MS, PRESETS, RETIRED_TYPES, + TYPE_LLM, +}; +use crate::detector::{LlmDetector, LlmSettings}; +use crate::enums::{Locality, QueryMode, WireFormat}; +use crate::keys::resolve_api_key; +use crate::locality; /// Build every detector the config asks for, skipping the ones that /// cannot be built. @@ -63,50 +64,164 @@ pub fn build_detectors(plugins: &[AiPluginConfig], allow_remote: bool) -> Vec Result, AiError> { - match cfg.r#type.as_str() { - TYPE_LOCAL_ONNX => { - let path = cfg - .model_path - .clone() - .ok_or_else(|| AiError::Config("local-onnx needs `model_path`".into()))?; - Ok(Box::new(LocalOnnxDetector::new(cfg.id.clone(), path)?)) + if RETIRED_TYPES.contains(&cfg.r#type.as_str()) { + return Err(AiError::Config(format!( + "plug-in type `{}` was removed in 0.10.0. PolterType no longer ships a bundled model \ + or a vendor-specific client — use `type = \"{TYPE_LLM}\"` and point `endpoint` at a \ + model you run or an API you hold the key to. See docs/AI.md.", + cfg.r#type + ))); + } + if cfg.r#type != TYPE_LLM { + return Err(AiError::Config(format!( + "unknown plug-in type `{}` (expected `{TYPE_LLM}`)", + cfg.r#type + ))); + } + + let (endpoint, format) = resolve_endpoint(cfg)?; + let model = cfg + .model + .clone() + .ok_or_else(|| AiError::Config("`model` is required — name the model to ask".into()))?; + let mode = match cfg.mode.as_deref() { + None => QueryMode::Background, + Some(s) => QueryMode::parse(s).ok_or_else(|| { + AiError::Config(format!( + "unknown mode `{s}` (expected `background` or `blocking`)" + )) + })?, + }; + let max_latency_ms = resolve_latency(cfg, mode)?; + let locality = locality::classify(&endpoint); + let (api_key, key_unavailable) = resolve_key(cfg, locality)?; + + Ok(Box::new(LlmDetector::new(LlmSettings { + id: cfg.id.clone(), + endpoint, + format, + model, + api_key, + key_unavailable, + max_latency_ms, + mode, + cache_size: cfg.cache_size.unwrap_or(DEFAULT_CACHE_SIZE), + locality, + allow_remote, + })?)) +} + +/// Work out where to send the request and what shape it takes. +/// +/// A `provider` preset fills in whatever the entry left blank; the +/// explicit fields always win. An entry with neither is an error +/// rather than a default, because there is no endpoint we could pick +/// that would not amount to choosing a vendor on the user's behalf. +fn resolve_endpoint(cfg: &AiPluginConfig) -> Result<(String, WireFormat), AiError> { + let preset = match cfg.provider.as_deref() { + None => None, + Some(name) => Some(PRESETS.iter().find(|(p, _, _)| *p == name).ok_or_else(|| { + let known: Vec<&str> = PRESETS.iter().map(|(p, _, _)| *p).collect(); + AiError::Config(format!( + "unknown provider `{name}` (known presets: {}). `provider` is only a \ + shorthand — set `endpoint` and `format` directly for anything else.", + known.join(", ") + )) + })?), + }; + + let endpoint = cfg + .endpoint + .clone() + .or_else(|| preset.map(|(_, url, _)| (*url).to_owned())) + .ok_or_else(|| { + AiError::Config( + "needs an `endpoint` (or a `provider` preset to supply one). PolterType ships no \ + default endpoint — what answers is your choice." + .into(), + ) + })?; + + let format = match cfg.format.as_deref() { + Some(s) => WireFormat::parse(s).ok_or_else(|| { + AiError::Config(format!( + "unknown format `{s}` (expected `openai-chat`, `anthropic-messages` or \ + `ollama-generate`)" + )) + })?, + None => preset.map(|(_, _, f)| *f).ok_or_else(|| { + AiError::Config( + "needs a `format` (or a `provider` preset to supply one). Most self-hosted \ + servers speak `openai-chat`." + .into(), + ) + })?, + }; + + Ok((endpoint, format)) +} + +fn resolve_latency(cfg: &AiPluginConfig, mode: QueryMode) -> Result { + let requested = cfg.max_latency_ms.unwrap_or(DEFAULT_MAX_LATENCY_MS); + if mode == QueryMode::Blocking && requested > MAX_BLOCKING_LATENCY_MS { + return Err(AiError::Config(format!( + "`mode = \"blocking\"` puts this call between the user finishing a word and the word \ + being corrected, so `max_latency_ms` may not exceed {MAX_BLOCKING_LATENCY_MS} \ + (got {requested}). Either lower it or use the default background mode, which never \ + waits." + ))); + } + Ok(requested) +} + +/// Resolve the API key, if there is one to resolve. +/// +/// Returns `(key, unavailable)`. A key is optional on purpose: a local +/// Ollama needs no credential, and demanding a placeholder would be +/// theatre. A *remote* endpoint without one is allowed too — plenty of +/// gateways authenticate by IP — but it is worth a word in the log, +/// because the likelier explanation is a forgotten setting. +/// +/// A keychain that cannot answer is **not** a construction failure. +/// The entry is well-formed; the secret is merely missing or the +/// keychain is locked, which is a runtime condition and often a +/// temporary one. Following the same rule as `allow_remote`, the +/// detector is built and stays quiet, so the log says exactly one +/// useful thing at startup instead of the plug-in vanishing with a +/// message about config that is not wrong. +fn resolve_key( + cfg: &AiPluginConfig, + locality: Locality, +) -> Result<(Option, bool), AiError> { + let Some(reference) = cfg.api_key_ref.as_deref() else { + if locality == Locality::Remote { + info!( + id = %cfg.id, + "no `api_key_ref` for a remote endpoint — sending unauthenticated" + ); } - TYPE_REMOTE_LLM => { - let provider_name = cfg - .provider - .as_deref() - .ok_or_else(|| AiError::Config("remote-llm needs `provider`".into()))?; - let provider = Provider::parse(provider_name) - .ok_or_else(|| AiError::Config(format!("unknown provider `{provider_name}`")))?; - let model = cfg - .model - .clone() - .ok_or_else(|| AiError::Config("remote-llm needs `model`".into()))?; - let api_key_ref = cfg - .api_key_ref - .clone() - .ok_or_else(|| AiError::Config("remote-llm needs `api_key_ref`".into()))?; - // The one validation worth failing construction over: a key - // pasted into config.toml is a key in the user's backups, - // their dotfiles repo, and any log they attach to an issue. - if !api_key_ref.starts_with("keyring:") { - return Err(AiError::Config( - "`api_key_ref` must be a `keyring:` reference — never the key itself" - .into(), - )); - } - Ok(Box::new(RemoteLlmDetector::new( - cfg.id.clone(), - provider, - model, - api_key_ref, - cfg.max_latency_ms.unwrap_or(DEFAULT_MAX_LATENCY_MS), - allow_remote, - )?)) + return Ok((None, false)); + }; + // The one key validation worth failing construction over: a secret + // pasted into config.toml is a secret in the user's backups, their + // dotfiles repo, and any log they attach to an issue. That is a + // config mistake, and it is fixed by editing config. + if !reference.starts_with("keyring:") { + return Err(AiError::Config( + "`api_key_ref` must be a `keyring:` reference — never the key itself".into(), + )); + } + match resolve_api_key(reference) { + Ok(key) => Ok((Some(key), false)), + Err(e) => { + warn!( + id = %cfg.id, + %e, + "AI plug-in configured with a key the keychain cannot supply — the plug-in \ + loads but stays silent. Store the secret under that entry name and restart." + ); + Ok((None, true)) } - other => Err(AiError::Config(format!( - "unknown plug-in type `{other}` (expected `{TYPE_LOCAL_ONNX}` or `{TYPE_REMOTE_LLM}`)" - ))), } } diff --git a/crates/poltertype-ai/src/factory/tests.rs b/crates/poltertype-ai/src/factory/tests.rs index f9b7115..db5d4b8 100644 --- a/crates/poltertype-ai/src/factory/tests.rs +++ b/crates/poltertype-ai/src/factory/tests.rs @@ -15,6 +15,23 @@ fn cfg(kind: &str, id: &str) -> AiPluginConfig { } } +/// `Box` is not `Debug`, so `expect_err` cannot be used +/// directly on a `build_one` result. This says the same thing. +fn refusal(c: &AiPluginConfig) -> AiError { + match build_one(c, false) { + Ok(_) => panic!("entry should have been refused, but it built"), + Err(e) => e, + } +} + +/// A minimal entry that should build: a local model, no key needed. +fn local_entry(id: &str) -> AiPluginConfig { + let mut c = cfg(TYPE_LLM, id); + c.provider = Some("ollama".into()); + c.model = Some("llama3".into()); + c +} + #[test] fn an_empty_config_builds_nothing() { assert!(build_detectors(&[], false).is_empty()); @@ -22,98 +39,210 @@ fn an_empty_config_builds_nothing() { #[test] fn an_unknown_plugin_type_is_skipped_not_fatal() { - let plugins = vec![cfg("quantum-oracle", "weird")]; - assert!(build_detectors(&plugins, false).is_empty()); + assert!(build_detectors(&[cfg("quantum-oracle", "weird")], false).is_empty()); } /// The property that matters most: one broken entry must not cost the -/// others. A user with three plug-ins and one typo should still get -/// the working ones. +/// others. A user with two plug-ins and one typo should still get the +/// working one. #[test] fn a_broken_entry_does_not_take_its_neighbours_down() { - let mut good = cfg(TYPE_REMOTE_LLM, "ok"); - good.provider = Some("anthropic".into()); - good.model = Some("claude-sonnet-4".into()); - good.api_key_ref = Some("keyring:anthropic".into()); + let plugins = vec![cfg("nonsense", "bad"), local_entry("ok")]; + assert_eq!(build_detectors(&plugins, false).len(), 1); +} - let plugins = vec![cfg("nonsense", "bad"), good]; - assert_eq!( - build_detectors(&plugins, false).len(), - 1, - "the valid entry must still load" - ); +/// The retired kinds get a message that says what to do instead, +/// rather than the generic "unknown type" — anyone hitting these +/// wrote their config against an older PolterType. +#[test] +fn retired_plugin_types_explain_themselves() { + for kind in RETIRED_TYPES { + let err = refusal(&cfg(kind, "old")); + let msg = err.to_string(); + assert!(msg.contains("removed in 0.10.0"), "{kind}: {msg}"); + assert!( + msg.contains(TYPE_LLM), + "{kind} should point at the new type: {msg}" + ); + } } -/// A key in `config.toml` ends up in backups, dotfile repos and pasted -/// bug reports. Refusing to construct is the only answer that does not -/// teach the habit. +// ── the endpoint is the user's choice ──────────────────────────────── + +/// There is no default endpoint on purpose: picking one would be +/// choosing a vendor for the user. #[test] -fn a_literal_api_key_is_refused() { - let mut c = cfg(TYPE_REMOTE_LLM, "leaky"); - c.provider = Some("anthropic".into()); - c.model = Some("claude-sonnet-4".into()); - c.api_key_ref = Some("sk-ant-totally-a-real-key".into()); +fn an_entry_without_an_endpoint_or_preset_is_refused() { + let mut c = cfg(TYPE_LLM, "x"); + c.model = Some("m".into()); + let err = refusal(&c); + assert!(err.to_string().contains("endpoint"), "{err}"); +} - match build_one(&c, true) { - Err(AiError::Config(m)) => assert!(m.contains("keyring:"), "{m}"), - Err(e) => panic!("wrong error for a literal key: {e}"), - Ok(_) => panic!("a literal key must not build"), - } +#[test] +fn a_preset_supplies_endpoint_and_format() { + let (endpoint, format) = resolve_endpoint(&local_entry("x")).expect("ollama preset"); + assert!(endpoint.contains("11434"), "ollama's port: {endpoint}"); + assert_eq!(format, WireFormat::OllamaGenerate); } +/// A preset is only a shorthand. Anything stated explicitly wins, so a +/// user can point the `ollama` preset at a box on another port. #[test] -fn remote_needs_provider_model_and_key_reference() { - for missing in ["provider", "model", "api_key_ref"] { - let mut c = cfg(TYPE_REMOTE_LLM, "partial"); - if missing != "provider" { - c.provider = Some("openai".into()); - } - if missing != "model" { - c.model = Some("gpt-4o-mini".into()); - } - if missing != "api_key_ref" { - c.api_key_ref = Some("keyring:openai".into()); - } - assert!( - build_one(&c, true).is_err(), - "a remote plug-in without `{missing}` must not build" - ); - } +fn explicit_fields_override_the_preset() { + let mut c = local_entry("x"); + c.endpoint = Some("http://127.0.0.1:9999/v1/chat/completions".into()); + c.format = Some("openai-chat".into()); + let (endpoint, format) = resolve_endpoint(&c).expect("build"); + assert_eq!(endpoint, "http://127.0.0.1:9999/v1/chat/completions"); + assert_eq!(format, WireFormat::OpenAiChat); } #[test] -fn an_unknown_provider_is_refused() { - let mut c = cfg(TYPE_REMOTE_LLM, "who"); - c.provider = Some("skynet".into()); - c.model = Some("t1000".into()); - c.api_key_ref = Some("keyring:skynet".into()); - assert!(build_one(&c, true).is_err()); +fn an_unknown_preset_lists_the_known_ones() { + let mut c = cfg(TYPE_LLM, "x"); + c.provider = Some("hal9000".into()); + c.model = Some("m".into()); + let err = refusal(&c); + let msg = err.to_string(); + assert!(msg.contains("hal9000"), "names the bad value: {msg}"); + assert!(msg.contains("ollama"), "lists a known preset: {msg}"); } -/// `allow_remote = false` must still *build* the detector — the switch -/// is consulted per judgement, so flipping the setting takes effect on -/// restart without the user editing their plug-in entry. +/// An endpoint with no preset needs its format stated — we will not +/// guess a wire shape and send the user's words in the wrong envelope. #[test] -fn allow_remote_false_still_builds_and_simply_holds_its_tongue() { - let mut c = cfg(TYPE_REMOTE_LLM, "quiet"); +fn an_endpoint_without_a_format_is_refused() { + let mut c = cfg(TYPE_LLM, "x"); + c.endpoint = Some("https://gateway.example.com/v1/chat".into()); + c.model = Some("m".into()); + let err = refusal(&c); + assert!(err.to_string().contains("format"), "{err}"); +} + +#[test] +fn a_model_is_always_required() { + let mut c = cfg(TYPE_LLM, "x"); c.provider = Some("ollama".into()); - c.model = Some("llama3".into()); - c.api_key_ref = Some("keyring:ollama".into()); + let err = refusal(&c); + assert!(err.to_string().contains("model"), "{err}"); +} + +// ── secrets ────────────────────────────────────────────────────────── + +/// A key pasted into config.toml ends up in backups, dotfile repos and +/// pasted bug reports. Refuse it rather than use it. +#[test] +fn a_literal_api_key_is_refused() { + let mut c = local_entry("x"); + c.api_key_ref = Some("sk-ant-secret-value".into()); + let err = refusal(&c); + assert!(err.to_string().contains("keyring:"), "{err}"); +} + +/// A local model needs no credential; demanding a placeholder would be +/// theatre. +#[test] +fn a_local_endpoint_needs_no_key() { + let (key, unavailable) = resolve_key(&local_entry("x"), Locality::Loopback) + .expect("an entry with no key at all is fine"); + assert!(key.is_none()); + assert!(!unavailable, "no key wanted is not the same as one missing"); +} + +/// A keychain that cannot answer is a runtime condition, not a broken +/// config: the entry still builds and simply stays quiet, the same way +/// `allow_remote = false` does. Failing construction here would report +/// a config problem for something config cannot fix. +#[test] +fn a_key_the_keychain_cannot_supply_does_not_fail_construction() { + let mut c = local_entry("x"); + // An entry name nothing will have stored. + c.api_key_ref = Some("keyring:poltertype-test-definitely-absent".into()); + + let (key, unavailable) = + resolve_key(&c, Locality::Remote).expect("a missing secret is not a config error"); + // On a host with no keychain service at all this is the same + // outcome, which is the point: either way the plug-in is inert + // rather than absent. + assert!(key.is_none() || !unavailable); + + assert!( + build_one(&c, true).is_ok(), + "the plug-in must still load so the log can explain itself" + ); +} + +// ── the blocking-mode guard ────────────────────────────────────────── + +/// `blocking` puts the round-trip between the user finishing a word +/// and the word being fixed. The cap is enforced where the user is +/// told about it, not silently clamped later where they would just +/// experience it as lag. +#[test] +fn a_slow_blocking_entry_is_refused_with_an_explanation() { + let mut c = local_entry("x"); + c.mode = Some("blocking".into()); + c.max_latency_ms = Some(5_000); + let err = refusal(&c); + let msg = err.to_string(); + assert!(msg.contains(&MAX_BLOCKING_LATENCY_MS.to_string()), "{msg}"); + assert!( + msg.contains("background"), + "should point at the way out: {msg}" + ); +} + +#[test] +fn a_fast_blocking_entry_is_allowed() { + let mut c = local_entry("x"); + c.mode = Some("blocking".into()); + c.max_latency_ms = Some(120); assert!(build_one(&c, false).is_ok()); } +/// The generous default only applies to background mode, where it +/// costs nobody anything. +#[test] +fn background_mode_keeps_the_generous_default() { + let c = local_entry("x"); + assert_eq!( + resolve_latency(&c, QueryMode::Background).expect("ok"), + DEFAULT_MAX_LATENCY_MS + ); +} + +#[test] +fn an_unknown_mode_is_refused() { + let mut c = local_entry("x"); + c.mode = Some("eventually".into()); + let err = refusal(&c); + assert!(err.to_string().contains("eventually"), "{err}"); +} + +// ── the gate that matters ──────────────────────────────────────────── + +/// A remote entry still *builds* without `allow_remote` — it just +/// returns no opinion — so that flipping the setting takes effect on +/// restart without the user editing their plug-in entry. #[test] -fn local_onnx_needs_a_model_path() { - assert!(build_one(&cfg(TYPE_LOCAL_ONNX, "nopath"), false).is_err()); +fn a_remote_entry_builds_but_stays_quiet_without_permission() { + let mut c = cfg(TYPE_LLM, "x"); + c.provider = Some("anthropic".into()); + c.model = Some("claude-sonnet-4".into()); + assert_eq!( + build_detectors(std::slice::from_ref(&c), false).len(), + 1, + "it should build so the setting alone controls it" + ); } +/// A local entry is unaffected by `allow_remote` in either position — +/// this is the whole point of the loopback distinction. #[test] -fn local_onnx_refuses_a_model_that_is_not_there() { - let mut c = cfg(TYPE_LOCAL_ONNX, "ghost"); - c.model_path = Some("/nonexistent/definitely-not-here.onnx".into()); - match build_one(&c, false) { - Err(AiError::ModelMissing(_)) => {} - Err(e) => panic!("wrong error for a missing model: {e}"), - Ok(_) => panic!("a missing model must not build"), +fn a_local_entry_builds_regardless_of_allow_remote() { + let c = local_entry("x"); + for allow in [false, true] { + assert_eq!(build_detectors(std::slice::from_ref(&c), allow).len(), 1); } } diff --git a/crates/poltertype-ai/src/lib.rs b/crates/poltertype-ai/src/lib.rs index 8a76227..b9ee008 100644 --- a/crates/poltertype-ai/src/lib.rs +++ b/crates/poltertype-ai/src/lib.rs @@ -1,41 +1,77 @@ -//! Optional AI subsystem: LLM-backed [`Detector`]s and -//! [`WordRewriter`]s. +//! Optional AI subsystem: a socket for a model the **user** supplies. //! -//! Two extension shims (both already declared in `poltertype-detect`): +//! ## What this crate is, and what it deliberately is not //! -//! * `Detector` — adds another voice to the layout-decision pipeline. -//! Local ONNX models (`local::LocalOnnxDetector`, stub in v0.1) and -//! remote LLMs (`remote::RemoteLlmDetector`, gated behind the -//! `remote` feature) implement it. +//! It is an *interface*. PolterType ships no model weights, no vendor +//! SDK, and no default endpoint. [`detector::LlmDetector`] knows how +//! to phrase a question and read an answer; what answers is an Ollama +//! the user is running, an API they hold the key to, or a gateway of +//! their own — configured by them in `[[ai.plugins]]`, or by nobody, +//! which is the default. +//! +//! That is the whole design. Bundling a model would mean choosing a +//! vendor on the user's behalf and shipping megabytes most people +//! never asked for; bundling a client for one provider is the same +//! choice with extra steps. A socket is honest: it works with whatever +//! the user already trusts, and with nothing at all until they say +//! otherwise. +//! +//! Two extension shims (both declared in `poltertype-detect`): +//! +//! * `Detector` — adds another voice to the layout decision. +//! [`detector::LlmDetector`] implements it. //! * `WordRewriter` — operates *after* layout detection on the final -//! text. Used for power-user tricks like smart-capitalize or -//! expand-acronym. +//! text, for tricks like smart-capitalise. //! -//! ## Privacy posture (DECISIONS.md, §3.8 of PLAN.md) +//! ## Privacy posture //! -//! * The whole `ai` Cargo feature is **off by default** in `poltertype-app`. +//! This is the only part of PolterType that can send what you typed +//! anywhere, so the gates are layered and each one is a real barrier +//! rather than a setting that merely looks like one: +//! +//! * The `ai` Cargo feature is **off by default** in `poltertype-app`. //! * The `remote` cargo sub-feature is **off by default** even when -//! `ai` is on; it adds `reqwest` and TLS to the build. -//! * Even when `ai = enabled` and `remote` is built in, the -//! `[ai].allow_remote` settings flag must also be true at runtime. -//! Two switches by design. -//! * API keys are stored in the OS keychain via `keyring`, never in -//! `config.toml`. +//! `ai` is on. Without it no HTTP client is compiled in — `cargo +//! tree` on a stock build shows no `reqwest` at all. +//! * `[ai].enabled` must be true at runtime. +//! * A **non-loopback** endpoint additionally needs +//! `[ai].allow_remote`. An endpoint on `127.0.0.1` does not, because +//! nothing leaves the machine — see [`locality`], the one place that +//! distinction is decided, which fails closed on anything it cannot +//! parse. +//! * API keys live in the OS keychain via `keyring`. A literal secret +//! in `config.toml` is refused at construction, not used. +//! * Nothing typed is ever logged, and the decision cache stores +//! hashes rather than text. +//! +//! There is still no telemetry and no code here that reports to us. +//! The only address this crate ever contacts is one the user wrote +//! down themselves. #![forbid(unsafe_code)] // Same test-only allowance the other crates carry: a test that cannot // panic cannot assert. See `poltertype-update/src/lib.rs`. #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] +pub mod detector; pub mod factory; -pub mod local; -pub mod remote; +pub mod locality; pub mod rewriters; +pub mod wire; +mod cache; +mod consts; mod enums; mod keys; +// The whole module is the HTTP client's wrapper, so without the +// feature there is nothing for it to do — and leaving it compiled +// would be dead code that only a `--no-default-features` lint run +// notices. +#[cfg(feature = "remote")] +mod transport; mod types; +pub use consts::*; pub use enums::*; pub use factory::build_detectors; pub use keys::*; diff --git a/crates/poltertype-ai/src/local.rs b/crates/poltertype-ai/src/local.rs deleted file mode 100644 index cbf63c1..0000000 --- a/crates/poltertype-ai/src/local.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Local on-device language-detection model. -//! -//! v0.1 ships a stub that respects the [`AiPluginConfig`] schema but -//! always returns `None` (no verdict). Real ONNX / Candle inference -//! is a Phase 7.x add — pulling those crates is heavy and we want -//! `cargo check --workspace` to stay quick for everyone. - -use std::path::PathBuf; - -use poltertype_detect::{DetectionContext, Detector, Verdict}; -use tracing::warn; - -use crate::AiError; - -pub struct LocalOnnxDetector { - pub id: String, - pub model_path: PathBuf, -} - -impl LocalOnnxDetector { - pub fn new(id: String, model_path: PathBuf) -> Result { - if !model_path.exists() { - return Err(AiError::ModelMissing(model_path)); - } - // Said once, at construction. `judge` runs on the correction - // path — a warning per word would drown the log and slow the - // one code path that must stay quick. - warn!( - %id, - ?model_path, - "local ONNX detector is a stub: it loads no model and returns no opinion. \ - The plug-in is wired and will start voting the day inference lands." - ); - Ok(Self { id, model_path }) - } -} - -impl Detector for LocalOnnxDetector { - fn name(&self) -> &'static str { - // Returning a static str isn't quite right with a runtime id, - // but the architecture treats `name()` as a backend tag, not - // an instance identifier. - "local-onnx" - } - - fn judge(&self, _ctx: &DetectionContext<'_>) -> Verdict { - Verdict::NoOpinion - } -} diff --git a/crates/poltertype-ai/src/locality.rs b/crates/poltertype-ai/src/locality.rs new file mode 100644 index 0000000..d93950c --- /dev/null +++ b/crates/poltertype-ai/src/locality.rs @@ -0,0 +1,78 @@ +//! Deciding whether an endpoint keeps the request on this machine. +//! +//! `[ai].allow_remote` gates typed text *leaving the computer*. A +//! query to a model listening on loopback does not leave it, so this +//! module is what lets an offline Ollama run without the user +//! enabling network access they are not actually using. +//! +//! The rule is deliberately strict and syntactic: only literal +//! loopback addresses and the name `localhost` count. We do not +//! resolve DNS to find out where a host really points — a resolver +//! answer can change between the check and the request, and a +//! `local.mycompany.net` that resolves to 127.0.0.1 today is exactly +//! the kind of thing that should still require the user to say yes. +//! Anything we are not certain about is [`Locality::Remote`], which +//! is the answer that asks permission. + +use crate::enums::Locality; + +/// Classify the host of an endpoint URL. +/// +/// Unparseable input is `Remote`: if we cannot tell where a request +/// goes, the honest answer is the one that needs consent. +pub fn classify(endpoint: &str) -> Locality { + match host_of(endpoint) { + Some(host) if is_loopback_host(&host) => Locality::Loopback, + _ => Locality::Remote, + } +} + +/// Pull the host out of a URL without taking a URL-parsing +/// dependency: strip the scheme, then userinfo, then everything from +/// the first `/`, `?` or `#`, then the port. +fn host_of(endpoint: &str) -> Option { + let after_scheme = endpoint.split_once("://")?.1; + let authority = after_scheme + .split(['/', '?', '#']) + .next() + .filter(|s| !s.is_empty())?; + // `user:pass@host` — the host is what follows the LAST `@`, so a + // password containing one cannot smuggle a different host past us. + let hostport = authority.rsplit('@').next()?; + + // Bracketed IPv6 (`[::1]:11434`) — take what is inside the + // brackets and ignore any port after them. + if let Some(rest) = hostport.strip_prefix('[') { + return rest.split(']').next().map(str::to_ascii_lowercase); + } + + // A bare IPv6 address has several colons and no port; anything + // with exactly one colon is host:port. + let host = if hostport.matches(':').count() > 1 { + hostport + } else { + hostport.split(':').next()? + }; + if host.is_empty() { + return None; + } + Some(host.to_ascii_lowercase()) +} + +fn is_loopback_host(host: &str) -> bool { + if host == "localhost" { + return true; + } + // Parse as an IP and ask the standard library, so the whole + // 127.0.0.0/8 block and every spelling of ::1 are covered without + // us hand-rolling the ranges. + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + // `localhost.` and subdomains of it, per RFC 6761. + let trimmed = host.strip_suffix('.').unwrap_or(host); + trimmed == "localhost" || trimmed.ends_with(".localhost") +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-ai/src/locality/tests.rs b/crates/poltertype-ai/src/locality/tests.rs new file mode 100644 index 0000000..2cfd76e --- /dev/null +++ b/crates/poltertype-ai/src/locality/tests.rs @@ -0,0 +1,77 @@ +use super::*; + +#[test] +fn loopback_endpoints_need_no_network_permission() { + for url in [ + "http://127.0.0.1:11434/api/generate", + "http://localhost:1234/v1/chat/completions", + "http://LocalHost:8080/v1/chat/completions", + "http://[::1]:11434/api/generate", + "http://127.1.2.3:8080/v1", + "https://127.0.0.1/v1/chat/completions", + "http://localhost./v1", + "http://ollama.localhost/v1", + // No port, no path. + "http://localhost", + ] { + assert_eq!(classify(url), Locality::Loopback, "{url} should be local"); + } +} + +#[test] +fn everything_else_is_remote() { + for url in [ + "https://api.openai.com/v1/chat/completions", + "https://api.anthropic.com/v1/messages", + "http://192.168.1.10:11434/api/generate", + "http://10.0.0.5/v1", + "http://ollama.internal:11434/api/generate", + ] { + assert_eq!(classify(url), Locality::Remote, "{url} should be remote"); + } +} + +/// A host we cannot parse must come out `Remote` — the answer that +/// asks the user rather than the one that assumes. +#[test] +fn unparseable_endpoints_fail_closed() { + for url in [ + "", + "not a url", + "127.0.0.1:11434", // no scheme — we do not guess one + "http://", // no authority + "http:///v1/chat", // empty authority + "file:///etc/passwd", + ] { + assert_eq!(classify(url), Locality::Remote, "{url:?} must fail closed"); + } +} + +/// Userinfo must not be able to disguise the real host. `@` in a +/// password is legal, so the host is what follows the *last* one. +#[test] +fn userinfo_cannot_disguise_the_host() { + assert_eq!( + classify("http://localhost@evil.example.com/v1"), + Locality::Remote, + "the host here is evil.example.com, not localhost" + ); + assert_eq!( + classify("http://user:p@ss@127.0.0.1:11434/api/generate"), + Locality::Loopback, + "an @ inside the password must not hide a genuinely local host" + ); +} + +/// A remote host that merely *contains* a loopback spelling is remote. +#[test] +fn lookalike_hosts_are_not_loopback() { + for url in [ + "http://localhost.evil.example.com/v1", + "http://127.0.0.1.evil.example.com/v1", + "http://notlocalhost/v1", + "http://localhosts/v1", + ] { + assert_eq!(classify(url), Locality::Remote, "{url} is not loopback"); + } +} diff --git a/crates/poltertype-ai/src/remote/detector.rs b/crates/poltertype-ai/src/remote/detector.rs deleted file mode 100644 index 9783d29..0000000 --- a/crates/poltertype-ai/src/remote/detector.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! `RemoteLlmDetector` — opt-in remote HTTP detection. - -use super::*; -use crate::AiError; -use poltertype_detect::{DetectionContext, Detector, Verdict}; -use tracing::warn; - -pub struct RemoteLlmDetector { - pub id: String, - pub provider: Provider, - pub model: String, - pub api_key_ref: String, - pub max_latency_ms: u64, - pub allow_remote: bool, - #[cfg(feature = "remote")] - client: reqwest::blocking::Client, -} - -impl RemoteLlmDetector { - #[cfg(feature = "remote")] - pub fn new( - id: String, - provider: Provider, - model: String, - api_key_ref: String, - max_latency_ms: u64, - allow_remote: bool, - ) -> Result { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_millis(max_latency_ms.max(100))) - .build() - .map_err(AiError::Remote)?; - let built = Self { - id, - provider, - model, - api_key_ref, - max_latency_ms, - allow_remote, - client, - }; - built.announce(); - Ok(built) - } - - #[cfg(not(feature = "remote"))] - pub fn new( - id: String, - provider: Provider, - model: String, - api_key_ref: String, - max_latency_ms: u64, - allow_remote: bool, - ) -> Result { - let built = Self { - id, - provider, - model, - api_key_ref, - max_latency_ms, - allow_remote, - }; - built.announce(); - Ok(built) - } - - /// Say once, at construction, why this detector will or will not - /// have an opinion. `judge` stays silent — it runs per word. - fn announce(&self) { - if !cfg!(feature = "remote") { - warn!( - id = %self.id, - "remote LLM detector loaded but built without the `remote` cargo feature — \ - it will return no opinion" - ); - } else if !self.allow_remote { - warn!( - id = %self.id, - "remote LLM detector loaded but `[ai].allow_remote = false` — it will return \ - no opinion until that is switched on" - ); - } else { - warn!( - id = %self.id, - provider = ?self.provider, - model = %self.model, - "remote LLM detector is a stub: it makes no request and returns no opinion. \ - No network call is performed." - ); - } - } -} - -impl Detector for RemoteLlmDetector { - fn name(&self) -> &'static str { - "remote-llm" - } - - fn judge(&self, _ctx: &DetectionContext<'_>) -> Verdict { - // Every early return here is silent on purpose: this runs per - // word boundary, and a detector that logs on the correction - // path is a detector that costs more than it gives. The - // reasons are reported once, at construction. - if !self.allow_remote { - return Verdict::NoOpinion; - } - #[cfg(not(feature = "remote"))] - { - Verdict::NoOpinion - } - // Real call goes here in v0.1.x. We keep the function honest - // about its current state rather than ship a half-baked - // detector that misbehaves under real load. - #[cfg(feature = "remote")] - { - let _ = &self.client; - let _ = &self.api_key_ref; - Verdict::NoOpinion - } - } -} diff --git a/crates/poltertype-ai/src/remote/enums.rs b/crates/poltertype-ai/src/remote/enums.rs deleted file mode 100644 index 39bcf23..0000000 --- a/crates/poltertype-ai/src/remote/enums.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Supported remote LLM providers. - -#[derive(Debug, Clone, Copy)] -pub enum Provider { - Anthropic, - OpenAi, - Ollama, - Custom, -} - -impl Provider { - pub fn parse(s: &str) -> Option { - Some(match s { - "anthropic" => Self::Anthropic, - "openai" => Self::OpenAi, - "ollama" => Self::Ollama, - "custom-openai-compatible" | "custom" => Self::Custom, - _ => return None, - }) - } -} diff --git a/crates/poltertype-ai/src/remote/mod.rs b/crates/poltertype-ai/src/remote/mod.rs deleted file mode 100644 index 398056a..0000000 --- a/crates/poltertype-ai/src/remote/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Remote LLM-backed language detector. -//! -//! Gated behind the `remote` cargo feature *and* the -//! `[ai].allow_remote` runtime setting. Even with both on, the -//! detector only fires when the existing pipeline reports low -//! confidence (configurable per-detector). All calls are subject to -//! a `max_latency_ms` budget — anything slower is dropped, since the -//! engine should never block typing. - -mod detector; -mod enums; - -pub use detector::*; -pub use enums::*; diff --git a/crates/poltertype-ai/src/transport.rs b/crates/poltertype-ai/src/transport.rs new file mode 100644 index 0000000..54f9d7a --- /dev/null +++ b/crates/poltertype-ai/src/transport.rs @@ -0,0 +1,60 @@ +//! The one place in this crate that opens a socket. +//! +//! Isolated behind the `remote` cargo feature so that a default build +//! contains no HTTP client at all — not a disabled one, not one behind +//! a runtime flag. `cargo tree` on a stock build shows no `reqwest`, +//! which is a stronger statement than any amount of documentation +//! about what the app does not do. + +use crate::AiError; +use crate::enums::WireFormat; +use crate::wire; + +/// Everything one query needs, resolved and validated. +pub struct Call<'a> { + pub endpoint: &'a str, + pub format: WireFormat, + pub model: &'a str, + pub api_key: Option<&'a str>, + pub candidates: &'a [String], +} + +/// Perform one query and return the model's chosen candidate index. +/// +/// `Ok(None)` means the model declined to pick — a legitimate answer, +/// cached like any other. `Err` means the call itself failed and +/// nothing should be remembered. +pub fn ask(client: &reqwest::blocking::Client, call: &Call<'_>) -> Result, AiError> { + let question = wire::Question { + model: call.model, + candidates: call.candidates, + }; + let body = wire::request_body(call.format, &question); + + let mut req = client + .post(call.endpoint) + .header("content-type", "application/json"); + for (name, value) in wire::headers(call.format, call.api_key) { + req = req.header(name, value); + } + + let resp = req.body(body).send()?; + // A non-2xx is not an exception here — a wrong key or a model name + // the server doesn't know is an ordinary misconfiguration. Report + // it as a failure so the caller can announce it once, and make + // sure the body (which may quote the request) never reaches a log. + if !resp.status().is_success() { + return Err(AiError::RemoteDisabled(format!( + "endpoint returned HTTP {}", + resp.status().as_u16() + ))); + } + let text = resp.text()?; + let Some(reply) = wire::extract_text(call.format, &text) else { + return Err(AiError::RemoteDisabled( + "response did not contain the expected field — is `format` right for this endpoint?" + .into(), + )); + }; + Ok(wire::parse_choice(&reply, call.candidates.len())) +} diff --git a/crates/poltertype-ai/src/wire.rs b/crates/poltertype-ai/src/wire.rs new file mode 100644 index 0000000..fd0a611 --- /dev/null +++ b/crates/poltertype-ai/src/wire.rs @@ -0,0 +1,178 @@ +//! Turning a question into a request body, and a response into an +//! answer, for each supported endpoint shape. +//! +//! Kept free of any HTTP type on purpose: the bodies are plain +//! strings, so the whole request/response contract is unit-testable on +//! every host, including the ones where the `remote` cargo feature is +//! off and `reqwest` is not even compiled. The only thing the +//! transport adds is sending the bytes. + +use crate::consts::{ANTHROPIC_VERSION, SYSTEM_PROMPT}; +use crate::enums::WireFormat; + +/// One question: the candidate readings, in the order they were +/// offered. The reply is an index into this list (1-based), or 0. +pub struct Question<'a> { + pub model: &'a str, + pub candidates: &'a [String], +} + +impl Question<'_> { + /// The user-visible half of the prompt. Only the candidate strings + /// — no surrounding text, no application name, no layout ids + /// (which would leak which languages the user has installed). + pub fn prompt(&self) -> String { + let mut s = String::with_capacity(32 + self.candidates.len() * 16); + for (i, cand) in self.candidates.iter().enumerate() { + s.push_str(&format!("{}. {cand}\n", i + 1)); + } + s + } +} + +/// JSON-encode a string value, including the surrounding quotes. +/// +/// Hand-rolled to keep `serde_json` out of the dependency tree for a +/// job this small. Escapes what RFC 8259 requires: quote, backslash, +/// and everything below U+0020. +fn json_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Build the POST body for `format`. +pub fn request_body(format: WireFormat, q: &Question<'_>) -> String { + let model = json_str(q.model); + let prompt = json_str(&q.prompt()); + let system = json_str(SYSTEM_PROMPT); + match format { + WireFormat::OpenAiChat => format!( + r#"{{"model":{model},"max_tokens":4,"temperature":0,"messages":[{{"role":"system","content":{system}}},{{"role":"user","content":{prompt}}}]}}"# + ), + WireFormat::AnthropicMessages => format!( + r#"{{"model":{model},"max_tokens":4,"temperature":0,"system":{system},"messages":[{{"role":"user","content":{prompt}}}]}}"# + ), + WireFormat::OllamaGenerate => { + // Ollama's native API takes one prompt string and needs + // `stream:false` or it replies with newline-delimited + // chunks that our single-shot parse would not survive. + let joined = json_str(&format!("{SYSTEM_PROMPT}\n\n{}", q.prompt())); + format!( + r#"{{"model":{model},"prompt":{joined},"stream":false,"options":{{"temperature":0,"num_predict":4}}}}"# + ) + } + } +} + +/// Headers beyond `content-type`, as `(name, value)` pairs. +pub fn headers(format: WireFormat, api_key: Option<&str>) -> Vec<(String, String)> { + let mut out = Vec::new(); + let Some(key) = api_key else { + return out; + }; + match format { + WireFormat::AnthropicMessages => { + out.push(("x-api-key".into(), key.to_owned())); + out.push(("anthropic-version".into(), ANTHROPIC_VERSION.into())); + } + // Ollama ignores auth locally but proxies in front of it often + // don't, so send the bearer if the user configured one. + WireFormat::OpenAiChat | WireFormat::OllamaGenerate => { + out.push(("authorization".into(), format!("Bearer {key}"))); + } + } + out +} + +/// Pull the model's answer text out of a response body. +/// +/// A hand-rolled scan for the one field each format puts the text in. +/// This is not a JSON parser and does not pretend to be: it finds the +/// key, then reads the following JSON string with escape handling. A +/// response shaped differently than expected yields `None`, which the +/// caller turns into "no opinion" — the same as any other failure. +pub fn extract_text(format: WireFormat, body: &str) -> Option { + let key = match format { + WireFormat::OpenAiChat => "\"content\"", + WireFormat::AnthropicMessages => "\"text\"", + WireFormat::OllamaGenerate => "\"response\"", + }; + let idx = body.find(key)?; + let after = &body[idx + key.len()..]; + let colon = after.find(':')?; + read_json_string(&after[colon + 1..]) +} + +/// Read one JSON string starting at (or before, skipping whitespace) +/// the opening quote. +fn read_json_string(s: &str) -> Option { + let start = s.find('"')?; + let mut out = String::new(); + let mut chars = s[start + 1..].chars(); + while let Some(c) = chars.next() { + match c { + '"' => return Some(out), + '\\' => match chars.next()? { + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'u' => { + let hex: String = chars.by_ref().take(4).collect(); + let cp = u32::from_str_radix(&hex, 16).ok()?; + out.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); + } + other => out.push(other), + }, + c => out.push(c), + } + } + None +} + +/// Interpret the model's reply as a 1-based index into the candidate +/// list, or `None` for "none of them" / anything unparseable. +/// +/// Tolerant of the ways a model garnishes a number — surrounding +/// whitespace, a trailing period, a wrapping quote — and strict about +/// the result: the first run of digits must name a candidate that +/// exists. Everything else is no opinion, which is the safe answer on +/// the correction path. +pub fn parse_choice(reply: &str, candidate_count: usize) -> Option { + let trimmed = reply.trim(); + let chars: Vec = trimmed.chars().collect(); + let first_digit = chars.iter().position(char::is_ascii_digit)?; + + // A minus sign immediately before the digits makes this a negative + // number, and the only thing a model means by one is "none of + // these". Skipping the sign would read `-1` as candidate 1 and + // retype the user's word as something they did not ask for. + if first_digit > 0 && chars[first_digit - 1] == '-' { + return None; + } + + let digits: String = chars[first_digit..] + .iter() + .take_while(|c| c.is_ascii_digit()) + .collect(); + let n: usize = digits.parse().ok()?; + if n == 0 || n > candidate_count { + return None; + } + Some(n - 1) +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-ai/src/wire/tests.rs b/crates/poltertype-ai/src/wire/tests.rs new file mode 100644 index 0000000..2eac85a --- /dev/null +++ b/crates/poltertype-ai/src/wire/tests.rs @@ -0,0 +1,151 @@ +use super::*; + +fn question<'a>(model: &'a str, cands: &'a [String]) -> Question<'a> { + Question { + model, + candidates: cands, + } +} + +#[test] +fn prompt_numbers_candidates_from_one() { + let cands = vec!["привіт".to_string(), "ghbdsn".to_string()]; + let q = question("m", &cands); + assert_eq!(q.prompt(), "1. привіт\n2. ghbdsn\n"); +} + +/// The prompt carries the candidate words and nothing else — no +/// layout ids (which would reveal the user's installed languages), no +/// surrounding sentence, no application name. +#[test] +fn prompt_leaks_nothing_but_the_candidates() { + let cands = vec!["слово".to_string()]; + let q = question("gpt-4o-mini", &cands); + let p = q.prompt(); + assert!(!p.contains("uk-UA"), "no layout ids: {p}"); + assert!(!p.contains("en-US"), "no layout ids: {p}"); + assert_eq!(p.lines().count(), 1, "one line per candidate: {p}"); +} + +#[test] +fn bodies_are_valid_json_for_every_format() { + let cands = vec!["té\"st".to_string(), "with\\slash".to_string()]; + let q = question("some-model", &cands); + for format in [ + WireFormat::OpenAiChat, + WireFormat::AnthropicMessages, + WireFormat::OllamaGenerate, + ] { + let body = request_body(format, &q); + // Round-trip through a real parser to prove the hand-rolled + // escaping is correct, including the embedded quote/backslash. + let parsed: Result = serde_json::from_str(&body); + assert!(parsed.is_ok(), "{format:?} produced invalid JSON: {body}"); + assert!( + body.contains("some-model"), + "{format:?} must name the model" + ); + } +} + +#[test] +fn openai_and_ollama_bodies_disable_streaming_and_cap_output() { + let cands = vec!["a".to_string()]; + let q = question("m", &cands); + let ollama = request_body(WireFormat::OllamaGenerate, &q); + assert!( + ollama.contains(r#""stream":false"#), + "a streamed reply would break the single-shot parse: {ollama}" + ); + let openai = request_body(WireFormat::OpenAiChat, &q); + assert!(openai.contains(r#""max_tokens":4"#), "cap the reply"); +} + +#[test] +fn anthropic_gets_its_key_header_and_version() { + let h = headers(WireFormat::AnthropicMessages, Some("sk-test")); + assert!(h.iter().any(|(k, v)| k == "x-api-key" && v == "sk-test")); + assert!(h.iter().any(|(k, _)| k == "anthropic-version")); + assert!( + !h.iter().any(|(k, _)| k == "authorization"), + "anthropic does not use a bearer token" + ); +} + +#[test] +fn openai_gets_a_bearer_and_no_key_means_no_header() { + let h = headers(WireFormat::OpenAiChat, Some("sk-test")); + assert!( + h.iter() + .any(|(k, v)| k == "authorization" && v == "Bearer sk-test") + ); + assert!( + headers(WireFormat::OpenAiChat, None).is_empty(), + "a local Ollama needs no key and must not get an empty one" + ); +} + +#[test] +fn extracts_the_answer_from_each_response_shape() { + let cases = [ + ( + WireFormat::OpenAiChat, + r#"{"choices":[{"message":{"role":"assistant","content":"2"}}]}"#, + "2", + ), + ( + WireFormat::AnthropicMessages, + r#"{"content":[{"type":"text","text":"1"}]}"#, + "1", + ), + ( + WireFormat::OllamaGenerate, + r#"{"model":"llama3","response":"3","done":true}"#, + "3", + ), + ]; + for (format, body, want) in cases { + assert_eq!( + extract_text(format, body).as_deref(), + Some(want), + "{format:?} on {body}" + ); + } +} + +#[test] +fn extraction_handles_escapes_and_survives_junk() { + assert_eq!( + extract_text(WireFormat::OllamaGenerate, r#"{"response":"a\"b\nc"}"#).as_deref(), + Some("a\"b\nc") + ); + // An error payload, a truncated body, or an unexpected shape all + // have to come back as None rather than panic. + for body in [ + r#"{"error":{"message":"bad key"}}"#, + r#"{"response":"unterminated"#, + "", + "not json at all", + ] { + let _ = extract_text(WireFormat::OllamaGenerate, body); + } +} + +#[test] +fn parses_the_choice_a_model_actually_returns() { + assert_eq!(parse_choice("2", 3), Some(1)); + assert_eq!(parse_choice(" 1 ", 3), Some(0)); + assert_eq!(parse_choice("1.", 3), Some(0)); + assert_eq!(parse_choice("\"2\"", 3), Some(1)); + assert_eq!(parse_choice("The answer is 2", 3), Some(1)); +} + +/// Everything ambiguous is no opinion. This runs on the correction +/// path: a wrong confident answer retypes the user's word incorrectly, +/// whereas no answer just leaves the offline detectors in charge. +#[test] +fn anything_unusable_is_no_opinion() { + for reply in ["0", "", "none", "4", "99", "-1", "I'm not sure"] { + assert_eq!(parse_choice(reply, 3), None, "reply {reply:?}"); + } +} diff --git a/crates/poltertype-app/src/detectors/tests.rs b/crates/poltertype-app/src/detectors/tests.rs index 4952a1d..21801cd 100644 --- a/crates/poltertype-app/src/detectors/tests.rs +++ b/crates/poltertype-app/src/detectors/tests.rs @@ -11,9 +11,10 @@ use poltertype_types::AiPluginConfig; use super::build_ai_detectors; +/// An entry pointing at a third-party API the user holds a key for. fn remote_entry(id: &str) -> AiPluginConfig { AiPluginConfig { - r#type: "remote-llm".to_owned(), + r#type: "llm".to_owned(), id: id.to_owned(), provider: Some("anthropic".to_owned()), model: Some("claude-sonnet-4".to_owned()), @@ -22,6 +23,18 @@ fn remote_entry(id: &str) -> AiPluginConfig { } } +/// An entry pointing at a model on the user's own machine. Needs no +/// key and no `allow_remote`, because nothing leaves the computer. +fn local_entry(id: &str) -> AiPluginConfig { + AiPluginConfig { + r#type: "llm".to_owned(), + id: id.to_owned(), + provider: Some("ollama".to_owned()), + model: Some("llama3".to_owned()), + ..Default::default() + } +} + /// The default. Nothing is built, and nothing is said. #[test] fn a_disabled_subsystem_builds_nothing() { @@ -85,3 +98,45 @@ fn allow_remote_does_not_decide_whether_the_plugin_loads() { }); assert_eq!(off.len(), on.len()); } + +/// A model the user runs themselves loads with `allow_remote` off. +/// That switch exists to gate typed words *leaving the machine*, and +/// a request to loopback does not — requiring it here would make +/// people enable network access they are not using. +#[test] +fn a_local_model_needs_no_network_permission() { + let built = build_ai_detectors(&AiSettings { + enabled: true, + allow_remote: false, + plugins: vec![local_entry("ollama")], + }); + if cfg!(feature = "ai") { + assert_eq!(built.len(), 1, "a loopback endpoint must load"); + } else { + assert!(built.is_empty()); + } +} + +/// A config written for 0.9.0 names plug-in kinds that no longer +/// exist. It must cost the user a log line, not the app: the entry is +/// skipped and anything valid beside it still loads. +#[test] +fn a_retired_plugin_kind_is_skipped_without_taking_the_rest_down() { + let mut old = local_entry("stale"); + old.r#type = "local-onnx".to_owned(); + + let built = build_ai_detectors(&AiSettings { + enabled: true, + allow_remote: false, + plugins: vec![old, local_entry("current")], + }); + if cfg!(feature = "ai") { + assert_eq!( + built.len(), + 1, + "the retired entry goes, the valid one stays" + ); + } else { + assert!(built.is_empty()); + } +} diff --git a/crates/poltertype-app/src/settings_ui/helpers.rs b/crates/poltertype-app/src/settings_ui/helpers.rs index 86ee2be..1d02f39 100644 --- a/crates/poltertype-app/src/settings_ui/helpers.rs +++ b/crates/poltertype-app/src/settings_ui/helpers.rs @@ -171,6 +171,7 @@ pub fn derive_command_id(name: &str, action: &CommandAction, existing: &[UserCom CommandAction::TypeText { .. } => "type-text".into(), CommandAction::SwitchLayout { .. } => "switch-layout".into(), CommandAction::OpenPath { .. } => "open-path".into(), + CommandAction::RunShell(_) => "run-shell".into(), } }; // Disambiguate by appending `-2`, `-3`, … as needed. @@ -203,6 +204,18 @@ pub fn format_command_summary(cmd: &UserCommand) -> String { // as tofu on a clean Linux install). CommandAction::SwitchLayout { layout } => format!("-> {layout}"), CommandAction::OpenPath { path } => format!("open `{path}`"), + // Shown with its arguments so a reader of the list can see + // exactly what would run — the whole point of this action + // being visible rather than convenient. + CommandAction::RunShell(shell) => { + let argv = std::iter::once(shell.program.as_str()) + .chain(shell.args.iter().map(String::as_str)) + .collect::>() + .join(" "); + let preview = argv.chars().take(40).collect::(); + let suffix = if argv.chars().count() > 40 { "…" } else { "" }; + format!("run `{preview}{suffix}`") + } }; let apps_blurb = if cmd.apps.is_empty() { String::new() diff --git a/crates/poltertype-app/src/settings_ui/mod.rs b/crates/poltertype-app/src/settings_ui/mod.rs index 9888c13..e67db3b 100644 --- a/crates/poltertype-app/src/settings_ui/mod.rs +++ b/crates/poltertype-app/src/settings_ui/mod.rs @@ -103,6 +103,16 @@ use state::*; pub fn run(open_setup: bool) -> Result<()> { let store = SettingsStore::load_or_default().context("load settings for UI")?; let initial_settings = store.snapshot(); + + // Before any widget exists: `tr` is called from the view function, + // which runs on every frame, so the catalog has to be in place + // first. Failing to find one is not an error — the interface is + // written in English at every call site and simply stays that way. + match poltertype_core::data_dir::resolve() { + Ok(dir) => poltertype_core::i18n::init(&dir, Some(&initial_settings.general.ui_language)), + Err(e) => warn!(?e, "no data dir; the interface stays in English"), + } + let store = Arc::new(store); // Querying the OS layout list is best-effort — if it fails we diff --git a/crates/poltertype-app/src/settings_ui/view.rs b/crates/poltertype-app/src/settings_ui/view.rs index bfd2a53..c719343 100644 --- a/crates/poltertype-app/src/settings_ui/view.rs +++ b/crates/poltertype-app/src/settings_ui/view.rs @@ -13,6 +13,8 @@ use iced::widget::{ }; use iced::{Alignment, Element, Font, Length, Padding}; +use poltertype_core::i18n::{tr, tr_args}; + use super::consts::*; use super::enums::*; use super::helpers::*; @@ -102,7 +104,11 @@ impl SettingsApp { .font(FONT_BOLD) .color(b.ink), ) - .push(Text::new("Settings").size(11).color(b.muted)), + .push( + Text::new(tr("ui.settings", "Settings")) + .size(11) + .color(b.muted), + ), ); Container::new( @@ -158,27 +164,38 @@ impl SettingsApp { // "add language" button that (deliberately) doesn't exist: // PolterType follows the OS keyboard configuration instead of // keeping a second list to drift out of sync. - let subtitle = "This list mirrors the keyboard layouts enabled in your \ + let subtitle = tr( + "languages.subtitle", + "This list mirrors the keyboard layouts enabled in your \ operating system. To add or remove a language, change your \ - system's keyboard settings, then reopen this window." - .to_owned(); + system's keyboard settings, then reopen this window.", + ) + .to_owned(); let status = if implicit_all { - "All of them are currently considered. Untick 'Active' to \ - restrict PolterType to a subset." - .to_owned() + tr( + "languages.status_all", + "All of them are currently considered. Untick 'Active' to \ + restrict PolterType to a subset.", + ) + .to_owned() } else { - format!( + tr_args( + "languages.status_restricted", "Restricted to {} layout(s). Tick more to include them, \ or hit 'Reset to defaults' on the About pane to go back \ to 'use every OS layout'.", - allow_list.len() + &[&allow_list.len().to_string()], ) }; let mut col = Column::new() .spacing(14) - .push(pane_header(b, "Languages", subtitle)) + .push(pane_header( + b, + tr("languages.languages", "Languages"), + subtitle, + )) .push(Text::new(status).size(12).color(b.muted)); if self.os_layouts.is_empty() { @@ -206,7 +223,7 @@ impl SettingsApp { .width(Length::FillPortion(2)), ) .push( - Checkbox::new("Active", is_active_effective) + Checkbox::new(tr("languages.active", "Active"), is_active_effective) .text_size(13) .on_toggle({ let id = id.clone(); @@ -215,7 +232,7 @@ impl SettingsApp { .width(Length::FillPortion(1)), ) .push( - Checkbox::new("Ignore", is_ignored) + Checkbox::new(tr("languages.ignore", "Ignore"), is_ignored) .text_size(13) .on_toggle({ let id = id.clone(); @@ -242,17 +259,22 @@ impl SettingsApp { let row = |label: &'static str, current: &str, kind: HotkeyKind| -> Element<'_, Message> { let capturing = self.capturing == Some(kind); let display: Element<'_, Message> = if capturing { - Text::new("Press a combination… (Esc to cancel)") - .size(13) - .color(b.warn) - .into() + Text::new(tr( + "hotkeys.press_combination_esc_cancel", + "Press a combination… (Esc to cancel)", + )) + .size(13) + .color(b.warn) + .into() } else { hotkey_chips(b, current) }; let action = if capturing { - Button::new(Text::new("Cancel").size(12)).on_press(Message::HotkeyRebindCancel) + Button::new(Text::new(tr("hotkeys.cancel", "Cancel")).size(12)) + .on_press(Message::HotkeyRebindCancel) } else { - Button::new(Text::new("Rebind").size(12)).on_press(Message::HotkeyRebindStart(kind)) + Button::new(Text::new(tr("hotkeys.rebind", "Rebind")).size(12)) + .on_press(Message::HotkeyRebindStart(kind)) }; Row::new() .spacing(16) @@ -272,7 +294,7 @@ impl SettingsApp { .spacing(14) .push(pane_header( b, - "Hotkeys", + tr("hotkeys.hotkeys", "Hotkeys"), "Global hotkeys are registered with the OS at startup. \ Click 'Rebind', press the new combination, then save. \ The new binding takes effect after the tray restarts \ @@ -309,7 +331,7 @@ impl SettingsApp { let b = self.brand(); let mut col = Column::new().spacing(14).push(pane_header( b, - "Commands", + tr("commands.commands", "Commands"), "Type a short token, get a phrase — like classic snippet expanders. \ For example: typing the trigger `anrl` + space expands into \ `Anatomical Reference List `. The engine watches every word \ @@ -322,9 +344,12 @@ impl SettingsApp { // ── Existing commands list ────────────────────────────────── if self.settings.commands.is_empty() { col = col.push(card( - Text::new("No commands yet — fill the form below to add one.") - .size(12) - .color(b.muted), + Text::new(tr( + "commands.no_commands_yet_fill", + "No commands yet — fill the form below to add one.", + )) + .size(12) + .color(b.muted), )); } else { let mut rows = Column::new().spacing(10); @@ -369,9 +394,10 @@ impl SettingsApp { .into() }; - let mut form = Column::new() - .spacing(10) - .push(section_title(b, "Add a new command")); + let mut form = Column::new().spacing(10).push(section_title( + b, + tr("commands.add_new_command", "Add a new command"), + )); form = form.push( Row::new() @@ -486,7 +512,7 @@ impl SettingsApp { .push(status) .push(Space::with_width(Length::Fill)) .push( - Button::new(Text::new("Add command").size(12)) + Button::new(Text::new(tr("commands.add_command", "Add command")).size(12)) .on_press(Message::CommandAdd) .style(theme::primary) .padding(Padding { @@ -514,7 +540,7 @@ impl SettingsApp { let b = self.brand(); let mut col = Column::new().spacing(14).push(pane_header( b, - "Wordlists", + tr("wordlists.wordlists", "Wordlists"), "Add language-specific words to the per-layout dictionary \ overlay. Use the Save button below to persist your edits, \ or just close the window — either way, the engine's \ @@ -652,17 +678,23 @@ impl SettingsApp { .placeholder("# one word per line — '#' starts a comment\n") .into() } else { - Text::new("Pick a layout above to start editing.") - .size(13) - .color(b.muted) - .into() + Text::new(tr( + "wordlists.pick_layout_above_start", + "Pick a layout above to start editing.", + )) + .size(13) + .color(b.muted) + .into() }; col = col.push(editor); let dirty_marker: Element<'_, Message> = if self.wordlist_dirty { // Plain text, no bullet glyph — the default UI font on a // clean Linux install may lack it and render tofu. - Text::new("unsaved changes").size(11).color(b.warn).into() + Text::new(tr("wordlists.unsaved_changes", "unsaved changes")) + .size(11) + .color(b.warn) + .into() } else { Space::with_width(Length::Shrink).into() }; @@ -699,7 +731,7 @@ impl SettingsApp { let b = self.brand(); let col = Column::new().spacing(14).push(pane_header( b, - "Exceptions", + tr("exceptions.exceptions", "Exceptions"), "PolterType skips auto-correction when the foreground app's \ executable basename is in this list. Manual switch (the \ hotkey on the Hotkeys pane) bypasses the list — devs can \ @@ -710,9 +742,12 @@ impl SettingsApp { let mut rows = Column::new().spacing(8); if self.settings.exceptions.disabled_apps.is_empty() { rows = rows.push( - Text::new("No exceptions — PolterType is active in every app.") - .size(12) - .color(b.muted), + Text::new(tr( + "exceptions.no_exceptions_poltertype_active", + "No exceptions — PolterType is active in every app.", + )) + .size(12) + .color(b.muted), ); } for (idx, entry) in self.settings.exceptions.disabled_apps.iter().enumerate() { @@ -754,7 +789,7 @@ impl SettingsApp { .width(Length::Fill), ) .push( - Button::new(Text::new("Add").size(13)) + Button::new(Text::new(tr("exceptions.add", "Add")).size(13)) .on_press(Message::ExceptionAdd) .style(theme::primary) .padding(Padding { @@ -780,20 +815,35 @@ impl SettingsApp { let behaviour = Column::new() .spacing(12) - .push(section_title(b, "Behaviour")) + .push(section_title(b, tr("general.behaviour", "Behaviour"))) .push( - Checkbox::new("Start automatically when I sign in", g.autostart) - .text_size(13) - .on_toggle(Message::AutostartToggled), + Checkbox::new( + tr( + "general.start_automatically_when_i", + "Start automatically when I sign in", + ), + g.autostart, + ) + .text_size(13) + .on_toggle(Message::AutostartToggled), ) .push( - Checkbox::new("Play a soft chime on correction", g.sound_on_correct) - .text_size(13) - .on_toggle(Message::SoundOnCorrectToggled), + Checkbox::new( + tr( + "general.play_soft_chime_on", + "Play a soft chime on correction", + ), + g.sound_on_correct, + ) + .text_size(13) + .on_toggle(Message::SoundOnCorrectToggled), ) .push( Checkbox::new( - "Show a 2-second system notification on auto-switch", + tr( + "general.show_second_system_notification", + "Show a 2-second system notification on auto-switch", + ), g.show_notifications, ) .text_size(13) @@ -801,7 +851,10 @@ impl SettingsApp { ) .push( Checkbox::new( - "Skip auto-switch on identifiers (foo_bar, snake_case, …)", + tr( + "general.skip_auto_switch_on", + "Skip auto-switch on identifiers (foo_bar, snake_case, …)", + ), e.suppress_in_identifiers, ) .text_size(13) @@ -811,9 +864,9 @@ impl SettingsApp { Row::new() .spacing(10) .align_y(Alignment::Center) - .push(Text::new("Idle timeout (ms):").size(13)) + .push(Text::new(tr("general.idle_timeout_ms", "Idle timeout (ms):")).size(13)) .push( - Button::new(Text::new("-100").size(12)) + Button::new(Text::new(tr("general.text", "-100")).size(12)) .on_press(Message::IdleTimeoutDelta(-100)) .style(theme::secondary) .padding(Padding { @@ -829,7 +882,7 @@ impl SettingsApp { .font(Font::MONOSPACE), ) .push( - Button::new(Text::new("+100").size(12)) + Button::new(Text::new(tr("general.text2", "+100")).size(12)) .on_press(Message::IdleTimeoutDelta(100)) .style(theme::secondary) .padding(Padding { @@ -840,9 +893,12 @@ impl SettingsApp { }), ) .push( - Text::new("Buffer is cleared after this much keyboard silence.") - .size(11) - .color(b.muted), + Text::new(tr( + "general.buffer_cleared_after_this", + "Buffer is cleared after this much keyboard silence.", + )) + .size(11) + .color(b.muted), ), ); @@ -864,12 +920,15 @@ impl SettingsApp { } let appearance = Column::new() .spacing(12) - .push(section_title(b, "Appearance")) + .push(section_title(b, tr("general.appearance", "Appearance"))) .push(theme_row) .push( - Text::new("System follows the OS light/dark preference. Save to persist.") - .size(11) - .color(b.muted), + Text::new(tr( + "general.system_follows_os_light", + "System follows the OS light/dark preference. Save to persist.", + )) + .size(11) + .color(b.muted), ); // Updates. This pane is the app's disclosure surface for the one @@ -902,7 +961,7 @@ impl SettingsApp { let interval_row = Row::new() .spacing(10) .align_y(Alignment::Center) - .push(Text::new("Check every (hours):").size(13)) + .push(Text::new(tr("general.check_every_hours", "Check every (hours):")).size(13)) .push(step("-1", -1)) .push( Text::new(format!("{:>3}", u.check_interval_hours)) @@ -914,10 +973,13 @@ impl SettingsApp { let updates = Column::new() .spacing(12) - .push(section_title(b, "Updates")) + .push(section_title(b, tr("general.updates", "Updates"))) .push( Checkbox::new( - "Download new versions automatically, install on restart", + tr( + "general.download_new_versions_automatically", + "Download new versions automatically, install on restart", + ), u.enabled, ) .text_size(13) @@ -944,7 +1006,7 @@ impl SettingsApp { let folders = Column::new() .spacing(12) - .push(section_title(b, "Folders")) + .push(section_title(b, tr("general.folders", "Folders"))) .push( Row::new() .spacing(8) @@ -958,7 +1020,7 @@ impl SettingsApp { .spacing(14) .push(pane_header( b, - "General", + tr("general.general", "General"), "Behaviour of the tray app and the correction engine.".to_owned(), )) .push(card(behaviour)) @@ -992,7 +1054,7 @@ impl SettingsApp { let max_row = Row::new() .spacing(10) .align_y(Alignment::Center) - .push(Text::new("Max suggestions (1–9):").size(13)) + .push(Text::new(tr("suggestions.max_suggestions", "Max suggestions (1–9):")).size(13)) .push(step("-1", Message::SuggestionMaxDelta(-1))) .push( Text::new(format!("{:>2}", s.max_suggestions)) @@ -1002,15 +1064,24 @@ impl SettingsApp { ) .push(step("+1", Message::SuggestionMaxDelta(1))) .push( - Text::new("Each entry is applied with one digit key, so 9 is the ceiling.") - .size(11) - .color(b.muted), + Text::new(tr( + "suggestions.each_entry_applied_with", + "Each entry is applied with one digit key, so 9 is the ceiling.", + )) + .size(11) + .color(b.muted), ); let timeout_row = Row::new() .spacing(10) .align_y(Alignment::Center) - .push(Text::new("Tooltip timeout (seconds):").size(13)) + .push( + Text::new(tr( + "suggestions.tooltip_timeout_seconds", + "Tooltip timeout (seconds):", + )) + .size(13), + ) .push(step("-5", Message::SuggestionTimeoutDelta(-5))) .push( Text::new(format!("{:>3}", s.tooltip_timeout_secs)) @@ -1020,18 +1091,27 @@ impl SettingsApp { ) .push(step("+5", Message::SuggestionTimeoutDelta(5))) .push( - Text::new("3–600 seconds; the tooltip hides itself when the time is up.") - .size(11) - .color(b.muted), + Text::new(tr( + "suggestions.seconds_tooltip_hides_itself", + "3–600 seconds; the tooltip hides itself when the time is up.", + )) + .size(11) + .color(b.muted), ); let tooltip_card = Column::new() .spacing(12) - .push(section_title(b, "Tooltip")) + .push(section_title(b, tr("suggestions.tooltip", "Tooltip"))) .push( - Checkbox::new("Show suggestions for mistyped words", s.enabled) - .text_size(13) - .on_toggle(Message::SuggestionsToggled), + Checkbox::new( + tr( + "suggestions.show_suggestions_mistyped_words", + "Show suggestions for mistyped words", + ), + s.enabled, + ) + .text_size(13) + .on_toggle(Message::SuggestionsToggled), ) .push(max_row) .push(timeout_row); @@ -1048,12 +1128,21 @@ impl SettingsApp { let mut chord_card = Column::new() .spacing(12) - .push(section_title(b, "Keyboard accept")) + .push(section_title( + b, + tr("suggestions.keyboard_accept", "Keyboard accept"), + )) .push( Row::new() .spacing(10) .align_y(Alignment::Center) - .push(Text::new("Keyboard accept modifiers:").size(13)) + .push( + Text::new(tr( + "suggestions.keyboard_accept_modifiers", + "Keyboard accept modifiers:", + )) + .size(13), + ) .push(modifiers_input), ) .push( @@ -1087,7 +1176,7 @@ impl SettingsApp { .spacing(14) .push(pane_header( b, - "Suggestions", + tr("suggestions.suggestions", "Suggestions"), "Offer dictionary suggestions in a small tooltip when a typed word looks \ misspelled. Clicking a suggestion (or pressing the accept chord + a digit) \ replaces the word." @@ -1125,7 +1214,13 @@ impl SettingsApp { .size(12) .color(b.muted), ) - .push(Text::new("Cross-platform automatic keyboard layout switcher.").size(13)) + .push( + Text::new(tr( + "about.cross_platform_automatic_keyboard", + "Cross-platform automatic keyboard layout switcher.", + )) + .size(13), + ) .push( Row::new() .spacing(4) @@ -1137,31 +1232,41 @@ impl SettingsApp { let escape_hatches = Column::new() .spacing(12) - .push(section_title(b, "Power-user escape hatches")) + .push(section_title( + b, + tr( + "about.power_user_escape_hatches", + "Power-user escape hatches", + ), + )) .push( Row::new() .spacing(8) .push( - Button::new(Text::new("Reset to defaults").size(13)) - .on_press(Message::ResetDefaults) - .style(theme::danger) - .padding(Padding { - top: 6.0, - right: 12.0, - bottom: 6.0, - left: 12.0, - }), + Button::new( + Text::new(tr("about.reset_defaults", "Reset to defaults")).size(13), + ) + .on_press(Message::ResetDefaults) + .style(theme::danger) + .padding(Padding { + top: 6.0, + right: 12.0, + bottom: 6.0, + left: 12.0, + }), ) .push( - Button::new(Text::new("Reload from disk").size(13)) - .on_press(Message::Reload) - .style(theme::secondary) - .padding(Padding { - top: 6.0, - right: 12.0, - bottom: 6.0, - left: 12.0, - }), + Button::new( + Text::new(tr("about.reload_from_disk", "Reload from disk")).size(13), + ) + .on_press(Message::Reload) + .style(theme::secondary) + .padding(Padding { + top: 6.0, + right: 12.0, + bottom: 6.0, + left: 12.0, + }), ), ) .push( @@ -1203,7 +1308,7 @@ impl SettingsApp { .push(banner) .push(Space::with_width(Length::Fill)) .push( - Button::new(Text::new("Reload").size(13)) + Button::new(Text::new(tr("footer.reload", "Reload")).size(13)) .on_press(Message::Reload) .style(theme::secondary) .padding(Padding { @@ -1214,7 +1319,7 @@ impl SettingsApp { }), ) .push( - Button::new(Text::new("Save").size(13)) + Button::new(Text::new(tr("footer.save", "Save")).size(13)) .on_press(Message::Save) .style(theme::primary) .padding(Padding { @@ -1305,7 +1410,7 @@ fn hotkey_chips(b: &'static theme::BrandPalette, combo: &str) -> Element<'static let mut row = Row::new().spacing(4).align_y(Alignment::Center); for (i, part) in combo.split('+').enumerate() { if i > 0 { - row = row.push(Text::new("+").size(11).color(b.muted)); + row = row.push(Text::new(tr("footer.text", "+")).size(11).color(b.muted)); } row = row.push(keycap_chip(display_key_token(part))); } diff --git a/crates/poltertype-app/src/settings_ui/view_setup.rs b/crates/poltertype-app/src/settings_ui/view_setup.rs index 603d706..79fada5 100644 --- a/crates/poltertype-app/src/settings_ui/view_setup.rs +++ b/crates/poltertype-app/src/settings_ui/view_setup.rs @@ -16,6 +16,7 @@ use iced::widget::{Button, Column, Container, Row, Space, Text}; use iced::{Alignment, Element, Length, Padding}; +use poltertype_core::i18n::tr; use poltertype_input::setup::{StepAction, StepState}; use super::consts::PERMISSIONS_DOC_URL; @@ -66,7 +67,11 @@ impl SettingsApp { let mut body = Column::new() .spacing(18) - .push(pane_header(b, "Setup", headline.to_owned())) + .push(pane_header( + b, + tr("setup.setup", "Setup"), + headline.to_owned(), + )) .push(card(steps)); // The second failure mode, and a genuinely different one: @@ -78,7 +83,13 @@ impl SettingsApp { body = body.push(card( Column::new() .spacing(8) - .push(section_title(b, "Layout switching is unavailable")) + .push(section_title( + b, + tr( + "setup.layout_switching_unavailable", + "Layout switching is unavailable", + ), + )) .push( Text::new( "PolterType found no way to change the keyboard layout on this \ @@ -90,16 +101,22 @@ impl SettingsApp { .color(b.muted), ) .push( - Button::new(Text::new("What backends are supported?").size(12)) - .on_press(Message::SetupOpen(PERMISSIONS_DOC_URL.to_owned())) - .style(theme::secondary) - .padding(button_padding()), + Button::new( + Text::new(tr( + "setup.what_backends_are_supported", + "What backends are supported?", + )) + .size(12), + ) + .on_press(Message::SetupOpen(PERMISSIONS_DOC_URL.to_owned())) + .style(theme::secondary) + .padding(button_padding()), ), )); } let mut footer = Row::new().spacing(10).align_y(Alignment::Center).push( - Button::new(Text::new("Check again").size(13)) + Button::new(Text::new(tr("setup.check_again", "Check again")).size(13)) .on_press(Message::SetupRecheck) .style(theme::primary) .padding(Padding { @@ -110,7 +127,7 @@ impl SettingsApp { }), ); footer = footer.push( - Button::new(Text::new("Full setup guide").size(12)) + Button::new(Text::new(tr("setup.full_setup_guide", "Full setup guide")).size(12)) .on_press(Message::SetupOpen(PERMISSIONS_DOC_URL.to_owned())) .style(theme::secondary) .padding(button_padding()), diff --git a/crates/poltertype-core/build.rs b/crates/poltertype-core/build.rs index d8be42d..a1c0b4b 100644 --- a/crates/poltertype-core/build.rs +++ b/crates/poltertype-core/build.rs @@ -111,6 +111,32 @@ fn main() { prepare_wordlist(&src_wordlists, &out_wordlists, stem, tag); } + // ─── UI translations: copy catalogs ──────────────────────────── + // Whole-directory copy rather than a list: catalogs are pure data + // with no build step, and a contributor adding `pl.toml` should + // not also have to edit a Rust file to make it ship. + let src_i18n = repo_root.join("data").join("i18n"); + let out_i18n = out_root.join("i18n"); + fs::create_dir_all(&out_i18n).expect("mkdir target/dist/data/i18n"); + println!("cargo:rerun-if-changed={}", src_i18n.display()); + match fs::read_dir(&src_i18n) { + Ok(entries) => { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("toml") { + continue; + } + println!("cargo:rerun-if-changed={}", path.display()); + if let Some(name) = path.file_name() { + if let Err(e) = fs::copy(&path, out_i18n.join(name)) { + println!("cargo:warning=i18n copy {} failed: {e}", path.display()); + } + } + } + } + Err(e) => println!("cargo:warning=no data/i18n directory ({e}); UI stays English"), + } + // ─── Layout mappings: copy TOMLs ─────────────────────────────── for (stem, _) in LAYOUTS { let src = src_mappings.join(format!("{stem}.toml")); diff --git a/crates/poltertype-core/src/commands/enums.rs b/crates/poltertype-core/src/commands/enums.rs index f90e493..10c0482 100644 --- a/crates/poltertype-core/src/commands/enums.rs +++ b/crates/poltertype-core/src/commands/enums.rs @@ -1,5 +1,7 @@ //! What a matched command does. +use super::shell::ShellCommand; + use poltertype_types::LayoutId; use serde::{Deserialize, Serialize}; @@ -48,4 +50,9 @@ pub enum CommandAction { /// mapping; URLs get the default browser. Trigger + boundary /// are deleted; nothing is re-emitted. OpenPath { path: String }, + /// Run a program. **Off unless `[commands].allow_run_shell` is + /// true**, executed without a shell, and never given anything the + /// user typed as an argument. `crate::commands::shell` carries + /// the threat model; read it before changing any of that. + RunShell(ShellCommand), } diff --git a/crates/poltertype-core/src/commands/matching.rs b/crates/poltertype-core/src/commands/matching.rs index bef2745..68119ac 100644 --- a/crates/poltertype-core/src/commands/matching.rs +++ b/crates/poltertype-core/src/commands/matching.rs @@ -20,9 +20,10 @@ pub fn find_matching_command<'a>( commands: &'a [UserCommand], typed_word: &str, focused_basename: Option<&str>, + history: &WordHistory, ) -> Option<&'a UserCommand> { commands.iter().find(|c| { - c.trigger == typed_word + phrase_matches(c, history, typed_word) && (c.apps.is_empty() || focused_basename .is_some_and(|b| c.apps.iter().any(|a| a.eq_ignore_ascii_case(b)))) diff --git a/crates/poltertype-core/src/commands/mod.rs b/crates/poltertype-core/src/commands/mod.rs index 9f13c18..6ac83f3 100644 --- a/crates/poltertype-core/src/commands/mod.rs +++ b/crates/poltertype-core/src/commands/mod.rs @@ -49,25 +49,44 @@ //! other two are power-user shortcuts that happen to fit the same //! "type a magic word, something happens" model. //! -//! What's intentionally **not** here in v1: -//! -//! * `RunShell { argv }` — full command execution. The blast radius -//! (a malicious `[[commands]]` entry in a stolen config could -//! mass-exfiltrate) makes this a separate security review. -//! * Multi-token triggers (`best regards` → `…`). The buffer is -//! reset at every word boundary; matching across boundaries -//! needs a sliding window we don't have today. -//! * Case-insensitive / case-preserving expansion. v1 matches -//! exactly — users pick triggers that don't collide with prose. +//! Since 0.10.0 there is a fourth, and it is the one with a threat +//! model rather than a one-line description: +//! +//! * [`CommandAction::RunShell`] → a program, executed directly. +//! **Off unless `[commands].allow_run_shell` is true**, never run +//! through a shell, and never handed anything the user typed as an +//! argument. [`shell`] is where the reasoning lives; read it before +//! changing any of that. +//! +//! Multi-token triggers also landed in 0.10.0. The word buffer still +//! resets at every boundary, so [`WordHistory`] holds the last few +//! completed words alongside it — bounded by length, by the idle +//! timeout, and by the focused application, because that history is +//! the only place the engine keeps more of the user's text than the +//! word being typed. +//! +//! What's still intentionally **not** here: +//! +//! * Case-insensitive / case-preserving expansion. Matching is +//! exact — users pick triggers that don't collide with prose, and a +//! case-insensitive `best regards` would fire on an ordinary +//! sign-off. +//! * Placeholders that substitute typed text into an action. For +//! `run_shell` that would be an argument-injection channel; for the +//! others it is a feature nobody has asked for yet. mod consts; mod enums; mod matching; +mod phrase; +mod shell; mod types; pub use consts::*; pub use enums::*; pub use matching::*; +pub use phrase::*; +pub use shell::*; pub use types::*; #[cfg(test)] diff --git a/crates/poltertype-core/src/commands/phrase.rs b/crates/poltertype-core/src/commands/phrase.rs new file mode 100644 index 0000000..5a130c6 --- /dev/null +++ b/crates/poltertype-core/src/commands/phrase.rs @@ -0,0 +1,168 @@ +//! Recent-word history, for triggers made of more than one token. +//! +//! A trigger used to be one word, because the word buffer resets at +//! every boundary and there was nothing to match a phrase against. +//! `best regards ` needs the engine to remember that `best` came +//! immediately before `regards`, so this is that memory — and +//! deliberately the smallest one that does the job. +//! +//! ## Why it is this small +//! +//! Keeping what the user typed is exactly what the rest of this +//! project works to avoid: the word buffer is RAM-only and +//! short-lived, and nothing is ever written to disk or a log. A word +//! history makes that memory *longer*, so it is bounded on three +//! axes at once: +//! +//! * **Length** — [`MAX_HISTORY_WORDS`] entries. Enough for the +//! longest trigger anyone reasonably writes, and no more. +//! * **Time** — [`clear`] is called on the same idle timeout that +//! already clears the word buffer, so a machine left alone is not +//! holding a sentence. +//! * **Context** — cleared when focus changes, so words typed in one +//! application cannot form a phrase with words typed in another. +//! +//! It also never leaves this process, is never logged (every debug +//! line about it goes through `redact_word`), and holds only words +//! that ended at a boundary — never the one being typed now. + +use super::UserCommand; + +/// How many completed words to remember. +/// +/// Four covers `best regards`, `kind regards`, `with best regards` +/// and the like. A longer window would buy vanishingly rare triggers +/// at the cost of holding more of the user's text in memory. +pub const MAX_HISTORY_WORDS: usize = 4; + +/// The most recent completed words, oldest first. +/// +/// `Default` is an empty history, which is also the state after +/// [`clear`] — there is no "uninitialised" case to handle. +#[derive(Debug, Default, Clone)] +pub struct WordHistory { + words: Vec, + /// The application these words were typed in, so a change of + /// focus can drop them. Kept inside the history rather than + /// beside it: "words from two applications never form a phrase" + /// is an invariant of this type, and a caller that forgot to + /// check would silently break it. + context: Option, +} + +impl WordHistory { + /// Record a completed word typed in `context`, dropping the + /// oldest when full. + /// + /// A different `context` than last time clears the history first: + /// half a trigger typed in one window must not combine with a + /// word typed in another. An unknown context (`None` — every + /// platform where focus tracking does not answer) is treated as + /// its own single context, which keeps the feature working there + /// rather than disabling it on a technicality. + pub fn push_in(&mut self, context: Option<&str>, word: &str) { + if self.context.as_deref() != context { + self.words.clear(); + self.context = context.map(str::to_owned); + } + self.push(word); + } + + /// Record a completed word, dropping the oldest when full. + pub fn push(&mut self, word: &str) { + if word.is_empty() { + return; + } + if self.words.len() == MAX_HISTORY_WORDS { + self.words.remove(0); + } + self.words.push(word.to_owned()); + } + + /// Forget everything. Called on the idle timeout, on a focus + /// change, and after a command fires — see the module docs. + pub fn clear(&mut self) { + self.words.clear(); + self.context = None; + } + + pub fn is_empty(&self) -> bool { + self.words.is_empty() + } + + pub fn len(&self) -> usize { + self.words.len() + } + + /// The last `n` words, oldest first — fewer if that many have not + /// been typed yet. + pub fn tail(&self, n: usize) -> &[String] { + let start = self.words.len().saturating_sub(n); + &self.words[start..] + } +} + +/// Split a trigger into its tokens. Whitespace-separated, with any +/// run of whitespace treated as one separator so a trigger written +/// with two spaces still matches text typed with one. +pub fn trigger_tokens(trigger: &str) -> Vec<&str> { + trigger.split_whitespace().collect() +} + +/// Does `cmd`'s trigger match the word just completed, given what +/// came before it? +/// +/// A single-token trigger is the old behaviour exactly: compare +/// against `current_word`. A multi-token trigger additionally +/// requires its earlier tokens to be the immediately preceding +/// words, in order. +/// +/// Case-sensitive, like single-token matching always was — users pick +/// triggers that do not collide with prose, and a case-insensitive +/// `best regards` would fire on an ordinary sign-off. +pub fn phrase_matches(cmd: &UserCommand, history: &WordHistory, current_word: &str) -> bool { + let tokens = trigger_tokens(&cmd.trigger); + let Some((last, earlier)) = tokens.split_last() else { + // An all-whitespace trigger matches nothing. Config + // validation rejects it too; this is the belt to that braces. + return false; + }; + if *last != current_word { + return false; + } + if earlier.is_empty() { + return true; + } + // More leading tokens than we remember: cannot match, and must + // not match a truncated prefix. + let preceding = history.tail(earlier.len()); + preceding.len() == earlier.len() + && preceding + .iter() + .zip(earlier) + .all(|(had, want)| had.as_str() == *want) +} + +/// How many on-screen characters a fired command has to erase. +/// +/// For a single-token trigger this is the buffered keys plus the +/// boundary the user just typed — what the engine already counted. +/// A multi-token trigger also has to take back the earlier words and +/// the separator after each, or half the phrase is left on screen. +/// +/// Counts **characters**, because that is what the screen shows and +/// what a backspace removes. +pub fn erase_len(cmd: &UserCommand, current_word_keys: usize) -> usize { + let tokens = trigger_tokens(&cmd.trigger); + let earlier: usize = tokens + .iter() + .rev() + .skip(1) + // +1 for the separator that followed each earlier token. + .map(|t| t.chars().count() + 1) + .sum(); + current_word_keys + 1 + earlier +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-core/src/commands/phrase/tests.rs b/crates/poltertype-core/src/commands/phrase/tests.rs new file mode 100644 index 0000000..4f97dc2 --- /dev/null +++ b/crates/poltertype-core/src/commands/phrase/tests.rs @@ -0,0 +1,184 @@ +use super::*; +use crate::commands::CommandAction; + +fn command(trigger: &str) -> UserCommand { + UserCommand { + id: "t".into(), + name: String::new(), + trigger: trigger.into(), + action: CommandAction::TypeText { text: "x".into() }, + apps: Vec::new(), + } +} + +fn history(words: &[&str]) -> WordHistory { + let mut h = WordHistory::default(); + for w in words { + h.push(w); + } + h +} + +// ── the bounded history ────────────────────────────────────────────── + +#[test] +fn history_keeps_only_the_most_recent_words() { + let h = history(&["one", "two", "three", "four", "five", "six"]); + assert_eq!(h.len(), MAX_HISTORY_WORDS); + assert_eq!(h.tail(2), ["five", "six"]); +} + +#[test] +fn asking_for_more_than_exists_returns_what_there_is() { + let h = history(&["only"]); + assert_eq!(h.tail(3), ["only"]); +} + +#[test] +fn clearing_forgets_everything() { + let mut h = history(&["a", "b"]); + h.clear(); + assert!(h.is_empty()); + assert!(h.tail(2).is_empty()); +} + +#[test] +fn empty_words_are_not_recorded() { + let mut h = WordHistory::default(); + h.push(""); + assert!(h.is_empty()); +} + +// ── matching ───────────────────────────────────────────────────────── + +/// The pre-existing behaviour, unchanged: a one-word trigger ignores +/// history entirely. +#[test] +fn a_single_token_trigger_matches_on_the_current_word_alone() { + let c = command("anrl"); + assert!(phrase_matches(&c, &WordHistory::default(), "anrl")); + assert!(phrase_matches(&c, &history(&["random", "words"]), "anrl")); + assert!(!phrase_matches(&c, &WordHistory::default(), "anr")); +} + +#[test] +fn a_two_token_trigger_needs_the_preceding_word() { + let c = command("best regards"); + assert!(phrase_matches(&c, &history(&["best"]), "regards")); + assert!(!phrase_matches(&c, &history(&["kind"]), "regards")); + assert!( + !phrase_matches(&c, &WordHistory::default(), "regards"), + "no history means no phrase" + ); +} + +#[test] +fn a_three_token_trigger_needs_both_preceding_words_in_order() { + let c = command("with best regards"); + assert!(phrase_matches(&c, &history(&["with", "best"]), "regards")); + assert!( + !phrase_matches(&c, &history(&["best", "with"]), "regards"), + "order matters" + ); + assert!( + !phrase_matches(&c, &history(&["best"]), "regards"), + "a truncated prefix must not match" + ); +} + +/// Words further back must not interfere: the trigger's tokens have +/// to be the *immediately* preceding ones. +#[test] +fn intervening_words_break_the_phrase() { + let c = command("best regards"); + assert!(!phrase_matches( + &c, + &history(&["best", "sincerely"]), + "regards" + )); +} + +#[test] +fn matching_stays_case_sensitive() { + let c = command("best regards"); + assert!(!phrase_matches(&c, &history(&["Best"]), "regards")); + assert!(!phrase_matches(&c, &history(&["best"]), "Regards")); +} + +#[test] +fn a_whitespace_only_trigger_matches_nothing() { + let c = command(" "); + assert!(!phrase_matches(&c, &history(&["a"]), "")); + assert!(!phrase_matches(&c, &history(&["a"]), "b")); +} + +#[test] +fn repeated_separators_in_a_trigger_are_one_separator() { + let c = command("best regards"); + assert!( + phrase_matches(&c, &history(&["best"]), "regards"), + "a trigger typed with two spaces should still match one" + ); +} + +// ── erasing ────────────────────────────────────────────────────────── + +/// A single-token trigger erases what it always did: the buffered +/// keys plus the boundary character. +#[test] +fn a_single_token_erases_word_plus_boundary() { + assert_eq!(erase_len(&command("anrl"), 4), 5); +} + +/// A phrase has to take back the earlier words and the space after +/// each, or half the trigger is left on screen. +#[test] +fn a_phrase_erases_the_earlier_words_and_their_separators() { + // "best regards " → 7 keys of "regards", +1 boundary, + // +5 for "best" and its space. + assert_eq!(erase_len(&command("best regards"), 7), 13); +} + +#[test] +fn erase_length_counts_characters_not_bytes() { + // Cyrillic tokens are two bytes per character; the screen shows + // one glyph each and a backspace removes one glyph. + assert_eq!(erase_len(&command("з повагою"), 8), 9 + 2); +} + +// ── focus scoping ──────────────────────────────────────────────────── + +/// The engine matches *before* recording the word just completed, so +/// these mirror that order: only the earlier words go into the +/// history, and the last token is passed as the current word. +/// +/// Half a trigger typed in one window must not combine with a word +/// typed in another. +#[test] +fn changing_application_drops_the_history() { + let mut h = WordHistory::default(); + h.push_in(Some("kate"), "best"); + // Focus moved; the next word arrives from a different app. + h.push_in(Some("firefox"), "and"); + assert!( + !phrase_matches(&command("best regards"), &h, "regards"), + "the `best` typed in kate must not complete a phrase in firefox" + ); +} + +#[test] +fn staying_in_one_application_keeps_the_history() { + let mut h = WordHistory::default(); + h.push_in(Some("kate"), "best"); + assert!(phrase_matches(&command("best regards"), &h, "regards")); +} + +/// Where focus tracking does not answer — macOS, most terminals on +/// GNOME/KDE — every word arrives with `None`, which is one context +/// rather than none. The feature keeps working there. +#[test] +fn an_unknown_context_is_still_one_context() { + let mut h = WordHistory::default(); + h.push_in(None, "best"); + assert!(phrase_matches(&command("best regards"), &h, "regards")); +} diff --git a/crates/poltertype-core/src/commands/shell.rs b/crates/poltertype-core/src/commands/shell.rs new file mode 100644 index 0000000..26b7f89 --- /dev/null +++ b/crates/poltertype-core/src/commands/shell.rs @@ -0,0 +1,230 @@ +//! `run_shell` — running a program when a trigger fires. +//! +//! This is the one action that can do anything, so it is the one with +//! a threat model written down. +//! +//! ## What the danger actually is +//! +//! PolterType already reads every keystroke. Adding "and can run a +//! program" turns a stolen or mistaken `config.toml` from an +//! annoyance into remote code execution that fires the next time the +//! user types an ordinary word. Three concrete routes: +//! +//! 1. **A synced config.** People keep dotfiles in git and share +//! them. A `[[commands]]` entry pulled in from someone else's repo +//! runs on this machine. +//! 2. **A trigger that collides with prose.** `date` looks like a +//! fine trigger until someone writes "the release date is". The +//! engine cannot tell the difference. +//! 3. **Shell metacharacters.** `sh -c "echo $FOO"` invites quoting +//! bugs, and every quoting bug in a string that came from a config +//! file is an injection. +//! +//! ## What is done about each +//! +//! * **Off unless switched on.** `[commands].allow_run_shell` is +//! `false` by default. A config full of `run_shell` entries on a +//! machine that never enabled it runs nothing and says so once per +//! entry at load. +//! * **No shell.** [`ShellCommand`] is a program plus an argument +//! vector, executed directly. There is no `sh -c`, so there is +//! nothing for a metacharacter to mean. Users who genuinely want a +//! pipeline write `sh` as the program and `-c` as an argument — +//! explicit, visible, and their decision. +//! * **Nothing the user typed becomes an argument.** No placeholder +//! substitutes the trigger, the buffer, or the surrounding text +//! into the command line. That would turn every expansion into a +//! way to smuggle arguments, and would put typed text into a +//! process table other users can read. +//! * **Bounded.** A timeout, a captured-output cap, and no stdin. +//! A command that hangs cannot wedge the correction pipeline, +//! because it does not run on it — see below. +//! * **Never on the typing path.** Dispatch is fire-and-forget on a +//! worker thread. The engine's word-boundary handler returns +//! immediately, exactly as it does for the other actions. +//! +//! ## Output insertion +//! +//! `insert_output = true` types the command's stdout at the cursor, +//! which is the point of the feature (`:date:` → today's date). It is +//! also the sharpest edge: whatever the program prints is typed into +//! whatever window has focus. So output is capped, trimmed to a +//! single logical line by default, and never inserted when the +//! command failed — a program that writes an error to stdout and +//! exits non-zero should not have its error typed into the user's +//! document. + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use tracing::{debug, warn}; + +/// Longest a triggered command may run before it is abandoned. +pub const RUN_TIMEOUT: Duration = Duration::from_secs(5); + +/// Most stdout bytes kept when `insert_output` is set. A command that +/// prints a megabyte should not have a megabyte typed into the user's +/// editor one keystroke at a time. +pub const MAX_OUTPUT_BYTES: usize = 4 * 1024; + +/// A program to run, already split — never a shell string. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ShellCommand { + /// Executable to run. Resolved through `PATH` by the OS, as any + /// other program launch is. + pub program: String, + /// Arguments, passed verbatim. No shell, no globbing, no + /// substitution of anything the user typed. + #[serde(default)] + pub args: Vec, + /// Type the command's stdout at the cursor when it succeeds. + #[serde(default)] + pub insert_output: bool, +} + +/// Why a `run_shell` entry will not run. +#[derive(Debug, PartialEq, Eq)] +pub enum ShellRefusal { + /// `[commands].allow_run_shell` is false. + NotEnabled, + /// The entry names no program. + EmptyProgram, +} + +impl std::fmt::Display for ShellRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotEnabled => write!( + f, + "`run_shell` commands are disabled — set `[commands].allow_run_shell = true` \ + to enable them, and read docs/SMART_COMMANDS.md first" + ), + Self::EmptyProgram => write!(f, "`run_shell` needs a non-empty `program`"), + } + } +} + +/// Check an entry before running it. Separated from execution so the +/// settings loader can report a refusal once, at load, instead of +/// silently doing nothing every time the user types the trigger. +pub fn check(cmd: &ShellCommand, allow_run_shell: bool) -> Result<(), ShellRefusal> { + if cmd.program.trim().is_empty() { + return Err(ShellRefusal::EmptyProgram); + } + if !allow_run_shell { + return Err(ShellRefusal::NotEnabled); + } + Ok(()) +} + +/// Run the command and return its stdout, if it should be inserted. +/// +/// Returns `None` whenever nothing should be typed: output insertion +/// off, the command failed, it produced nothing, or it had to be +/// abandoned. Never returns `Err` — a failing user command is a +/// normal event that belongs in the log, not an error the engine has +/// to thread anywhere. +/// +/// **Must not be called from the word-boundary handler.** It blocks +/// for up to [`RUN_TIMEOUT`]. +pub fn run(cmd: &ShellCommand) -> Option { + let started = Instant::now(); + let mut child = match Command::new(&cmd.program) + .args(&cmd.args) + // No stdin: a program that waits for input would otherwise + // block until the timeout, every single time. + .stdin(Stdio::null()) + .stdout(if cmd.insert_output { + Stdio::piped() + } else { + Stdio::null() + }) + .stderr(Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { + warn!(program = %cmd.program, %e, "smart command failed to start"); + return None; + } + }; + + // Poll rather than `wait()`: a hung program must not keep this + // worker thread forever. + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() >= RUN_TIMEOUT => { + warn!( + program = %cmd.program, + timeout_s = RUN_TIMEOUT.as_secs(), + "smart command timed out; killing it" + ); + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(e) => { + warn!(program = %cmd.program, %e, "smart command wait failed"); + return None; + } + } + }; + + if !status.success() { + warn!( + program = %cmd.program, + code = status.code().unwrap_or(-1), + "smart command exited non-zero; nothing will be typed" + ); + return None; + } + if !cmd.insert_output { + debug!(program = %cmd.program, "smart command finished"); + return None; + } + + let mut buf = Vec::new(); + if let Some(mut out) = child.stdout.take() { + use std::io::Read; + // Cap at the source: read one byte past the limit so an + // over-long output is detected without buffering all of it. + let mut limited = std::io::Read::take(&mut out, MAX_OUTPUT_BYTES as u64 + 1); + if let Err(e) = limited.read_to_end(&mut buf) { + warn!(program = %cmd.program, %e, "could not read smart command output"); + return None; + } + } + Some(sanitise_output(&buf)).filter(|s| !s.is_empty()) +} + +/// Turn raw stdout into something safe to type. +/// +/// Three things happen here, each because typing is not printing: +/// +/// * **Truncate** to [`MAX_OUTPUT_BYTES`], on a character boundary. +/// * **Drop control characters.** A newline in the middle of typed +/// output submits a chat message or runs a shell line; a `\r` or an +/// escape sequence does stranger things still. Interior newlines +/// become spaces, everything else in the C0 range goes. +/// * **Trim** the trailing newline every well-behaved command emits, +/// which the user did not ask to have typed. +pub fn sanitise_output(raw: &[u8]) -> String { + let text = String::from_utf8_lossy(raw); + let mut cut = text.len().min(MAX_OUTPUT_BYTES); + while cut > 0 && !text.is_char_boundary(cut) { + cut -= 1; + } + text[..cut] + .trim() + .chars() + .map(|c| if c == '\n' || c == '\t' { ' ' } else { c }) + .filter(|c| !c.is_control()) + .collect::() + .trim() + .to_owned() +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-core/src/commands/shell/tests.rs b/crates/poltertype-core/src/commands/shell/tests.rs new file mode 100644 index 0000000..d08054d --- /dev/null +++ b/crates/poltertype-core/src/commands/shell/tests.rs @@ -0,0 +1,153 @@ +use super::*; + +fn cmd(program: &str, args: &[&str], insert: bool) -> ShellCommand { + ShellCommand { + program: program.into(), + args: args.iter().map(|s| (*s).to_string()).collect(), + insert_output: insert, + } +} + +// ── the gate ───────────────────────────────────────────────────────── + +/// The default. A config full of `run_shell` entries on a machine +/// that never enabled them must run nothing. +#[test] +fn nothing_runs_while_the_setting_is_off() { + assert_eq!( + check(&cmd("echo", &["hi"], false), false), + Err(ShellRefusal::NotEnabled) + ); +} + +#[test] +fn the_refusal_says_which_setting_to_flip() { + let msg = ShellRefusal::NotEnabled.to_string(); + assert!(msg.contains("allow_run_shell"), "{msg}"); +} + +#[test] +fn an_empty_program_is_refused_even_when_enabled() { + assert_eq!( + check(&cmd(" ", &[], false), true), + Err(ShellRefusal::EmptyProgram) + ); +} + +#[test] +fn a_valid_entry_passes_when_enabled() { + assert!(check(&cmd("echo", &["hi"], true), true).is_ok()); +} + +// ── no shell means no injection ────────────────────────────────────── + +/// The property the whole design rests on: arguments are passed +/// verbatim to the program, never to a shell. A metacharacter is +/// therefore just a character. +#[test] +fn metacharacters_are_data_not_syntax() { + let out = run(&cmd( + "echo", + &["hello; touch /tmp/pt-should-not-exist"], + true, + )); + assert_eq!( + out.as_deref(), + Some("hello; touch /tmp/pt-should-not-exist"), + "the semicolon must be echoed, not executed" + ); + assert!( + !std::path::Path::new("/tmp/pt-should-not-exist").exists(), + "a shell ran when none should have" + ); +} + +// ── output handling ────────────────────────────────────────────────── + +#[test] +fn stdout_comes_back_when_insertion_is_on() { + assert_eq!( + run(&cmd("echo", &["poltertype"], true)).as_deref(), + Some("poltertype") + ); +} + +#[test] +fn nothing_comes_back_when_insertion_is_off() { + assert_eq!(run(&cmd("echo", &["poltertype"], false)), None); +} + +/// A program that writes to stdout and then fails should not have its +/// message typed into the user's document. +#[test] +fn a_failing_command_types_nothing() { + assert_eq!(run(&cmd("sh", &["-c", "echo oops; exit 3"], true)), None); +} + +#[test] +fn a_missing_program_is_survivable() { + assert_eq!(run(&cmd("poltertype-no-such-binary-xyz", &[], true)), None); +} + +/// A hung command must not hold the worker thread forever. Uses a +/// sleep just past the timeout so the test stays quick. +#[test] +fn a_hanging_command_is_killed() { + let started = Instant::now(); + let out = run(&cmd("sleep", &["30"], true)); + assert_eq!(out, None); + assert!( + started.elapsed() < RUN_TIMEOUT + Duration::from_secs(3), + "should have given up near the timeout, took {:?}", + started.elapsed() + ); +} + +// ── sanitising ─────────────────────────────────────────────────────── + +/// Typing is not printing. A newline in the middle of inserted text +/// submits a chat message or runs a shell line. +#[test] +fn newlines_never_reach_the_keyboard() { + let s = sanitise_output(b"first\nsecond\r\nthird"); + assert!(!s.contains('\n') && !s.contains('\r'), "got {s:?}"); + assert!(s.contains("first") && s.contains("third")); +} + +#[test] +fn the_trailing_newline_every_command_emits_is_dropped() { + assert_eq!(sanitise_output(b"2026-08-02\n"), "2026-08-02"); +} + +#[test] +fn escape_sequences_are_stripped() { + let s = sanitise_output(b"\x1b[31mred\x1b[0m"); + assert!(!s.contains('\x1b'), "got {s:?}"); + assert!(s.contains("red")); +} + +#[test] +fn output_is_capped() { + let big = vec![b'x'; MAX_OUTPUT_BYTES * 4]; + assert!(sanitise_output(&big).len() <= MAX_OUTPUT_BYTES); +} + +/// Truncation must not split a multi-byte character into invalid +/// UTF-8 — the result is typed, and half a character is not typeable. +#[test] +fn truncation_respects_character_boundaries() { + let mut raw = Vec::new(); + while raw.len() < MAX_OUTPUT_BYTES + 8 { + raw.extend_from_slice("привіт".as_bytes()); + } + let s = sanitise_output(&raw); + assert!(s.len() <= MAX_OUTPUT_BYTES); + assert!(!s.is_empty()); + // Round-tripping proves it is valid UTF-8 with no partial char. + assert_eq!(s, String::from_utf8_lossy(s.as_bytes())); +} + +#[test] +fn invalid_utf8_does_not_panic() { + let _ = sanitise_output(&[0xFF, 0xFE, b'o', b'k']); +} diff --git a/crates/poltertype-core/src/commands/tests.rs b/crates/poltertype-core/src/commands/tests.rs index 5254fa0..6fe172c 100644 --- a/crates/poltertype-core/src/commands/tests.rs +++ b/crates/poltertype-core/src/commands/tests.rs @@ -118,17 +118,18 @@ fn find_matching_command_basic_match() { let list = vec![cmd("a", "anrl", &[]), cmd("b", "((en))", &[])]; // Exact match. - let m = find_matching_command(&list, "anrl", None).expect("matches"); + let m = find_matching_command(&list, "anrl", None, &WordHistory::default()).expect("matches"); assert_eq!(m.id, "a"); // Different trigger. - let m2 = find_matching_command(&list, "((en))", None).expect("matches"); + let m2 = + find_matching_command(&list, "((en))", None, &WordHistory::default()).expect("matches"); assert_eq!(m2.id, "b"); // No match — case-sensitive, "ANRL" != "anrl". - assert!(find_matching_command(&list, "ANRL", None).is_none()); + assert!(find_matching_command(&list, "ANRL", None, &WordHistory::default()).is_none()); // No match — completely unrelated word. - assert!(find_matching_command(&list, "hello", None).is_none()); + assert!(find_matching_command(&list, "hello", None, &WordHistory::default()).is_none()); // No match — empty word. - assert!(find_matching_command(&list, "", None).is_none()); + assert!(find_matching_command(&list, "", None, &WordHistory::default()).is_none()); } /// `apps` filter: empty list = match anywhere; non-empty = the @@ -138,14 +139,20 @@ fn find_matching_command_app_filter() { let list = vec![cmd("a", "anrl", &["Code.exe", "idea64.exe"])]; // No app reported → fail closed (filter set, can't verify). - assert!(find_matching_command(&list, "anrl", None).is_none()); + assert!(find_matching_command(&list, "anrl", None, &WordHistory::default()).is_none()); // Wrong app → no match. - assert!(find_matching_command(&list, "anrl", Some("chrome.exe")).is_none()); + assert!( + find_matching_command(&list, "anrl", Some("chrome.exe"), &WordHistory::default()).is_none() + ); // Right app, exact case → match. - assert!(find_matching_command(&list, "anrl", Some("Code.exe")).is_some()); + assert!( + find_matching_command(&list, "anrl", Some("Code.exe"), &WordHistory::default()).is_some() + ); // Right app, wrong case → still match (case-insensitive // basename comparison, mirrors `disabled_apps` rules). - assert!(find_matching_command(&list, "anrl", Some("CODE.EXE")).is_some()); + assert!( + find_matching_command(&list, "anrl", Some("CODE.EXE"), &WordHistory::default()).is_some() + ); } /// First match wins when two commands share a trigger. That's @@ -154,6 +161,6 @@ fn find_matching_command_app_filter() { #[test] fn find_matching_command_first_match_wins() { let list = vec![cmd("a", "dup", &[]), cmd("b", "dup", &[])]; - let m = find_matching_command(&list, "dup", None).expect("matches"); + let m = find_matching_command(&list, "dup", None, &WordHistory::default()).expect("matches"); assert_eq!(m.id, "a"); } diff --git a/crates/poltertype-core/src/engine/switcher/commands.rs b/crates/poltertype-core/src/engine/switcher/commands.rs index 4e56f5c..1dac165 100644 --- a/crates/poltertype-core/src/engine/switcher/commands.rs +++ b/crates/poltertype-core/src/engine/switcher/commands.rs @@ -1,6 +1,8 @@ //! Keystream hotkey chords (Wayland path), the suggestion-accept //! digit chords (every platform), and smart-command dispatch. +use std::sync::Arc; + use crossbeam_channel::Receiver; use poltertype_input::{KeyDirection, KeyEvent}; use tracing::{info, warn}; @@ -201,6 +203,55 @@ impl SwitcherEngine { warn!(?e, id = %cmd.id, path = %path, "smart command: open failed"); } } + CommandAction::RunShell(shell) => self.dispatch_run_shell(cmd, shell, boundary_char), + } + } + + /// Run a `run_shell` command off the correction path. + /// + /// The word-boundary handler must return promptly — it is what + /// stands between the user's keystroke and the corrected word — + /// and a user command can block for up to `shell::RUN_TIMEOUT`. + /// So the process is started on a worker thread, and the thread + /// types the output when there is any. + /// + /// The refusal check happens here as well as at settings load: + /// `allow_run_shell` can be turned off while the app runs, and + /// the entry that was legal at startup must stop working the + /// moment it is. + fn dispatch_run_shell( + &self, + cmd: &UserCommand, + shell: &crate::commands::ShellCommand, + boundary_char: char, + ) { + let allow = self.settings.snapshot().commands_allow_run_shell; + if let Err(refusal) = crate::commands::check(shell, allow) { + warn!(id = %cmd.id, %refusal, "smart command: refused"); + return; + } + + let shell = shell.clone(); + let id = cmd.id.clone(); + let emitter = Arc::clone(&self.key_emitter); + let spawned = std::thread::Builder::new() + .name("poltertype-smart-command".into()) + .spawn(move || { + let Some(output) = crate::commands::run(&shell) else { + return; + }; + // Typing from a worker thread is safe for the same + // reason the correction replay is: the emitter is + // `Send + Sync` and every emitted key comes back + // through the listener marked as ours. + let mut text = output; + text.push(boundary_char); + if let Err(e) = emitter.send_text(&text) { + warn!(?e, %id, "smart command: typing output failed"); + } + }); + if let Err(e) = spawned { + warn!(%e, id = %cmd.id, "smart command: could not start worker thread"); } } } diff --git a/crates/poltertype-core/src/engine/switcher/decide.rs b/crates/poltertype-core/src/engine/switcher/decide.rs index 2a2c0be..4741c39 100644 --- a/crates/poltertype-core/src/engine/switcher/decide.rs +++ b/crates/poltertype-core/src/engine/switcher/decide.rs @@ -8,7 +8,7 @@ use poltertype_layout::LayoutId; use poltertype_types::{SwitchAction, logsafe}; use tracing::{debug, warn}; -use crate::commands::find_matching_command; +use crate::commands::{erase_len, find_matching_command}; use crate::engine::buffer::WordBuffer; use crate::engine::enums::SwitcherEvent; use crate::engine::heuristics::{ @@ -156,19 +156,35 @@ impl SwitcherEngine { .and_then(|f| f.to_str()) .map(str::to_owned) }); - if let Some(cmd) = - find_matching_command(&snap.commands, ¤t_text, focused_basename.as_deref()) - { + // A multi-token trigger ("best regards") also has to see the + // words before this one, so the history is consulted here and + // updated below whatever the outcome. + let history = self.word_history.read().clone(); + if let Some(cmd) = find_matching_command( + &snap.commands, + ¤t_text, + focused_basename.as_deref(), + &history, + ) { // Erase one on-screen character per buffered key plus the - // boundary. Counting keys (not rendered chars) survives - // scancodes our mapping table can't render — the screen - // still shows a character for those. - self.dispatch_smart_command(cmd, keys.len() + 1, boundary_char); - // The trigger text no longer exists on screen — the word - // must not be re-openable via backspace. + // boundary — and, for a phrase, the earlier words and the + // separator after each. Counting keys (not rendered + // chars) for the current word survives scancodes our + // mapping table can't render; the screen still shows a + // character for those. + self.dispatch_smart_command(cmd, erase_len(cmd, keys.len()), boundary_char); + // The trigger text no longer exists on screen — neither + // the word nor the phrase leading to it may be re-opened + // by backspace, or matched again by the next word. buffer.forget_completed(); + self.word_history.write().clear(); return; } + // Not a trigger: remember it, so it can be the first half of + // one next time. + self.word_history + .write() + .push_in(focused_basename.as_deref(), ¤t_text); // ---- Pre-decision filters (auto-switch only) ---- // diff --git a/crates/poltertype-core/src/engine/switcher/engine.rs b/crates/poltertype-core/src/engine/switcher/engine.rs index 2c396f2..b7a99cb 100644 --- a/crates/poltertype-core/src/engine/switcher/engine.rs +++ b/crates/poltertype-core/src/engine/switcher/engine.rs @@ -14,6 +14,7 @@ use poltertype_layout::LayoutSwitcher; use poltertype_types::Modifiers; use crate::audio::AudioPlayer; +use crate::commands::WordHistory; use crate::engine::enums::SwitcherEvent; use crate::engine::types::{KeystreamHotkeys, LastWord, PendingSuggestion}; use crate::layouts::LayoutDb; @@ -45,6 +46,14 @@ pub struct SwitcherEngine { pub(super) audio: Arc, pub(super) out_tx: Sender, pub(super) paused: Arc>, + /// The last few completed words, so a smart-command trigger can + /// span more than one of them (`best regards`). + /// + /// Bounded three ways — length, the idle timeout, and a focus + /// change — because this is the one place the engine holds more + /// of the user's text than the word being typed. See + /// [`crate::commands::phrase`]. + pub(super) word_history: Arc>, /// Buffer of the previous fully-completed word (for "switch-last"). pub(super) last_word: Arc>>, /// Expected echoes of our own injected keystrokes: scancodes of @@ -131,6 +140,7 @@ impl SwitcherEngine { audio, out_tx, paused: Arc::new(RwLock::new(false)), + word_history: Arc::new(RwLock::new(WordHistory::default())), last_word: Arc::new(RwLock::new(None)), expected_echo: Mutex::new(VecDeque::new()), keystream_hotkeys: RwLock::new(KeystreamHotkeys::default()), diff --git a/crates/poltertype-core/src/engine/switcher/run_loop.rs b/crates/poltertype-core/src/engine/switcher/run_loop.rs index 0e6dc02..2702f2f 100644 --- a/crates/poltertype-core/src/engine/switcher/run_loop.rs +++ b/crates/poltertype-core/src/engine/switcher/run_loop.rs @@ -91,6 +91,12 @@ impl SwitcherEngine { // with the buffer. buffer.abandon(); *self.last_word.write() = None; + // The phrase history is bounded by time as + // well as by length: a machine left alone + // must not still be holding a sentence, + // and a trigger should not fire from words + // typed before a long pause. + self.word_history.write().clear(); self.dismiss_suggestions(None); } } diff --git a/crates/poltertype-core/src/i18n/catalog.rs b/crates/poltertype-core/src/i18n/catalog.rs new file mode 100644 index 0000000..fd233c6 --- /dev/null +++ b/crates/poltertype-core/src/i18n/catalog.rs @@ -0,0 +1,102 @@ +//! One locale's key → text table. + +use std::collections::HashMap; +use std::path::Path; + +use tracing::warn; + +pub struct Catalog { + locale: String, + entries: HashMap, +} + +impl Catalog { + /// A catalog with no translations — English, or a locale whose + /// file could not be read. `tr` then always returns its fallback. + pub fn empty(locale: String) -> Self { + Self { + locale, + entries: HashMap::new(), + } + } + + /// Read `/.toml`, falling back to the bare language + /// subtag: a user with `uk_UA.UTF-8` gets `uk.toml`, which is the + /// common case and saves shipping a file per region. + pub fn load(dir: &Path, locale: &str) -> Self { + let bare = locale + .split(['_', '-', '.']) + .next() + .unwrap_or(locale) + .to_owned(); + for candidate in [locale.to_owned(), bare] { + let path = dir.join(format!("{candidate}.toml")); + match std::fs::read_to_string(&path) { + Ok(text) => return Self::parse(locale, &text, &path.display().to_string()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + warn!(path = %path.display(), %e, "UI translation unreadable"); + continue; + } + } + } + Self::empty(locale.to_owned()) + } + + /// Parse one catalog file: a flat table of `key = "text"`. + /// + /// Nested tables and non-string values are skipped with a warning + /// rather than rejecting the whole file — one bad line in a + /// community translation should cost that line, not the language. + pub fn parse(locale: &str, text: &str, origin: &str) -> Self { + let parsed: toml::Value = match toml::from_str(text) { + Ok(v) => v, + Err(e) => { + warn!(%origin, %e, "UI translation is not valid TOML; staying in English"); + return Self::empty(locale.to_owned()); + } + }; + let Some(table) = parsed.as_table() else { + warn!(%origin, "UI translation is not a table; staying in English"); + return Self::empty(locale.to_owned()); + }; + + let mut entries = HashMap::with_capacity(table.len()); + let mut skipped = 0usize; + for (key, value) in table { + match value.as_str() { + // An empty translation means "not translated yet" — + // storing it would shadow the English fallback with a + // blank label, which is the one outcome worse than + // being untranslated. + Some(s) if !s.trim().is_empty() => { + entries.insert(key.clone(), s.to_owned()); + } + _ => skipped += 1, + } + } + if skipped > 0 { + warn!(%origin, skipped, "UI translation entries skipped (empty or not a string)"); + } + Self { + locale: locale.to_owned(), + entries, + } + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.entries.get(key).map(String::as_str) + } + + pub fn locale(&self) -> &str { + &self.locale + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} diff --git a/crates/poltertype-core/src/i18n/consts.rs b/crates/poltertype-core/src/i18n/consts.rs new file mode 100644 index 0000000..498a1f0 --- /dev/null +++ b/crates/poltertype-core/src/i18n/consts.rs @@ -0,0 +1,17 @@ +//! Fixed values the translation loader is built around. + +/// Sub-directory of `` holding `.toml` catalogs. +pub const I18N_DIR: &str = "i18n"; + +/// Locales with a catalog in the repository, for the Settings +/// language picker and the build script that copies them. +/// +/// English is not listed: it is not a catalog, it is the fallback +/// compiled into every `tr` call site, and it works even if this +/// directory is missing entirely. +/// +/// A locale absent from this list still *works* — drop a +/// `/poltertype/i18n/.toml` in and set +/// `[ui].language`. The list is what the picker offers, not what the +/// loader accepts. +pub const SHIPPED_LOCALES: &[(&str, &str)] = &[("uk", "Українська")]; diff --git a/crates/poltertype-core/src/i18n/detect.rs b/crates/poltertype-core/src/i18n/detect.rs new file mode 100644 index 0000000..aab6d49 --- /dev/null +++ b/crates/poltertype-core/src/i18n/detect.rs @@ -0,0 +1,53 @@ +//! Which language to show the interface in. + +/// Resolve the UI locale: an explicit setting wins, otherwise the +/// environment, otherwise English. +/// +/// `"auto"`, `"system"` and the empty string all mean "ask the +/// environment". `"system"` is the value `[general].ui_language` has +/// shipped with since the setting was added — it was never read until +/// 0.10.0, so honouring it costs nothing and silently upgrading every +/// existing config file to a new spelling would be worse. +/// +/// Environment detection is the POSIX trio (`LC_ALL`, `LC_MESSAGES`, +/// `LANG`), checked in the order the C library uses. Windows sets none +/// of these and therefore lands on English unless the user picks a +/// language in Settings — deliberate: reading the Windows locale means +/// a `#[cfg(target_os)]`, and platform code belongs in the seven +/// crates allowed to hold it, not in `poltertype-core`. A picker the +/// user can reach is a better answer than a guess anyway. +pub fn resolve_locale(requested: Option<&str>) -> String { + if let Some(explicit) = requested { + let trimmed = explicit.trim(); + let automatic = + trimmed.eq_ignore_ascii_case("auto") || trimmed.eq_ignore_ascii_case("system"); + if !trimmed.is_empty() && !automatic { + return normalise(trimmed); + } + } + for var in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Some(raw) = std::env::var_os(var) { + let value = raw.to_string_lossy(); + let trimmed = value.trim(); + // `C` and `POSIX` are "no locale", not a language. + if trimmed.is_empty() || trimmed == "C" || trimmed == "POSIX" { + continue; + } + return normalise(trimmed); + } + } + "en".to_owned() +} + +/// `uk_UA.UTF-8` → `uk_UA`; lowercase the language subtag so lookups +/// are predictable. The encoding and any `@modifier` are dropped — +/// they say nothing about which words to show. +fn normalise(locale: &str) -> String { + let without_encoding = locale.split(['.', '@']).next().unwrap_or(locale); + let mut parts = without_encoding.splitn(2, ['_', '-']); + let language = parts.next().unwrap_or("").to_ascii_lowercase(); + match parts.next() { + Some(region) if !region.is_empty() => format!("{language}_{}", region.to_ascii_uppercase()), + _ => language, + } +} diff --git a/crates/poltertype-core/src/i18n/mod.rs b/crates/poltertype-core/src/i18n/mod.rs new file mode 100644 index 0000000..b2ee411 --- /dev/null +++ b/crates/poltertype-core/src/i18n/mod.rs @@ -0,0 +1,127 @@ +//! UI translation. +//! +//! The interface was English-only until 0.10.0, which is an odd look +//! on an app whose entire subject is other people's languages. +//! +//! ## The shape, and why this one +//! +//! Every translatable string is fetched with [`tr`], which takes a +//! stable key **and the English text**: +//! +//! ```ignore +//! Text::new(tr("languages.title", "Languages")) +//! ``` +//! +//! Passing the English at the call site is the load-bearing choice. +//! It means English is compiled in and cannot go missing: a catalog +//! that fails to load, a key nobody translated, a file a packager +//! forgot — every one of those degrades to readable English rather +//! than to a blank button or a raw `languages.title` staring at the +//! user. It also keeps the source readable; you do not have to open a +//! TOML file to find out what a screen says. +//! +//! Translations themselves live in data, like layouts and wordlists: +//! `/i18n/.toml`, one flat table of `key = "text"`. +//! Adding a language is a file, not a rebuild — the same promise +//! `docs/ADDING_A_LANGUAGE.md` makes about keyboard layouts. +//! +//! ## Loaded once, read forever +//! +//! [`init`] is called once while the settings window starts, before +//! any widget is built. After that [`tr`] is a hash lookup returning a +//! `&'static str` borrowed from the process-lifetime catalog, so the +//! view function — which runs on every frame — allocates nothing. +//! +//! Calling [`tr`] before [`init`], or after an `init` that found no +//! catalog, returns the English fallback. There is no failure mode +//! that produces a wrong-looking UI, only a less-translated one. + +mod catalog; +mod consts; +mod detect; + +pub use catalog::Catalog; +pub use consts::{I18N_DIR, SHIPPED_LOCALES}; +pub use detect::resolve_locale; + +use std::path::Path; +use std::sync::OnceLock; + +use tracing::{debug, info}; + +static CATALOG: OnceLock = OnceLock::new(); + +/// Load the catalog for `requested` (or the environment's locale when +/// `None`) out of `/i18n/`. +/// +/// Idempotent and infallible by design: a missing directory, an +/// unreadable file or a malformed TOML all leave the UI in English, +/// which is a perfectly good outcome and not worth an error path +/// through the window's startup. +pub fn init(data_dir: &Path, requested: Option<&str>) { + if CATALOG.get().is_some() { + return; + } + let locale = resolve_locale(requested); + if locale.starts_with("en") { + debug!(%locale, "UI language is English; no catalog needed"); + let _ = CATALOG.set(Catalog::empty(locale)); + return; + } + let catalog = Catalog::load(&data_dir.join(I18N_DIR), &locale); + if catalog.is_empty() { + info!( + %locale, + "no UI translation found for this locale; the interface stays in English" + ); + } else { + info!(%locale, entries = catalog.len(), "UI translation loaded"); + } + let _ = CATALOG.set(catalog); +} + +/// The translated text for `key`, or `english` when there is none. +/// +/// Never allocates and never fails. See the module docs for why the +/// English is a parameter rather than a lookup of its own. +pub fn tr(key: &str, english: &'static str) -> &'static str { + CATALOG.get().and_then(|c| c.get(key)).unwrap_or(english) +} + +/// [`tr`] for a string with `{}` placeholders. +/// +/// `format!` needs a literal, so an interpolated sentence cannot be +/// translated through the macro — and interpolated sentences are +/// exactly the long explanatory ones that most need translating. +/// Substitution is positional and deliberately dumb: each `{}` in +/// order takes the next argument. +/// +/// A translation with **fewer** placeholders than arguments is +/// honoured as written — some languages genuinely need to drop a +/// number — and one with more leaves the extras as literal `{}` +/// rather than panicking. Nothing here can fail; the worst outcome is +/// a sentence that reads oddly, which a translator can see and fix. +pub fn tr_args(key: &str, english: &'static str, args: &[&str]) -> String { + let template = tr(key, english); + let mut out = String::with_capacity(template.len() + 16); + let mut rest = template; + let mut next = args.iter(); + while let Some(pos) = rest.find("{}") { + out.push_str(&rest[..pos]); + match next.next() { + Some(arg) => out.push_str(arg), + None => out.push_str("{}"), + } + rest = &rest[pos + 2..]; + } + out.push_str(rest); + out +} + +/// The locale actually in force, for the About pane and the logs. +pub fn active_locale() -> &'static str { + CATALOG.get().map_or("en", Catalog::locale) +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-core/src/i18n/tests.rs b/crates/poltertype-core/src/i18n/tests.rs new file mode 100644 index 0000000..a2d7b67 --- /dev/null +++ b/crates/poltertype-core/src/i18n/tests.rs @@ -0,0 +1,236 @@ +use super::*; + +// ── locale resolution ──────────────────────────────────────────────── + +#[test] +fn an_explicit_setting_wins_over_the_environment() { + assert_eq!(resolve_locale(Some("uk")), "uk"); + assert_eq!(resolve_locale(Some("pt-BR")), "pt_BR"); +} + +#[test] +fn auto_and_blank_defer_to_the_environment() { + // Whatever this machine's env says, both must agree — the point + // is that neither is treated as an explicit choice. + assert_eq!(resolve_locale(Some("auto")), resolve_locale(None)); + assert_eq!(resolve_locale(Some(" ")), resolve_locale(None)); + assert_eq!(resolve_locale(Some("AUTO")), resolve_locale(None)); +} + +#[test] +fn encodings_and_modifiers_are_stripped() { + assert_eq!(resolve_locale(Some("uk_UA.UTF-8")), "uk_UA"); + assert_eq!(resolve_locale(Some("de_DE@euro")), "de_DE"); + assert_eq!(resolve_locale(Some("EL_gr.utf8")), "el_GR"); +} + +// ── catalogs ───────────────────────────────────────────────────────── + +fn catalog(body: &str) -> Catalog { + Catalog::parse("uk", body, "") +} + +#[test] +fn a_catalog_translates_known_keys() { + let c = catalog( + r#" +"languages.title" = "Мови" +"general.save" = "Зберегти" +"#, + ); + assert_eq!(c.get("languages.title"), Some("Мови")); + assert_eq!(c.get("general.save"), Some("Зберегти")); + assert_eq!(c.get("nothing.here"), None); + assert_eq!(c.len(), 2); +} + +/// The property the whole design rests on: anything wrong with a +/// catalog degrades to English, never to a blank or a raw key. +#[test] +fn a_broken_catalog_is_empty_rather_than_wrong() { + for body in [ + "this is not toml at all {{{", + "[nested]\nkey = \"x\"", // tables are skipped, not adopted + "count = 3", // non-string values + "", + ] { + let c = catalog(body); + assert!( + c.get("count").is_none() && c.get("key").is_none(), + "nothing usable should come out of {body:?}" + ); + } +} + +/// An empty string is "not translated yet". Storing it would shadow +/// the English fallback with a blank label — the one result worse +/// than staying untranslated. +#[test] +fn empty_translations_do_not_shadow_the_english() { + let c = catalog( + r#" +"a" = "" +"b" = " " +"c" = "справжній" +"#, + ); + assert_eq!(c.get("a"), None); + assert_eq!(c.get("b"), None); + assert_eq!(c.get("c"), Some("справжній")); +} + +/// One bad entry costs that entry, not the language — the same rule +/// the AI plug-in factory follows for one bad `[[ai.plugins]]` block. +#[test] +fn one_bad_entry_does_not_discard_the_good_ones() { + let c = catalog( + r#" +"good" = "добре" +"numeric" = 42 +"alsogood" = "теж" +"#, + ); + assert_eq!(c.get("good"), Some("добре")); + assert_eq!(c.get("alsogood"), Some("теж")); + assert_eq!(c.get("numeric"), None); +} + +#[test] +fn a_missing_directory_yields_an_empty_catalog() { + let c = Catalog::load(std::path::Path::new("/nonexistent/poltertype/i18n"), "uk"); + assert!(c.is_empty()); + assert_eq!(c.locale(), "uk"); +} + +/// `uk_UA` should find `uk.toml`: shipping one file per region would +/// be a lot of duplication for no benefit. +#[test] +fn a_regional_locale_falls_back_to_the_bare_language() { + let dir = std::env::temp_dir().join(format!("pt-i18n-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(dir.join("uk.toml"), "\"k\" = \"значення\"\n"); + + let c = Catalog::load(&dir, "uk_UA"); + assert_eq!(c.get("k"), Some("значення"), "uk_UA must find uk.toml"); + + let _ = std::fs::remove_dir_all(&dir); +} + +// ── the public entry point ─────────────────────────────────────────── + +/// `tr` must be safe to call before `init`, because a panic in the +/// view function would take the settings window down. +#[test] +fn tr_falls_back_to_english_when_uninitialised() { + assert_eq!(tr("some.key.no.one.set", "English text"), "English text"); +} + +#[test] +fn shipped_locales_are_well_formed() { + for (code, name) in SHIPPED_LOCALES { + assert!(!code.is_empty() && !name.is_empty()); + assert_eq!(*code, code.to_ascii_lowercase(), "codes are lowercase"); + assert!( + !code.starts_with("en"), + "English is the fallback, not a catalog" + ); + } +} + +// ── placeholder substitution ───────────────────────────────────────── + +#[test] +fn placeholders_are_filled_in_order() { + assert_eq!( + tr_args("k", "Restricted to {} of {} layouts", &["2", "15"]), + "Restricted to 2 of 15 layouts" + ); +} + +#[test] +fn a_string_without_placeholders_is_returned_as_is() { + assert_eq!( + tr_args("k", "No placeholders here", &[]), + "No placeholders here" + ); +} + +/// Neither mismatch may panic: this runs inside the view function, and +/// a panic there takes the settings window down over a typo in a +/// community translation. +#[test] +fn placeholder_count_mismatches_are_survivable() { + // More arguments than slots: a translation that legitimately drops + // one is honoured as written. + assert_eq!( + tr_args("k", "Only {} shown", &["3", "unused"]), + "Only 3 shown" + ); + // More slots than arguments: the extra stays visible rather than + // silently swallowing text around it. + assert_eq!(tr_args("k", "{} of {}", &["3"]), "3 of {}"); + assert_eq!(tr_args("k", "{}{}{}", &[]), "{}{}{}"); +} + +#[test] +fn braces_that_are_not_placeholders_survive() { + assert_eq!( + tr_args("k", "Use {braces} and {} here", &["this"]), + "Use {braces} and this here" + ); +} + +// ── the shipped catalog ────────────────────────────────────────────── + +/// The Ukrainian catalog in `data/i18n/` must parse and actually +/// translate. Reads the repository file directly rather than the +/// built dist tree, so it fails on a bad edit even before a build +/// copies anything. +#[test] +fn the_shipped_ukrainian_catalog_is_usable() { + let repo_file = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/i18n/uk.toml"); + let Ok(text) = std::fs::read_to_string(&repo_file) else { + // A consumer building from a package without `data/` — not a + // failure of this crate. + return; + }; + let c = Catalog::parse("uk", &text, "uk.toml"); + assert!(c.len() > 40, "catalog looks truncated: {} entries", c.len()); + + // Spot-check one label per pane, so a wholesale key rename in the + // UI shows up here instead of as a silently English window. + for key in [ + "ui.settings", + "languages.languages", + "hotkeys.hotkeys", + "commands.commands", + "wordlists.wordlists", + "general.general", + "suggestions.suggestions", + "exceptions.exceptions", + "setup.setup", + ] { + let value = c.get(key); + assert!(value.is_some(), "missing translation for `{key}`"); + assert!( + value.is_some_and(|v| v.chars().any(|ch| ('\u{0400}'..'\u{04FF}').contains(&ch))), + "`{key}` should be Cyrillic, got {value:?}" + ); + } +} + +/// Placeholder counts have to survive translation, or a sentence +/// loses the number it was built around. +#[test] +fn the_ukrainian_catalog_keeps_its_placeholders() { + let repo_file = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/i18n/uk.toml"); + let Ok(text) = std::fs::read_to_string(&repo_file) else { + return; + }; + let c = Catalog::parse("uk", &text, "uk.toml"); + if let Some(v) = c.get("languages.status_restricted") { + assert!(v.contains("{}"), "the layout count must survive: {v}"); + } +} diff --git a/crates/poltertype-core/src/layouts/mod.rs b/crates/poltertype-core/src/layouts/mod.rs index f5c9522..85da98f 100644 --- a/crates/poltertype-core/src/layouts/mod.rs +++ b/crates/poltertype-core/src/layouts/mod.rs @@ -46,7 +46,7 @@ mod types; pub use db::LayoutDb; pub use enums::LayoutLoadError; pub use files::{user_layout_dir, user_profile_wordlist_dir, user_wordlist_dir}; -pub use types::{LayoutMapping, LoadOptions}; +pub use types::{LayoutMapping, LoadOptions, PluginManifest}; #[cfg(test)] mod tests; diff --git a/crates/poltertype-core/src/lib.rs b/crates/poltertype-core/src/lib.rs index dedd2ae..679f43f 100644 --- a/crates/poltertype-core/src/lib.rs +++ b/crates/poltertype-core/src/lib.rs @@ -7,7 +7,9 @@ pub mod audio; pub mod commands; pub mod data_dir; pub mod engine; +pub mod i18n; pub mod layouts; +pub mod plugins; pub mod settings; pub mod wordlist_profiles; diff --git a/crates/poltertype-core/src/plugins/consts.rs b/crates/poltertype-core/src/plugins/consts.rs new file mode 100644 index 0000000..89fde19 --- /dev/null +++ b/crates/poltertype-core/src/plugins/consts.rs @@ -0,0 +1,40 @@ +//! What a data-only pack is allowed to contain. + +/// Directory under `` holding installed packs. +pub const PLUGINS_DIR: &str = "plugins"; + +/// The manifest every pack must have. +pub const MANIFEST_NAME: &str = "manifest.toml"; + +/// Sub-directories a pack may populate, and the file extensions +/// allowed in each. +/// +/// An allow-list rather than a deny-list on purpose: a deny-list has +/// to guess every dangerous thing, and it will be wrong about the one +/// that matters. This says what a *language pack* is, and everything +/// else is left on the floor with a warning. +pub const ALLOWED_CONTENT: &[(&str, &[&str])] = &[ + ("layout-mappings", &["toml"]), + ("wordlists", &["fst", "txt", "gz"]), + ("i18n", &["toml"]), +]; + +/// Files permitted at the top level of a pack, beyond the manifest. +/// Documentation and licence text, so a pack can carry its own terms. +pub const ALLOWED_TOP_LEVEL: &[&str] = &[ + "manifest.toml", + "README.md", + "LICENSE", + "LICENSE.txt", + "LICENSE.md", + "CREDITS.md", +]; + +/// Total bytes one pack may occupy. Generous next to a bundled +/// language (the Turkish FST alone is 15 MB) and still a bound: a +/// "language pack" that wants a gigabyte is not one. +pub const MAX_PACK_BYTES: u64 = 256 * 1024 * 1024; + +/// Most files a pack may contain. Guards the enumeration itself, so a +/// directory with a million entries cannot make installation hang. +pub const MAX_PACK_FILES: usize = 512; diff --git a/crates/poltertype-core/src/plugins/enums.rs b/crates/poltertype-core/src/plugins/enums.rs new file mode 100644 index 0000000..6121f3b --- /dev/null +++ b/crates/poltertype-core/src/plugins/enums.rs @@ -0,0 +1,48 @@ +//! Why an install refused. + +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PluginError { + #[error("{0} does not exist or is not a directory")] + NotADirectory(PathBuf), + #[error("no {manifest} in {dir} — every pack needs one")] + MissingManifest { dir: PathBuf, manifest: String }, + #[error("manifest is not valid TOML: {0}")] + BadManifest(String), + #[error("manifest has no `id`, or it is empty")] + MissingId, + /// Pack ids become a directory name, so they are restricted to + /// characters that cannot escape one or surprise a shell. + #[error( + "pack id {0:?} is not usable as a directory name — use letters, digits, `-`, `_` and `.`" + )] + UnsafeId(String), + #[error("pack is {actual} bytes, over the {limit}-byte limit")] + TooLarge { actual: u64, limit: u64 }, + #[error("pack has more than {0} files")] + TooManyFiles(usize), + /// A path that would land outside the pack directory, or a + /// symlink. Refused rather than resolved. + #[error("refusing unsafe path in pack: {0}")] + UnsafePath(PathBuf), + #[error("pack contains nothing installable — no layouts, wordlists or translations")] + Empty, + #[error("{context}: {source}")] + Io { + context: String, + #[source] + source: std::io::Error, + }, +} + +impl PluginError { + pub(crate) fn io(context: impl Into, source: std::io::Error) -> Self { + Self::Io { + context: context.into(), + source, + } + } +} diff --git a/crates/poltertype-core/src/plugins/install.rs b/crates/poltertype-core/src/plugins/install.rs new file mode 100644 index 0000000..94e3865 --- /dev/null +++ b/crates/poltertype-core/src/plugins/install.rs @@ -0,0 +1,310 @@ +//! Install, list and remove packs. + +use std::path::{Component, Path, PathBuf}; + +use tracing::{info, warn}; + +use super::consts::*; +use super::enums::PluginError; +use super::types::InstalledPack; +use crate::layouts::PluginManifest; + +/// Install the pack in `src` into `/plugins//`. +/// +/// Replaces an existing pack of the same id — that is the update +/// path, and it is deliberately the same code: an update that behaved +/// differently from a fresh install is an update that gets tested +/// half as often. +/// +/// The copy is staged in a sibling directory and renamed into place, +/// so an interrupted install leaves the previous pack intact rather +/// than a half-written one. +pub fn install(src: &Path, data_dir: &Path) -> Result { + if !src.is_dir() { + return Err(PluginError::NotADirectory(src.to_path_buf())); + } + let manifest = read_manifest(src)?; + let id = manifest.id.trim().to_owned(); + if id.is_empty() { + return Err(PluginError::MissingId); + } + if !is_safe_id(&id) { + return Err(PluginError::UnsafeId(id)); + } + + let plan = plan_copy(src)?; + if plan.content_files == 0 { + return Err(PluginError::Empty); + } + + let plugins_dir = data_dir.join(PLUGINS_DIR); + std::fs::create_dir_all(&plugins_dir) + .map_err(|e| PluginError::io(format!("create {}", plugins_dir.display()), e))?; + + let dest = plugins_dir.join(&id); + // Staged beside the destination so the rename is on one + // filesystem — a cross-device rename would fall back to a copy + // and lose the atomicity this exists for. + let staging = plugins_dir.join(format!(".{id}.incoming")); + let _ = std::fs::remove_dir_all(&staging); + + let copied = copy_plan(src, &staging, &plan).inspect_err(|_| { + // Never leave a half-written staging directory behind. + let _ = std::fs::remove_dir_all(&staging); + })?; + + let replaced = dest.exists(); + if replaced { + // Move the old pack aside rather than deleting it first, so a + // failure between the two renames still leaves something + // loadable on disk. + let previous = plugins_dir.join(format!(".{id}.previous")); + let _ = std::fs::remove_dir_all(&previous); + std::fs::rename(&dest, &previous) + .map_err(|e| PluginError::io(format!("move aside {}", dest.display()), e))?; + if let Err(e) = std::fs::rename(&staging, &dest) { + // Put the old one back before reporting. + let _ = std::fs::rename(&previous, &dest); + let _ = std::fs::remove_dir_all(&staging); + return Err(PluginError::io(format!("install {}", dest.display()), e)); + } + let _ = std::fs::remove_dir_all(&previous); + } else { + std::fs::rename(&staging, &dest) + .map_err(|e| PluginError::io(format!("install {}", dest.display()), e))?; + } + + let installed = InstalledPack { + id, + name: manifest.name, + version: manifest.version, + path: dest, + files: copied.0, + bytes: copied.1, + skipped: plan.skipped, + replaced, + }; + info!( + id = %installed.id, + version = %installed.version, + files = installed.files, + bytes = installed.bytes, + replaced = installed.replaced, + skipped = installed.skipped.len(), + "plug-in pack installed" + ); + for entry in &installed.skipped { + warn!(id = %installed.id, entry, "pack entry skipped — not allowed in a data-only pack"); + } + Ok(installed) +} + +/// Remove an installed pack. `Ok(false)` if there was nothing there. +pub fn uninstall(id: &str, data_dir: &Path) -> Result { + if !is_safe_id(id) { + return Err(PluginError::UnsafeId(id.to_owned())); + } + let dir = data_dir.join(PLUGINS_DIR).join(id); + if !dir.is_dir() { + return Ok(false); + } + std::fs::remove_dir_all(&dir) + .map_err(|e| PluginError::io(format!("remove {}", dir.display()), e))?; + info!(%id, "plug-in pack removed"); + Ok(true) +} + +/// Manifests of everything currently installed, sorted by id. +pub fn list_installed(data_dir: &Path) -> Vec { + let plugins_dir = data_dir.join(PLUGINS_DIR); + let Ok(entries) = std::fs::read_dir(&plugins_dir) else { + return Vec::new(); + }; + let mut out: Vec = entries + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + // Staging directories are dot-prefixed and are not packs. + .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) + .filter_map(|e| read_manifest(&e.path()).ok()) + .collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + out +} + +/// Read and parse a pack's manifest. +pub fn read_manifest(dir: &Path) -> Result { + let path = dir.join(MANIFEST_NAME); + let text = std::fs::read_to_string(&path).map_err(|_| PluginError::MissingManifest { + dir: dir.to_path_buf(), + manifest: MANIFEST_NAME.to_owned(), + })?; + toml::from_str(&text).map_err(|e| PluginError::BadManifest(e.to_string())) +} + +/// A pack id becomes a directory name, so it may not contain anything +/// that escapes one or means something to a shell or a path parser. +pub fn is_safe_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id != "." + && id != ".." + && !id.starts_with('.') + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +/// What a copy would move, decided before anything is written. +struct CopyPlan { + /// Paths relative to the source root. + files: Vec, + /// How many of `files` are actual content — layouts, wordlists, + /// translations. The manifest and a README are metadata; a + /// "pack" consisting only of those installs nothing and would + /// leave a directory the loader silently ignores. + content_files: usize, + skipped: Vec, + bytes: u64, +} + +/// Walk the source and decide what is installable. +/// +/// One pass, no writes: the budget and the allow-list are enforced +/// here so that a refusal never leaves a partial install. +fn plan_copy(src: &Path) -> Result { + let mut plan = CopyPlan { + files: Vec::new(), + content_files: 0, + skipped: Vec::new(), + bytes: 0, + }; + + // Top level: the manifest, the documentation allow-list, and the + // content directories. Everything else is reported and skipped. + for entry in read_dir_sorted(src)? { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + let meta = std::fs::symlink_metadata(&path) + .map_err(|e| PluginError::io(format!("stat {}", path.display()), e))?; + + if meta.file_type().is_symlink() { + // Not followed, not copied: a symlink named + // `layout-mappings` pointing at somewhere private would + // otherwise become a copy of that. + return Err(PluginError::UnsafePath(path)); + } + if meta.is_dir() { + match ALLOWED_CONTENT.iter().find(|(d, _)| *d == name) { + Some((_, exts)) => collect_dir(src, &path, exts, &mut plan)?, + None => plan.skipped.push(format!("{name}/")), + } + continue; + } + if ALLOWED_TOP_LEVEL.contains(&name.as_str()) { + plan.bytes += meta.len(); + plan.files.push(PathBuf::from(&name)); + } else { + plan.skipped.push(name); + } + check_budget(&plan)?; + } + + check_budget(&plan)?; + Ok(plan) +} + +/// Collect the allowed files of one content directory. +fn collect_dir( + src_root: &Path, + dir: &Path, + exts: &[&str], + plan: &mut CopyPlan, +) -> Result<(), PluginError> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let meta = std::fs::symlink_metadata(&path) + .map_err(|e| PluginError::io(format!("stat {}", path.display()), e))?; + if meta.file_type().is_symlink() { + return Err(PluginError::UnsafePath(path)); + } + let rel = path + .strip_prefix(src_root) + .map_err(|_| PluginError::UnsafePath(path.clone()))? + .to_path_buf(); + if !is_contained(&rel) { + return Err(PluginError::UnsafePath(path)); + } + // One level deep. Nesting buys nothing for a data pack and + // would need its own traversal budget. + if meta.is_dir() { + plan.skipped.push(format!("{}/", rel.display())); + continue; + } + let ok = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| exts.iter().any(|a| a.eq_ignore_ascii_case(e))); + if ok { + plan.bytes += meta.len(); + plan.files.push(rel); + plan.content_files += 1; + check_budget(plan)?; + } else { + plan.skipped.push(rel.display().to_string()); + } + } + Ok(()) +} + +fn check_budget(plan: &CopyPlan) -> Result<(), PluginError> { + if plan.bytes > MAX_PACK_BYTES { + return Err(PluginError::TooLarge { + actual: plan.bytes, + limit: MAX_PACK_BYTES, + }); + } + if plan.files.len() > MAX_PACK_FILES { + return Err(PluginError::TooManyFiles(MAX_PACK_FILES)); + } + Ok(()) +} + +/// A relative path that stays inside its root: no `..`, no absolute +/// prefix, no root component. +fn is_contained(rel: &Path) -> bool { + rel.components() + .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) +} + +/// Perform the planned copy into `dest`. Returns `(files, bytes)`. +fn copy_plan(src: &Path, dest: &Path, plan: &CopyPlan) -> Result<(usize, u64), PluginError> { + std::fs::create_dir_all(dest) + .map_err(|e| PluginError::io(format!("create {}", dest.display()), e))?; + let mut bytes = 0u64; + for rel in &plan.files { + // Re-checked at write time, not only at plan time: the two are + // separated by I/O, and the check that matters is the one next + // to the operation it protects. + if !is_contained(rel) { + return Err(PluginError::UnsafePath(rel.clone())); + } + let to = dest.join(rel); + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| PluginError::io(format!("create {}", parent.display()), e))?; + } + let n = std::fs::copy(src.join(rel), &to) + .map_err(|e| PluginError::io(format!("copy {}", rel.display()), e))?; + bytes += n; + } + Ok((plan.files.len(), bytes)) +} + +fn read_dir_sorted(dir: &Path) -> Result, PluginError> { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| PluginError::io(format!("read {}", dir.display()), e))? + .flatten() + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + Ok(entries) +} diff --git a/crates/poltertype-core/src/plugins/mod.rs b/crates/poltertype-core/src/plugins/mod.rs new file mode 100644 index 0000000..d57b9d5 --- /dev/null +++ b/crates/poltertype-core/src/plugins/mod.rs @@ -0,0 +1,60 @@ +//! Installing, verifying and removing plug-in language packs. +//! +//! The loader in [`crate::layouts::plugins`] has read +//! `/plugins//` since v0.1; what was missing was any +//! supported way to *get* a pack in there. People copied directories +//! by hand, which meant no validation ran and a malformed pack showed +//! up as a puzzling startup warning. +//! +//! ## What this deliberately is not +//! +//! **There is no download.** `install` takes a directory that already +//! exists on the user's disk; fetching a pack is the browser's job. +//! +//! That is not laziness, it is the security boundary. PolterType reads +//! every keystroke, and its one network call today is an updater that +//! sends nothing and verifies a signature made by a key that never +//! touches CI. Adding "and also fetches arbitrary third-party content +//! from a URL in a config file" would be a second, much wider channel +//! guarding data that lands in the same process. A user who has +//! already downloaded a pack has made the trust decision explicitly, +//! at a moment when they can see what they are downloading — which is +//! exactly where that decision belongs. +//! +//! It also removes a whole class of bug at the root: there is no +//! archive to unpack, so there is no zip-slip, no decompression bomb, +//! and no partially-extracted pack to clean up. +//! +//! ## What `install` actually guards against +//! +//! A pack is *data* — layout TOMLs and dictionaries — and the loader +//! is built on that assumption. So installation copies only what a +//! data-only pack is allowed to contain, rather than copying a +//! directory and hoping: +//! +//! * **An allow-list of names and extensions.** Anything else in the +//! source directory is reported and left behind. A pack cannot +//! deliver an executable, a `.so`, a dotfile, or a `config.toml` +//! that would shadow the user's settings. +//! * **No traversal, no links.** Every destination path is checked to +//! be inside the pack directory, and symlinks are refused rather +//! than followed — a symlink named `layout-mappings` pointing at +//! `~/.ssh` must not become a readable copy. +//! * **A size budget**, so a "language pack" cannot quietly fill the +//! user's disk. +//! * **Atomic replacement.** The pack is staged beside its final +//! location and renamed into place, so an interrupted install +//! leaves either the old pack or none — never half of a new one. + +mod consts; +mod enums; +mod install; +mod types; + +pub use consts::*; +pub use enums::*; +pub use install::*; +pub use types::*; + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-core/src/plugins/tests.rs b/crates/poltertype-core/src/plugins/tests.rs new file mode 100644 index 0000000..fc7efe2 --- /dev/null +++ b/crates/poltertype-core/src/plugins/tests.rs @@ -0,0 +1,295 @@ +use super::*; +use std::path::{Path, PathBuf}; + +/// A scratch directory that removes itself. Tests here touch the +/// filesystem by nature — the thing under test is a file copier. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "poltertype-plugins-{}-{tag}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + Self(dir) + } + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn write(path: &Path, contents: &str) { + if let Some(p) = path.parent() { + let _ = std::fs::create_dir_all(p); + } + let _ = std::fs::write(path, contents); +} + +/// A minimal well-formed pack. +fn make_pack(root: &Path, id: &str) { + write( + &root.join("manifest.toml"), + &format!("id = \"{id}\"\nname = \"Test pack\"\nversion = \"1.0\"\n"), + ); + write( + &root.join("layout-mappings/xx_xx.toml"), + "id = \"xx-XX\"\nname = \"Test\"\nscript = \"Latin\"\n\n[keys]\n0x10 = { plain = \"q\" }\n", + ); +} + +// ── the happy path ─────────────────────────────────────────────────── + +#[test] +fn installs_a_well_formed_pack() { + let src = Scratch::new("src"); + let data = Scratch::new("data"); + make_pack(src.path(), "testpack"); + + let out = install(src.path(), data.path()).expect("install"); + assert_eq!(out.id, "testpack"); + assert_eq!(out.version, "1.0"); + assert!(!out.replaced); + assert!(out.path.join("layout-mappings/xx_xx.toml").is_file()); + assert!(out.path.join("manifest.toml").is_file()); +} + +/// Installing over an existing pack is the update path, and is +/// deliberately the same code — an update that behaved differently +/// would be tested half as often. +#[test] +fn installing_again_replaces_and_reports_it() { + let src = Scratch::new("src2"); + let data = Scratch::new("data2"); + make_pack(src.path(), "testpack"); + install(src.path(), data.path()).expect("first"); + + // A second version, with one file fewer. + let src2 = Scratch::new("src2b"); + write( + &src2.path().join("manifest.toml"), + "id = \"testpack\"\nname = \"Test pack\"\nversion = \"2.0\"\n", + ); + write(&src2.path().join("wordlists/xx_xx-stop.txt"), "a\nb\n"); + + let out = install(src2.path(), data.path()).expect("second"); + assert!(out.replaced); + assert_eq!(out.version, "2.0"); + assert!(out.path.join("wordlists/xx_xx-stop.txt").is_file()); + assert!( + !out.path.join("layout-mappings/xx_xx.toml").exists(), + "the previous pack's files must not survive an update" + ); +} + +#[test] +fn lists_and_removes() { + let src = Scratch::new("src3"); + let data = Scratch::new("data3"); + make_pack(src.path(), "testpack"); + install(src.path(), data.path()).expect("install"); + + let listed = list_installed(data.path()); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "testpack"); + + assert!(uninstall("testpack", data.path()).expect("uninstall")); + assert!(list_installed(data.path()).is_empty()); + assert!( + !uninstall("testpack", data.path()).expect("second uninstall"), + "removing what is not there is not an error" + ); +} + +// ── the allow-list ─────────────────────────────────────────────────── + +/// A pack is data. Anything that is not gets left behind — and +/// reported, so nobody wonders where their file went. +#[test] +fn executables_and_stray_files_are_not_installed() { + let src = Scratch::new("src4"); + let data = Scratch::new("data4"); + make_pack(src.path(), "testpack"); + write(&src.path().join("evil.sh"), "#!/bin/sh\necho pwned\n"); + write(&src.path().join("payload.so"), "binary"); + write(&src.path().join(".bashrc"), "echo pwned"); + write(&src.path().join("config.toml"), "schema_version = 1"); + write(&src.path().join("bin/tool"), "binary"); + // Wrong extension inside an allowed directory. + write(&src.path().join("layout-mappings/notes.txt"), "hi"); + + let out = install(src.path(), data.path()).expect("install"); + for unwanted in ["evil.sh", "payload.so", ".bashrc", "config.toml"] { + assert!( + !out.path.join(unwanted).exists(), + "{unwanted} must not be installed" + ); + } + assert!(!out.path.join("bin").exists(), "bin/ must not be installed"); + assert!(!out.path.join("layout-mappings/notes.txt").exists()); + assert!( + out.skipped.len() >= 5, + "everything skipped should be reported: {:?}", + out.skipped + ); +} + +#[test] +fn documentation_alongside_the_manifest_is_kept() { + let src = Scratch::new("src5"); + let data = Scratch::new("data5"); + make_pack(src.path(), "testpack"); + write(&src.path().join("README.md"), "# pack"); + write(&src.path().join("LICENSE"), "MIT"); + + let out = install(src.path(), data.path()).expect("install"); + assert!(out.path.join("README.md").is_file()); + assert!(out.path.join("LICENSE").is_file()); +} + +// ── refusals ───────────────────────────────────────────────────────── + +#[test] +fn a_pack_without_a_manifest_is_refused() { + let src = Scratch::new("src6"); + let data = Scratch::new("data6"); + write(&src.path().join("layout-mappings/x.toml"), "id = \"x\""); + assert!(matches!( + install(src.path(), data.path()), + Err(PluginError::MissingManifest { .. }) + )); +} + +#[test] +fn a_pack_with_nothing_installable_is_refused() { + let src = Scratch::new("src7"); + let data = Scratch::new("data7"); + write( + &src.path().join("manifest.toml"), + "id = \"empty\"\nname = \"Empty\"\nversion = \"1\"\n", + ); + write(&src.path().join("readme.rst"), "not allowed"); + // The manifest alone is not content, so this must not install. + assert!(matches!( + install(src.path(), data.path()), + Err(PluginError::Empty) + )); +} + +/// A pack id becomes a directory name. `../../` in one would write +/// outside the plugins directory entirely. +#[test] +fn a_traversing_id_is_refused() { + for bad in ["../escape", "..", ".", "a/b", "with space", ".hidden", ""] { + assert!(!is_safe_id(bad), "{bad:?} must be rejected"); + } + for good in ["uk-extra", "my_pack", "pack.v2", "Pack1"] { + assert!(is_safe_id(good), "{good:?} should be allowed"); + } +} + +#[test] +fn a_traversing_id_in_a_manifest_is_refused_at_install() { + let src = Scratch::new("src8"); + let data = Scratch::new("data8"); + write( + &src.path().join("manifest.toml"), + "id = \"../../escaped\"\nname = \"n\"\nversion = \"1\"\n", + ); + write(&src.path().join("layout-mappings/x.toml"), "id = \"x\""); + assert!(matches!( + install(src.path(), data.path()), + Err(PluginError::UnsafeId(_)) + )); + assert!( + !data.path().join("../../escaped").exists(), + "nothing may be written outside the plugins directory" + ); +} + +#[test] +fn uninstall_refuses_a_traversing_id() { + let data = Scratch::new("data9"); + assert!(matches!( + uninstall("../../etc", data.path()), + Err(PluginError::UnsafeId(_)) + )); +} + +/// A symlink named like a content directory must not become a copy of +/// whatever it points at. +#[cfg(unix)] +#[test] +fn symlinks_are_refused_rather_than_followed() { + let src = Scratch::new("src10"); + let data = Scratch::new("data10"); + let secret = Scratch::new("secret"); + write(&secret.path().join("id_rsa"), "PRIVATE KEY"); + write( + &src.path().join("manifest.toml"), + "id = \"sneaky\"\nname = \"n\"\nversion = \"1\"\n", + ); + let _ = std::os::unix::fs::symlink(secret.path(), src.path().join("layout-mappings")); + + let result = install(src.path(), data.path()); + assert!( + matches!(result, Err(PluginError::UnsafePath(_))), + "a symlinked content directory must be refused, got {result:?}" + ); + assert!( + !data + .path() + .join("plugins/sneaky/layout-mappings/id_rsa") + .exists(), + "nothing from the symlink target may be copied" + ); +} + +/// A failed install must not leave a staging directory behind, and +/// must not disturb an already-installed pack. +#[test] +fn a_failed_install_leaves_the_previous_pack_intact() { + let src = Scratch::new("src11"); + let data = Scratch::new("data11"); + make_pack(src.path(), "testpack"); + install(src.path(), data.path()).expect("first install"); + + // Now try to install something refused. + let bad = Scratch::new("bad"); + write( + &bad.path().join("manifest.toml"), + "id = \"testpack\"\nname=\"n\"\nversion=\"9\"\n", + ); + assert!(install(bad.path(), data.path()).is_err()); + + let listed = list_installed(data.path()); + assert_eq!(listed.len(), 1, "the good pack must still be listed"); + assert_eq!(listed[0].version, "1.0", "and still be the old version"); + assert!( + data.path() + .join("plugins/testpack/layout-mappings/xx_xx.toml") + .is_file(), + "its files must be untouched" + ); +} + +/// Staging directories are dot-prefixed so the loader and the listing +/// both ignore them; make sure a stray one is not reported as a pack. +#[test] +fn staging_directories_are_not_listed_as_packs() { + let data = Scratch::new("data12"); + let stray = data.path().join("plugins/.testpack.incoming"); + write( + &stray.join("manifest.toml"), + "id = \"testpack\"\nname = \"n\"\nversion = \"1\"\n", + ); + assert!(list_installed(data.path()).is_empty()); +} diff --git a/crates/poltertype-core/src/plugins/types.rs b/crates/poltertype-core/src/plugins/types.rs new file mode 100644 index 0000000..a4bf8ee --- /dev/null +++ b/crates/poltertype-core/src/plugins/types.rs @@ -0,0 +1,27 @@ +//! What an install reports back. + +use std::path::PathBuf; + +/// The outcome of a successful install. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledPack { + pub id: String, + pub name: String, + pub version: String, + /// Where it now lives. + pub path: PathBuf, + /// Files copied in. + pub files: usize, + pub bytes: u64, + /// Entries found in the source and deliberately not copied, + /// relative to the source root. + /// + /// Surfaced rather than silently dropped: a pack author who put a + /// file somewhere unexpected should learn that it was ignored, + /// and a user installing someone else's pack should see that it + /// tried to ship something a language pack has no business + /// shipping. + pub skipped: Vec, + /// Whether this replaced an existing pack of the same id. + pub replaced: bool, +} diff --git a/crates/poltertype-core/src/settings/tests.rs b/crates/poltertype-core/src/settings/tests.rs index ca2524c..72126a3 100644 --- a/crates/poltertype-core/src/settings/tests.rs +++ b/crates/poltertype-core/src/settings/tests.rs @@ -334,16 +334,19 @@ enabled = true allow_remote = false [[ai.plugins]] -type = "remote-llm" +type = "llm" id = "claude" provider = "anthropic" model = "claude-sonnet-4" api_key_ref = "keyring:anthropic" [[ai.plugins]] -type = "local-onnx" -id = "lid176" -model_path = "/models/lid.176.onnx" +type = "llm" +id = "local" +endpoint = "http://127.0.0.1:11434/api/generate" +format = "ollama-generate" +model = "llama3" +mode = "background" "#; let s: Settings = toml::from_str(raw).expect("parse"); assert!(s.ai.enabled); @@ -354,8 +357,37 @@ model_path = "/models/lid.176.onnx" s.ai.plugins[0].api_key_ref.as_deref(), Some("keyring:anthropic") ); - assert_eq!(s.ai.plugins[1].r#type, "local-onnx"); - assert!(s.ai.plugins[1].model_path.is_some()); + // The second entry is the shape that needs no key and no network + // permission: a model the user runs themselves. + assert_eq!( + s.ai.plugins[1].endpoint.as_deref(), + Some("http://127.0.0.1:11434/api/generate") + ); + assert_eq!(s.ai.plugins[1].format.as_deref(), Some("ollama-generate")); + assert!(s.ai.plugins[1].api_key_ref.is_none()); +} + +/// A config written against 0.9.0 or earlier still *parses* — the +/// schema is deliberately a flat struct of optional fields, so an +/// entry naming a retired plug-in kind reaches the factory and is +/// reported there with an explanation, rather than failing the whole +/// settings file and leaving the user with no app. +#[test] +fn a_pre_0_10_ai_config_still_parses() { + let raw = r#" +schema_version = 1 + +[ai] +enabled = true + +[[ai.plugins]] +type = "local-onnx" +id = "lid176" +model_path = "/models/lid.176.onnx" +"#; + let s: Settings = toml::from_str(raw).expect("an old config must not be a parse error"); + assert_eq!(s.ai.plugins.len(), 1); + assert_eq!(s.ai.plugins[0].r#type, "local-onnx"); } /// The schema lives in `poltertype-types`, not in the optional diff --git a/crates/poltertype-core/src/settings/types.rs b/crates/poltertype-core/src/settings/types.rs index 19dacc4..9448421 100644 --- a/crates/poltertype-core/src/settings/types.rs +++ b/crates/poltertype-core/src/settings/types.rs @@ -29,6 +29,17 @@ pub struct Settings { /// `[hotkeys]` and the rest here. #[serde(default)] pub commands: Vec, + /// Whether `run_shell` smart commands may execute at all. + /// + /// **Off by default, and that is the security boundary.** A + /// `[[commands]]` entry that runs a program turns a shared or + /// stolen `config.toml` into code that fires the next time the + /// user types an ordinary word — see the threat model in + /// [`crate::commands::shell`]. Entries are still parsed and shown + /// in Settings while this is false; they simply refuse to run, + /// and say so once per firing rather than failing silently. + #[serde(default)] + pub commands_allow_run_shell: bool, /// Per-application wordlist profiles. Each profile points at /// its own subdirectory under `/poltertype/wordlists/profiles//` /// and gets activated when the foreground app matches the @@ -60,6 +71,7 @@ impl Default for Settings { exceptions: ExceptionSettings::default(), hotkeys: HotkeySettings::default(), commands: Vec::new(), + commands_allow_run_shell: false, wordlists: WordlistSettings::default(), sounds: SoundSettings::default(), suggestions: SuggestionSettings::default(), diff --git a/crates/poltertype-input/Cargo.toml b/crates/poltertype-input/Cargo.toml index ee85cbf..c69f562 100644 --- a/crates/poltertype-input/Cargo.toml +++ b/crates/poltertype-input/Cargo.toml @@ -49,3 +49,4 @@ libc = "0.2" # source of on-screen caret coordinates on Linux. Blocking API only; # the watcher owns one background thread, no async runtime of ours. zbus = { workspace = true } +directories = { workspace = true } diff --git a/crates/poltertype-input/src/focus/linux_impl/atspi_focus.rs b/crates/poltertype-input/src/focus/linux_impl/atspi_focus.rs new file mode 100644 index 0000000..963e3b8 --- /dev/null +++ b/crates/poltertype-input/src/focus/linux_impl/atspi_focus.rs @@ -0,0 +1,224 @@ +//! AT-SPI2 focused-application watcher — the window half of focus +//! tracking on compositors that answer no window query. +//! +//! GNOME and KDE on Wayland expose no "which window is active" +//! interface, by design and for the same reason they expose no global +//! keyboard. The plan of record was a KWin script plus a GNOME Shell +//! extension: two out-of-tree artifacts, in two languages, that the +//! user has to install, and neither testable without running that +//! desktop. +//! +//! This is cheaper and covers more. Every AT-SPI event arrives over +//! the a11y bus from the *application's own* connection, so the bus +//! itself can be asked who that connection belongs to — +//! `GetConnectionUnixProcessID` gives the PID, and `/proc//exe` +//! gives the executable basename, which is exactly what the Hyprland +//! and X11 trackers already report. One backend, no user-installed +//! artifacts, and it works on any compositor with an a11y bridge. +//! +//! ## What it does not cover, and this matters +//! +//! **Only applications with a live accessibility bridge are ever +//! seen.** A GTK or Qt app answers; so does Electron with a11y on. +//! A terminal like foot, alacritty or kitty typically does not — and +//! a terminal is precisely where a developer types. An app that never +//! emits also never *un*-focuses the previous one, so the freshest +//! answer can be stale in a way a real window query never is. +//! +//! That is why [`AtspiFocusWatcher::latest`] returns the sample's age +//! and the caller decides. It is a genuine improvement on `None` +//! everywhere, and it is not equivalent to a compositor answer. Do +//! not describe it as "focus tracking works on GNOME/KDE". +//! +//! PRIVACY: this module reads *identity*, never content. Sender +//! names, PIDs and executable paths only — no accessible names, no +//! window titles, no text. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; +use tracing::{debug, warn}; +use zbus::blocking::connection::Builder; +use zbus::blocking::{Connection, MessageIterator}; +use zbus::{MatchRule, Message, message}; + +use super::proc_exe::exe_basename_for_pid; + +/// Per-iterator signal queue. Focus changes are rare next to caret +/// motion, but the same burst-shedding logic applies. +const SIGNAL_QUEUE: usize = 16; + +/// A focus observation: which executable, and when we learned it. +#[derive(Debug, Clone)] +pub(crate) struct FocusSample { + pub(crate) exe: String, + pub(crate) at: Instant, +} + +impl FocusSample { + pub(crate) fn age(&self) -> Duration { + self.at.elapsed() + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum AtspiFocusError { + #[error("session bus unavailable: {0}")] + SessionBus(zbus::Error), + #[error("a11y bus address lookup failed: {0}")] + A11yAddress(zbus::Error), + #[error("a11y bus connection failed: {0}")] + A11yConnect(zbus::Error), + #[error("signal subscription failed: {0}")] + Subscribe(zbus::Error), + #[error("watcher thread failed to start: {0}")] + Spawn(std::io::Error), +} + +pub(crate) struct AtspiFocusWatcher { + latest: Arc>>, +} + +impl AtspiFocusWatcher { + /// Connect, register interest in window activation, start the + /// thread. Every bus round-trip happens here, on the caller's + /// thread, so a dead a11y stack surfaces as an error rather than + /// a silently idle thread. + pub(crate) fn try_new() -> Result { + let session = Connection::session().map_err(AtspiFocusError::SessionBus)?; + let reply = session + .call_method( + Some("org.a11y.Bus"), + "/org/a11y/bus", + Some("org.a11y.Bus"), + "GetAddress", + &(), + ) + .map_err(AtspiFocusError::A11yAddress)?; + let address: String = reply + .body() + .deserialize() + .map_err(AtspiFocusError::A11yAddress)?; + let conn = Builder::address(address.as_str()) + .map_err(AtspiFocusError::A11yConnect)? + .build() + .map_err(AtspiFocusError::A11yConnect)?; + + // Toolkits ask the registry which events have listeners and + // emit only those, so registering is what makes apps speak. + // `window:activate` is the one that means "this window is now + // the active one"; a failure to register is not fatal on its + // own, because another AT client may already have asked for + // it and the events would flow regardless. + if let Err(e) = conn.call_method( + Some("org.a11y.atspi.Registry"), + "/org/a11y/atspi/registry", + Some("org.a11y.atspi.Registry"), + "RegisterEvent", + &("window:activate",), + ) { + debug!(%e, "AT-SPI focus: RegisterEvent failed; relying on other clients"); + } + + // Same flag the caret watcher raises, same reasoning: without + // an AT client asserting it, most toolkits keep their bridge + // dormant and nothing is emitted at all. Session-scoped and + // never cleared — a real AT arriving later depends on it too. + if let Err(e) = session.call_method( + Some("org.a11y.Bus"), + "/org/a11y/bus", + Some("org.freedesktop.DBus.Properties"), + "Set", + &( + "org.a11y.Status", + "IsEnabled", + zbus::zvariant::Value::from(true), + ), + ) { + debug!(%e, "could not raise org.a11y.Status.IsEnabled; apps may stay silent"); + } + + let rule = MatchRule::builder() + .msg_type(message::Type::Signal) + .interface("org.a11y.atspi.Event.Window") + .map_err(AtspiFocusError::Subscribe)? + .member("Activate") + .map_err(AtspiFocusError::Subscribe)? + .build(); + let messages = MessageIterator::for_match_rule(rule, &conn, Some(SIGNAL_QUEUE)) + .map_err(AtspiFocusError::Subscribe)?; + + let latest = Arc::new(Mutex::new(None)); + let slot = Arc::clone(&latest); + std::thread::Builder::new() + .name("poltertype-atspi-focus".into()) + .spawn(move || watch(&conn, messages, &slot)) + .map_err(AtspiFocusError::Spawn)?; + Ok(Self { latest }) + } + + /// Freshest focus observation, if any has arrived. Cloning a short + /// string per call — this sits behind the factory's TTL cache. + pub(crate) fn latest(&self) -> Option { + self.latest.lock().clone() + } +} + +/// Blocking signal loop. Ends — with a single `warn` — when the bus +/// dies; the caller degrades to "no focus information", which is +/// where these sessions started. +fn watch(conn: &Connection, messages: MessageIterator, latest: &Mutex>) { + for msg in messages { + let msg = match msg { + Ok(m) => m, + Err(e) => { + warn!(%e, "AT-SPI focus watcher: a11y bus error; focus tracking stops"); + return; + } + }; + if let Some(sample) = sample_for_signal(conn, &msg) { + debug!(exe = %sample.exe, "AT-SPI focus: active application changed"); + *latest.lock() = Some(sample); + } + } + warn!("AT-SPI focus watcher: a11y bus stream ended; focus tracking stops"); +} + +/// One `window:activate` signal → the activating app's executable. +/// +/// The signal body is ignored entirely: it carries accessible names +/// and window titles, which this module must not read. The *sender* +/// is the identity we want. +fn sample_for_signal(conn: &Connection, msg: &Message) -> Option { + let header = msg.header(); + let sender = header.sender()?; + let pid = connection_pid(conn, sender.as_str())?; + let exe = exe_basename_for_pid(pid)?; + Some(FocusSample { + exe, + at: Instant::now(), + }) +} + +/// Ask the a11y bus which process owns a connection. +/// +/// This is the whole trick: the app talks to the a11y bus itself, so +/// the bus daemon knows its PID and will say. No compositor, no +/// extension, no user-installed script. +fn connection_pid(conn: &Connection, sender: &str) -> Option { + let reply = conn + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "GetConnectionUnixProcessID", + &(sender,), + ) + .map_err(|e| debug!(%e, "AT-SPI focus: PID lookup failed")) + .ok()?; + reply.body().deserialize::().ok() +} + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-input/src/focus/linux_impl/atspi_focus/tests.rs b/crates/poltertype-input/src/focus/linux_impl/atspi_focus/tests.rs new file mode 100644 index 0000000..3834b0b --- /dev/null +++ b/crates/poltertype-input/src/focus/linux_impl/atspi_focus/tests.rs @@ -0,0 +1,92 @@ +use super::*; + +/// A sample's age is what the caller uses to decide whether to trust +/// it, so it has to actually advance. +#[test] +fn a_sample_ages() { + let s = FocusSample { + exe: "kate".into(), + at: Instant::now() - Duration::from_secs(10), + }; + assert!(s.age() >= Duration::from_secs(10)); +} + +/// Constructing the watcher must never panic, whatever the session +/// looks like. On a machine with no a11y bus at all — headless CI is +/// the case that matters — it has to come back as a plain error the +/// factory can log and move past. +#[test] +fn construction_is_infallible_in_the_panic_sense() { + match AtspiFocusWatcher::try_new() { + // A live a11y bus. Nothing has necessarily been activated + // yet, so the only guarantee is that reading is safe. + Ok(w) => drop(w.latest()), + // Every variant must describe a missing or unusable a11y + // stack rather than a bug — the caller treats it as normal. + Err(e) => assert!(!e.to_string().is_empty()), + } +} + +/// PID lookup must fail gracefully for a name nobody owns rather than +/// propagating a bus error upward: an app can exit between sending an +/// event and our asking about it, and that is ordinary churn. +#[test] +fn an_unknown_sender_yields_no_pid() { + let Some(conn) = test_a11y_connection() else { + return; // no a11y bus here; nothing to assert against + }; + assert_eq!( + connection_pid(&conn, ":99.99999"), + None, + "a sender that does not exist must not resolve to a PID" + ); +} + +/// The end-to-end check, run by hand against a live desktop: +/// +/// ```text +/// cargo test -p poltertype-input -- --ignored --nocapture \ +/// names_the_application_that_takes_focus +/// # …then focus an a11y-capable window (kate, a GTK app, a browser) +/// ``` +/// +/// Ignored by default because it needs a session, a window manager and +/// something to move focus — none of which CI has. Verified by hand on +/// 2026-08-01: reported `exe=chrome` 272 ms after the activation. +#[test] +#[ignore = "needs a live desktop and a focus change"] +fn names_the_application_that_takes_focus() { + let Ok(w) = AtspiFocusWatcher::try_new() else { + eprintln!("no a11y bus — cannot run this check here"); + return; + }; + eprintln!("watching for window:activate — focus another window now…"); + for _ in 0..30 { + if let Some(s) = w.latest() { + eprintln!("observed focus: exe={} age={:?}", s.exe, s.age()); + assert!(!s.exe.is_empty(), "an observation must name an executable"); + return; + } + std::thread::sleep(Duration::from_millis(500)); + } + // Not an assertion failure: on a desktop where nothing focused is + // a11y-capable this is the documented limitation, not a bug. + eprintln!("no window:activate in 15s — was any focused app a11y-capable?"); +} + +/// Open a connection to the a11y bus, or `None` if this machine has +/// none. Mirrors the production path without duplicating its logging. +fn test_a11y_connection() -> Option { + let session = Connection::session().ok()?; + let reply = session + .call_method( + Some("org.a11y.Bus"), + "/org/a11y/bus", + Some("org.a11y.Bus"), + "GetAddress", + &(), + ) + .ok()?; + let address: String = reply.body().deserialize().ok()?; + Builder::address(address.as_str()).ok()?.build().ok() +} diff --git a/crates/poltertype-input/src/focus/linux_impl/caret_only.rs b/crates/poltertype-input/src/focus/linux_impl/caret_only.rs index f3c529a..6e5c2d3 100644 --- a/crates/poltertype-input/src/focus/linux_impl/caret_only.rs +++ b/crates/poltertype-input/src/focus/linux_impl/caret_only.rs @@ -1,41 +1,73 @@ -//! Caret-only tracker for Wayland sessions with no active-window query. +//! AT-SPI-only tracker for Wayland sessions with no active-window query. //! //! GNOME and KDE expose no compositor-agnostic "which window has //! focus" — by design, the same reasoning that keeps global input out -//! of reach. That rules out `focused_exe()` and window geometry, and +//! of reach. That ruled out `focused_exe()` and window geometry, and //! for a long time the whole tracker was therefore a noop there. //! -//! But the *caret* comes from AT-SPI, which is a session bus and knows -//! nothing about compositors: it answers on GNOME and KDE exactly as -//! it does on Hyprland. Leaving it unbuilt cost the suggestion tooltip -//! its best anchor on the two largest desktops and pinned it to the -//! bottom of the screen — not for a technical reason, but because the -//! watcher was only ever constructed inside the two branches that had -//! a window query as well. +//! Both halves now come from AT-SPI, which is a session-bus service +//! and knows nothing about compositors: it answers on GNOME and KDE +//! exactly as it does on Hyprland. +//! +//! * The **caret** comes from `object:text-caret-moved` extents. +//! * The **focused application** comes from `window:activate`, by +//! asking the a11y bus which process owns the sending connection — +//! see [`super::atspi_focus`], which is also where the limits of +//! that answer are written down. +//! +//! The limit worth repeating here, because this is the type that +//! decides whether `disabled_apps` fires: **an application with no +//! accessibility bridge is invisible to this tracker**, and a +//! terminal usually has none. A stale answer is therefore possible in +//! a way it is not on Hyprland or X11, so focus samples carry an age +//! and anything older than [`FOCUS_MAX_AGE`] is treated as no answer. +//! Reporting the wrong application would silence PolterType in a +//! window the user expects it to work in — the exact regression that +//! made `disabled_apps` empty by default. use std::sync::Arc; +use std::time::Duration; use crate::focus::{CaretHint, FocusTracker}; use super::atspi_caret::{AtspiCaretWatcher, CaretSample}; +use super::atspi_focus::AtspiFocusWatcher; + +/// How long a focus observation stays trustworthy. +/// +/// Generous, because the events are sparse: a user can sit in one +/// window for hours and the last `window:activate` is still correct. +/// What this bounds is the other case — the user moved to an app with +/// no a11y bridge, nothing was emitted, and the previous answer is +/// now a lie. Five minutes keeps the common case working while +/// ensuring a wrong answer expires rather than persisting all session. +const FOCUS_MAX_AGE: Duration = Duration::from_secs(300); pub(crate) struct CaretOnlyFocusTracker { caret: Arc, + focus: Option>, } impl CaretOnlyFocusTracker { - pub(crate) fn new(caret: Arc) -> Self { - Self { caret } + pub(crate) fn new( + caret: Arc, + focus: Option>, + ) -> Self { + Self { caret, focus } } } impl FocusTracker for CaretOnlyFocusTracker { - /// Always `None`, and deliberately so — everything keyed off the - /// focused app (`[exceptions].disabled_apps`, per-app wordlist - /// profiles, `apps = [...]` on smart commands) stays inert here - /// rather than acting on a guess. + /// The focused application, when AT-SPI has told us recently. + /// + /// `None` whenever the watcher could not start, nothing has been + /// heard yet, or the last observation has gone stale — all three + /// leave the focus-keyed features inert, which is the same + /// behaviour these sessions had before and is the safe direction + /// to be wrong in. fn focused_exe(&self) -> Option { - None + let sample = self.focus.as_ref()?.latest()?; + (sample.age() < FOCUS_MAX_AGE).then_some(sample.exe) } fn caret_hint(&self) -> Option { @@ -43,6 +75,6 @@ impl FocusTracker for CaretOnlyFocusTracker { } fn backend_name(&self) -> &'static str { - "linux-atspi-caret-only" + "linux-atspi" } } diff --git a/crates/poltertype-input/src/focus/linux_impl/mod.rs b/crates/poltertype-input/src/focus/linux_impl/mod.rs index 2776193..5b18082 100644 --- a/crates/poltertype-input/src/focus/linux_impl/mod.rs +++ b/crates/poltertype-input/src/focus/linux_impl/mod.rs @@ -33,6 +33,7 @@ //! outside the TTL cache entirely. mod atspi_caret; +mod atspi_focus; mod cache; mod caret_only; mod consts; diff --git a/crates/poltertype-input/src/focus/linux_impl/pick.rs b/crates/poltertype-input/src/focus/linux_impl/pick.rs index 4c5965e..ceedbc1 100644 --- a/crates/poltertype-input/src/focus/linux_impl/pick.rs +++ b/crates/poltertype-input/src/focus/linux_impl/pick.rs @@ -8,6 +8,7 @@ use crate::focus::{FocusTracker, NoopFocusTracker}; use crate::linux::{SessionKind, session_kind}; use super::atspi_caret::AtspiCaretWatcher; +use super::atspi_focus::AtspiFocusWatcher; use super::cache::CachedFocusTracker; use super::caret_only::CaretOnlyFocusTracker; use super::consts::FOCUS_CACHE_TTL; @@ -19,16 +20,26 @@ use super::x11::X11FocusTracker; /// (its IPC works regardless of what `XDG_SESSION_TYPE` says), then /// plain X11 sessions get EWMH. Everything else — GNOME / KDE on /// Wayland — has no compositor-agnostic active-window query, by -/// design, so `focused_exe()` stays `None` there. +/// design. /// /// It does **not** follow that those sessions get nothing. AT-SPI is a /// session-bus service and does not care which compositor is running, -/// so the caret watcher answers on GNOME and KDE exactly as it does on -/// Hyprland — and the caret is the tooltip's *best* anchor, better -/// than the window geometry the other backends can offer. Building it -/// on its own is what stops the tooltip being pinned to the bottom of -/// the screen on the two largest desktops. No TTL cache: the caret -/// watcher is event-driven and already cheap to read. +/// so it answers on GNOME and KDE exactly as it does on Hyprland, and +/// it supplies *both* halves: +/// +/// * the caret, which is the tooltip's best anchor — better than the +/// window geometry the other backends can offer; +/// * the focused application, by asking the a11y bus which process +/// owns the connection that sent a `window:activate`. +/// +/// The second one is why `focused_exe()` is no longer flatly `None` +/// on GNOME and KDE. Read [`super::atspi_focus`] before relying on +/// it: an application with no accessibility bridge — most terminals — +/// is invisible to it, so it is an improvement on nothing rather than +/// an equivalent of a compositor answer. +/// +/// No TTL cache on this branch: both watchers are event-driven and +/// already cheap to read. /// /// Note the X11 backend is deliberately NOT used on non-Hyprland /// Wayland even when `DISPLAY` points at XWayland: XWayland only sees @@ -49,11 +60,27 @@ pub(crate) fn create_linux_focus_tracker() -> Arc { )); } match caret_watcher() { - Some(caret) => Arc::new(CaretOnlyFocusTracker::new(caret)), + Some(caret) => Arc::new(CaretOnlyFocusTracker::new(caret, focus_watcher())), None => Arc::new(NoopFocusTracker), } } +/// The AT-SPI focused-application watcher, for the branch that has no +/// compositor to ask. Like the caret watcher it owns a thread and a +/// bus connection, and failing to start is a normal, log-once +/// condition rather than an error — the tracker simply keeps +/// answering `None` for `focused_exe()`, which is where these +/// sessions were before. +fn focus_watcher() -> Option> { + match AtspiFocusWatcher::try_new() { + Ok(w) => Some(Arc::new(w)), + Err(e) => { + info!(%e, "AT-SPI focus watcher unavailable; per-app features stay inert"); + None + } + } +} + /// One AT-SPI caret watcher per tracker — created only for a branch /// that actually builds one (the probe branches are exclusive, so /// this runs at most once per factory call). It owns a thread and a diff --git a/crates/poltertype-input/src/linux/mod.rs b/crates/poltertype-input/src/linux/mod.rs index 5123690..69351a9 100644 --- a/crates/poltertype-input/src/linux/mod.rs +++ b/crates/poltertype-input/src/linux/mod.rs @@ -14,7 +14,15 @@ //! permissions aren't granted, the listener returns `InputError::Os` //! so the tray can show an onboarding banner. //! -//! AT-SPI fallback (no `sudo` required, less reliable) lands in v0.1.x. +//! There is no third backend, and there is not going to be an AT-SPI +//! one. The accessibility stack looked like a way to read keystrokes +//! without `input`-group membership, but `at-spi2-registryd` has no +//! keyboard of its own — on Wayland it relays only what the compositor +//! hands it, and only mutter does. Measured on wlroots: +//! `RegisterKeystrokeListener` returns false and no events arrive even +//! with injected keys. On X11, where it would work, the XInput2 +//! listener above already needs no permissions. See `DECISIONS.md`, +//! 2026-08-01. #![allow(unused_imports, dead_code)] // Linux-only code; Windows doesn't compile this. @@ -22,6 +30,7 @@ use tracing::info; use crate::{InputError, InputListener, KeyEmitter}; +pub mod portal; pub mod wayland; pub mod x11; @@ -58,10 +67,44 @@ pub fn create_key_gate() -> crate::KeyGate { } } +/// Pick the emitter for this session. +/// +/// On Wayland `uinput` stays the default: it works on every +/// compositor, needs no consent dialog, and is what the key gate is +/// built around. The portal is tried only when uinput cannot be +/// opened — which is precisely the "user has not run +/// `setup-linux.sh`" case, and the one where an app that types +/// nothing is indistinguishable from a broken one. +/// +/// Deliberately in this order. Reversing it would put a consent +/// dialog in front of every GNOME and KDE user who had already +/// granted the group membership and did not need to be asked +/// anything. pub fn create_emitter() -> Result, InputError> { match session_kind() { SessionKind::X11 => Ok(Box::new(x11::X11Emitter::new())), - SessionKind::Wayland | SessionKind::Unknown => Ok(Box::new(wayland::UinputEmitter::new())), + SessionKind::Wayland | SessionKind::Unknown => { + let uinput = wayland::UinputEmitter::new(); + if uinput.is_usable() { + return Ok(Box::new(uinput)); + } + if !portal::portal_available() { + // No portal either: hand back uinput anyway so the + // failure the user sees is the familiar + // permissions one the Setup pane explains, not a + // second story about portals they cannot act on. + info!("no uinput and no RemoteDesktop portal; staying on uinput for diagnostics"); + return Ok(Box::new(uinput)); + } + info!("uinput unavailable — trying the RemoteDesktop portal"); + match portal::PortalEmitter::try_new() { + Ok(p) => Ok(Box::new(p)), + Err(e) => { + info!(%e, "portal emitter unavailable; falling back to uinput"); + Ok(Box::new(uinput)) + } + } + } } } diff --git a/crates/poltertype-input/src/linux/portal/consts.rs b/crates/poltertype-input/src/linux/portal/consts.rs new file mode 100644 index 0000000..fc30899 --- /dev/null +++ b/crates/poltertype-input/src/linux/portal/consts.rs @@ -0,0 +1,43 @@ +//! Portal names, numbers and our own budgets. + +/// The portal service, and the object every call goes to. +pub const PORTAL_BUS: &str = "org.freedesktop.portal.Desktop"; +pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop"; +pub const REMOTE_DESKTOP_IFACE: &str = "org.freedesktop.portal.RemoteDesktop"; +pub const REQUEST_IFACE: &str = "org.freedesktop.portal.Request"; +pub const SESSION_IFACE: &str = "org.freedesktop.portal.Session"; + +/// `DeviceType` bitmask — we only ever ask for the keyboard. Asking +/// for the pointer as well would widen the consent dialog to +/// something PolterType has no use for. +pub const DEVICE_KEYBOARD: u32 = 1; + +/// `KeyState`. +pub const KEY_RELEASED: u32 = 0; +pub const KEY_PRESSED: u32 = 1; + +/// `PersistMode::Persistent` — ask the portal to remember this grant +/// so later runs do not re-prompt. The compositor decides whether to +/// honour it; a restore token only comes back if it does. +pub const PERSIST_PERSISTENT: u32 = 2; + +/// How long to wait for a portal `Response` signal. +/// +/// Generous on purpose: `Start` puts a dialog in front of a human, +/// and a human takes longer than any protocol. The other calls answer +/// immediately, so this is really the deadline for "the user walked +/// away" — at which point failing is right, and the emitter falls +/// back to whatever the caller had before. +pub const RESPONSE_TIMEOUT_SECS: u64 = 120; + +/// Portal `Response` codes. +pub const RESPONSE_SUCCESS: u32 = 0; +pub const RESPONSE_CANCELLED: u32 = 1; + +/// File under `/poltertype/` holding the restore +/// token, so a granted session is not re-prompted every launch. +/// +/// Not in `config.toml`: it is an opaque credential the portal issued +/// to this install, not a setting anyone should edit, and it has no +/// business in a file people paste into bug reports. +pub const RESTORE_TOKEN_FILE: &str = "portal-restore-token"; diff --git a/crates/poltertype-input/src/linux/portal/emitter.rs b/crates/poltertype-input/src/linux/portal/emitter.rs new file mode 100644 index 0000000..2922de3 --- /dev/null +++ b/crates/poltertype-input/src/linux/portal/emitter.rs @@ -0,0 +1,172 @@ +//! `KeyEmitter` over a RemoteDesktop portal session. + +use std::sync::Mutex; + +use tracing::warn; + +use super::session::{PortalError, PortalSession}; +use crate::{EmittedKey, InputError, KeyDirection, KeyEmitter, Modifiers, ReplayKey}; + +/// Evdev keycodes this backend needs by name. Same numbering the +/// portal takes and the same the rest of the crate uses — no X11 `+8` +/// anywhere in this file. +const KEY_BACKSPACE: i32 = 14; +const KEY_LEFTCTRL: i32 = 29; +const KEY_LEFTSHIFT: i32 = 42; +const KEY_LEFTALT: i32 = 56; +const KEY_LEFTMETA: i32 = 125; + +pub struct PortalEmitter { + session: PortalSession, + /// Everything we have put on the wire since the engine last + /// drained it. The portal's keys arrive back through evdev + /// indistinguishable from a real keyboard — exactly the uinput + /// situation — so the engine's echo filter needs the same record. + emitted: Mutex>, +} + +impl PortalEmitter { + /// Negotiate a session. Blocks and may prompt, so this belongs at + /// startup and never on the correction path. + pub fn try_new() -> Result { + Ok(Self { + session: PortalSession::open()?, + emitted: Mutex::new(Vec::new()), + }) + } + + /// Press and release one keycode, recording both edges. + fn tap(&self, keycode: i32) -> Result<(), InputError> { + self.press(keycode, true)?; + self.press(keycode, false) + } + + fn press(&self, keycode: i32, down: bool) -> Result<(), InputError> { + self.session + .notify_keycode(keycode, down) + .map_err(|e| InputError::Os(e.to_string()))?; + // Both edges are recorded: the echo filter matches the stream + // it actually sees, and evdev reports releases too. + if let Ok(mut log) = self.emitted.lock() { + log.push(EmittedKey { + scancode: evdev_to_scancode(keycode), + direction: if down { + KeyDirection::Press + } else { + KeyDirection::Release + }, + }); + } + Ok(()) + } +} + +impl KeyEmitter for PortalEmitter { + fn send_backspaces(&self, n: usize) -> Result<(), InputError> { + for _ in 0..n { + self.tap(KEY_BACKSPACE)?; + } + Ok(()) + } + + /// The portal speaks keycodes, not characters. + /// + /// Not a gap: the Wayland correction path is scancode replay for + /// the same reason the uinput backend uses it — the compose-key + /// trick `send_text` would need is swallowed by most terminals + /// and Wayland-native apps. Returning `Unsupported` is what tells + /// the engine to use [`send_keys`](Self::send_keys). + fn send_text(&self, _text: &str) -> Result<(), InputError> { + Err(InputError::Unsupported( + "the RemoteDesktop portal emits keycodes, not text; use send_keys".into(), + )) + } + + fn send_keys(&self, keys: &[ReplayKey]) -> Result<(), InputError> { + for key in keys { + let code = scancode_to_evdev(key.scancode); + if key.shift { + self.press(KEY_LEFTSHIFT, true)?; + } + self.tap(code)?; + if key.shift { + self.press(KEY_LEFTSHIFT, false)?; + } + } + Ok(()) + } + + /// Let go of modifiers the user is physically holding. + /// + /// Same reasoning as every other backend: our keys travel the + /// user's path to the application, so a held `Ctrl` turns the + /// replay into shortcuts and types nothing. We release and never + /// re-press — re-pressing one the user has meanwhile let go of + /// would leave it stuck down. + fn release_modifiers(&self, held: Modifiers) -> Result<(), InputError> { + for (is_held, code) in [ + (held.control, KEY_LEFTCTRL), + (held.alt, KEY_LEFTALT), + (held.shift, KEY_LEFTSHIFT), + (held.meta, KEY_LEFTMETA), + ] { + if is_held && let Err(e) = self.press(code, false) { + warn!(?e, code, "portal: could not release a held modifier"); + } + } + Ok(()) + } + + fn take_emitted(&self) -> Vec { + self.emitted + .lock() + .map(|mut log| std::mem::take(&mut *log)) + .unwrap_or_default() + } + + fn backend_name(&self) -> &'static str { + "linux-portal-remotedesktop" + } +} + +/// Win SC Set-1 scancode → evdev keycode. +/// +/// The engine's buffers carry Set-1 scancodes (what the layout TOMLs +/// are keyed by); evdev codes are what both the portal and +/// `/dev/input` speak. For the alphanumeric block the two coincide, +/// which is why this is an identity — the block PolterType records is +/// exactly the block where Set-1 and evdev agree. +fn scancode_to_evdev(scancode: u32) -> i32 { + i32::try_from(scancode).unwrap_or(0) +} + +fn evdev_to_scancode(keycode: i32) -> u32 { + u32::try_from(keycode).unwrap_or(0) +} + +/// Test-only views of the private constants and conversions, so the +/// tests pin what the code actually uses rather than a copy of it. +#[cfg(test)] +pub(super) mod testing { + pub fn key_backspace() -> i32 { + super::KEY_BACKSPACE + } + pub fn key_leftshift() -> i32 { + super::KEY_LEFTSHIFT + } + pub fn key_leftctrl() -> i32 { + super::KEY_LEFTCTRL + } + pub fn key_leftalt() -> i32 { + super::KEY_LEFTALT + } + pub fn key_leftmeta() -> i32 { + super::KEY_LEFTMETA + } + pub fn to_evdev(scancode: u32) -> i32 { + super::scancode_to_evdev(scancode) + } + pub fn from_evdev(keycode: i32) -> u32 { + super::evdev_to_scancode(keycode) + } +} diff --git a/crates/poltertype-input/src/linux/portal/mod.rs b/crates/poltertype-input/src/linux/portal/mod.rs new file mode 100644 index 0000000..2df3796 --- /dev/null +++ b/crates/poltertype-input/src/linux/portal/mod.rs @@ -0,0 +1,63 @@ +//! Typing through the `org.freedesktop.portal.RemoteDesktop` portal. +//! +//! **Never executed.** There is no RemoteDesktop backend on the +//! machine this was written on — `hyprland.portal` offers Screenshot, +//! ScreenCast, GlobalShortcuts and InputCapture, and only +//! `kde.portal` declares RemoteDesktop — so every line here is +//! written from the portal specification and has run exactly zero +//! times. Treat it the way `CLAUDE.md` tells you to treat the macOS +//! paths. If it misbehaves on a real GNOME or KDE session, start by +//! assuming this file is wrong rather than the compositor. +//! +//! ## Why this exists +//! +//! `uinput` is the only way PolterType can type on Wayland, and it +//! needs `input`-group membership plus a udev rule — one `sudo` the +//! user has to run before the app does anything. X11 needs nothing; +//! Wayland needs a setup script. The portal is the standard, +//! permissioned way to ask the compositor to synthesise input, so it +//! is the one path that could remove that step for GNOME and KDE. +//! +//! ## Why not libei +//! +//! The plan said "`libei` (`reis`) as the portal variant of +//! send-keys", and libei is indeed where this is all heading. But the +//! portal already exposes [`NotifyKeyboardKeycode`][spec] as a plain +//! D-Bus method, which does exactly what a correction needs: press +//! and release an evdev keycode. Going through `ConnectToEIS` and the +//! libei protocol instead would mean a new protocol implementation +//! and a heavyweight dependency to send perhaps twenty keystrokes per +//! correction, and it would still need everything in +//! [`session`] to get the file descriptor in the first place. +//! +//! So this takes the short path deliberately. `zbus` is already in +//! the tree for the a11y bus, the whole backend is a few hundred +//! reviewable lines, and if a compositor ever drops the Notify +//! methods in favour of EIS-only, [`session`] is the part that +//! already exists and `emitter` is what gets replaced. +//! +//! ## What it cannot do +//! +//! * **No `send_text`.** The portal speaks keycodes, not characters. +//! That is fine — the engine's Wayland path is scancode replay +//! anyway, for exactly the same reason (see `KeyEmitter::send_keys`). +//! * **It asks.** `Start` shows a consent dialog. A restore token is +//! requested so that later sessions are silent, but the first run +//! on any machine puts a dialog in front of the user, and a +//! compositor is free to ask again. +//! * **No echo suppression of its own.** Injected keys come back +//! through evdev like anything else, so the emitter records what it +//! sent for the engine's echo filter, exactly as the uinput backend +//! does. +//! +//! [spec]: https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html + +mod consts; +mod emitter; +mod session; + +pub use emitter::PortalEmitter; +pub use session::{PortalError, PortalSession, portal_available}; + +#[cfg(test)] +mod tests; diff --git a/crates/poltertype-input/src/linux/portal/session.rs b/crates/poltertype-input/src/linux/portal/session.rs new file mode 100644 index 0000000..149b8af --- /dev/null +++ b/crates/poltertype-input/src/linux/portal/session.rs @@ -0,0 +1,323 @@ +//! Negotiating a RemoteDesktop session. +//! +//! Every portal call is asynchronous in the same shape: the method +//! returns a `Request` object path immediately, and the actual answer +//! arrives later as a `Response` signal on that path. So each step +//! here subscribes *before* calling — a portal that answers quickly +//! would otherwise deliver the signal before we were listening, and +//! the call would hang until the timeout for no reason. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use thiserror::Error; +use tracing::{debug, info, warn}; +use zbus::blocking::{Connection, MessageIterator}; +use zbus::zvariant::{ObjectPath, OwnedValue, Value}; +use zbus::{MatchRule, message}; + +use super::consts::*; + +#[derive(Debug, Error)] +pub enum PortalError { + #[error("session bus unavailable: {0}")] + Bus(zbus::Error), + #[error("the RemoteDesktop portal is not available on this session")] + NotAvailable, + #[error("portal call {call} failed: {source}")] + Call { + call: &'static str, + #[source] + source: zbus::Error, + }, + #[error("portal call {0} timed out after {RESPONSE_TIMEOUT_SECS}s")] + Timeout(&'static str), + #[error("the user declined the screen-sharing prompt")] + Cancelled, + #[error("portal call {call} returned response code {code}")] + Refused { call: &'static str, code: u32 }, + #[error("portal reply had an unexpected shape during {0}")] + BadReply(&'static str), +} + +/// Is a RemoteDesktop portal present at all? +/// +/// Cheap enough to call before deciding on a backend: it reads one +/// property. A session without the interface (wlroots today) answers +/// no, and the caller stays on `uinput`. +pub fn portal_available() -> bool { + let Ok(conn) = Connection::session() else { + return false; + }; + conn.call_method( + Some(PORTAL_BUS), + PORTAL_PATH, + Some("org.freedesktop.DBus.Properties"), + "Get", + &(REMOTE_DESKTOP_IFACE, "version"), + ) + .is_ok() +} + +/// A negotiated session: the portal has granted keyboard emulation +/// and will accept `NotifyKeyboardKeycode` on this handle. +pub struct PortalSession { + conn: Connection, + handle: String, +} + +impl PortalSession { + /// Create, configure and start a session. Blocks — `Start` shows + /// the consent dialog, so this must never be called from the + /// correction path. The caller does it once, at startup. + pub fn open() -> Result { + let conn = Connection::session().map_err(PortalError::Bus)?; + if !portal_available() { + return Err(PortalError::NotAvailable); + } + + let session_token = unique_token("pt_session"); + let session_handle = create_session(&conn, &session_token)?; + select_devices(&conn, &session_handle)?; + let restore_token = start(&conn, &session_handle)?; + + if let Some(token) = restore_token { + store_restore_token(&token); + } + info!( + "RemoteDesktop portal session established — PolterType can type without \ + input-group membership on this session" + ); + Ok(Self { + conn, + handle: session_handle, + }) + } + + /// Press or release one evdev keycode. + /// + /// **Evdev codes, not X11 keycodes**: the portal takes the code + /// the kernel uses (`KEY_A` = 30), the same numbering the rest of + /// this crate speaks, with no `+8` offset anywhere. + pub fn notify_keycode(&self, keycode: i32, pressed: bool) -> Result<(), PortalError> { + let options: HashMap<&str, Value<'_>> = HashMap::new(); + let state = if pressed { KEY_PRESSED } else { KEY_RELEASED }; + self.conn + .call_method( + Some(PORTAL_BUS), + PORTAL_PATH, + Some(REMOTE_DESKTOP_IFACE), + "NotifyKeyboardKeycode", + &( + ObjectPath::try_from(self.handle.as_str()) + .map_err(|_| PortalError::BadReply("NotifyKeyboardKeycode"))?, + options, + keycode, + state, + ), + ) + .map_err(|source| PortalError::Call { + call: "NotifyKeyboardKeycode", + source, + })?; + Ok(()) + } +} + +impl Drop for PortalSession { + /// Close the session so the compositor drops its "an app is + /// controlling this screen" indicator rather than leaving it up + /// until the bus connection dies. + fn drop(&mut self) { + if let Ok(path) = ObjectPath::try_from(self.handle.as_str()) { + let _ = + self.conn + .call_method(Some(PORTAL_BUS), path, Some(SESSION_IFACE), "Close", &()); + } + } +} + +fn create_session(conn: &Connection, session_token: &str) -> Result { + let mut options: HashMap<&str, Value<'_>> = HashMap::new(); + let handle_token = unique_token("pt_req"); + options.insert("handle_token", Value::from(handle_token.as_str())); + options.insert("session_handle_token", Value::from(session_token)); + + let call = "CreateSession"; + let mut signals = subscribe(conn, call)?; + let reply = conn + .call_method( + Some(PORTAL_BUS), + PORTAL_PATH, + Some(REMOTE_DESKTOP_IFACE), + call, + &(&options,), + ) + .map_err(|source| PortalError::Call { call, source })?; + let path = request_path_of(&reply, call)?; + let results = wait_response(call, &mut signals, &path)?; + results + .get("session_handle") + .and_then(|v| String::try_from(v.clone()).ok()) + .ok_or(PortalError::BadReply("CreateSession")) +} + +fn select_devices(conn: &Connection, session_handle: &str) -> Result<(), PortalError> { + let mut options: HashMap<&str, Value<'_>> = HashMap::new(); + let handle_token = unique_token("pt_req"); + options.insert("handle_token", Value::from(handle_token.as_str())); + options.insert("types", Value::from(DEVICE_KEYBOARD)); + options.insert("persist_mode", Value::from(PERSIST_PERSISTENT)); + // A token from a previous grant, if the compositor gave us one — + // this is what stops the dialog appearing on every launch. + let restored = load_restore_token(); + if let Some(token) = &restored { + debug!("reusing a stored portal restore token"); + options.insert("restore_token", Value::from(token.as_str())); + } + + let call = "SelectDevices"; + let session = ObjectPath::try_from(session_handle).map_err(|_| PortalError::BadReply(call))?; + let mut signals = subscribe(conn, call)?; + let reply = conn + .call_method( + Some(PORTAL_BUS), + PORTAL_PATH, + Some(REMOTE_DESKTOP_IFACE), + call, + &(session, &options), + ) + .map_err(|source| PortalError::Call { call, source })?; + let path = request_path_of(&reply, call)?; + wait_response(call, &mut signals, &path)?; + Ok(()) +} + +/// `Start` is the one that prompts. Returns the restore token when +/// the compositor issued one. +fn start(conn: &Connection, session_handle: &str) -> Result, PortalError> { + let mut options: HashMap<&str, Value<'_>> = HashMap::new(); + let handle_token = unique_token("pt_req"); + options.insert("handle_token", Value::from(handle_token.as_str())); + + let call = "Start"; + let session = ObjectPath::try_from(session_handle).map_err(|_| PortalError::BadReply(call))?; + let mut signals = subscribe(conn, call)?; + // Empty parent window: PolterType is a tray app and has no window + // to parent the dialog to. + let reply = conn + .call_method( + Some(PORTAL_BUS), + PORTAL_PATH, + Some(REMOTE_DESKTOP_IFACE), + call, + &(session, "", &options), + ) + .map_err(|source| PortalError::Call { call, source })?; + let path = request_path_of(&reply, call)?; + let results = wait_response(call, &mut signals, &path)?; + Ok(results + .get("restore_token") + .and_then(|v| String::try_from(v.clone()).ok())) +} + +/// Subscribe to `Response` before making a call. +/// +/// Order matters: a portal that answers immediately would emit the +/// signal before we were listening, and we would then wait out the +/// whole timeout for something already delivered. +fn subscribe(conn: &Connection, call: &'static str) -> Result { + let rule = MatchRule::builder() + .msg_type(message::Type::Signal) + .interface(REQUEST_IFACE) + .map_err(|source| PortalError::Call { call, source })? + .member("Response") + .map_err(|source| PortalError::Call { call, source })? + .build(); + MessageIterator::for_match_rule(rule, conn, Some(8)) + .map_err(|source| PortalError::Call { call, source }) +} + +/// Wait for the `Response` belonging to `request_path`. +fn wait_response( + call: &'static str, + signals: &mut MessageIterator, + request_path: &str, +) -> Result, PortalError> { + let deadline = std::time::Instant::now() + Duration::from_secs(RESPONSE_TIMEOUT_SECS); + while std::time::Instant::now() < deadline { + let Some(Ok(msg)) = signals.next() else { + break; + }; + let header = msg.header(); + if header.path().map(ToString::to_string).as_deref() != Some(request_path) { + continue; // somebody else's request + } + let (code, results): (u32, HashMap) = msg + .body() + .deserialize() + .map_err(|_| PortalError::BadReply(call))?; + return match code { + RESPONSE_SUCCESS => Ok(results), + RESPONSE_CANCELLED => Err(PortalError::Cancelled), + other => Err(PortalError::Refused { call, code: other }), + }; + } + Err(PortalError::Timeout(call)) +} + +/// The `Request` object path a portal call returns. +fn request_path_of(reply: &zbus::Message, call: &'static str) -> Result { + // Bind the body: deserialising borrows from it, so a temporary + // would be dropped while the ObjectPath still points into it. + let body = reply.body(); + let path: ObjectPath<'_> = body + .deserialize() + .map_err(|_| PortalError::BadReply(call))?; + Ok(path.to_string()) +} + +/// Portal handle tokens must be unique per connection and are +/// restricted to `[A-Za-z0-9_]`. +fn unique_token(prefix: &str) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + format!("{prefix}_{pid}_{n}") +} + +fn restore_token_path() -> Option { + directories::ProjectDirs::from("dev", "opensource", "poltertype") + .map(|d| d.data_local_dir().join(RESTORE_TOKEN_FILE)) +} + +fn load_restore_token() -> Option { + let path = restore_token_path()?; + let token = std::fs::read_to_string(path).ok()?; + let token = token.trim().to_owned(); + (!token.is_empty()).then_some(token) +} + +/// Store the token so the next launch is silent. +/// +/// Best-effort: a machine where this cannot be written just prompts +/// again next time, which is annoying rather than broken. +fn store_restore_token(token: &str) { + let Some(path) = restore_token_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&path, token) { + warn!(path = %path.display(), %e, "could not store the portal restore token"); + } +} + +/// Test-only view of the token generator. +#[cfg(test)] +pub(super) fn token_for_test(prefix: &str) -> String { + unique_token(prefix) +} diff --git a/crates/poltertype-input/src/linux/portal/tests.rs b/crates/poltertype-input/src/linux/portal/tests.rs new file mode 100644 index 0000000..897f901 --- /dev/null +++ b/crates/poltertype-input/src/linux/portal/tests.rs @@ -0,0 +1,123 @@ +//! What can honestly be tested without a RemoteDesktop portal. +//! +//! There is none on the machine this was written on, so nothing here +//! exercises the protocol. What these do check is the part that would +//! be wrong in a way no compositor could forgive: that probing is +//! safe on a session with no portal, that the keycodes are the evdev +//! numbers the spec asks for rather than X11's, and that the option +//! values match the constants in the specification. +//! +//! The protocol itself stays unverified until someone runs this on +//! GNOME or KDE. Said plainly in the module docs and in `CLAUDE.md`. + +use super::consts::*; +use super::*; + +/// Probing must be safe and honest on a session without the portal — +/// which is every wlroots session today, including the one this is +/// most likely to be run on first. +#[test] +fn probing_a_session_without_the_portal_is_safe_and_false() { + // Whatever this machine has, the call must not panic or hang. + let available = portal_available(); + // On a session that genuinely lacks RemoteDesktop the answer has + // to be `false`, or the emitter chooser would pick a backend that + // cannot work and abandon a uinput that can. + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + // Only an observation, not an assertion: a GNOME or KDE + // session would legitimately answer true. + eprintln!("RemoteDesktop portal available here: {available}"); + } +} + +/// Opening a session on a machine with no portal must fail with the +/// specific error, not hang waiting for a dialog nobody will show. +#[test] +fn opening_without_a_portal_fails_rather_than_hanging() { + if portal_available() { + return; // a real portal; not this test's business + } + let started = std::time::Instant::now(); + let result = PortalSession::open(); + assert!( + matches!( + result, + Err(PortalError::NotAvailable) | Err(PortalError::Bus(_)) + ), + "expected a clean unavailability error" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "must fail fast, not wait out the response timeout" + ); +} + +/// The portal takes **evdev** keycodes. Sending X11 keycodes, which +/// are evdev plus eight, would type the wrong character for every +/// single key — the easiest mistake to make in this file. +#[test] +fn keycodes_are_evdev_numbers() { + // From `linux/input-event-codes.h`. + assert_eq!( + super::emitter::testing::key_backspace(), + 14, + "KEY_BACKSPACE" + ); + assert_eq!( + super::emitter::testing::key_leftshift(), + 42, + "KEY_LEFTSHIFT" + ); + assert_eq!(super::emitter::testing::key_leftctrl(), 29, "KEY_LEFTCTRL"); + assert_eq!(super::emitter::testing::key_leftalt(), 56, "KEY_LEFTALT"); + assert_eq!(super::emitter::testing::key_leftmeta(), 125, "KEY_LEFTMETA"); +} + +/// The engine's scancodes and evdev's keycodes coincide across the +/// block PolterType records, which is why the conversion is an +/// identity. If that ever stops being true this test is where it +/// surfaces, rather than as mistyped corrections. +#[test] +fn scancodes_and_evdev_codes_agree_across_the_recorded_block() { + // 0x10 is `Q` in Set-1 and KEY_Q = 16 in evdev. + assert_eq!(super::emitter::testing::to_evdev(0x10), 16); + // 0x1E is `A`; KEY_A = 30. + assert_eq!(super::emitter::testing::to_evdev(0x1E), 30); + // 0x2C is `Z`; KEY_Z = 44. + assert_eq!(super::emitter::testing::to_evdev(0x2C), 44); + // Round-trip, since the echo filter depends on it. + for sc in [0x02u32, 0x10, 0x1E, 0x2C, 0x35, 0x39] { + assert_eq!( + super::emitter::testing::from_evdev(super::emitter::testing::to_evdev(sc)), + sc + ); + } +} + +/// Values from the portal specification. A wrong bitmask here asks +/// for the pointer as well and widens the consent dialog; a wrong +/// key state types a permanent key-down. +#[test] +fn option_values_match_the_specification() { + assert_eq!(DEVICE_KEYBOARD, 1, "DeviceType::KEYBOARD"); + assert_eq!(KEY_RELEASED, 0); + assert_eq!(KEY_PRESSED, 1); + assert_eq!(PERSIST_PERSISTENT, 2, "PersistMode::Persistent"); + assert_eq!(RESPONSE_SUCCESS, 0); + assert_eq!(RESPONSE_CANCELLED, 1); +} + +/// Handle tokens go into a D-Bus object path, so anything outside +/// `[A-Za-z0-9_]` would make the portal reject the call. +#[test] +fn handle_tokens_are_path_safe_and_unique() { + let a = super::session::token_for_test("pt_req"); + let b = super::session::token_for_test("pt_req"); + assert_ne!(a, b, "two tokens on one connection must differ"); + for t in [&a, &b] { + assert!( + t.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), + "token {t:?} is not path-safe" + ); + } +} diff --git a/crates/poltertype-input/src/linux/wayland/emitter.rs b/crates/poltertype-input/src/linux/wayland/emitter.rs index a15ba15..f505630 100644 --- a/crates/poltertype-input/src/linux/wayland/emitter.rs +++ b/crates/poltertype-input/src/linux/wayland/emitter.rs @@ -47,6 +47,21 @@ impl UinputEmitter { s } + /// Whether the virtual keyboard actually exists. + /// + /// `new` creates it eagerly and tolerates failure, so this is how + /// a caller learns the difference between "ready" and "will retry + /// and probably fail again". The emitter-choosing code uses it to + /// decide whether the portal is worth asking about. + pub fn is_usable(&self) -> bool { + if self.device.lock().is_some() { + return true; + } + // One retry: the eager attempt may have run before udev + // applied the rule that grants access. + self.ensure_device().is_ok() + } + fn ensure_device(&self) -> Result<(), InputError> { let mut g = self.device.lock(); if g.is_some() { diff --git a/crates/poltertype-types/src/ai_plugin.rs b/crates/poltertype-types/src/ai_plugin.rs index 179cab0..2d6a6c4 100644 --- a/crates/poltertype-types/src/ai_plugin.rs +++ b/crates/poltertype-types/src/ai_plugin.rs @@ -18,27 +18,60 @@ use serde::{Deserialize, Serialize}; /// the whole settings file down with it. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct AiPluginConfig { - /// Which backend to construct: `local-onnx` or `remote-llm`. + /// Which backend to construct. Today there is exactly one: `llm`. + /// + /// PolterType ships the *interface*, never a model or a bundled + /// vendor client. Whatever answers at [`endpoint`](Self::endpoint) + /// is the user's own choice — an Ollama on their machine, an API + /// they hold the key to, a gateway of their own. We provide the + /// socket; they decide what is plugged into it, if anything. pub r#type: String, /// Stable identifier, used in logs and to tell two entries of the /// same kind apart. pub id: String, - // ── remote-llm ──────────────────────────────────────────────── + /// Convenience preset that fills in `endpoint` and `format`: + /// `ollama`, `openai`, `anthropic`, `llama-cpp`, `lm-studio`. + /// Purely a shorthand — anything it sets, the explicit fields + /// below override, and an entry may skip it entirely. #[serde(default)] pub provider: Option, + /// Full URL of the chat/completion endpoint to POST to. + /// + /// **A loopback host (`127.0.0.1`, `::1`, `localhost`) is treated + /// as local** and works without `[ai].allow_remote`, because + /// nothing leaves the machine. Any other host needs that switch + /// turned on explicitly. + #[serde(default)] + pub endpoint: Option, + /// Request/response shape: `openai-chat`, `anthropic-messages`, + /// or `ollama-generate`. Most self-hosted servers speak + /// `openai-chat`. + #[serde(default)] + pub format: Option, #[serde(default)] pub model: Option, /// `keyring:` — never the key itself. A literal secret here - /// is rejected at construction time. + /// is rejected at construction time. Optional: a local Ollama + /// needs no key at all. #[serde(default)] pub api_key_ref: Option, + /// How long a single query may take before it is abandoned. #[serde(default)] pub max_latency_ms: Option, - - // ── local-onnx ──────────────────────────────────────────────── + /// `background` (default) or `blocking`. + /// + /// `judge` runs on the correction path, so `blocking` puts the + /// round-trip between the user finishing a word and the word being + /// fixed. `background` answers instantly from a cache and queries + /// off-thread for next time, which costs the first occurrence of + /// each word and nothing after it. + #[serde(default)] + pub mode: Option, + /// How many decided words to remember. `0` disables the cache, + /// which in `background` mode means the detector never answers. #[serde(default)] - pub model_path: Option, + pub cache_size: Option, // ── shared ──────────────────────────────────────────────────── #[serde(default)] diff --git a/data/i18n/uk.toml b/data/i18n/uk.toml new file mode 100644 index 0000000..088a30b --- /dev/null +++ b/data/i18n/uk.toml @@ -0,0 +1,99 @@ +# Ukrainian translation of the PolterType settings window. +# +# Format: one flat table of `key = "text"`. The key is stable; the +# English original lives in the source at each call site and is what +# you get when a key is missing here, so an incomplete file is safe — +# untranslated strings simply stay English. +# +# An empty value counts as "not translated yet" and is ignored, so +# leaving `key = ""` in place is fine and better than a placeholder. +# +# `{}` marks a value substituted at run time, in order. Keep the same +# number of them as the English unless your language genuinely needs +# fewer; extras are left visible rather than crashing anything. +# +# Do not translate: the product name PolterType, layout ids (en-US), +# config keys, and file names. + +# ── window chrome ──────────────────────────────────────────────────── +"ui.settings" = "Налаштування" +"footer.save" = "Зберегти" +"footer.reload" = "Перезавантажити" + +# ── Setup pane ─────────────────────────────────────────────────────── +"setup.setup" = "Налаштування системи" +"setup.check_again" = "Перевірити ще раз" +"setup.full_setup_guide" = "Повна інструкція" +"setup.layout_switching_unavailable" = "Перемикання розкладки недоступне" +"setup.what_backends_are_supported" = "Які механізми підтримуються?" + +# ── Languages pane ─────────────────────────────────────────────────── +"languages.languages" = "Мови" +"languages.active" = "Активна" +"languages.ignore" = "Ігнорувати" +"languages.subtitle" = """ +Цей список повторює розкладки, увімкнені у вашій операційній системі. \ +Щоб додати або прибрати мову, змініть налаштування клавіатури в системі \ +та відкрийте це вікно знову.""" +"languages.status_all" = """ +Зараз враховуються всі. Зніміть позначку «Активна», щоб обмежити \ +PolterType підмножиною.""" +"languages.status_restricted" = """ +Обмежено до {} розкладок. Позначте більше, щоб їх додати, або \ +натисніть «Скинути до типових» на вкладці «Про програму», щоб \ +повернутися до «використовувати всі розкладки системи».""" + +# ── Hotkeys pane ───────────────────────────────────────────────────── +"hotkeys.hotkeys" = "Гарячі клавіші" +"hotkeys.cancel" = "Скасувати" +"hotkeys.rebind" = "Перепризначити" +"hotkeys.press_combination_esc_cancel" = "Натисніть комбінацію… (Esc — скасувати)" + +# ── Commands pane ──────────────────────────────────────────────────── +"commands.commands" = "Команди" +"commands.add_command" = "Додати команду" +"commands.add_new_command" = "Додати нову команду" +"commands.no_commands_yet_fill" = "Команд ще немає — заповніть форму нижче, щоб додати." + +# ── Wordlists pane ─────────────────────────────────────────────────── +"wordlists.wordlists" = "Словники" +"wordlists.pick_layout_above_start" = "Оберіть розкладку вгорі, щоб почати редагування." +"wordlists.unsaved_changes" = "незбережені зміни" + +# ── Exceptions pane ────────────────────────────────────────────────── +"exceptions.exceptions" = "Винятки" +"exceptions.add" = "Додати" +"exceptions.no_exceptions_poltertype_active" = "Винятків немає — PolterType працює в усіх застосунках." + +# ── Suggestions pane ───────────────────────────────────────────────── +"suggestions.suggestions" = "Підказки" +"suggestions.tooltip" = "Спливна підказка" +"suggestions.keyboard_accept" = "Прийняття з клавіатури" +"suggestions.show_suggestions_mistyped_words" = "Показувати підказки для слів із помилками" +"suggestions.max_suggestions" = "Максимум підказок (1–9):" +"suggestions.each_entry_applied_with" = "Кожен пункт застосовується однією цифрою, тому 9 — це межа." +"suggestions.tooltip_timeout_seconds" = "Час показу підказки (секунди):" +"suggestions.seconds_tooltip_hides_itself" = "3–600 секунд; підказка ховається сама, коли час вийде." +"suggestions.keyboard_accept_modifiers" = "Модифікатори для прийняття з клавіатури:" + +# ── General pane ───────────────────────────────────────────────────── +"general.general" = "Загальні" +"general.behaviour" = "Поведінка" +"general.appearance" = "Вигляд" +"general.folders" = "Теки" +"general.updates" = "Оновлення" +"general.start_automatically_when_i" = "Запускати автоматично при вході в систему" +"general.play_soft_chime_on" = "Тихий звук при виправленні" +"general.show_second_system_notification" = "Показувати системне сповіщення на 2 секунди при автоперемиканні" +"general.skip_auto_switch_on" = "Не перемикати на ідентифікаторах (foo_bar, snake_case, …)" +"general.idle_timeout_ms" = "Пауза до скидання буфера (мс):" +"general.buffer_cleared_after_this" = "Буфер очищується після такої тиші на клавіатурі." +"general.system_follows_os_light" = "«Системна» слідує за світлою/темною темою ОС. Збережіть, щоб застосувати." +"general.download_new_versions_automatically" = "Завантажувати нові версії автоматично, встановлювати при перезапуску" +"general.check_every_hours" = "Перевіряти кожні (годин):" + +# ── About pane ─────────────────────────────────────────────────────── +"about.cross_platform_automatic_keyboard" = "Кросплатформний автоматичний перемикач розкладки." +"about.power_user_escape_hatches" = "Для досвідчених користувачів" +"about.reload_from_disk" = "Перечитати з диска" +"about.reset_defaults" = "Скинути до типових" diff --git a/docs/AI.md b/docs/AI.md index 1b3235d..a35c67e 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -1,117 +1,188 @@ # AI subsystem -> **Status (v0.8.0): wired, no backend yet.** The extension traits are -> real, the built-in detectors use them, and since 0.8.0 the app -> *does* construct AI detectors — `[[ai.plugins]]` entries become -> `Detector`s and join the pipeline. What they are not is useful: both -> shipped backends are **stubs that return `NoOpinion`**. The local one -> loads no ONNX model; the remote one makes no request. **No shipped -> build makes an AI-related network call, with or without the feature -> flags.** +> **Status (v0.10.0): a working socket, and nothing plugged into it.** +> PolterType ships the *interface* for a language model and never a +> model, a vendor SDK, or a default endpoint. Configure +> `[[ai.plugins]]` to point at an Ollama on your own machine, an API +> you hold the key to, or a gateway of your own, and the engine gains +> another voice in the layout decision. Configure nothing — the +> default — and there is no AI in PolterType at all. > -> So the honest sentence is "the seam is real and empty": a model can -> be dropped in without touching the app, and until one is, enabling -> the feature changes no decision. -> -> Read that claim narrowly. Since v0.4.0 the app *does* make one -> network call, in every build, on by default: the updater's check -> against GitHub Releases. It has nothing to do with this subsystem — -> it lives in `poltertype-update`, uses a different HTTP client -> (`ureq`, not `reqwest`), and sends nothing about the user. See -> [DECISIONS.md](DECISIONS.md) and the README's "Staying up to date". -> The point of this document is that **AI** adds no network call; it -> is no longer true that the binary makes none at all. -> -> This document describes the intended design and marks, in each -> section, what is actually implemented today. Nothing below is a -> promise about current behaviour unless it says "implemented". - -The plan is an opt-in AI/LLM subsystem that would let users: - -* extend the layout-detection pipeline with smarter classifiers - (local ONNX models, remote LLMs); -* run *word rewriters* — post-correction tricks like - smart-capitalize, expand-acronym, slang→formal — without rebuilding - the whole engine. - -Everything here is **off by default**, and today it is inert. - -## What exists today - -| Piece | Where | State | -|---|---|---| -| `Detector` / `WordRewriter` traits | `poltertype-detect::traits` | **implemented**, and `Detector` is what the built-in detectors run on | -| `DictionaryDetector`, `WordPlausibilityDetector` | `poltertype-detect` | **implemented** — these are the *only* detectors the engine runs | -| `LocalOnnxDetector` | `poltertype-ai::local` | **stub** — logs a warning, returns `NoOpinion`. No ONNX runtime is even a dependency. | -| `RemoteLlmDetector` | `poltertype-ai::remote` | **stub** — with `remote` on it builds an HTTP client and never uses it. Returns `NoOpinion`. | -| `SmartCapitalize` rewriter | `poltertype-ai::rewriters` | **implemented, unreachable** — real logic over a hardcoded 7-word list; nothing calls it. Not AI-backed. | -| `resolve_api_key()` | `poltertype-ai::keys` | **implemented, no callers** | -| `[ai] enabled` / `allow_remote` | `poltertype-core::settings` | **parsed, inert** — both default `false` and no runtime code reads them | - -The gap that matters: **`poltertype-ai` is never imported by -`poltertype-app` or `poltertype-core`.** It appears in -`poltertype-app/Cargo.toml` as an optional dependency and nowhere -else. The engine's detector list is constructed by hand in -`poltertype-app::main`. Until that list is built from configuration, -none of the above can run, however the flags are set. - -There is no rewriter stage in the engine at all — `WordRewriter` is a -trait with no consumer. - -## Privacy posture - -**Today: no build can make an AI network call.** `reqwest` is an -optional dependency of `poltertype-ai` alone, and the one type that -holds a client never issues a request. That is a stronger guarantee -than the design below, and it is the one that currently holds. - -It is, however, a claim about *this subsystem* — not about the -process. The app has had exactly one network capability since v0.4.0: -the updater (`poltertype-update`, `ureq`, on by default, GitHub -Releases only). Nothing routes user text through it and it cannot be -used to reach an LLM. When wiring the AI subsystem up, do not treat -the updater's existence as precedent — the gates below still apply in -full, and "the app already talks to the network" is not an argument -for skipping any of them. - -The design keeps three independent gates between a user and a network -call. Gates 1 and 2 are real (they are Cargo features); gate 3 is -parsed but not yet enforced anywhere, because there is nothing to -enforce it against: - -1. **Cargo feature `ai`** in `poltertype-app`. Off by default; enabling - adds the `poltertype-ai` crate to the build. (Note it does *not* - forward the crate's own `remote` feature — see below.) -2. **Cargo feature `remote`** in `poltertype-ai`. Off by default; - enabling adds `reqwest` + `rustls` so a `RemoteLlmDetector` *could* - make HTTP calls. Local detectors don't need it. Enabling it from an - app build takes `--features ai,poltertype-ai/remote`. -3. **`[ai].allow_remote = true`** in `config.toml`. Off by default. - Intended to gate network use at runtime in a binary that is - otherwise capable. **Not yet read by any code path.** - -When the subsystem is wired, the tray tooltip should surface the -runtime state (whether AI is on, whether remote is permitted, and how -often the engine has reached out). It does not do so today — the -tooltip renders only the app name, the active layout, and a paused -marker. +> This is different from every previous release, where the backends +> existed as stubs that returned no opinion. They are gone. What +> replaced them is one detector that speaks three common HTTP shapes +> and asks the model exactly one question. + +## The design, and why + +Bundling a model would mean choosing a vendor on the user's behalf and +shipping megabytes most people never asked for. Bundling a client for +one provider is the same choice with extra steps. So PolterType +bundles neither: it provides a socket, and what answers is whatever +the user already trusts. + +That is also what keeps the zero-telemetry posture intact. There is no +address in this subsystem that we chose. The only endpoint it ever +contacts is one the user typed into their own config file, and the +only credential it uses is one they stored in their own keychain. + +## Configuration + +```toml +[ai] +enabled = true +allow_remote = false # only needed for a non-loopback endpoint + +# A model running on your own machine. No key, no network permission: +# nothing leaves the computer. +[[ai.plugins]] +type = "llm" +id = "local" +provider = "ollama" # preset: fills in endpoint + format +model = "llama3" + +# A third-party API. Needs `allow_remote = true` above, and a key you +# stored in the OS keychain yourself. +[[ai.plugins]] +type = "llm" +id = "claude" +provider = "anthropic" +model = "claude-haiku-4-5-20251001" +api_key_ref = "keyring:anthropic" + +# Anything else that speaks a shape we know — a llama.cpp server, an +# LM Studio, a vLLM, a company gateway. No preset needed. +[[ai.plugins]] +type = "llm" +id = "work-gateway" +endpoint = "https://llm.internal.example.com/v1/chat/completions" +format = "openai-chat" +model = "qwen2.5-7b" +``` + +| Field | Meaning | +|---|---| +| `type` | `llm` — the only kind today | +| `id` | your name for the entry; appears in logs and in the verdict reason | +| `provider` | optional preset filling in `endpoint` + `format`: `ollama`, `llama-cpp`, `lm-studio`, `openai`, `anthropic` | +| `endpoint` | full URL to POST to. Overrides the preset | +| `format` | `openai-chat`, `anthropic-messages`, `ollama-generate`. Overrides the preset | +| `model` | required — the model name to ask for | +| `api_key_ref` | `keyring:`. Optional; a local model needs none | +| `mode` | `background` (default) or `blocking` — see below | +| `max_latency_ms` | per-query budget. Default 2000; capped at 250 in `blocking` mode | +| `cache_size` | decided words remembered. Default 2048; `0` disables | + +There is deliberately **no default endpoint**. An entry with neither +`endpoint` nor `provider` is refused with a message saying so. + +## The two things that make this safe to turn on + +### It cannot slow your typing down + +`judge()` runs on the correction path — between you finishing a word +and the word being fixed. A round-trip there, even to localhost, is +the difference between a correction and a glitch. + +So the default mode **never waits**. It answers from a cache of +already-decided words; on a miss it returns "no opinion" immediately +and queues the question so the *next* occurrence of that word is +decided. The first time you type a word the model contributes nothing, +which is exactly what happened before there was a backend. After that +it is free. + +That trade works because of how people type: the same few thousand +words, over and over. A 2048-entry cache warms up within a session. + +`mode = "blocking"` puts the call inline if you want it, and is capped +at 250 ms — past roughly a fifth of a second you have already started +the next word, and a "correction" arriving then is just corruption +arriving late. Asking for more is refused at startup, with the reason, +rather than silently clamped into lag you would have to diagnose. + +### Local is not remote + +`[ai].allow_remote` exists to gate **typed words leaving your +machine**. A request to `127.0.0.1` does not leave it, so a local +model does not need that switch — requiring it would make people +enable network access they are not using. + +The distinction is decided in one place, `poltertype-ai::locality`, +and it is deliberately strict: + +* only literal loopback addresses and the name `localhost` count; +* DNS is **not** resolved — a resolver answer can change between the + check and the request, and a `local.corp.net` that happens to point + at 127.0.0.1 today is exactly the kind of thing that should still + require a yes; +* anything unparseable is treated as remote, because the answer that + asks permission is the safe one to be wrong with. + +## What is sent, and what is not + +One request per newly-seen ambiguous word, containing: + +* the candidate readings of that word, numbered; +* the model name you configured; +* a fixed one-sentence instruction asking which reading is real. + +That is all. Not the surrounding sentence, not the document, not the +application you are typing in, and **not the layout ids** — those +would reveal which languages you have installed. The model is asked to +reply with a single number, and anything that is not a number naming a +candidate is treated as no opinion. + +Nothing typed is ever logged: words reaching a `tracing` call go +through `redact_word` like everywhere else in the engine, and the +decision cache stores hashes of the question rather than the text. + +## The gates, in order + +Each is a real barrier, not a setting that looks like one: + +1. **Cargo feature `ai`** in `poltertype-app`. Off by default; + enabling links the `poltertype-ai` crate. +2. **Cargo feature `remote`** in `poltertype-ai`. Off by default. + Without it no HTTP client is compiled in — `cargo tree` on a stock + build shows no `reqwest` at all, which is checkable rather than + merely documented. Enabling from an app build takes + `--features ai,poltertype-ai/remote`. +3. **`[ai].enabled = true`** in `config.toml`. Off by default. +4. **`[ai].allow_remote = true`** — additionally, and only for a + non-loopback endpoint. +5. **A key in your keychain**, if the endpoint needs one. A literal + secret in `config.toml` is refused at construction, never used: a + key in that file is a key in your backups, your dotfiles repo, and + any log you attach to an issue. + +A plug-in that is not permitted still *loads* — it just returns no +opinion, and says why once at startup. That way flipping a setting +takes effect on the next restart without editing the entry. + +## What the updater has to do with this: nothing + +The app has had exactly one network capability since v0.4.0 — the +updater, which fetches a release manifest from GitHub. It is a +different crate, a different HTTP client (`ureq`), and no user text +goes near it. + +Do not treat its existence as precedent when touching this subsystem. +"The app already talks to the network" is not an argument for relaxing +any of the five gates above. The updater sends nothing about you; this +subsystem, when you switch it on, sends the words you type. Those are +different things and the difference is the whole point. ## Architecture -The `Detector` trait lives in `poltertype-detect` and is the real -extension point — the built-in detectors implement it, and an AI -detector would be one more implementation: +The `Detector` trait in `poltertype-detect` is the extension point. +The built-in detectors implement it and an AI detector is one more +implementation: ```rust pub trait Detector: Send + Sync { fn name(&self) -> &'static str; fn judge(&self, ctx: &DetectionContext<'_>) -> Verdict; } - -pub trait WordRewriter: Send + Sync { - fn name(&self) -> &'static str; - fn rewrite(&self, req: &RewriteRequest<'_>) -> RewriteVerdict; -} ``` `Verdict` is three-way, which is the load-bearing detail: @@ -124,89 +195,52 @@ pub enum Verdict { } ``` -The engine runs detectors in priority order and stops at the first -non-`NoOpinion`. `Keep` is what lets the dictionary say "this is a -real word, don't ask anyone else" — the main defence against false -positives. - -## Planned configuration (not implemented) - -The intended shape is declarative: detectors and rewriters described -in the user's `config.toml`, with `[ai]` carrying only the master -switches. - -> **`[[ai.plugins]]` is real since 0.8.0; `[[ai.rewriters]]` is not.** -> The settings struct is -> `AiSettings { enabled, allow_remote, plugins }`, and each plug-in -> entry is constructed into a `Detector` and appended to the pipeline. -> What it will not do yet is *decide* anything — both backends are -> stubs returning no opinion. -> -> Rewriters remain unimplemented: there is no rewriter stage in -> `poltertype-core`, so an `[[ai.rewriters]]` block is *silently -> ignored* (settings parse with `#[serde(default)]` and no -> `deny_unknown_fields`). Do not write one expecting an effect. - -```toml -[ai] -enabled = false -allow_remote = false - -[[ai.plugins]] -type = "local-onnx" -id = "fasttext-lid-176" -model_path = "models/lid.176.onnx" - -[[ai.plugins]] -type = "remote-llm" -id = "anthropic-haiku" -provider = "anthropic" -model = "claude-haiku-4-5-20251001" -api_key_ref = "keyring:anthropic" -max_latency_ms = 600 - -[[ai.rewriters]] -type = "smart-capitalize" -id = "default" -require_confirmation = false -``` - -Wiring this up means: a settings schema for the two arrays, a factory -mapping each `type` string to a struct, and a detector list in -`poltertype-app::main` built from configuration instead of by hand. +Plug-ins are **appended** to the built-in detectors, never substituted +for them. The offline dictionary and plausibility detectors keep +working exactly as before; an LLM adds a voice to a decision it does +not own. If the model picks the layout you are already typing in, that +becomes a `Keep` — a vote to leave the word alone — rather than a +switch to where you already are. + +| Piece | Where | +|---|---| +| `LlmDetector` | `poltertype-ai::detector` | +| Request/response shaping | `poltertype-ai::wire` — no HTTP types, so it is unit-tested on every host | +| The one place a socket opens | `poltertype-ai::transport`, behind `feature = "remote"` | +| Loopback-vs-remote | `poltertype-ai::locality` | +| Decision cache | `poltertype-ai::cache` | +| Config → detector | `poltertype-ai::factory` | ## API keys -The lookup helper is implemented (it has no callers yet, because -nothing makes a request). Keys resolve via -`keyring::Entry::new("poltertype", )`, which uses: +Keys resolve via `keyring::Entry::new("poltertype", )`: * Windows Credential Manager * macOS Keychain * Linux: GNOME Secret Service / KWallet (whichever is up) -Storing a key (one-time, from your shell): +Storing one, from your shell: ```bash -# macOS / Linux +# Linux secret-tool store --label "poltertype Anthropic" \ service poltertype account anthropic -# Windows: cmdkey /add:poltertype /user:anthropic /pass: +# macOS +security add-generic-password -s poltertype -a anthropic -w +# Windows +cmdkey /add:poltertype /user:anthropic /pass: ``` -`api_key_ref = "keyring:anthropic"` is then meant to resolve to the -stored secret at request time. Keys never live in `config.toml`. - -## Why the traits landed before the implementations +`api_key_ref = "keyring:anthropic"` then resolves to it at startup. If +the keychain cannot supply it — missing entry, locked keychain — the +plug-in loads and stays silent with one explanatory warning, rather +than disappearing with a message about config that config cannot fix. -Because the *shape* of the plug-in API is the load-bearing decision, -and it is settled: a detector is anything that turns a -`DetectionContext` into a three-way `Verdict`, and the engine already -runs a priority-ordered list of them. Swapping in a real -implementation is a matter of dropping in a struct that implements -`Detector` — no engine surgery. +## Word rewriters remain unimplemented -What is *not* settled, and is what the remaining work consists of, is -the wiring: config schema, a factory, and the runtime enforcement of -`allow_remote`. Until that exists, treat this document as a design -note rather than a feature description. +`WordRewriter` is a trait with no consumer: there is no rewriter stage +in `poltertype-core`, so an `[[ai.rewriters]]` block is **silently +ignored**. `SmartCapitalize` in `poltertype-ai::rewriters` is real +logic over a hardcoded word list that nothing calls, and it is not +AI-backed. Do not write an `[[ai.rewriters]]` entry expecting an +effect. diff --git a/docs/CODE_SIGNING.md b/docs/CODE_SIGNING.md index 85444ce..4ef704a 100644 --- a/docs/CODE_SIGNING.md +++ b/docs/CODE_SIGNING.md @@ -5,7 +5,7 @@ > can read is not a policy — and because SignPath Foundation requires > one from projects it signs for. > -> Last updated: 2026-08-01 (v0.9.0). +> Last updated: 2026-08-02 (v0.10.0). PolterType asks for an unusual amount of trust: it reads every keystroke on the machine and can type. A signature is how a user checks diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5c16a67..154ad82 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6,6 +6,66 @@ and any **alternatives** considered. --- +## 2026-08-01 — No AT-SPI keystroke listener: measured, refused, and the reason is architectural + +`PLAN.md` has carried "Wayland AT-SPI fallback listener via `atspi`" +as an open item since Phase 6, on the theory that the accessibility +stack could read keystrokes without `input`-group membership and so +retire `scripts/setup-linux.sh` for Wayland users. We finally measured +it instead of assuming it. It does not work, and the reason is not +something we can fix. + +**What was measured.** A probe registered a keystroke listener with +`org.a11y.atspi.DeviceEventController` exactly as an assistive +technology does — empty key set (all keys), press+release, global mode +— on this Hyprland session, whose a11y bus is live and whose registry +publishes the full `DeviceEventController` interface including +`RegisterKeystrokeListener`. Two results: + +* `RegisterKeystrokeListener` returned **false**. The registry + declined outright. +* With five real keystrokes injected through `uinput` inside the + listening window, **zero** `NotifyEvent` callbacks arrived. + +**Why it cannot be fixed here.** `at-spi2-registryd` has no privileged +path to the keyboard of its own. On X11 it snoops via the X server; on +Wayland it can only relay what the *compositor* hands it, and only +mutter does that. A wlroots compositor never feeds the registry, so +the interface exists, answers introspection, and refuses to register — +which is exactly the failure shape that made this look plausible for +so long. + +**And where it would work, it is redundant.** The matrix: + +| Session | Existing listener | What AT-SPI would add | +|---|---|---| +| X11 | XInput2, **needs no permissions at all** | nothing | +| GNOME Wayland | evdev (`input` group) | possibly a permission-free path — untested, no GNOME box here | +| KDE / wlroots Wayland | evdev (`input` group) | nothing; registration refused | + +So the feature is redundant on the one session type where it reliably +works, unverifiable on the one where it might help, and dead on the +rest. Writing it would mean several hundred lines of code that this +project could never honestly describe as working — the same standard +that keeps the macOS caveats in `CLAUDE.md` explicit. + +**Alternatives considered.** `libei` through the +`org.freedesktop.portal.RemoteDesktop` portal is the *emitting* half +of the same wish and remains genuinely promising — mutter and KWin +both implement that portal. It is not implemented either, for a +narrower reason: no RemoteDesktop backend exists on this machine +(`hyprland.portal` declares Screenshot, ScreenCast, GlobalShortcuts +and InputCapture; only `kde.portal` declares RemoteDesktop), so it +cannot be exercised here at all. That one stays open rather than +decided — see the entry in `PLAN.md`. + +**Consequence for users.** Wayland still needs `setup-linux.sh` once. +That is the honest state, and the Setup pane already says so. Anyone +who wants a zero-permission session today has one: X11, where the +listener and emitter both need nothing. + +--- + ## 2026-07-31 — The setup walkthrough probes, and refuses to act on the user's behalf Replacing "here is a link to PERMISSIONS.md" with a screen that knows diff --git a/docs/PLAN.md b/docs/PLAN.md index 8275297..9352603 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,7 +1,7 @@ # PolterType — Project Plan > A living roadmap. Updated as implementation proceeds. -> Created: 2026-05-02. Last updated: 2026-08-01 (v0.9.0). +> Created: 2026-05-02. Last updated: 2026-08-02 (v0.10.0). > **How to read this document.** This is a **plan**, not a description > of the implementation. Wherever the code has diverged from the @@ -9,18 +9,23 @@ > freshest summaries: > > * **What has shipped** — `CHANGELOG.md` (0.1.0 "First stable" through -> 0.9.0; most recently nine more bundled languages, a Windows -> keystroke gate that is opt-in and unverified, and the AI seam -> connected to the engine) and §10 below, where every item is marked. +> 0.10.0; most recently a user-supplied LLM interface, focused-app +> tracking on GNOME/KDE via the a11y bus, UI translation, multi-token +> smart commands with `run_shell`, and plug-in installation) and §10 +> below, where every item is marked. > * **Why it is this way** — `DECISIONS.md`; several decisions below > have since been revisited (most notably "the full GUI is deferred", > even though it shipped back in 0.1.0-beta). > * **What does not exist despite being described below** — -> `../CLAUDE.md`, the "Known gaps" section: `focused_exe()` answers -> on Windows / Hyprland / X11 and nowhere else, and AT-SPI listening -> / `libei` do not exist. The AI subsystem *is* wired to the engine -> as of 0.8.0, but both backends are still stubs that return no -> opinion — "wired, no backend yet", never "AI-powered". The guided +> `../CLAUDE.md`, the "Known gaps" section: `focused_exe()` is +> complete on Windows / Hyprland / X11, partial on other Wayland +> (AT-SPI sees only apps with an accessibility bridge, which +> excludes most terminals) and absent on macOS; AT-SPI *keystroke +> listening* is decided against with measurements, and `libei` does +> not exist. The AI subsystem ships an *interface* and no backend +> at all as of 0.10.0 — a socket the user points at their own model. +> Never call it "AI-powered": out of the box nothing answers. The +> guided > onboarding window does exist as of 0.7.0 — but its macOS half has > never run on a Mac. > @@ -916,90 +921,25 @@ separate `poltertype --settings` process. itself) was **rejected** — see `DECISIONS.md`; that rejection still stands, and the Setup pane (0.7.0) copies the command to the clipboard rather than running it. -- [ ] **Wayland AT-SPI fallback listener** via `atspi` — not - implemented (the dependency is not in the tree). -- [ ] **`libei` (`reis`) as the portal variant of send-keys** — not - implemented; `uinput` is currently the only path. -- [ ] **`FocusTracker` for GNOME/KDE Wayland** — the *window* half - still needs per-DE backends (KWin script / GNOME shell - extension), see §3.9. The *caret* half landed in 0.7.0: - AT-SPI answers on any compositor, so those sessions get a - caret-only tracker and the tooltip anchors properly there. - -### Phase 7 — AI skeleton - -The skeleton exists and **is wired to the engine since 0.8.0**: -`poltertype-app` reads `[[ai.plugins]]`, builds detectors through -`poltertype_ai::build_detectors` and appends them to the pipeline. -What it still has no use for is a **backend** — both shipped ones are -stubs that return no opinion, so enabling the feature changes no -decision and opens no socket. The seam is real and empty. Details in -`docs/AI.md`. - -- [x] The `poltertype-ai` crate behind `feature = "ai"` (disabled by - default). -- [x] The `Detector` + `WordRewriter` traits declared in - `poltertype-detect`. -- [x] `docs/AI.md`. -- [x] **Traits integrated into the pipeline** — 0.8.0. `[[ai.plugins]]` - entries become detectors and are *appended* to the built-in list; - `[ai].enabled` and `allow_remote` are both read. A bad entry is - skipped with its id logged rather than costing the others, and an - `api_key_ref` that is not a `keyring:` reference is refused. -- [ ] A reference `LocalOnnxDetector` with `lid.176` — still a stub: - it validates the model path and then loads nothing. -- [ ] A reference `RemoteLlmDetector` (Anthropic API) — still a stub; - no build makes network calls. - -### Phase 8 — Polish, release ✅ (partially) - -- [x] Icons (rendered by `xtask assets icon-png`). -- [x] GitHub Action — artifacts on tag (`release.yml`). -- [x] **Installers**: MSI (WiX), universal DMG, AppImage (x86_64 - since 0.1 — earlier than Phase 9 planned; aarch64 added in - 0.7.0, built natively on an ARM64 runner). -- [ ] UI translation (i18n) — the interface is English-only. -- [x] Screenshots in the README — landed 2026-07-13 - (`docs/screenshots/settings-window.png`). - -- [x] **Auto-update** from GitHub Releases — background check, - checksum-verified download, install on restart - (`poltertype-update`). Landed 2026-07-13. - -### Phase 9 (later) - -- **Signing** the installers (Apple Developer ID, Windows EV/OV) — - today all artifacts ship **unsigned**. This is also what would let - the macOS updater stop stripping `com.apple.quarantine`. -- **Signing the update manifest** — *started in 0.7.0, not finished.* - `latest.json` now carries a detached ed25519 signature, made on the - maintainer's machine with a key that never enters CI and verified - against a public key compiled into the binary. What remains is the - second half of the rollout: `REQUIRE_SIGNATURE` is still `false`, so - a missing signature is accepted with a warning. Flip it once a signed - manifest has been the published `latest.json` for a full release - cycle — until then the updater still trusts whoever can publish a - release. See `docs/DECISIONS.md`, 2026-07-31. -- Stores: Microsoft Store. winget, brew and AUR manifests are staged in - `packaging/` and not yet published anywhere. -- Plugin marketplace: the loader is live (data-only packs); what - remains open is the pack install / update / signing UX. WASM - plugins — a separate topic. - ---- - -## 11. v0.1 readiness metrics (definition of done) - -> **v0.1.0 has shipped** (the "First stable" release, see CHANGELOG). -> Below — what from this list came true, and what did not. - -- [x] Windows: the full cycle "`руддщ` → `hello`, sound". -- [x] Linux: the full cycle on Wayland (Hyprland + keyd — the - maintainer's daily machine) after `setup-linux.sh`; on X11 — - **without any script at all** (no longer a "fallback" but a - first-class path). -- [x] macOS (Intel): the full cycle, confirmed on real hardware by an - outside contributor at 0.6.2 (macOS 15). +- [x] ~~**Wayland AT-SPI fallback listener** via `atspi`~~ — + **decided against 2026-08-01, with measurements.** + `RegisterKeystrokeListener` returns false on a wlroots session + and delivers nothing even with injected keys, because + `at-spi2-registryd` can only relay what the compositor hands it + and only mutter does. Where it *would* work (X11) we already + have a listener that needs no permissions, so it adds nothing + there. See `DECISIONS.md`, 2026-08-01. +- [x] **Portal send-keys** — done in 0.10.0, and not via `libei`. + The portal exposes `NotifyKeyboardKeycode` as a plain D-Bus + method, which is exactly what a correction needs; going through + `ConnectToEIS` and the libei protocol would have meant a new + protocol implementation and a heavy dependency to send twenty + keystrokes, and would still need the same session negotiation. + `zbus` was already in the tree. + **Never executed** — there is no RemoteDesktop backend on the + maintainer's machine. Tried only when `uinput` cannot be opened, + so nobody who already ran `setup-linux.sh` sees a consent + dialog. See `linux/portal/`. - [ ] macOS (Apple Silicon): still nobody. And since 0.7.0 changed the macOS input path — `FlagsChanged`, modifier release — even the Intel confirmation predates what now ships. The only unconfirmed diff --git a/docs/TRANSLATING_THE_UI.md b/docs/TRANSLATING_THE_UI.md new file mode 100644 index 0000000..cb0cc85 --- /dev/null +++ b/docs/TRANSLATING_THE_UI.md @@ -0,0 +1,127 @@ +# Translating the PolterType interface + +The settings window can speak your language. Adding one is **a single +TOML file** — no Rust, no rebuild, and you can test it on your own +machine before sending anything. + +This is deliberately the same promise +[ADDING_A_LANGUAGE.md](ADDING_A_LANGUAGE.md) makes about keyboard +layouts: the things that vary between people live in data. + +--- + +## The short version + +1. Copy `data/i18n/uk.toml` to `data/i18n/.toml`. +2. Translate the right-hand side of each line. +3. Set `[general].ui_language` in `config.toml` to your language code + and reopen the window. + +That is the whole loop. To try it without touching the source tree, +drop the file into `/poltertype/i18n/` instead. + +--- + +## The format + +One flat table. The key is stable and never translated; the value is +what appears on screen. + +```toml +"languages.languages" = "Мови" +"footer.save" = "Зберегти" +``` + +Four rules, each of which exists because breaking it is easy: + +* **A missing key is fine.** The English original is compiled into the + program at every call site, so anything you have not translated + stays English. A half-finished file is a useful file — send it. +* **An empty value means "not yet".** `"key" = ""` is ignored rather + than drawn, because a blank button is worse than an English one. +* **`{}` is a value filled in at run time**, in order. Keep the same + number as the English unless your language genuinely needs fewer; + extra ones are left visible rather than crashing anything. +* **One bad line costs that line**, not your language. A value that + isn't a string is skipped with a warning and everything else loads. + +### What not to translate + +* **PolterType.** The product name is the same in every language. +* **Layout ids** (`en-US`, `uk-UA`), config keys, file names and + paths — they are things the user types, not things they read. +* **Keycap names** in hotkey chips (`Ctrl`, `Alt`, `Shift`) unless + your platform genuinely labels them differently. + +--- + +## Which file gets loaded + +`[general].ui_language` in `config.toml` decides: + +| Value | Effect | +|---|---| +| `"system"` (default) or `"auto"` | ask the environment | +| `"uk"`, `"pl"`, `"pt_BR"`, … | force that language | + +Environment detection reads `LC_ALL`, `LC_MESSAGES` and `LANG`, in +that order — the same sequence the C library uses. **Windows sets none +of those**, so it lands on English unless the user picks a language +explicitly. That is a deliberate trade: reading the Windows locale +would mean platform-specific code in a crate that is not allowed to +hold any, and a picker the user can reach beats a guess. + +A regional code falls back to the bare language: `uk_UA` finds +`uk.toml`. Ship a regional file only when the difference is real — +`pt_BR.toml` alongside `pt.toml` earns its place, `en_GB.toml` for one +word probably does not. + +Files are looked up in `/i18n/` (shipped with the app) and +`/poltertype/i18n/` (yours). Yours wins, which is what +makes the edit-and-reopen loop possible. + +--- + +## Getting it upstream + +Open a PR with the one file. Two things make it easy to review: + +* **Say which strings you were unsure about.** Several are terms of + art — "identifier guard", "plausibility", "stop words" — and a + translator's note is more useful than a confident wrong guess. +* **Keep the section comments** from `uk.toml`. They group the file by + pane, which is how the next person will read it. + +If a string reads awkwardly because the English is awkward, say so. +Fixing the English is usually the better patch, and it improves every +other language at the same time. + +--- + +## Adding a string as a developer + +Wrap it at the call site: + +```rust +Text::new(tr("general.behaviour", "Behaviour")) +``` + +Key convention is `.`. Pass the English +text as the second argument — that is what makes a missing catalog +harmless, and it keeps the source readable without a lookup table. + +For interpolated text, `format!` cannot be used (it needs a literal), +so use the positional form: + +```rust +tr_args( + "languages.status_restricted", + "Restricted to {} layout(s).", + &[&count.to_string()], +) +``` + +Then add the key to `data/i18n/uk.toml` — or leave it, and the next +translator will pick it up. `cargo test -p poltertype-core i18n` +checks that the shipped catalog still parses and still covers one +label per pane. diff --git a/installers/wix/main.wxs b/installers/wix/main.wxs index 0318b57..31fbbbb 100644 --- a/installers/wix/main.wxs +++ b/installers/wix/main.wxs @@ -74,6 +74,7 @@ + + @@ -198,6 +200,26 @@ On="uninstall" /> + + + + + + + +