From ea89c23950e1994d70463e9b484f22769d768e0d Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sat, 1 Aug 2026 21:56:10 +0300 Subject: [PATCH 1/9] ai: replace both stubs with one socket the user plugs a model into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolterType now ships the AI *interface* and nothing else — no model weights, no vendor SDK, no default endpoint. `LlmDetector` speaks three common HTTP shapes (openai-chat, anthropic-messages, ollama-generate) and asks one question; what answers is an Ollama on the user's own machine, an API they hold the key to, or a gateway of their own. Configure nothing, which is the default, and there is no AI in PolterType at all. The two stubs are gone. `LocalOnnxDetector` promised a bundled model we were never going to ship, and `RemoteLlmDetector` was a vendor-shaped hole; both returned NoOpinion forever. A config naming either gets an error saying what to write instead, and still parses, so an old config costs a log line rather than the whole settings file. Two properties the implementation exists to hold up. **It cannot slow typing down.** `judge` runs between the user finishing a word and the word being fixed, so the default mode never waits: it answers from a cache of decided words and queues a miss for next time. The first occurrence of a word contributes nothing — which is what the stubs did for every word — and everything after it is free. That works because people retype the same few thousand words all day. `mode = "blocking"` exists for anyone who wants the call inline and is capped at 250 ms, refused at startup with the reason rather than silently clamped into lag they would have to diagnose. **Local is not remote.** `allow_remote` gates typed words *leaving the machine*, and a request to 127.0.0.1 does not, so a local model needs no network permission — demanding one would make people enable access they are not using. `locality` decides that in one place and fails closed: literal loopback addresses only, no DNS resolution (a resolver answer can change between check and request), anything unparseable treated 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 app, and not the layout ids — those would reveal which languages the user has installed. Replies that are not a number naming a candidate are no opinion; `-1` in particular now parses as "none" rather than as candidate 1, which a test caught. Keys stay in the OS keychain, and a keychain that cannot answer no longer fails construction — the entry is well-formed, the secret is merely absent or locked, so the plug-in loads and stays quiet with one warning. Same rule `allow_remote` already followed. The `remote` cargo feature still decides whether an HTTP client is compiled in at all, so "a stock build cannot make an AI call" stays checkable with `cargo tree` rather than merely documented. --- Cargo.lock | 1 + crates/poltertype-ai/Cargo.toml | 20 +- crates/poltertype-ai/src/cache.rs | 107 ++++++ crates/poltertype-ai/src/cache/tests.rs | 87 +++++ crates/poltertype-ai/src/consts.rs | 81 ++++ crates/poltertype-ai/src/detector.rs | 348 +++++++++++++++++ crates/poltertype-ai/src/detector/tests.rs | 220 +++++++++++ crates/poltertype-ai/src/enums.rs | 74 +++- crates/poltertype-ai/src/factory.rs | 237 +++++++++--- crates/poltertype-ai/src/factory/tests.rs | 261 +++++++++---- crates/poltertype-ai/src/lib.rs | 69 +++- crates/poltertype-ai/src/local.rs | 49 --- crates/poltertype-ai/src/locality.rs | 78 ++++ crates/poltertype-ai/src/locality/tests.rs | 77 ++++ crates/poltertype-ai/src/remote/detector.rs | 121 ------ crates/poltertype-ai/src/remote/enums.rs | 21 -- crates/poltertype-ai/src/remote/mod.rs | 14 - crates/poltertype-ai/src/transport.rs | 71 ++++ crates/poltertype-ai/src/wire.rs | 178 +++++++++ crates/poltertype-ai/src/wire/tests.rs | 151 ++++++++ crates/poltertype-app/src/detectors/tests.rs | 57 ++- crates/poltertype-core/src/settings/tests.rs | 44 ++- crates/poltertype-types/src/ai_plugin.rs | 45 ++- docs/AI.md | 378 ++++++++++--------- 24 files changed, 2243 insertions(+), 546 deletions(-) create mode 100644 crates/poltertype-ai/src/cache.rs create mode 100644 crates/poltertype-ai/src/cache/tests.rs create mode 100644 crates/poltertype-ai/src/consts.rs create mode 100644 crates/poltertype-ai/src/detector.rs create mode 100644 crates/poltertype-ai/src/detector/tests.rs delete mode 100644 crates/poltertype-ai/src/local.rs create mode 100644 crates/poltertype-ai/src/locality.rs create mode 100644 crates/poltertype-ai/src/locality/tests.rs delete mode 100644 crates/poltertype-ai/src/remote/detector.rs delete mode 100644 crates/poltertype-ai/src/remote/enums.rs delete mode 100644 crates/poltertype-ai/src/remote/mod.rs create mode 100644 crates/poltertype-ai/src/transport.rs create mode 100644 crates/poltertype-ai/src/wire.rs create mode 100644 crates/poltertype-ai/src/wire/tests.rs diff --git a/Cargo.lock b/Cargo.lock index e330d0a..22eab3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3975,6 +3975,7 @@ dependencies = [ "poltertype-types", "reqwest", "serde", + "serde_json", "thiserror 1.0.69", "tracing", ] 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..e9176f4 --- /dev/null +++ b/crates/poltertype-ai/src/detector.rs @@ -0,0 +1,348 @@ +//! `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}; +use std::sync::mpsc::{Receiver, 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}; +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. +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..dc42448 100644 --- a/crates/poltertype-ai/src/lib.rs +++ b/crates/poltertype-ai/src/lib.rs @@ -1,41 +1,72 @@ -//! 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; +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..9a94833 --- /dev/null +++ b/crates/poltertype-ai/src/transport.rs @@ -0,0 +1,71 @@ +//! 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. +#[cfg(feature = "remote")] +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())) +} + +/// Without the `remote` feature there is no client type to take, and +/// the detector never constructs one — this exists so the module +/// compiles and any accidental call site fails loudly. +#[cfg(not(feature = "remote"))] +pub fn ask(_call: &Call<'_>) -> Result, AiError> { + Err(AiError::RemoteDisabled( + "built without the `remote` cargo feature — no HTTP client exists in this binary".into(), + )) +} 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-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-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/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. From 900547894b5551e605c7b1c950d7929f2c5ba017 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sat, 1 Aug 2026 22:22:57 +0300 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20no=20AT-SPI=20keystroke=20listener?= =?UTF-8?q?=20=E2=80=94=20measured,=20not=20assumed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md has carried "Wayland AT-SPI fallback listener" as an open item since Phase 6, on the theory that the accessibility stack could read keystrokes without input-group membership and retire setup-linux.sh for Wayland. Measured it instead of continuing to assume. A probe registered a keystroke listener with the AT-SPI DeviceEventController exactly as an assistive technology does, on a session whose a11y bus is live and whose registry publishes the whole interface. RegisterKeystrokeListener returned false, and with five keystrokes injected through uinput inside the listening window, zero NotifyEvent callbacks arrived. The reason is architectural rather than a bug to chase: at-spi2-registryd has no privileged path to the keyboard: on X11 it snoops via the X server, on Wayland it can only relay what the compositor hands it, and only mutter does. Which also means the feature is redundant exactly where it works — on X11 the XInput2 listener already needs no permissions at all. So it is now a decision with evidence rather than a todo, in the same shape as the Flatpak entry. Wayland still needs setup-linux.sh once, the Setup pane already says so, and anyone wanting a zero-permission session has X11 today. libei through the RemoteDesktop portal — the emitting half of the same wish — stays open rather than decided. Mutter and KWin both implement that portal, so it is genuinely promising; it simply cannot be built and verified here, because no installed backend offers RemoteDesktop (hyprland.portal has Screenshot, ScreenCast, GlobalShortcuts and InputCapture; only kde.portal has RemoteDesktop). That one needs a GNOME or KDE session to develop against. --- crates/poltertype-input/src/linux/mod.rs | 10 +++- docs/DECISIONS.md | 60 ++++++++++++++++++++++++ docs/PLAN.md | 19 ++++++-- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/poltertype-input/src/linux/mod.rs b/crates/poltertype-input/src/linux/mod.rs index 5123690..79ba457 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. 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..dea1ccb 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -916,10 +916,23 @@ 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). +- [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. - [ ] **`libei` (`reis`) as the portal variant of send-keys** — not - implemented; `uinput` is currently the only path. + implemented; `uinput` is currently the only path. Still open + rather than decided: mutter and KWin do implement + `org.freedesktop.portal.RemoteDesktop`, so this one is real — + but no RemoteDesktop backend exists on the maintainer's machine + (`hyprland.portal` offers Screenshot, ScreenCast, + GlobalShortcuts, InputCapture; only `kde.portal` offers + RemoteDesktop), so it cannot be written and verified here. + Needs a GNOME or KDE session to develop against. - [ ] **`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: From e099f4291d36ae92e6b943f612d13d5dab6930da Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sat, 1 Aug 2026 22:59:13 +0300 Subject: [PATCH 3/9] focus: name the focused app on GNOME and KDE, via the a11y bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `focused_exe()` has returned None on every Wayland session but Hyprland since the tracker landed, so `[exceptions].disabled_apps`, per-app wordlist profiles and `apps = [...]` scoping have been quietly inert on the two largest desktops. The plan of record was a KWin script plus a GNOME Shell extension: two out-of-tree artifacts, in two languages, that a user has to install and that neither of us can test without running those desktops. That turned out to be unnecessary. Every AT-SPI event arrives over the a11y bus from the *application's own* connection, so the bus itself can be asked who sent it — GetConnectionUnixProcessID gives the PID, /proc//exe gives the executable basename, which is exactly what the Hyprland and X11 backends already report. One backend, no user-installed artifacts, works on any compositor with an a11y bridge, and — unlike the KWin/ GNOME plan — verifiable right here. It is: watching window:activate reported `exe=chrome` 272 ms after the activation, on this machine, in the code path GNOME and KDE take. The limitation is real and is written down everywhere it could mislead. Only applications with a live accessibility bridge are ever seen: GTK, Qt and Electron-with-a11y answer, most terminals do not — and a terminal is exactly where a developer types. An app that never emits also never un-focuses the previous one, so an answer can go stale in a way a compositor query cannot; samples carry an age and anything older than five minutes is treated as no answer, because reporting the wrong application would silence PolterType in a window the user expects it to work in. That is the 0.4.2 regression, and it is the reason the age check exists rather than a "last value wins". README, CLAUDE.md and PLAN.md now say "complete on Windows/Hyprland/ X11, partial on other Wayland, absent on macOS" rather than the old "nowhere else", and none of them says focus tracking simply works on GNOME/KDE. --- README.md | 15 +- .../src/focus/linux_impl/atspi_focus.rs | 224 ++++++++++++++++++ .../src/focus/linux_impl/atspi_focus/tests.rs | 92 +++++++ .../src/focus/linux_impl/caret_only.rs | 66 ++++-- .../src/focus/linux_impl/mod.rs | 1 + .../src/focus/linux_impl/pick.rs | 43 +++- docs/PLAN.md | 24 +- 7 files changed, 426 insertions(+), 39 deletions(-) create mode 100644 crates/poltertype-input/src/focus/linux_impl/atspi_focus.rs create mode 100644 crates/poltertype-input/src/focus/linux_impl/atspi_focus/tests.rs diff --git a/README.md b/README.md index 0f40ff1..5b433c0 100644 --- a/README.md +++ b/README.md @@ -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-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/docs/PLAN.md b/docs/PLAN.md index dea1ccb..16e5d15 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -16,9 +16,12 @@ > 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 +> `../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 *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 > onboarding window does exist as of 0.7.0 — but its macOS half has @@ -933,11 +936,16 @@ separate `poltertype --settings` process. GlobalShortcuts, InputCapture; only `kde.portal` offers RemoteDesktop), so it cannot be written and verified here. Needs a GNOME or KDE session to develop against. -- [ ] **`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. +- [x] **`FocusTracker` for GNOME/KDE Wayland** — done in 0.10.0, and + not the way this line planned. The per-DE backends (KWin script + / GNOME shell extension) turned out to be unnecessary: AT-SPI + events arrive from the application's own bus connection, so the + a11y bus can be asked which process sent one. One backend, no + user-installed artifacts, every compositor. The caret half + landed the same way in 0.7.0. + **Partial by nature:** only apps with an accessibility bridge + are visible, which excludes most terminals. See + `atspi_focus.rs`. ### Phase 7 — AI skeleton From 41db84adde2417b56773f31986c60e9129cb0346 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sun, 2 Aug 2026 00:10:40 +0300 Subject: [PATCH 4/9] ui: the settings window speaks other languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app whose entire subject is other people's languages had an English-only interface. It now loads translations from `data/i18n/.toml`, and Ukrainian ships. The load-bearing choice is that `tr` takes the English text as an argument: Text::new(tr("languages.languages", "Languages")) so English is compiled into every call site and cannot go missing. A catalog that fails to parse, a key nobody translated, a file a packager forgot to include — each degrades to readable English rather than to a blank button or a raw `languages.languages` on screen. It also means the source still says what the screen says, without a lookup table in between. Everything else follows from wanting a half-finished translation to be useful: an empty value counts as "not translated yet" and is ignored rather than drawn, and one malformed entry costs that entry instead of the language — the same rule the AI plug-in factory already applies to one bad `[[ai.plugins]]` block. `format!` needs a literal, so the long explanatory paragraphs — the ones that most need translating — could not go through it. `tr_args` does positional `{}` substitution instead, and tolerates a translation with a different number of placeholders: this runs inside the view function, where a panic would take the window down over a typo in a community file. Catalogs are looked up in the shipped data dir and in `/poltertype/i18n/`, with the user's copy winning, which is what makes an edit-and-reopen loop possible without a rebuild. `uk_UA` finds `uk.toml`, so a regional file is only worth shipping when the difference is real. `[general].ui_language` already existed, defaulting to "system", and nothing had ever read it; "system" and "auto" both mean "ask the environment" so no existing config file changes meaning. Detection is the POSIX trio in libc order. Windows sets none of those and lands on English until the user picks — reading its locale needs platform code in a crate not allowed to hold any, and a picker beats a guess. docs/TRANSLATING_THE_UI.md is the contributor path, written to the same shape as ADDING_A_LANGUAGE.md: one file, no Rust, testable locally before sending. --- crates/poltertype-app/src/settings_ui/mod.rs | 10 + crates/poltertype-app/src/settings_ui/view.rs | 305 ++++++++++++------ .../src/settings_ui/view_setup.rs | 33 +- crates/poltertype-core/build.rs | 26 ++ crates/poltertype-core/src/i18n/catalog.rs | 102 ++++++ crates/poltertype-core/src/i18n/consts.rs | 17 + crates/poltertype-core/src/i18n/detect.rs | 53 +++ crates/poltertype-core/src/i18n/mod.rs | 127 ++++++++ crates/poltertype-core/src/i18n/tests.rs | 236 ++++++++++++++ crates/poltertype-core/src/lib.rs | 1 + data/i18n/uk.toml | 99 ++++++ docs/PLAN.md | 5 +- docs/TRANSLATING_THE_UI.md | 127 ++++++++ installers/wix/main.wxs | 22 ++ 14 files changed, 1054 insertions(+), 109 deletions(-) create mode 100644 crates/poltertype-core/src/i18n/catalog.rs create mode 100644 crates/poltertype-core/src/i18n/consts.rs create mode 100644 crates/poltertype-core/src/i18n/detect.rs create mode 100644 crates/poltertype-core/src/i18n/mod.rs create mode 100644 crates/poltertype-core/src/i18n/tests.rs create mode 100644 data/i18n/uk.toml create mode 100644 docs/TRANSLATING_THE_UI.md 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/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/lib.rs b/crates/poltertype-core/src/lib.rs index dedd2ae..d1ff4b5 100644 --- a/crates/poltertype-core/src/lib.rs +++ b/crates/poltertype-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod audio; pub mod commands; pub mod data_dir; pub mod engine; +pub mod i18n; pub mod layouts; pub mod settings; pub mod wordlist_profiles; 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/PLAN.md b/docs/PLAN.md index 16e5d15..eeff4f7 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -979,7 +979,10 @@ decision and opens no socket. The seam is real and empty. Details in - [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] UI translation (i18n) — done in 0.10.0. Catalogs are data + (`data/i18n/.toml`), English is compiled in at every call + site as the fallback, and Ukrainian ships. Adding a language is + one file — see `docs/TRANSLATING_THE_UI.md`. - [x] Screenshots in the README — landed 2026-07-13 (`docs/screenshots/settings-window.png`). 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" /> + + + + + + + + From 8d43f593ffb1e64d7c7eef227008bf554ce3af5b Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sun, 2 Aug 2026 01:08:59 +0300 Subject: [PATCH 5/9] commands: multi-token triggers, and run_shell behind a real gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two items that have sat in the "deliberately not in v1" list since smart commands shipped. **Multi-token triggers.** The word buffer resets at every boundary, so `best regards` had nothing to match against. `WordHistory` is that memory, and it is the smallest one that does the job: keeping what the user typed is exactly what this project works to avoid, so it is bounded on three axes at once — four words, cleared on the same idle timeout that already abandons the word buffer, and scoped to the focused application so half a trigger typed in one window cannot complete in another. That last invariant lives inside the type rather than beside it, because a caller who forgot to check would break it silently. Erasing had to learn phrases too: a fired command now takes back the earlier words and the separator after each, counting characters rather than bytes, or half the trigger is left on screen. **run_shell.** The reason this waited is that 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 the user types an ordinary word. So: * off unless `[commands].allow_run_shell` is true, and entries still parse and display while it is false — they refuse at firing time and say which setting to flip; * no shell. A program plus an argv, executed directly, so a metacharacter is a character and there is no quoting bug to have. Anyone who genuinely wants a pipeline writes `sh` and `-c` explicitly, which is visible in the Commands list; * nothing the user typed becomes an argument — that would be an injection channel and would put typed text in a process table; * a timeout, a capped output, no stdin, and dispatch on a worker thread so a hung command cannot wedge the correction path. Output insertion is the sharp edge and is treated as such: stdout is capped, truncated on a character boundary, stripped of control characters (a newline typed into a chat window submits it), and never inserted when the command failed — a program that prints an error and exits non-zero must not have its error typed into the user's document. --- .../poltertype-app/src/settings_ui/helpers.rs | 13 + crates/poltertype-core/src/commands/enums.rs | 7 + .../poltertype-core/src/commands/matching.rs | 3 +- crates/poltertype-core/src/commands/mod.rs | 39 ++- crates/poltertype-core/src/commands/phrase.rs | 168 +++++++++++++ .../src/commands/phrase/tests.rs | 184 ++++++++++++++ crates/poltertype-core/src/commands/shell.rs | 230 ++++++++++++++++++ .../src/commands/shell/tests.rs | 153 ++++++++++++ crates/poltertype-core/src/commands/tests.rs | 27 +- .../src/engine/switcher/commands.rs | 51 ++++ .../src/engine/switcher/decide.rs | 36 ++- .../src/engine/switcher/engine.rs | 10 + .../src/engine/switcher/run_loop.rs | 6 + crates/poltertype-core/src/settings/types.rs | 12 + 14 files changed, 908 insertions(+), 31 deletions(-) create mode 100644 crates/poltertype-core/src/commands/phrase.rs create mode 100644 crates/poltertype-core/src/commands/phrase/tests.rs create mode 100644 crates/poltertype-core/src/commands/shell.rs create mode 100644 crates/poltertype-core/src/commands/shell/tests.rs 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-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/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(), From 87bf9e4cade4a531e0fb10a85850d85c3e896db5 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sun, 2 Aug 2026 01:33:03 +0300 Subject: [PATCH 6/9] plugins: a supported way to install a language pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader has read `/plugins//` since v0.1, but there was no supported way to get a pack in there — people copied directories by hand, so no validation ran and a malformed pack surfaced as a puzzling startup warning. `install` takes a directory that is already on disk. There is no download, and that is the security boundary rather than a missing feature. PolterType reads every keystroke; its one network call today is an updater that sends nothing and checks a signature made by a key that never touches CI. Fetching arbitrary third-party content from a URL would be a second, far wider channel feeding data into the same process. A user who downloaded a pack themselves made that trust decision at the moment they could see what they were downloading. It also deletes a class of bug at the root: no archive means no zip-slip, no decompression bomb, and no half-extracted pack to clean up. What installation actually enforces, given a pack is data and the loader is built on that assumption: * an allow-list of directories and extensions, so a pack cannot deliver an executable, a .so, a dotfile, or a config.toml that would shadow the user's settings — and everything left behind is reported rather than silently dropped; * symlinks refused rather than followed, so a link named `layout-mappings` pointing at ~/.ssh does not become a readable copy; * containment re-checked at write time, not only at plan time, because the two are separated by I/O; * a size and file-count budget; * atomic replacement — staged beside the destination, old pack moved aside rather than deleted first, so an interrupted install leaves either the old pack or none. Replacing an existing pack is the update path and is deliberately the same code; an update that behaved differently would be tested half as often. One bug the tests caught: a "pack" containing only a manifest passed, because the manifest counted as an installed file. Content is now counted separately — metadata alone installs nothing and would leave a directory the loader silently ignores. --- crates/poltertype-core/src/layouts/mod.rs | 2 +- crates/poltertype-core/src/lib.rs | 1 + crates/poltertype-core/src/plugins/consts.rs | 40 +++ crates/poltertype-core/src/plugins/enums.rs | 48 +++ crates/poltertype-core/src/plugins/install.rs | 310 ++++++++++++++++++ crates/poltertype-core/src/plugins/mod.rs | 60 ++++ crates/poltertype-core/src/plugins/tests.rs | 295 +++++++++++++++++ crates/poltertype-core/src/plugins/types.rs | 27 ++ docs/PLAN.md | 10 +- 9 files changed, 788 insertions(+), 5 deletions(-) create mode 100644 crates/poltertype-core/src/plugins/consts.rs create mode 100644 crates/poltertype-core/src/plugins/enums.rs create mode 100644 crates/poltertype-core/src/plugins/install.rs create mode 100644 crates/poltertype-core/src/plugins/mod.rs create mode 100644 crates/poltertype-core/src/plugins/tests.rs create mode 100644 crates/poltertype-core/src/plugins/types.rs 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 d1ff4b5..679f43f 100644 --- a/crates/poltertype-core/src/lib.rs +++ b/crates/poltertype-core/src/lib.rs @@ -9,6 +9,7 @@ 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/docs/PLAN.md b/docs/PLAN.md index eeff4f7..22376af 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -967,10 +967,12 @@ decision and opens no socket. The seam is real and empty. Details in `[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. +- [x] ~~A reference `LocalOnnxDetector` / `RemoteLlmDetector`~~ — + **replaced in 0.10.0.** PolterType ships the interface and no + backend at all: one `LlmDetector` speaking three HTTP shapes, + pointed at whatever the user runs or holds a key for. Bundling + a model or a vendor client would be choosing for them. See + `docs/AI.md`. ### Phase 8 — Polish, release ✅ (partially) From 425e9e1fce980ab3ebd148396f30549d1227c133 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sun, 2 Aug 2026 01:51:47 +0300 Subject: [PATCH 7/9] docs: refresh for the 0.10.0 feature block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-stamps the dated headers and corrects the claims this block made false. Three mattered: The AI subsystem is no longer "wired to stubs that return no opinion" — the stubs are gone and what ships is an interface with no backend at all. Both CLAUDE.md's known-gaps entry and PLAN.md's header said the old thing, and "wired, no backend yet" would now read as if a backend were coming from us. It is not: out of the box nothing answers, and that is the design. AT-SPI moved in two directions at once, which is exactly the shape that goes stale silently. The keystroke *listener* is refused with measurements; the caret and focused-application watchers are live and are a different interface. The old bullet said AT-SPI does not exist, which is now wrong in both directions. The crate table still described focus tracking on non-Hyprland Wayland as caret-only. --- README.md | 4 ++-- docs/CODE_SIGNING.md | 2 +- docs/PLAN.md | 16 +++++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5b433c0..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. 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/PLAN.md b/docs/PLAN.md index 22376af..7d37ec8 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,9 +9,10 @@ > 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). @@ -21,9 +22,10 @@ > (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 *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 +> 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. > From cf4b2de2371ef9d2180daa5eef7cf43d3a3bc504 Mon Sep 17 00:00:00 2001 From: Leshiy Date: Sun, 2 Aug 2026 02:12:14 +0300 Subject: [PATCH 8/9] ai: compile no HTTP module without the `remote` feature All three CI platforms failed clippy on this branch while every local check passed, and the gap is the interesting part: CI lints WITHOUT `--all-features`, so the feature-off shape of an optional crate was a configuration nothing local had ever checked. CLAUDE.md has warned about that difference for releases; nothing enforced it. With `remote` off there is no HTTP client, so `transport` has nothing to wrap, `Call` is never constructed, `ask` is never called and the worker's `Job` is never read. That is all genuinely dead code rather than a lint being fussy, so the module is now gated out entirely and the two imports that only serve it are gated with it. The pre-commit hook runs both clippy configurations from now on, CI's first, and CLAUDE.md's command list says why. A lint that only one of two build shapes ever sees is a lint that finds problems after the push. --- .githooks/pre-commit | 16 +++ CHANGELOG.md | 138 ++++++++++++++++++++++++++ crates/poltertype-ai/src/detector.rs | 9 +- crates/poltertype-ai/src/lib.rs | 5 + crates/poltertype-ai/src/transport.rs | 11 -- 5 files changed, 167 insertions(+), 12 deletions(-) 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..3619807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,144 @@ 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. + +### 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/crates/poltertype-ai/src/detector.rs b/crates/poltertype-ai/src/detector.rs index e9176f4..9a2e692 100644 --- a/crates/poltertype-ai/src/detector.rs +++ b/crates/poltertype-ai/src/detector.rs @@ -21,7 +21,9 @@ //! the rest of the engine. use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +#[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}; @@ -31,6 +33,7 @@ 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. @@ -65,6 +68,10 @@ impl LlmSettings { } /// 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, diff --git a/crates/poltertype-ai/src/lib.rs b/crates/poltertype-ai/src/lib.rs index dc42448..b9ee008 100644 --- a/crates/poltertype-ai/src/lib.rs +++ b/crates/poltertype-ai/src/lib.rs @@ -63,6 +63,11 @@ 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; diff --git a/crates/poltertype-ai/src/transport.rs b/crates/poltertype-ai/src/transport.rs index 9a94833..54f9d7a 100644 --- a/crates/poltertype-ai/src/transport.rs +++ b/crates/poltertype-ai/src/transport.rs @@ -24,7 +24,6 @@ pub struct Call<'a> { /// `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. -#[cfg(feature = "remote")] pub fn ask(client: &reqwest::blocking::Client, call: &Call<'_>) -> Result, AiError> { let question = wire::Question { model: call.model, @@ -59,13 +58,3 @@ pub fn ask(client: &reqwest::blocking::Client, call: &Call<'_>) -> Result