Replace all instances of the legacy product names "Kon" and "Corbie" with "Magnotia" across user-facing copy, code identifiers, package names, bundle ids, file paths, and documentation. Preserves the unrelated "konsole" (KDE terminal) reference and the parent CORBEL company name. - Renames 10 Rust crates (kon-* → magnotia-*) and the tauri binary - Updates package.json, tauri.conf.json (productName + identifier) - Renames CSS classes (kon-rh-* → magnotia-rh-*) and animations - Renames brand and roadmap docs - Regenerates Cargo.lock and package-lock.json Verified: svelte-check passes; pure-rust crates compile under new names.
791 lines
25 KiB
Rust
791 lines
25 KiB
Rust
//! Auto-insert-at-cursor.
|
|
//!
|
|
//! `copy_to_clipboard` puts the transcript on the user's clipboard. That is
|
|
//! fine when the user wants to choose where it lands. It is friction when
|
|
//! they were about to paste into the already-focused window — which is
|
|
//! almost always, for a dictation tool.
|
|
//!
|
|
//! This module adds the follow-on step: send the platform's Ctrl+V / Cmd+V
|
|
//! keystroke to the focused window so the transcript lands where the cursor
|
|
//! already is. Each platform uses a different primitive (wtype / xdotool /
|
|
//! ydotool / osascript / SendKeys), so we probe and fall back.
|
|
//!
|
|
//! NOTE: focus must already be on the target window when the keystroke
|
|
//! fires. The global hotkey flow preserves this naturally; clicking Magnotia's
|
|
//! window does not. The frontend surfaces this caveat next to the toggle.
|
|
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
|
|
use arboard::Clipboard;
|
|
use serde::Serialize;
|
|
use tauri::Manager;
|
|
|
|
/// Window after the paste keystroke at which we restore the user's
|
|
/// prior clipboard content. 300 ms is enough for even a slow Wayland
|
|
/// compositor to have fully delivered the synthesised Ctrl+V to the
|
|
/// focused app; longer risks the user manually pasting again and
|
|
/// stomping themselves. See Handy #921.
|
|
const CLIPBOARD_RESTORE_MS: u64 = 300;
|
|
|
|
/// Compositor settle time after hiding the preview overlay before firing
|
|
/// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter
|
|
/// Wayland for focus to return to the previously-focused app; shorter
|
|
/// risks the keystroke still landing on the (now-invisible) overlay.
|
|
const PREVIEW_HIDE_SETTLE_MS: u64 = 80;
|
|
|
|
/// Gap between the undo keystroke and the follow-up paste in the
|
|
/// replace flow (brief item #17). Most GUI apps process undo
|
|
/// instantly, but a short beat keeps the paste from racing the undo
|
|
/// on slower-to-redraw targets (Electron apps, LibreOffice).
|
|
const UNDO_PASTE_GAP_MS: u64 = 60;
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PasteOutcome {
|
|
/// The backend that executed the paste, if any (`"wtype"`, `"xdotool"`,
|
|
/// `"ydotool"`, `"osascript"`, `"sendkeys"`).
|
|
pub backend: Option<String>,
|
|
pub pasted: bool,
|
|
pub copied: bool,
|
|
/// Diagnostic message when either step failed. Present even on success
|
|
/// if the paste fell back to copy-only so the frontend can toast it.
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
/// Copy `text` to the clipboard, then trigger a paste keystroke in the
|
|
/// focused window. Returns a structured result so the frontend can surface
|
|
/// partial success (clipboard set, paste failed).
|
|
///
|
|
/// Wayland compositor quirk: if Magnotia's always-on-top preview overlay is
|
|
/// visible when the keystroke fires, KWin / Mutter may resolve the keystroke
|
|
/// against the overlay (even with `focused: false` set at build time) and
|
|
/// the paste lands inside Magnotia instead of the previously-focused app. We hide
|
|
/// the preview window and give the compositor a beat to re-focus the real
|
|
/// target before dispatching. Matches OpenWhispr's PR #246 fix on GNOME.
|
|
#[tauri::command]
|
|
pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutcome, String> {
|
|
let mut outcome = PasteOutcome {
|
|
backend: None,
|
|
pasted: false,
|
|
copied: false,
|
|
message: None,
|
|
};
|
|
|
|
let prior_clipboard = snapshot_clipboard_text();
|
|
|
|
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
|
|
Ok(()) => outcome.copied = true,
|
|
Err(err) => {
|
|
outcome.message = Some(format!("clipboard: {err}"));
|
|
return Ok(outcome);
|
|
}
|
|
}
|
|
|
|
// Brief item #10: if the focused window is a known terminal
|
|
// emulator, skip the synthesised Ctrl+V — it duplicates the
|
|
// keystroke through the PTY, which Kitty / Alacritty / Windows
|
|
// Terminal all demonstrably mishandle (Handy #692). Clipboard-only
|
|
// paste with a "Terminal detected" message lets the user finish
|
|
// with a manual right-click paste or Ctrl+Shift+V.
|
|
if let Some(term) = detect_focused_terminal() {
|
|
outcome.message = Some(format!(
|
|
"Terminal detected ({term}) — transcript is on your clipboard. \
|
|
Use Ctrl+Shift+V (or right-click → paste) to insert."
|
|
));
|
|
// Prior clipboard snapshot is intentionally NOT restored here:
|
|
// the user's path to recover the transcript is their own paste,
|
|
// which needs the transcript on the clipboard. The restore
|
|
// task below only runs when `outcome.pasted` is true.
|
|
return Ok(outcome);
|
|
}
|
|
|
|
hide_preview_overlay_for_paste(&app).await;
|
|
|
|
match trigger_paste_keystroke() {
|
|
Ok(backend) => {
|
|
outcome.backend = Some(backend);
|
|
outcome.pasted = true;
|
|
}
|
|
Err(err) => outcome.message = Some(err),
|
|
}
|
|
|
|
if outcome.pasted {
|
|
schedule_clipboard_restore(prior_clipboard, text);
|
|
}
|
|
|
|
Ok(outcome)
|
|
}
|
|
|
|
/// Snapshot the clipboard's current text, or `None` when the clipboard
|
|
/// holds non-text content (images, files, empty). Shared between
|
|
/// `paste_text` and `paste_text_replacing` so both halves of the
|
|
/// dictation flow obey the "never silently clobber the user's
|
|
/// clipboard" contract from Handy #921 (brief item #3).
|
|
fn snapshot_clipboard_text() -> Option<String> {
|
|
match Clipboard::new() {
|
|
Ok(mut cb) => cb.get_text().ok(),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Fire-and-forget: restore `prior` to the clipboard after
|
|
/// `CLIPBOARD_RESTORE_MS`, but only if the clipboard still holds the
|
|
/// `transcript` we set (i.e., the user hasn't copied something new in
|
|
/// the window). No-op when `prior` is `None` — nothing safe to
|
|
/// restore.
|
|
fn schedule_clipboard_restore(prior: Option<String>, transcript: String) {
|
|
let Some(prior) = prior else {
|
|
return;
|
|
};
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await;
|
|
restore_prior_clipboard(&prior, &transcript);
|
|
});
|
|
}
|
|
|
|
/// Write `prior` back to the clipboard only if the current clipboard
|
|
/// content is still the transcript we just set — i.e. the user hasn't
|
|
/// already copied something new themselves in the last 300 ms. This
|
|
/// avoids stomping a deliberate Cmd+C that happened right after the
|
|
/// paste.
|
|
fn restore_prior_clipboard(prior: &str, transcript: &str) {
|
|
let mut cb = match Clipboard::new() {
|
|
Ok(cb) => cb,
|
|
Err(_) => return,
|
|
};
|
|
|
|
if should_restore(cb.get_text().ok().as_deref(), transcript) {
|
|
let _ = cb.set_text(prior.to_string());
|
|
}
|
|
}
|
|
|
|
/// Pure decision function: should we restore the prior clipboard?
|
|
///
|
|
/// - Some(current) == transcript → yes, the clipboard still holds
|
|
/// what we wrote, so restore is safe.
|
|
/// - Some(current) != transcript → no, user has already copied
|
|
/// something else in the 300 ms window; respect it.
|
|
/// - None → couldn't read the clipboard (image, no access, etc.);
|
|
/// conservative no-op.
|
|
fn should_restore(current: Option<&str>, transcript: &str) -> bool {
|
|
matches!(current, Some(text) if text == transcript)
|
|
}
|
|
|
|
/// Replace-flow paste (brief item #17): send the platform's undo
|
|
/// keystroke to the previously-focused app, wait a beat so the undo
|
|
/// takes effect, then paste `text`. The caller (preview overlay's
|
|
/// "Replace with raw" button) is responsible for only invoking this
|
|
/// when a paste actually happened moments earlier — we don't probe to
|
|
/// verify there's something to undo.
|
|
///
|
|
/// Returns the same PasteOutcome shape as `paste_text` so the frontend
|
|
/// can surface identical partial-success messaging.
|
|
#[tauri::command]
|
|
pub async fn paste_text_replacing(
|
|
app: tauri::AppHandle,
|
|
text: String,
|
|
) -> Result<PasteOutcome, String> {
|
|
let mut outcome = PasteOutcome {
|
|
backend: None,
|
|
pasted: false,
|
|
copied: false,
|
|
message: None,
|
|
};
|
|
|
|
// Same clipboard-preservation contract as `paste_text`: snapshot
|
|
// the user's prior clipboard before we write the raw transcript,
|
|
// restore it in the background after the paste keystroke fires.
|
|
// Without this, a successful revert leaves the raw transcript on
|
|
// the clipboard permanently — stomping whatever the user had
|
|
// copied before invoking replace-with-raw.
|
|
let prior_clipboard = snapshot_clipboard_text();
|
|
|
|
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
|
|
Ok(()) => outcome.copied = true,
|
|
Err(err) => {
|
|
outcome.message = Some(format!("clipboard: {err}"));
|
|
return Ok(outcome);
|
|
}
|
|
}
|
|
|
|
hide_preview_overlay_for_paste(&app).await;
|
|
|
|
if let Err(err) = trigger_undo_keystroke() {
|
|
outcome.message = Some(format!("undo: {err}"));
|
|
return Ok(outcome);
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(UNDO_PASTE_GAP_MS)).await;
|
|
|
|
match trigger_paste_keystroke() {
|
|
Ok(backend) => {
|
|
outcome.backend = Some(backend);
|
|
outcome.pasted = true;
|
|
}
|
|
Err(err) => outcome.message = Some(err),
|
|
}
|
|
|
|
if outcome.pasted {
|
|
schedule_clipboard_restore(prior_clipboard, text);
|
|
}
|
|
|
|
Ok(outcome)
|
|
}
|
|
|
|
/// Hide the transcription-preview window if it's currently visible, then
|
|
/// sleep a short beat so the compositor can recompute focus. No-ops when
|
|
/// the window isn't registered yet (user never enabled the overlay) or
|
|
/// isn't currently shown.
|
|
async fn hide_preview_overlay_for_paste(app: &tauri::AppHandle) {
|
|
let Some(window) = app.get_webview_window("transcription-preview") else {
|
|
return;
|
|
};
|
|
let visible = window.is_visible().unwrap_or(false);
|
|
if !visible {
|
|
return;
|
|
}
|
|
if window.hide().is_err() {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(PREVIEW_HIDE_SETTLE_MS)).await;
|
|
}
|
|
|
|
/// Report which paste backends the OS has available right now. Pure probe —
|
|
/// does not paste. Used by Settings to tell the user "install wtype" when
|
|
/// nothing is available on their session.
|
|
#[tauri::command]
|
|
pub fn detect_paste_backends() -> Vec<String> {
|
|
let mut available = Vec::new();
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
for tool in ["wtype", "xdotool", "ydotool"] {
|
|
if which_on_path(tool) {
|
|
available.push(tool.to_string());
|
|
}
|
|
}
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
available.push("osascript".to_string());
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
available.push("sendkeys".to_string());
|
|
}
|
|
|
|
available
|
|
}
|
|
|
|
/// Well-known terminal-emulator window classes / process names. Each
|
|
/// entry is matched case-insensitively against whatever the
|
|
/// focused-window probe returns on each platform. The list is
|
|
/// deliberately conservative — users can always copy + paste manually,
|
|
/// so false negatives are cheap; false positives silently break
|
|
/// auto-paste on a legitimate GUI, which is expensive.
|
|
/// Longest / most specific matches come first so that `classify_terminal`
|
|
/// returns the specific name when two needles would both hit
|
|
/// (e.g. "windowsterminal" contains "terminal"). Substring match is
|
|
/// otherwise blunt enough that a `foot`/`pwsh` match on a non-terminal
|
|
/// window would be vanishingly unlikely.
|
|
const KNOWN_TERMINAL_CLASSES: &[&str] = &[
|
|
// Windows — specific names first so they win over plain "terminal".
|
|
"windowsterminal",
|
|
"powershell",
|
|
"conhost",
|
|
"console",
|
|
"pwsh",
|
|
"cmd",
|
|
// macOS
|
|
"iterm2",
|
|
"iterm",
|
|
"terminal",
|
|
// Linux
|
|
"gnome-terminal-server",
|
|
"alacritty",
|
|
"konsole",
|
|
"wezterm",
|
|
"kitty",
|
|
"tilix",
|
|
"xterm",
|
|
"urxvt",
|
|
"st-256color",
|
|
"hyper",
|
|
"foot",
|
|
];
|
|
|
|
/// Return `Some(class)` when the focused window matches a known
|
|
/// terminal emulator, `None` otherwise. Pure by design: the actual
|
|
/// platform probe is isolated in `detect_focused_window_class` so the
|
|
/// classification rule is unit-testable.
|
|
fn detect_focused_terminal() -> Option<String> {
|
|
let raw = detect_focused_window_class()?;
|
|
classify_terminal(&raw)
|
|
}
|
|
|
|
/// Case-insensitive substring match of the focused window class /
|
|
/// process name against `KNOWN_TERMINAL_CLASSES`. Returns the matched
|
|
/// needle (for user-facing copy: "Terminal detected (kitty)") when
|
|
/// positive.
|
|
fn classify_terminal(raw: &str) -> Option<String> {
|
|
let haystack = raw.to_ascii_lowercase();
|
|
for needle in KNOWN_TERMINAL_CLASSES {
|
|
if haystack.contains(needle) {
|
|
return Some((*needle).to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Probe the focused window's class name / process name. Returns
|
|
/// `None` when the probe tool is missing or fails — we fall back to
|
|
/// the default auto-paste path rather than guessing wrong and
|
|
/// breaking a GUI user's workflow.
|
|
fn detect_focused_window_class() -> Option<String> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
detect_focused_window_class_linux()
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
return detect_focused_window_class_macos();
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
return detect_focused_window_class_windows();
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
|
{
|
|
return None;
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn detect_focused_window_class_linux() -> Option<String> {
|
|
// X11: xdotool getactivewindow getwindowclassname
|
|
if let Ok(output) = Command::new("xdotool")
|
|
.args(["getactivewindow", "getwindowclassname"])
|
|
.output()
|
|
{
|
|
if output.status.success() {
|
|
let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !class.is_empty() {
|
|
return Some(class);
|
|
}
|
|
}
|
|
}
|
|
// Wayland: no reliable focused-window probe from an unprivileged
|
|
// client (by design). Return None and let the normal paste path
|
|
// run; users on Kitty-under-Wayland already lean on Ctrl+Shift+V
|
|
// themselves.
|
|
None
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn detect_focused_window_class_macos() -> Option<String> {
|
|
// `osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`
|
|
let output = Command::new("osascript")
|
|
.args([
|
|
"-e",
|
|
"tell application \"System Events\" to get name of first application process whose frontmost is true",
|
|
])
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(name)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn detect_focused_window_class_windows() -> Option<String> {
|
|
// PowerShell one-liner: (Get-Process -Id (Get-Process | Where-Object ...
|
|
// Keeping it simple here — GetForegroundWindow + GetWindowThreadProcessId
|
|
// via PowerShell works without a `windows` crate dep.
|
|
let script = r#"
|
|
$w = Add-Type -MemberDefinition '
|
|
[DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow();
|
|
[DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(System.IntPtr hWnd, out int pid);
|
|
' -Name W -PassThru
|
|
$pid = 0
|
|
[void]$w::GetWindowThreadProcessId($w::GetForegroundWindow(), [ref]$pid)
|
|
(Get-Process -Id $pid).ProcessName
|
|
"#;
|
|
let output = Command::new("powershell")
|
|
.args(["-NoProfile", "-Command", script])
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(name)
|
|
}
|
|
}
|
|
|
|
fn trigger_paste_keystroke() -> Result<String, String> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
linux_paste(
|
|
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
|
|
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
|
)
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
macos_paste()
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
windows_paste()
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
|
{
|
|
Err("auto-paste not implemented on this platform".into())
|
|
}
|
|
}
|
|
|
|
/// Send Ctrl+Z / Cmd+Z to the focused window. Mirrors the shape of
|
|
/// `trigger_paste_keystroke`: reuse the same per-OS backend ordering,
|
|
/// just swap the keycode. Used by `paste_text_replacing` for the
|
|
/// raw-revert flow (brief item #17).
|
|
fn trigger_undo_keystroke() -> Result<String, String> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
linux_undo(
|
|
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
|
|
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
|
)
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
macos_undo()
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
windows_undo()
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
|
{
|
|
Err("undo-keystroke not implemented on this platform".into())
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_paste(
|
|
xdg_session_type: Option<&str>,
|
|
wayland_display_set: bool,
|
|
) -> Result<String, String> {
|
|
for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
|
|
match run_linux_tool(tool) {
|
|
Ok(()) => return Ok(tool.to_string()),
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
Err(
|
|
"No paste backend available. Install wtype (Wayland) or xdotool (X11) to enable \
|
|
auto-insert-at-cursor."
|
|
.into(),
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn pick_linux_backend_order(
|
|
xdg_session_type: Option<&str>,
|
|
wayland_display_set: bool,
|
|
) -> &'static [&'static str] {
|
|
let is_wayland = xdg_session_type
|
|
.map(|value| value.eq_ignore_ascii_case("wayland"))
|
|
.unwrap_or(false)
|
|
|| wayland_display_set;
|
|
if is_wayland {
|
|
&["wtype", "ydotool", "xdotool"]
|
|
} else {
|
|
&["xdotool", "ydotool", "wtype"]
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn run_linux_tool(tool: &str) -> Result<(), String> {
|
|
let output = match tool {
|
|
// wtype -M ctrl v -m ctrl (press ctrl, tap v, release ctrl)
|
|
"wtype" => Command::new("wtype")
|
|
.args(["-M", "ctrl", "v", "-m", "ctrl"])
|
|
.output(),
|
|
"xdotool" => Command::new("xdotool").args(["key", "ctrl+v"]).output(),
|
|
// ydotool linux input keycodes: 29=LEFTCTRL, 47=V. Format is
|
|
// `code:state` pairs. Requires ydotoold running with access to
|
|
// /dev/uinput.
|
|
"ydotool" => Command::new("ydotool")
|
|
.args(["key", "29:1", "47:1", "47:0", "29:0"])
|
|
.output(),
|
|
other => return Err(format!("unknown backend: {other}")),
|
|
}
|
|
.map_err(|e| format!("{tool} unavailable: {e}"))?;
|
|
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"{tool} exit {}: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn linux_undo(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result<String, String> {
|
|
for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
|
|
match run_linux_undo_tool(tool) {
|
|
Ok(()) => return Ok(tool.to_string()),
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
Err("No undo backend available. Install wtype (Wayland) or xdotool (X11).".into())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn run_linux_undo_tool(tool: &str) -> Result<(), String> {
|
|
let output = match tool {
|
|
// wtype -M ctrl z -m ctrl (press ctrl, tap z, release ctrl)
|
|
"wtype" => Command::new("wtype")
|
|
.args(["-M", "ctrl", "z", "-m", "ctrl"])
|
|
.output(),
|
|
"xdotool" => Command::new("xdotool").args(["key", "ctrl+z"]).output(),
|
|
// 29 = LEFTCTRL, 44 = Z in linux input keycodes.
|
|
"ydotool" => Command::new("ydotool")
|
|
.args(["key", "29:1", "44:1", "44:0", "29:0"])
|
|
.output(),
|
|
other => return Err(format!("unknown backend: {other}")),
|
|
}
|
|
.map_err(|e| format!("{tool} unavailable: {e}"))?;
|
|
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"{tool} exit {}: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn which_on_path(tool: &str) -> bool {
|
|
Command::new("sh")
|
|
.args(["-c", &format!("command -v {tool}")])
|
|
.output()
|
|
.map(|output| output.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn macos_paste() -> Result<String, String> {
|
|
let output = Command::new("osascript")
|
|
.args([
|
|
"-e",
|
|
"tell application \"System Events\" to keystroke \"v\" using command down",
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("osascript: {e}"))?;
|
|
if output.status.success() {
|
|
Ok("osascript".into())
|
|
} else {
|
|
Err(format!(
|
|
"osascript exit {}: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn macos_undo() -> Result<String, String> {
|
|
let output = Command::new("osascript")
|
|
.args([
|
|
"-e",
|
|
"tell application \"System Events\" to keystroke \"z\" using command down",
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("osascript: {e}"))?;
|
|
if output.status.success() {
|
|
Ok("osascript".into())
|
|
} else {
|
|
Err(format!(
|
|
"osascript exit {}: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn windows_paste() -> Result<String, String> {
|
|
// SendKeys("^v") simulates Ctrl+V in the foreground window. Requires
|
|
// no extra permissions on Windows. A native SendInput call would skip
|
|
// the PowerShell spawn but pulls in the windows crate; cost not worth
|
|
// the complexity until this hot path actually shows up.
|
|
let output = Command::new("powershell")
|
|
.args([
|
|
"-NoProfile",
|
|
"-Command",
|
|
"(New-Object -ComObject WScript.Shell).SendKeys('^v')",
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("powershell: {e}"))?;
|
|
if output.status.success() {
|
|
Ok("sendkeys".into())
|
|
} else {
|
|
Err(format!(
|
|
"powershell exit {}: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn windows_undo() -> Result<String, String> {
|
|
let output = Command::new("powershell")
|
|
.args([
|
|
"-NoProfile",
|
|
"-Command",
|
|
"(New-Object -ComObject WScript.Shell).SendKeys('^z')",
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("powershell: {e}"))?;
|
|
if output.status.success() {
|
|
Ok("sendkeys".into())
|
|
} else {
|
|
Err(format!(
|
|
"powershell exit {}: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, target_os = "linux"))]
|
|
mod tests_linux {
|
|
use super::pick_linux_backend_order;
|
|
|
|
#[test]
|
|
fn wayland_session_prefers_wtype_then_ydotool() {
|
|
assert_eq!(
|
|
pick_linux_backend_order(Some("wayland"), true),
|
|
&["wtype", "ydotool", "xdotool"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn x11_session_prefers_xdotool() {
|
|
assert_eq!(
|
|
pick_linux_backend_order(Some("x11"), false),
|
|
&["xdotool", "ydotool", "wtype"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wayland_display_env_var_alone_is_enough() {
|
|
assert_eq!(
|
|
pick_linux_backend_order(None, true),
|
|
&["wtype", "ydotool", "xdotool"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uppercase_wayland_token_still_detects_wayland() {
|
|
assert_eq!(
|
|
pick_linux_backend_order(Some("WAYLAND"), false),
|
|
&["wtype", "ydotool", "xdotool"]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests_clipboard_restore {
|
|
use super::should_restore;
|
|
|
|
#[test]
|
|
fn restores_when_clipboard_still_holds_transcript() {
|
|
assert!(should_restore(Some("dictated text"), "dictated text"));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_restore_when_user_copied_something_else() {
|
|
assert!(!should_restore(
|
|
Some("user copied this just now"),
|
|
"dictated text"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_restore_when_clipboard_unreadable() {
|
|
assert!(!should_restore(None, "dictated text"));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_restore_on_empty_mismatch() {
|
|
assert!(!should_restore(Some(""), "dictated text"));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests_terminal_classification {
|
|
use super::classify_terminal;
|
|
|
|
#[test]
|
|
fn kitty_class_is_a_terminal() {
|
|
assert_eq!(classify_terminal("kitty"), Some("kitty".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn alacritty_matches_case_insensitively() {
|
|
assert_eq!(classify_terminal("Alacritty"), Some("alacritty".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_terminal_matches_collapsed_name() {
|
|
// Windows reports the process name sans space: "WindowsTerminal"
|
|
assert_eq!(
|
|
classify_terminal("WindowsTerminal"),
|
|
Some("windowsterminal".into()),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn iterm_matches_iterm2_as_well() {
|
|
assert_eq!(classify_terminal("iTerm2"), Some("iterm2".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn firefox_is_not_a_terminal() {
|
|
assert_eq!(classify_terminal("firefox"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_class_is_not_a_terminal() {
|
|
assert_eq!(classify_terminal(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn substring_match_survives_xdotool_decorations() {
|
|
// Some X11 window managers report "Alacritty.Alacritty"
|
|
assert_eq!(
|
|
classify_terminal("Alacritty.Alacritty"),
|
|
Some("alacritty".into()),
|
|
);
|
|
}
|
|
}
|