From f33330335c9ff71e6e3c9a8b92dced6b39959da8 Mon Sep 17 00:00:00 2001 From: ArushKhasru Date: Fri, 19 Jun 2026 16:09:44 +0530 Subject: [PATCH 1/3] feat: add Windows support with foreground app detection, clipboard integration, and dynamic command handling --- Cargo.lock | 1 + Cargo.toml | 8 + README.md | 1 + RUNNING.md | 1 + docs/WINDOWS.md | 466 ++++++++++++++++++++++++++++++++++++++++ src/extraction/mod.rs | 60 +++++- src/input/controller.rs | 110 +++++++++- src/input/simulator.rs | 20 ++ src/main.rs | 64 ++++-- src/pipeline.rs | 63 +++++- src/platform/windows.rs | 204 +++++++++++++++++- src/runtime.rs | 13 +- 12 files changed, 976 insertions(+), 35 deletions(-) create mode 100644 docs/WINDOWS.md diff --git a/Cargo.lock b/Cargo.lock index e7313b2..fed9343 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2423,6 +2423,7 @@ dependencies = [ "serde", "serde_json", "toml", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index de84f73..64f215a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,11 @@ enigo = "0.2" rdev = "0.5" directories = "5" keyring = "2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/README.md b/README.md index 28170be..acb8970 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ sudo apt install build-essential pkg-config libdbus-1-dev libxdo-dev libx11-dev ## Documentation - [RUNNING.md](RUNNING.md): developer/local run instructions. +- [docs/WINDOWS.md](docs/WINDOWS.md): Windows setup, installation, usage, and troubleshooting. - [docs/RELEASES.md](docs/RELEASES.md): downloadable artifact instructions. - [docs/MACOS_APP.md](docs/MACOS_APP.md): macOS app wrapper notes. - [SPEC.md](SPEC.md): product and architecture spec. diff --git a/RUNNING.md b/RUNNING.md index 202f2cf..2496e11 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -190,6 +190,7 @@ See [docs/WAYLAND_POC.md](docs/WAYLAND_POC.md) before using it. It reads keyboar ## 10) Notes - This repo uses select-all + clipboard to read active-field text. The app restores the clipboard after each operation. - For development, `cargo run -- run` is easiest; for production, prefer `cargo build --release` and run the release binary. +- For Windows-specific setup, installation, usage, and troubleshooting, see [docs/WINDOWS.md](docs/WINDOWS.md). --- For architecture details and behavioral specs, see [SPEC.md](SPEC.md). diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md new file mode 100644 index 0000000..62089fa --- /dev/null +++ b/docs/WINDOWS.md @@ -0,0 +1,466 @@ +# Windows Guide (Stringcast) + +This guide explains how to install, configure, run, and use Stringcast on Windows. + +Stringcast is currently a Rust CLI/runtime app. It runs in a terminal, listens for text triggers in normal Windows text fields, sends the selected text to the configured AI provider, and replaces the field with the result. + +## Current Windows Status + +Windows support is implemented for the core runtime path: + +- Global input listening through `rdev` +- Foreground app detection through Win32 APIs +- Exclusion checks for apps such as password managers +- Elevated-window blocking when Stringcast is not elevated +- Clipboard-based extraction and replacement +- Static commands such as `?fix`, `?improve`, and `?summarize` +- Dynamic commands such as `?translate:` and `?ask:` +- API providers: Gemini, OpenAI, Anthropic, and custom OpenAI-compatible APIs +- API key metadata in config and API secrets in Windows Credential Manager through the OS keyring backend + +Known remaining Windows hardening items: + +- Packaged installer is not available yet. +- Startup-at-login is not wired into the app config yet. +- Focus-change events are not emitted through a Windows foreground-event hook yet. +- Binary signing and SmartScreen hardening are release tasks. + +## Requirements + +Supported OS: + +- Windows 10 64-bit +- Windows 11 64-bit + +Required for building from source: + +- Rust toolchain through rustup +- Microsoft C++ Build Tools with MSVC +- Windows 10/11 SDK +- Git + +If you only use a prebuilt `stringcast.exe`, you do not need Rust or Visual Studio Build Tools. + +## Install Development Tools + +Install Rust: + +```powershell +winget install Rustlang.Rustup +rustup toolchain install stable +rustup default stable +``` + +Install Visual Studio Build Tools: + +```powershell +winget install Microsoft.VisualStudio.2022.BuildTools +``` + +In Visual Studio Installer, select: + +- Desktop development with C++ +- MSVC v143 or newer +- Windows 10 SDK or Windows 11 SDK + +Verify: + +```powershell +rustc --version +cargo --version +rustup target list --installed +``` + +The expected default host target is usually: + +```text +x86_64-pc-windows-msvc +``` + +## Build From Source + +From the repository root: + +```powershell +cargo build +cargo test +``` + +For a release binary: + +```powershell +cargo build --release +``` + +The release executable will be: + +```text +target\release\stringcast.exe +``` + +## Optional Portable Install + +After building a release binary, copy it to a stable local folder: + +```powershell +New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\Stringcast\bin" +Copy-Item ".\target\release\stringcast.exe" "$env:LOCALAPPDATA\Stringcast\bin\stringcast.exe" -Force +``` + +Run it directly: + +```powershell +& "$env:LOCALAPPDATA\Stringcast\bin\stringcast.exe" status +``` + +Optional: add it to your user `PATH`: + +```powershell +$bin = "$env:LOCALAPPDATA\Stringcast\bin" +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if (($userPath -split ";") -notcontains $bin) { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$bin", "User") +} +``` + +Open a new terminal after changing `PATH`. + +## Initialize Config + +Using Cargo: + +```powershell +cargo run -- init +cargo run -- show-config +cargo run -- status +``` + +Using an installed binary: + +```powershell +stringcast init +stringcast show-config +stringcast status +``` + +Default config location: + +```text +%APPDATA%\Stringcast\config\config.toml +``` + +Example: + +```text +C:\Users\\AppData\Roaming\Stringcast\config\config.toml +``` + +## Add an API Key + +Set the API key only for the current PowerShell session: + +```powershell +$env:STRINGCAST_API_KEY="your-api-key-here" +``` + +Add key metadata and store the secret in Windows Credential Manager: + +```powershell +cargo run -- add-key gemini main "Gemini" +cargo run -- set-provider gemini +cargo run -- api-test +``` + +Or, if installed: + +```powershell +stringcast add-key gemini main "Gemini" +stringcast set-provider gemini +stringcast api-test +``` + +Supported providers: + +```text +gemini +openai +anthropic +custom +``` + +Provider examples: + +```powershell +$env:STRINGCAST_API_KEY="your-gemini-key" +stringcast add-key gemini main "Gemini" +stringcast set-provider gemini +stringcast api-test +``` + +```powershell +$env:STRINGCAST_API_KEY="your-openai-key" +stringcast add-key openai main "OpenAI" +stringcast set-provider openai +stringcast api-test +``` + +## Run Stringcast + +From source: + +```powershell +cargo run -- run +``` + +Or simply: + +```powershell +cargo run +``` + +Installed binary: + +```powershell +stringcast run +``` + +Leave the terminal open while using Stringcast. Press `Ctrl+C` in that terminal to stop it. + +For debugging input and pipeline behavior: + +```powershell +$env:STRINGCAST_LOG_EVENTS="1" +cargo run -- run +``` + +## How To Use It + +1. Start Stringcast and leave it running. +2. Open a normal editable text field, such as Notepad, VS Code, a browser textarea, or a chat input. +3. Type text followed by a trigger. +4. Wait for Stringcast to replace the field with the AI output. + +Static command examples: + +```text +i dont knwo whats happening ?fix +Make this clearer and easier to read ?improve +Turn this into bullet points ?bullets +Summarize this paragraph ?summarize +``` + +Dynamic command examples: + +```text +hello, how are you ?translate:hi +This sounds too direct ?ask:make it sound polite +This is too long ?ask:summarize it in five words +``` + +Dynamic commands execute when either: + +- You stop typing for about 650 ms after a valid dynamic trigger. +- For `?translate:`, you type a trailing space after the language code. + +Examples: + +```text +hello world ?translate:es +hello world ?translate:es +quarterly report ?ask:what are the three key risks +``` + +## Built-In Commands + +```text +?fix Fix grammar, spelling, and punctuation +?improve Improve clarity and readability +?shorten Shorten text +?expand Expand with more detail +?formal Rewrite formally +?casual Rewrite casually +?emoji Add tasteful emojis +?reply Generate a reply +?bullets Convert to bullet points +?summarize Summarize in 1-3 sentences +``` + +Dynamic commands: + +```text +?translate: +?ask: +``` + +Language codes use a BCP-47-style subset: + +```text +es +hi +ja +fr +de +pt-BR +zh-Hant +``` + +## Windows Security Behavior + +Stringcast uses foreground app detection before executing a command. + +Blocked by default: + +- Password managers and known sensitive apps from the built-in exclusion list +- Elevated/admin windows when Stringcast itself is not running elevated +- Apps added to your config exclusions + +Default Windows exclusions include: + +```text +1Password.exe +KeePass.exe +KeePassXC.exe +Bitwarden.exe +LastPass.exe +``` + +To add your own exclusions, edit: + +```text +%APPDATA%\Stringcast\config\config.toml +``` + +Example: + +```toml +[exclusions] +apps = ["MySensitiveApp.exe"] +``` + +## Startup At Login + +Built-in startup-at-login support is not implemented yet. For now, use Task Scheduler if you want Stringcast to start when you sign in. + +Suggested Task Scheduler settings: + +- Trigger: At log on +- Action: Start a program +- Program: full path to `stringcast.exe` +- Arguments: `run` +- Start in: folder containing `stringcast.exe` + +If running from source during development, prefer starting it manually with: + +```powershell +cargo run -- run +``` + +## Troubleshooting + +### `Pipeline(Platform(Unavailable))` + +This used to indicate missing Windows foreground detection. If it appears again, rebuild the latest code: + +```powershell +cargo build +``` + +Then retry: + +```powershell +cargo run -- run +``` + +### `Pipeline(Extraction(TriggerMissingFromSnapshot))` + +This means Stringcast detected the trigger in the keystroke buffer, but the selected/copied text did not contain that trigger. Common causes: + +- The target app did not allow normal `Ctrl+A` / `Ctrl+C`. +- The text field changed between trigger detection and extraction. +- Another app or clipboard manager interfered with clipboard contents. + +Try Notepad first. If Notepad works but another app fails, that app may be blocking selection or clipboard operations. + +### The sentence disappears or the whole field stays selected + +This should be prevented by the failed-extraction cleanup path. If it happens: + +1. Stop Stringcast with `Ctrl+C`. +2. Rebuild and rerun: + + ```powershell + cargo build + cargo run -- run + ``` + +3. Retest in Notepad. + +### Dynamic commands do not fire + +Use one of these patterns: + +```text +hello ?translate:hi +hello ?translate:hi +text ?ask:make it more concise +``` + +For `?ask:`, stop typing for about 650 ms. For `?translate:`, either stop typing or type one trailing space after the language code. + +### API test fails + +Check the active provider and key count: + +```powershell +cargo run -- status +``` + +Re-add the key: + +```powershell +$env:STRINGCAST_API_KEY="your-api-key-here" +cargo run -- add-key gemini main "Gemini" +cargo run -- set-provider gemini +cargo run -- api-test +``` + +### Elevated apps do not work + +If the target window is running as Administrator and Stringcast is not, Stringcast blocks the operation. Either: + +- Use a non-elevated target app, recommended. +- Run Stringcast elevated only if you understand the security tradeoff. + +## Validation Checklist + +Use this checklist after building on Windows: + +```powershell +cargo fmt --check +cargo test +cargo clippy --all-targets -- -D warnings +cargo run -- check-permissions +cargo run -- api-test +cargo run -- run +``` + +Manual smoke tests: + +- `hello world ?fix` in Notepad +- `hello world ?translate:hi` in Notepad +- `hello world ?ask:make it formal` in Notepad +- Same tests in VS Code +- Same tests in a browser textarea +- Confirm password manager windows are ignored +- Confirm elevated windows are blocked unless Stringcast is also elevated + +## Related Files + +- `README.md` +- `RUNNING.md` +- `SPEC.md` +- `src/platform/windows.rs` +- `src/input/controller.rs` +- `src/main.rs` +- `src/pipeline.rs` +- `src/runtime.rs` diff --git a/src/extraction/mod.rs b/src/extraction/mod.rs index fcb7600..941ba48 100644 --- a/src/extraction/mod.rs +++ b/src/extraction/mod.rs @@ -95,21 +95,40 @@ where ) -> Result { let original_clipboard = self.clipboard.snapshot()?; - self.input.select_all()?; thread::sleep(self.select_all_wait); - self.input.copy()?; + self.input.select_all().map_err(ExtractionError::from)?; + thread::sleep(self.select_all_wait); + if let Err(error) = self.input.copy() { + self.cleanup_failed_extraction(&original_clipboard); + return Err(error.into()); + } thread::sleep(self.clipboard_read_wait); - let copied_text = self - .clipboard - .get_text()? - .ok_or(ExtractionError::TriggerMissingFromSnapshot)?; + let copied_text = match self.clipboard.get_text() { + Ok(Some(text)) => text, + Ok(None) => { + self.cleanup_failed_extraction(&original_clipboard); + return Err(ExtractionError::TriggerMissingFromSnapshot); + } + Err(error) => { + self.cleanup_failed_extraction(&original_clipboard); + return Err(error.into()); + } + }; + + let transform_input = match transform_input_from_snapshot( + &copied_text, + &context.trigger_match.trigger_text, + ) { + Ok(transform_input) => transform_input, + Err(error) => { + self.cleanup_failed_extraction(&original_clipboard); + return Err(error); + } + }; self.clipboard.restore(&original_clipboard)?; - let transform_input = - transform_input_from_snapshot(&copied_text, &context.trigger_match.trigger_text)?; - Ok(OperationSnapshot { operation_id: context.operation_id, app_id: context.app_id, @@ -122,6 +141,20 @@ where } } +impl ClipboardTextExtractor +where + C: ClipboardBackend, + I: InputSimulator, +{ + fn cleanup_failed_extraction( + &mut self, + original_clipboard: &crate::clipboard::ClipboardSnapshot, + ) { + let _ = self.input.collapse_selection(); + let _ = self.clipboard.restore(original_clipboard); + } +} + fn transform_input_from_snapshot( copied_text: &str, trigger_text: &str, @@ -205,7 +238,16 @@ mod tests { let mut extractor = ClipboardTextExtractor::new(clipboard, input); let result = extractor.extract(context()); + let (_, input) = extractor.into_parts(); assert_eq!(result, Err(ExtractionError::TriggerMissingFromSnapshot)); + assert_eq!( + input.actions, + vec![ + RecordedInputAction::SelectAll, + RecordedInputAction::Copy, + RecordedInputAction::CollapseSelection + ] + ); } } diff --git a/src/input/controller.rs b/src/input/controller.rs index 41586f3..2d5db73 100644 --- a/src/input/controller.rs +++ b/src/input/controller.rs @@ -1,9 +1,10 @@ use super::{InputEvent, KeystrokeBuffer, SyntheticInputGuard}; +use crate::detection::DYNAMIC_DEBOUNCE_MS; use crate::extraction::TextExtractor; use crate::pipeline::{PipelineError, PipelineOutcome, TextTransformer, TransformationPipeline}; use crate::platform::{ForegroundAppProvider, OperationGate}; use crate::replacement::TextReplacer; -use std::time::Instant; +use std::time::{Duration, Instant}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum InputControllerOutcome { @@ -30,6 +31,7 @@ pub struct InputController { foreground_provider: P, gate: OperationGate, synthetic_guard: SyntheticInputGuard, + pending_dynamic_deadline: Option, } impl InputController @@ -51,6 +53,7 @@ where foreground_provider, gate, synthetic_guard, + pending_dynamic_deadline: None, } } @@ -72,25 +75,33 @@ where &self.gate, )?; match outcome { - PipelineOutcome::NoMatch => Ok(InputControllerOutcome::BufferUpdated( - self.buffer.as_str().to_string(), - )), - PipelineOutcome::PendingDynamic => Ok(InputControllerOutcome::Pipeline( - PipelineOutcome::PendingDynamic, - )), + PipelineOutcome::NoMatch => { + self.pending_dynamic_deadline = None; + Ok(InputControllerOutcome::BufferUpdated( + self.buffer.as_str().to_string(), + )) + } + PipelineOutcome::PendingDynamic => { + self.pending_dynamic_deadline = + Some(now + Duration::from_millis(DYNAMIC_DEBOUNCE_MS)); + Ok(InputControllerOutcome::Pipeline( + PipelineOutcome::PendingDynamic, + )) + } PipelineOutcome::Blocked(decision) => { - self.buffer.clear(); + self.clear_buffer_and_pending(); Ok(InputControllerOutcome::Pipeline(PipelineOutcome::Blocked( decision, ))) } PipelineOutcome::Replaced { .. } => { - self.buffer.clear(); + self.clear_buffer_and_pending(); Ok(InputControllerOutcome::Pipeline(outcome)) } } } InputEvent::Backspace => { + self.pending_dynamic_deadline = None; self.buffer.backspace(); Ok(InputControllerOutcome::BufferUpdated( self.buffer.as_str().to_string(), @@ -105,12 +116,56 @@ where | InputEvent::Shortcut(_) | InputEvent::FocusChanged | InputEvent::SleepOrLock => { - self.buffer.clear(); + self.clear_buffer_and_pending(); Ok(InputControllerOutcome::BufferCleared) } } } + pub fn handle_pending_timeout( + &mut self, + now: Instant, + ) -> Result { + let Some(deadline) = self.pending_dynamic_deadline else { + return Ok(InputControllerOutcome::BufferUpdated( + self.buffer.as_str().to_string(), + )); + }; + + if now < deadline { + return Ok(InputControllerOutcome::BufferUpdated( + self.buffer.as_str().to_string(), + )); + } + + self.pending_dynamic_deadline = None; + let outcome = self.pipeline.finalize_pending_foreground_buffer( + self.buffer.as_str(), + &mut self.foreground_provider, + &self.gate, + )?; + + match outcome { + PipelineOutcome::NoMatch | PipelineOutcome::PendingDynamic => Ok( + InputControllerOutcome::BufferUpdated(self.buffer.as_str().to_string()), + ), + PipelineOutcome::Blocked(decision) => { + self.clear_buffer_and_pending(); + Ok(InputControllerOutcome::Pipeline(PipelineOutcome::Blocked( + decision, + ))) + } + PipelineOutcome::Replaced { .. } => { + self.clear_buffer_and_pending(); + Ok(InputControllerOutcome::Pipeline(outcome)) + } + } + } + + pub fn pending_dynamic_deadline(&self) -> Option { + self.pending_dynamic_deadline + } + pub fn buffer(&self) -> &str { self.buffer.as_str() } @@ -118,12 +173,18 @@ where pub fn into_parts(self) -> (TransformationPipeline, P) { (self.pipeline, self.foreground_provider) } + + fn clear_buffer_and_pending(&mut self) { + self.buffer.clear(); + self.pending_dynamic_deadline = None; + } } #[cfg(test)] mod tests { use super::*; use crate::commands::{CommandDefinition, CommandRegistry}; + use crate::detection::DYNAMIC_DEBOUNCE_MS; use crate::extraction::BufferTextExtractor; use crate::platform::{ ExclusionMatcher, ForegroundApp, OperationGate, StaticForegroundAppProvider, @@ -195,6 +256,35 @@ mod tests { assert_eq!(controller.buffer(), ""); } + #[test] + fn pending_dynamic_trigger_replaces_after_debounce() { + let now = Instant::now(); + let mut controller = controller(); + + let first = controller + .handle_event( + InputEvent::Text("hello ?ask:make this warmer".to_string()), + now, + ) + .unwrap(); + + assert_eq!( + first, + InputControllerOutcome::Pipeline(PipelineOutcome::PendingDynamic) + ); + assert!(controller.pending_dynamic_deadline().is_some()); + + let outcome = controller + .handle_pending_timeout(now + Duration::from_millis(DYNAMIC_DEBOUNCE_MS + 1)) + .unwrap(); + + assert!(matches!( + outcome, + InputControllerOutcome::Pipeline(PipelineOutcome::Replaced { .. }) + )); + assert_eq!(controller.buffer(), ""); + } + #[test] fn navigation_clears_buffer() { let now = Instant::now(); diff --git a/src/input/simulator.rs b/src/input/simulator.rs index e5b49bd..2b43fc5 100644 --- a/src/input/simulator.rs +++ b/src/input/simulator.rs @@ -12,6 +12,7 @@ pub trait InputSimulator { fn copy(&mut self) -> Result<(), InputSimulationError>; fn paste(&mut self) -> Result<(), InputSimulationError>; fn type_text(&mut self, text: &str) -> Result<(), InputSimulationError>; + fn collapse_selection(&mut self) -> Result<(), InputSimulationError>; } #[derive(Debug)] @@ -53,6 +54,11 @@ where let _token = self.guard.acquire(Instant::now()); self.inner.type_text(text) } + + fn collapse_selection(&mut self) -> Result<(), InputSimulationError> { + let _token = self.guard.acquire(Instant::now()); + self.inner.collapse_selection() + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -61,6 +67,7 @@ pub enum RecordedInputAction { Copy, Paste, TypeText(String), + CollapseSelection, } #[derive(Debug, Clone, Default)] @@ -89,6 +96,11 @@ impl InputSimulator for RecordingInputSimulator { .push(RecordedInputAction::TypeText(text.to_string())); Ok(()) } + + fn collapse_selection(&mut self) -> Result<(), InputSimulationError> { + self.actions.push(RecordedInputAction::CollapseSelection); + Ok(()) + } } pub struct EnigoInputSimulator { @@ -143,6 +155,14 @@ impl InputSimulator for EnigoInputSimulator { .text(text) .map_err(|_| InputSimulationError::Unavailable) } + + fn collapse_selection(&mut self) -> Result<(), InputSimulationError> { + use enigo::{Direction, Keyboard}; + + self.enigo + .key(enigo::Key::RightArrow, Direction::Click) + .map_err(|_| InputSimulationError::Unavailable) + } } #[cfg(target_os = "macos")] diff --git a/src/main.rs b/src/main.rs index 7251f46..de40b2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use std::env; use std::path::Path; +use std::sync::mpsc; +use std::thread; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use stringcast::api::{ @@ -61,29 +63,65 @@ fn run() -> Result<(), String> { let mut hook = input_hook(); let log_events = log_events(); + let (event_sender, event_receiver) = mpsc::channel(); + let worker_log_events = log_events; + thread::spawn(move || loop { + let received = match runtime.pending_dynamic_deadline() { + Some(deadline) => { + let timeout = deadline.saturating_duration_since(Instant::now()); + match event_receiver.recv_timeout(timeout) { + Ok(received) => received, + Err(mpsc::RecvTimeoutError::Timeout) => { + log_runtime_outcome( + runtime.handle_pending_timeout(Instant::now()), + worker_log_events, + ); + continue; + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + None => match event_receiver.recv() { + Ok(received) => received, + Err(_) => break, + }, + }; + + let (event, received_at) = received; + log_runtime_outcome(runtime.handle_event(event, received_at), worker_log_events); + }); + hook.run(move |event| { + let received_at = Instant::now(); if log_events { eprintln!("Stringcast input event: {}", describe_input_event(&event)); } - let outcome = runtime.handle_event(event, Instant::now()); - - match outcome { - Ok(outcome) if log_events => { - eprintln!( - "Stringcast input outcome: {}", - describe_input_outcome(&outcome) - ); - } - Ok(_) => {} - Err(error) => { - eprintln!("Stringcast event error: {error:?}"); - } + if event_sender.send((event, received_at)).is_err() { + eprintln!("Stringcast event error: input worker stopped"); } }) .map_err(|error| format!("input hook error: {error:?}")) } +fn log_runtime_outcome( + outcome: Result, + log_events: bool, +) { + match outcome { + Ok(outcome) if log_events => { + eprintln!( + "Stringcast input outcome: {}", + describe_input_outcome(&outcome) + ); + } + Ok(_) => {} + Err(error) => { + eprintln!("Stringcast event error: {error:?}"); + } + } +} + fn input_hook() -> RdevInputHook { RdevInputHook::new() } diff --git a/src/pipeline.rs b/src/pipeline.rs index 7b96b66..474af59 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,5 +1,5 @@ use crate::commands::{CommandDefinition, CommandRegistry}; -use crate::detection::{detect_trigger, DetectionDecision, TriggerMatch}; +use crate::detection::{detect_trigger, finalize_pending_dynamic, DetectionDecision, TriggerMatch}; use crate::extraction::{ExtractionContext, ExtractionError, TextExtractor}; use crate::orchestrator::{OperationOrchestrator, OrchestratorError}; use crate::platform::{ @@ -130,6 +130,21 @@ where } } + pub fn finalize_pending_buffer( + &mut self, + buffer: &str, + app_id: impl Into, + window_id: Option, + ) -> Result { + match finalize_pending_dynamic(buffer) { + DetectionDecision::NoMatch => Ok(PipelineOutcome::NoMatch), + DetectionDecision::PendingDynamic(_) => Ok(PipelineOutcome::PendingDynamic), + DetectionDecision::Matched(trigger_match) => { + self.execute_match(trigger_match, app_id.into(), window_id) + } + } + } + pub fn process_foreground_buffer

( &mut self, buffer: &str, @@ -148,6 +163,24 @@ where self.process_buffer(buffer, app.app_id, app.window_id) } + pub fn finalize_pending_foreground_buffer

( + &mut self, + buffer: &str, + provider: &mut P, + gate: &OperationGate, + ) -> Result + where + P: ForegroundAppProvider, + { + let app = provider.foreground_app()?; + let decision = gate.evaluate(&app); + if decision != OperationGateDecision::Allow { + return Ok(PipelineOutcome::Blocked(decision)); + } + + self.finalize_pending_buffer(buffer, app.app_id, app.window_id) + } + pub fn into_parts(self) -> (OperationOrchestrator, E, T, R) { ( self.orchestrator, @@ -356,6 +389,34 @@ mod tests { assert!(replacer.replacements.is_empty()); } + #[test] + fn pipeline_finalizes_pending_dynamic_trigger() { + let mut pipeline = TransformationPipeline::new( + CommandRegistry::new(), + BufferTextExtractor, + FakeTransformer { + output: Ok("Hola".to_string()), + calls: vec![], + }, + NoopTextReplacer::default(), + ); + + let outcome = pipeline + .finalize_pending_buffer("hello ?translate:es", "com.example.App", None) + .unwrap(); + let (_, _, transformer, replacer) = pipeline.into_parts(); + + assert_eq!( + outcome, + PipelineOutcome::Replaced { + trigger_text: "?translate:es".to_string(), + replacement_text: "Hola".to_string() + } + ); + assert_eq!(transformer.calls.len(), 1); + assert_eq!(replacer.replacements[0].1, "Hola"); + } + #[test] fn transform_failure_does_not_replace() { let mut pipeline = TransformationPipeline::new( diff --git a/src/platform/windows.rs b/src/platform/windows.rs index d5ee844..cd29607 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,4 +1,20 @@ use super::{ForegroundApp, ForegroundAppProvider, PlatformContextError}; +use std::ffi::OsString; +use std::mem::{size_of, zeroed}; +use std::os::windows::ffi::OsStringExt; +use std::path::Path; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{CloseHandle, FALSE, HANDLE, HWND}; +use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, OpenProcess, OpenProcessToken, QueryFullProcessImageNameW, + PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}; + +const PROCESS_IMAGE_BUFFER_LEN: usize = 32_768; #[derive(Debug, Clone, Default)] pub struct WindowsForegroundAppProvider; @@ -11,6 +27,192 @@ impl WindowsForegroundAppProvider { impl ForegroundAppProvider for WindowsForegroundAppProvider { fn foreground_app(&mut self) -> Result { - Err(PlatformContextError::Unavailable) + foreground_app() + } +} + +fn foreground_app() -> Result { + let hwnd = unsafe { GetForegroundWindow() }; + if hwnd.is_null() { + return Err(PlatformContextError::Unavailable); + } + + let process_id = foreground_process_id(hwnd)?; + let process = ProcessHandle::open(process_id)?; + let process_image_path = query_process_image_path(process.raw())?; + let app_id = executable_name(&process_image_path)?; + let elevated = process_blocks_unelevated_access(process.raw()); + + Ok(ForegroundApp { + app_id, + window_id: Some(format!("{:p}", hwnd)), + display_name: Some(process_image_path), + secure_input: false, + elevated, + }) +} + +fn foreground_process_id(hwnd: HWND) -> Result { + let mut process_id = 0; + let thread_id = unsafe { GetWindowThreadProcessId(hwnd, &mut process_id) }; + + if thread_id == 0 || process_id == 0 { + return Err(PlatformContextError::Unavailable); + } + + Ok(process_id) +} + +fn query_process_image_path(process: HANDLE) -> Result { + let mut buffer = vec![0u16; PROCESS_IMAGE_BUFFER_LEN]; + let mut size = buffer.len() as u32; + let ok = unsafe { + QueryFullProcessImageNameW(process, PROCESS_NAME_WIN32, buffer.as_mut_ptr(), &mut size) + }; + + if ok == FALSE || size == 0 { + return Err(PlatformContextError::CommandFailed); + } + + wide_to_string(&buffer[..size as usize]) +} + +fn wide_to_string(wide: &[u16]) -> Result { + let value = OsString::from_wide(wide).to_string_lossy().into_owned(); + if value.trim().is_empty() { + return Err(PlatformContextError::InvalidOutput); + } + + Ok(value) +} + +fn executable_name(process_image_path: &str) -> Result { + Path::new(process_image_path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .map(str::to_string) + .ok_or(PlatformContextError::InvalidOutput) +} + +fn process_blocks_unelevated_access(process: HANDLE) -> bool { + let target_elevated = is_process_elevated(process).unwrap_or(true); + if !target_elevated { + return false; + } + + let current_elevated = current_process_elevated().unwrap_or(false); + target_elevated && !current_elevated +} + +fn current_process_elevated() -> Result { + let process = unsafe { GetCurrentProcess() }; + is_process_elevated(process) +} + +fn is_process_elevated(process: HANDLE) -> Result { + let token = TokenHandle::open(process)?; + let mut elevation = unsafe { zeroed::() }; + let mut return_length = 0; + + let ok = unsafe { + GetTokenInformation( + token.raw(), + TokenElevation, + &mut elevation as *mut TOKEN_ELEVATION as *mut _, + size_of::() as u32, + &mut return_length, + ) + }; + + if ok == FALSE || return_length == 0 { + return Err(PlatformContextError::CommandFailed); + } + + Ok(elevation.TokenIsElevated != 0) +} + +#[derive(Debug)] +struct ProcessHandle(HANDLE); + +impl ProcessHandle { + fn open(process_id: u32) -> Result { + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_id) }; + if handle.is_null() { + return Err(PlatformContextError::Unavailable); + } + + Ok(Self(handle)) + } + + fn raw(&self) -> HANDLE { + self.0 + } +} + +impl Drop for ProcessHandle { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + CloseHandle(self.0); + } + } + } +} + +#[derive(Debug)] +struct TokenHandle(HANDLE); + +impl TokenHandle { + fn open(process: HANDLE) -> Result { + let mut token = null_mut(); + let ok = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }; + if ok == FALSE || token.is_null() { + return Err(PlatformContextError::Unavailable); + } + + Ok(Self(token)) + } + + fn raw(&self) -> HANDLE { + self.0 + } +} + +impl Drop for TokenHandle { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + CloseHandle(self.0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_executable_name_from_process_path() { + assert_eq!( + executable_name(r"C:\Windows\System32\notepad.exe").unwrap(), + "notepad.exe" + ); + } + + #[test] + fn rejects_empty_executable_name() { + assert_eq!( + executable_name(""), + Err(PlatformContextError::InvalidOutput) + ); + } + + #[test] + fn converts_wide_string_to_utf8_lossy_string() { + let wide: Vec = "notepad.exe".encode_utf16().collect(); + + assert_eq!(wide_to_string(&wide).unwrap(), "notepad.exe"); } } diff --git a/src/runtime.rs b/src/runtime.rs index 7eae46e..5a8426f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -130,7 +130,7 @@ where extractor, transformer, replacer, - "[Stringcast working...]", + "[...]", ) } else { TransformationPipeline::new(plan.registry, extractor, transformer, replacer) @@ -154,6 +154,17 @@ where self.controller.handle_event(event, now) } + pub fn handle_pending_timeout( + &mut self, + now: Instant, + ) -> Result { + self.controller.handle_pending_timeout(now) + } + + pub fn pending_dynamic_deadline(&self) -> Option { + self.controller.pending_dynamic_deadline() + } + pub fn buffer(&self) -> &str { self.controller.buffer() } From da18f58d736055fc69dff02d22db243f017d67d9 Mon Sep 17 00:00:00 2001 From: ArushKhasru Date: Fri, 19 Jun 2026 16:42:42 +0530 Subject: [PATCH 2/3] fix: handle errors in input selection and extend dynamic debounce on backspace --- src/extraction/mod.rs | 6 ++++-- src/input/controller.rs | 40 +++++++++++++++++++++++++++++++++++++++- src/main.rs | 16 +++++++++++++--- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/extraction/mod.rs b/src/extraction/mod.rs index 941ba48..26b06e9 100644 --- a/src/extraction/mod.rs +++ b/src/extraction/mod.rs @@ -95,8 +95,10 @@ where ) -> Result { let original_clipboard = self.clipboard.snapshot()?; - thread::sleep(self.select_all_wait); - self.input.select_all().map_err(ExtractionError::from)?; + if let Err(error) = self.input.select_all() { + self.cleanup_failed_extraction(&original_clipboard); + return Err(error.into()); + } thread::sleep(self.select_all_wait); if let Err(error) = self.input.copy() { self.cleanup_failed_extraction(&original_clipboard); diff --git a/src/input/controller.rs b/src/input/controller.rs index 2d5db73..3936f6d 100644 --- a/src/input/controller.rs +++ b/src/input/controller.rs @@ -101,8 +101,12 @@ where } } InputEvent::Backspace => { - self.pending_dynamic_deadline = None; + let rearm_pending_dynamic = self.pending_dynamic_deadline.is_some(); self.buffer.backspace(); + if rearm_pending_dynamic { + self.pending_dynamic_deadline = + Some(now + Duration::from_millis(DYNAMIC_DEBOUNCE_MS)); + } Ok(InputControllerOutcome::BufferUpdated( self.buffer.as_str().to_string(), )) @@ -285,6 +289,40 @@ mod tests { assert_eq!(controller.buffer(), ""); } + #[test] + fn backspace_extends_pending_dynamic_debounce() { + let now = Instant::now(); + let mut controller = controller(); + + controller + .handle_event( + InputEvent::Text("hello ?ask:make this warmer".to_string()), + now, + ) + .unwrap(); + let original_deadline = controller.pending_dynamic_deadline().unwrap(); + + controller + .handle_event(InputEvent::Backspace, now + Duration::from_millis(100)) + .unwrap(); + let updated_deadline = controller.pending_dynamic_deadline().unwrap(); + + assert!(updated_deadline > original_deadline); + + let early = controller + .handle_pending_timeout(original_deadline + Duration::from_millis(1)) + .unwrap(); + assert!(matches!(early, InputControllerOutcome::BufferUpdated(_))); + + let final_outcome = controller + .handle_pending_timeout(updated_deadline + Duration::from_millis(1)) + .unwrap(); + assert!(matches!( + final_outcome, + InputControllerOutcome::Pipeline(PipelineOutcome::Replaced { .. }) + )); + } + #[test] fn navigation_clears_buffer() { let now = Instant::now(); diff --git a/src/main.rs b/src/main.rs index de40b2f..76627fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,8 @@ use stringcast::platform::{PermissionChecker, SystemPermissionChecker}; use stringcast::runtime::StringcastRuntime; use stringcast::storage::{config_file_path, ApiKeyConfig, AppConfig, KeyringKeyMaterialStore}; +const INPUT_EVENT_QUEUE_CAPACITY: usize = 1024; + fn main() { if let Err(error) = run() { eprintln!("Stringcast failed to start: {error}"); @@ -63,7 +65,7 @@ fn run() -> Result<(), String> { let mut hook = input_hook(); let log_events = log_events(); - let (event_sender, event_receiver) = mpsc::channel(); + let (event_sender, event_receiver) = mpsc::sync_channel(INPUT_EVENT_QUEUE_CAPACITY); let worker_log_events = log_events; thread::spawn(move || loop { let received = match runtime.pending_dynamic_deadline() { @@ -97,8 +99,16 @@ fn run() -> Result<(), String> { eprintln!("Stringcast input event: {}", describe_input_event(&event)); } - if event_sender.send((event, received_at)).is_err() { - eprintln!("Stringcast event error: input worker stopped"); + match event_sender.try_send((event, received_at)) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(_)) => { + if log_events { + eprintln!("Stringcast event dropped: input worker queue full"); + } + } + Err(mpsc::TrySendError::Disconnected(_)) => { + eprintln!("Stringcast event error: input worker stopped"); + } } }) .map_err(|error| format!("input hook error: {error:?}")) From 47804962e14e43d8af55b98d7b54d5ae6dded98e Mon Sep 17 00:00:00 2001 From: ArushKhasru Date: Fri, 19 Jun 2026 18:44:42 +0530 Subject: [PATCH 3/3] fix: improve clipboard restoration error handling and add tests for failure scenarios --- src/extraction/mod.rs | 55 +++++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 +- src/platform/windows.rs | 12 +++++++-- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/extraction/mod.rs b/src/extraction/mod.rs index 26b06e9..f7d151f 100644 --- a/src/extraction/mod.rs +++ b/src/extraction/mod.rs @@ -129,7 +129,10 @@ where } }; - self.clipboard.restore(&original_clipboard)?; + if let Err(error) = self.clipboard.restore(&original_clipboard) { + self.cleanup_failed_extraction(&original_clipboard); + return Err(error.into()); + } Ok(OperationSnapshot { operation_id: context.operation_id, @@ -190,10 +193,36 @@ impl From for ExtractionError { #[cfg(test)] mod tests { use super::*; - use crate::clipboard::MemoryClipboard; + use crate::clipboard::{ClipboardBackend, ClipboardSnapshot, MemoryClipboard}; use crate::commands::{BuiltInCommand, CommandDefinition, CommandKind}; use crate::input::{RecordedInputAction, RecordingInputSimulator}; + #[derive(Debug, Clone)] + struct RestoreFailingClipboard { + text: Option, + } + + impl ClipboardBackend for RestoreFailingClipboard { + fn snapshot(&mut self) -> Result { + Ok(ClipboardSnapshot { + text: self.text.clone(), + }) + } + + fn get_text(&mut self) -> Result, ClipboardError> { + Ok(self.text.clone()) + } + + fn set_text(&mut self, text: &str) -> Result<(), ClipboardError> { + self.text = Some(text.to_string()); + Ok(()) + } + + fn restore(&mut self, _snapshot: &ClipboardSnapshot) -> Result<(), ClipboardError> { + Err(ClipboardError::Unavailable) + } + } + fn context() -> ExtractionContext { ExtractionContext { operation_id: 7, @@ -252,4 +281,26 @@ mod tests { ] ); } + + #[test] + fn clipboard_extractor_collapses_selection_when_restore_fails() { + let clipboard = RestoreFailingClipboard { + text: Some("actual field text ?fix".to_string()), + }; + let input = RecordingInputSimulator::default(); + let mut extractor = ClipboardTextExtractor::new(clipboard, input); + + let result = extractor.extract(context()); + let (_, input) = extractor.into_parts(); + + assert_eq!(result, Err(ExtractionError::ClipboardUnavailable)); + assert_eq!( + input.actions, + vec![ + RecordedInputAction::SelectAll, + RecordedInputAction::Copy, + RecordedInputAction::CollapseSelection + ] + ); + } } diff --git a/src/main.rs b/src/main.rs index 76627fa..5b46dc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,7 +67,7 @@ fn run() -> Result<(), String> { let log_events = log_events(); let (event_sender, event_receiver) = mpsc::sync_channel(INPUT_EVENT_QUEUE_CAPACITY); let worker_log_events = log_events; - thread::spawn(move || loop { + let _input_worker = thread::spawn(move || loop { let received = match runtime.pending_dynamic_deadline() { Some(deadline) => { let timeout = deadline.saturating_duration_since(Instant::now()); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index cd29607..b91782a 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -33,7 +33,7 @@ impl ForegroundAppProvider for WindowsForegroundAppProvider { fn foreground_app() -> Result { let hwnd = unsafe { GetForegroundWindow() }; - if hwnd.is_null() { + if hwnd_is_null(hwnd) { return Err(PlatformContextError::Unavailable); } @@ -45,13 +45,21 @@ fn foreground_app() -> Result { Ok(ForegroundApp { app_id, - window_id: Some(format!("{:p}", hwnd)), + window_id: Some(format_hwnd(hwnd)), display_name: Some(process_image_path), secure_input: false, elevated, }) } +fn hwnd_is_null(hwnd: HWND) -> bool { + hwnd as usize == 0 +} + +fn format_hwnd(hwnd: HWND) -> String { + format!("0x{:x}", hwnd as usize) +} + fn foreground_process_id(hwnd: HWND) -> Result { let mut process_id = 0; let thread_id = unsafe { GetWindowThreadProcessId(hwnd, &mut process_id) };