From b92df7526ed0232cdd240de740b3670e38233465 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 26 Sep 2026 18:29:18 +0400 Subject: [PATCH 1/7] feat(gui): copy the terminal selection with Ctrl+Shift+C --- crates/omnyssh-gui/ui/e2e/terminal.spec.ts | 72 +++++++++++++++++++ crates/omnyssh-gui/ui/src/lib/platform.ts | 4 ++ .../ui/src/lib/screens/TerminalView.svelte | 16 ++++- .../ui/src/lib/screens/terminalInput.test.ts | 53 +++++++++++++- .../ui/src/lib/screens/terminalInput.ts | 30 +++++++- 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 crates/omnyssh-gui/ui/src/lib/platform.ts diff --git a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts index bd77c85..2fc958f 100644 --- a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts @@ -59,6 +59,8 @@ async function boot(page: Page): Promise { } case 'terminal_write': { const { sessionId, data } = args as { sessionId: number; data: number[] }; + // Every byte the shell would get, for tests that assert what a key sent. + ((win.__writes ??= []) as number[][]).push(data); const chId = sessionChannel[sessionId]; // Echo a canned result once Enter (\r == 13) arrives, so output is assertable. if (chId != null && data.includes(13)) { @@ -171,3 +173,73 @@ test('a remote exit (terminal-exited) tears the tab down', async ({ page }) => { await expect(page.getByRole('button', { name: 'web-1 · terminal', exact: true })).toHaveCount(0); await expect(page.locator('.xterm')).toHaveCount(0); }); + +// Windows and Linux copy with Ctrl+Shift+C. The Desktop Chrome device reports a Windows +// user agent, so this is the path those platforms take; the clipboard is stubbed at the +// boundary like the IPC, which also keeps parallel runs apart. +async function bootWithClipboard(page: Page): Promise { + await page.addInitScript(() => { + const win = window as unknown as { __copied: string[] }; + win.__copied = []; + navigator.clipboard.writeText = (text: string) => { + win.__copied.push(text); + return Promise.resolve(); + }; + }); + await boot(page); + await page.getByTitle('sh on web-1').click(); + await expect(page.locator('.xterm-rows')).toContainText('omnyssh-ready'); +} + +const copied = (page: Page) => + page.evaluate(() => (window as unknown as { __copied: string[] }).__copied); +const writes = (page: Page) => + page.evaluate(() => (window as unknown as { __writes?: number[][] }).__writes ?? []); + +/** Double-clicks the first word of the first row, as a user selects it. */ +async function selectPrompt(page: Page): Promise { + const row = (await page.locator('.xterm-rows > div').first().boundingBox())!; + await page.mouse.dblclick(row.x + 20, row.y + row.height / 2); +} + +test('Ctrl+Shift+C copies the selection and sends the shell nothing', async ({ page }) => { + await bootWithClipboard(page); + await selectPrompt(page); + + await page.keyboard.press('Control+Shift+C'); + await expect.poll(() => copied(page)).toEqual(['omnyssh-ready>']); + expect(await writes(page)).toEqual([]); + + // Bare Ctrl+C stays the interrupt, selection or not. + await page.keyboard.press('Control+C'); + await expect.poll(() => writes(page)).toEqual([[3]]); + expect(await copied(page)).toEqual(['omnyssh-ready>']); + + // Ctrl+Shift+V is the webview's own paste; xterm must not turn it into ^V or a V. + await page.keyboard.press('Control+Shift+V'); + expect(await writes(page)).toEqual([[3]]); +}); + +test('Ctrl+Shift+C with nothing selected copies nothing', async ({ page }) => { + await bootWithClipboard(page); + await page.locator('.xterm-helper-textarea').focus(); + + await page.keyboard.press('Control+Shift+C'); + expect(await copied(page)).toEqual([]); + expect(await writes(page)).toEqual([]); +}); + +test.describe('on macOS', () => { + test.use({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)' + }); + + test('Ctrl+Shift+C is left alone — Cmd+C copies there', async ({ page }) => { + await bootWithClipboard(page); + await selectPrompt(page); + + await page.keyboard.press('Control+Shift+C'); + expect(await copied(page)).toEqual([]); + }); +}); diff --git a/crates/omnyssh-gui/ui/src/lib/platform.ts b/crates/omnyssh-gui/ui/src/lib/platform.ts new file mode 100644 index 0000000..bce2ee5 --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/platform.ts @@ -0,0 +1,4 @@ +// The platform the webview runs on, from its user agent: WKWebView says Macintosh, +// WebView2 Windows NT, WebKitGTK X11; Linux. app.html makes the same macOS test for the +// title-bar inset, and the e2e suite emulates a platform by its user agent alone. +export const isMac = /Mac/.test(navigator.userAgent); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte index 7ba4f48..6bd33ec 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte @@ -19,7 +19,8 @@ import { dialogs } from '$lib/stores/dialogs'; import { terminalOpen, terminalWrite, terminalResize, terminalClose } from '$lib/ipc/commands'; import { shouldFadeTop } from './terminalFade'; - import { chunkBytes } from './terminalInput'; + import { chunkBytes, isCopyShortcut } from './terminalInput'; + import { isMac } from '$lib/platform'; import type { TerminalBytes } from '$lib/bindings'; let { session, active }: { session: Session; active: boolean } = $props(); @@ -154,6 +155,19 @@ return; } + // Copy takes Ctrl+Shift+C whether or not anything is selected, so the chord never + // reaches the shell. Returning false only keeps xterm out of it; the default is + // ours to stop. The write happens inside the keydown, which WebKit requires. + term.attachCustomKeyEventHandler((e) => { + if (!isCopyShortcut(e, isMac)) return true; + e.preventDefault(); + if (term?.hasSelection()) { + navigator.clipboard.writeText(term.getSelection()).catch((err) => { + lastError.set(`Copy failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } + return false; + }); // Text keystrokes/paste are UTF-8; onBinary carries raw 8-bit sequences // (e.g. legacy mouse reporting) that must go byte-for-byte, not re-encoded. term.onData((data) => sendInput(ENCODER.encode(data))); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts index 884bd9b..f4354da 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts @@ -1,8 +1,59 @@ import { describe, expect, it } from 'vitest'; -import { chunkBytes, INPUT_CHUNK } from './terminalInput'; +import { chunkBytes, INPUT_CHUNK, isCopyShortcut, type KeyPress } from './terminalInput'; const seq = (n: number) => new Uint8Array(Array.from({ length: n }, (_, i) => i & 0xff)); +const press = (over: Partial): KeyPress => ({ + type: 'keydown', + key: 'C', + code: 'KeyC', + keyCode: 67, + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + isComposing: false, + ...over +}); + +describe('isCopyShortcut — Ctrl+Shift+C copies on Windows and Linux', () => { + it('copies on Ctrl+Shift+C, whichever case the key reports', () => { + expect(isCopyShortcut(press({}), false)).toBe(true); + expect(isCopyShortcut(press({ key: 'c' }), false)).toBe(true); + }); + + it('copies on the same physical key under a non-Latin layout', () => { + // What the C key types on a Russian layout, with the keyCode WebKitGTK gives it. + expect(isCopyShortcut(press({ key: '\u0421', keyCode: 0 }), false)).toBe(true); + }); + + it('follows the letter, not the key, on a Latin layout like Dvorak', () => { + expect(isCopyShortcut(press({ key: 'J', code: 'KeyC' }), false)).toBe(false); + expect(isCopyShortcut(press({ key: 'C', code: 'KeyI' }), false)).toBe(true); + }); + + it('leaves bare Ctrl+C to the shell as ^C', () => { + expect(isCopyShortcut(press({ shiftKey: false, key: 'c' }), false)).toBe(false); + }); + + it('leaves Ctrl+Shift+V, other keys and extra modifiers alone', () => { + expect(isCopyShortcut(press({ key: 'V', code: 'KeyV' }), false)).toBe(false); + expect(isCopyShortcut(press({ altKey: true }), false)).toBe(false); + expect(isCopyShortcut(press({ metaKey: true }), false)).toBe(false); + }); + + it('acts on keydown only, and never mid-composition', () => { + expect(isCopyShortcut(press({ type: 'keyup' }), false)).toBe(false); + expect(isCopyShortcut(press({ type: 'keypress' }), false)).toBe(false); + expect(isCopyShortcut(press({ isComposing: true }), false)).toBe(false); + expect(isCopyShortcut(press({ key: 'Process', keyCode: 229 }), false)).toBe(false); + }); + + it('does nothing on macOS, where Cmd+C already copies', () => { + expect(isCopyShortcut(press({}), true)).toBe(false); + }); +}); + describe('chunkBytes — bounded terminal input', () => { it('yields nothing for empty input', () => { expect(chunkBytes(new Uint8Array(0))).toEqual([]); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts index 5631107..f5c910c 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts @@ -1,12 +1,40 @@ // Terminal input is sent over `terminal_write` as a `number[]` (§4.2). A single huge // paste would serialize as one giant array on the main thread and freeze the UI, so the // view splits it into bounded chunks and awaits each (yielding between). This is the -// pure split; the view owns the ordered dispatch. +// pure split; the view owns the ordered dispatch. The copy shortcut is decided here too, +// before xterm turns the key into input. /** The per-write byte cap. Small enough that one chunk's `number[]` serialization is * imperceptible, so a multi-MB paste streams without a visible stall (§9). */ export const INPUT_CHUNK = 8192; +/** The fields of a key event the copy shortcut reads, so tests can pass plain objects. */ +export type KeyPress = Pick< + KeyboardEvent, + | 'type' + | 'key' + | 'code' + | 'keyCode' + | 'ctrlKey' + | 'shiftKey' + | 'altKey' + | 'metaKey' + | 'isComposing' +>; + +/** Ctrl+Shift+C on Windows and Linux, as in GNOME Terminal and Windows Terminal: a bare + * Ctrl+C has to stay ^C. macOS needs none — Cmd+C copies there through the Edit menu. + * Ctrl+Shift+V needs no twin — xterm makes no input of it, so the webview pastes + * natively. */ +export function isCopyShortcut(e: KeyPress, mac: boolean): boolean { + // keyCode 229 marks the keydown that starts an IME composition. + if (mac || e.type !== 'keydown' || e.isComposing || e.keyCode === 229) return false; + if (e.altKey || e.metaKey || !e.ctrlKey || !e.shiftKey) return false; + // The physical key stands in only where the layout puts no Latin letter on it, so + // a Cyrillic layout copies with the same keys while Dvorak's J there stays a J. + return e.key === 'c' || e.key === 'C' || (e.code === 'KeyC' && !/^[a-z]$/i.test(e.key)); +} + /** Split `data` into <=`size` slices, in order. Empty input yields nothing; input at or * below the cap yields a single slice (the ordinary keystroke path). */ export function chunkBytes(data: Uint8Array, size: number = INPUT_CHUNK): Uint8Array[] { From 991f3ef81ccd2a43fa56414825b00dcbfa9269dd Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 26 Sep 2026 18:29:18 +0400 Subject: [PATCH 2/7] feat: forward the SSH agent to hosts set to lend it --- crates/omnyssh-core/src/config/ssh_config.rs | 149 ++++++++- crates/omnyssh-core/src/ssh/client.rs | 5 + crates/omnyssh-core/src/ssh/pty.rs | 21 +- crates/omnyssh-core/src/ssh/session.rs | 146 ++++++-- crates/omnyssh-core/tests/agent_forward.rs | 315 ++++++++++++++++++ .../omnyssh-core/tests/ssh_config_parser.rs | 28 ++ crates/omnyssh-gui/src/commands/hosts.rs | 18 + crates/omnyssh-gui/src/dto.rs | 16 + crates/omnyssh-gui/ui/e2e/hosts.spec.ts | 40 ++- crates/omnyssh-gui/ui/e2e/keysetup.spec.ts | 2 +- crates/omnyssh-gui/ui/e2e/palette.spec.ts | 6 +- crates/omnyssh-gui/ui/e2e/passphrase.spec.ts | 4 +- crates/omnyssh-gui/ui/e2e/password.spec.ts | 2 +- crates/omnyssh-gui/ui/e2e/settings.spec.ts | 2 +- crates/omnyssh-gui/ui/e2e/sftp.spec.ts | 4 +- crates/omnyssh-gui/ui/e2e/snippets.spec.ts | 4 +- crates/omnyssh-gui/ui/e2e/terminal.spec.ts | 4 +- crates/omnyssh-gui/ui/e2e/tunnels.spec.ts | 6 +- crates/omnyssh-gui/ui/src/lib/bindings.ts | 4 +- .../omnyssh-gui/ui/src/lib/ipc/router.test.ts | 5 +- crates/omnyssh-gui/ui/src/lib/platform.ts | 1 + .../ui/src/lib/screens/HostEditor.svelte | 33 ++ .../ui/src/lib/screens/hostForm.test.ts | 14 + .../ui/src/lib/screens/hostForm.ts | 10 +- .../ui/src/lib/screens/serverCard.test.ts | 2 +- .../ui/src/lib/stores/hostSummary.test.ts | 2 +- .../ui/src/lib/stores/palette.test.ts | 1 + crates/omnyssh/src/app/host.rs | 63 +++- crates/omnyssh/src/ui/popup.rs | 12 +- 29 files changed, 844 insertions(+), 75 deletions(-) create mode 100644 crates/omnyssh-core/tests/agent_forward.rs diff --git a/crates/omnyssh-core/src/config/ssh_config.rs b/crates/omnyssh-core/src/config/ssh_config.rs index 2da8c21..6495ac3 100644 --- a/crates/omnyssh-core/src/config/ssh_config.rs +++ b/crates/omnyssh-core/src/config/ssh_config.rs @@ -1,8 +1,9 @@ //! Parser for `~/.ssh/config`. //! //! Supported directives: `Host`, `HostName`, `User`, `Port`, -//! `IdentityFile`, `ProxyJump`, `LocalForward`, `Include`. `Match` blocks are -//! skipped. +//! `IdentityFile`, `ProxyJump`, `LocalForward`, `ForwardAgent`, `Include`. +//! Wildcard `Host` and `Match` blocks are skipped, except that a `ForwardAgent no` +//! there, or in the global section, keeps the agent from every host after it. //! //! The original file is **never modified**. @@ -20,7 +21,13 @@ use crate::ssh::tunnel::LocalForward; /// configuration. pub fn parse_ssh_config(content: &str) -> Vec { let mut visited: HashSet = HashSet::new(); - parse_content(content, default_include_base().as_deref(), 0, &mut visited) + parse_content( + content, + default_include_base().as_deref(), + 0, + &mut visited, + &mut false, + ) } /// Loads and parses an SSH config file from disk. @@ -35,7 +42,13 @@ pub fn load_from_file(path: &Path) -> anyhow::Result> { .filter(|p| !p.as_os_str().is_empty()) .map_or_else(default_include_base, |p| Some(p.to_path_buf())); let mut visited: HashSet = HashSet::new(); - Ok(parse_content(&content, base.as_deref(), 0, &mut visited)) + Ok(parse_content( + &content, + base.as_deref(), + 0, + &mut visited, + &mut false, + )) } /// `~/.ssh` — where `ssh_config(5)` resolves a relative `Include` in a user @@ -48,11 +61,16 @@ fn default_include_base() -> Option { // Internal helpers // --------------------------------------------------------------------------- +/// `agent_barred` is set by a `ForwardAgent no` in a place this parser does not +/// read hosts from — the global section, a wildcard `Host`, a `Match` — which ssh(1) +/// may take first for any host after it. Shared with the files an `Include` pulls in, +/// which ssh(1) reads in place. fn parse_content( content: &str, base: Option<&Path>, depth: usize, visited: &mut HashSet, + agent_barred: &mut bool, ) -> Vec { if depth > 3 { return Vec::new(); @@ -65,6 +83,9 @@ fn parse_content( let mut deferred: Vec = Vec::new(); // True when we are inside a wildcard `Host *` block (skip directives). let mut in_wildcard = false; + // ForwardAgent is first-wins, as in ssh(1): a later `yes` in the same block + // must not turn on what an earlier `no` kept off. + let mut agent_seen = false; for raw_line in content.lines() { let line = strip_comment(raw_line).trim().to_string(); @@ -83,6 +104,7 @@ fn parse_content( hosts.push(h); } hosts.append(&mut deferred); + agent_seen = false; in_wildcard = value.contains('*') || value.contains('?'); if !in_wildcard { let h = Host { @@ -132,6 +154,26 @@ fn parse_content( } } } + // Lending the agent is never read from outside a host's own block, but a + // `no` there still counts: which host it covers is not worked out here, + // so it covers every host after it. A socket path or `$VAR` names another + // agent, and lending the default one instead is not what was asked. + "forwardagent" => { + let answer = value.to_ascii_lowercase(); + let off = matches!(answer.as_str(), "no" | "false"); + match current { + Some(ref mut h) if !agent_seen => { + agent_seen = true; + match answer.as_str() { + "yes" | "true" => h.forward_agent = !*agent_barred, + "no" | "false" => h.forward_agent = false, + _ => tracing::warn!(host = %h.name, value, "ForwardAgent skipped"), + } + } + None if off => *agent_barred = true, + _ => {} + } + } // A Match block's directives apply by condition, not to the host // above it; skip them like a wildcard block — a `LocalForward` there // must not open a port for a host that never asked for it. @@ -173,7 +215,13 @@ fn parse_content( continue; // already visited — break cycle } match std::fs::read_to_string(&path) { - Ok(sub) => sink.extend(parse_content(&sub, base, depth + 1, visited)), + Ok(sub) => sink.extend(parse_content( + &sub, + base, + depth + 1, + visited, + agent_barred, + )), Err(e) => { tracing::warn!(path = %path.display(), error = %e, "Include file unreadable") } @@ -555,6 +603,97 @@ Host web assert!(hosts[0].local_forwards.is_empty()); } + #[test] + fn test_forward_agent() { + let cfg = "\ +Host bastion + ForwardAgent yes +Host lab + ForwardAgent True +Host web + ForwardAgent no +Host plain + HostName 10.0.0.1 +"; + let hosts = parse_ssh_config(cfg); + let forwarding: Vec = hosts.iter().map(|h| h.forward_agent).collect(); + assert_eq!(forwarding, [true, true, false, false]); + } + + #[test] + fn test_forward_agent_first_value_wins() { + let cfg = "\ +Host web + ForwardAgent no + ForwardAgent yes +Host db + ForwardAgent yes +"; + let hosts = parse_ssh_config(cfg); + assert!( + !hosts[0].forward_agent, + "a later yes overrode an earlier no" + ); + assert!(hosts[1].forward_agent, "the next block starts afresh"); + } + + #[test] + fn test_forward_agent_to_another_socket_skipped() { + // A path or `$VAR` names a different agent; lending the default one + // instead would hand out keys the config never meant to. + let cfg = "\ +Host a + ForwardAgent /run/user/1000/other.sock +Host b + ForwardAgent $OTHER_SOCK +"; + let hosts = parse_ssh_config(cfg); + assert!(hosts.iter().all(|h| !h.forward_agent)); + } + + #[test] + fn test_wildcard_forward_agent_ignored() { + let cfg = "\ +Host * + ForwardAgent yes + +Host web + HostName 10.0.0.1 +"; + let hosts = parse_ssh_config(cfg); + assert!(!hosts[0].forward_agent); + } + + #[test] + fn test_an_earlier_general_no_keeps_the_agent_home() { + // ssh(1) takes the first value that applies, so a `no` in the global + // section, a wildcard block or a Match block ahead of a host wins over + // the host's own `yes`. + for general in [ + "ForwardAgent no\n", + "Host *\n ForwardAgent no\n", + "Host *.internal\n ForwardAgent no\n", + "Match all\n ForwardAgent no\n", + ] { + let cfg = format!("{general}Host web\n ForwardAgent yes\n"); + let hosts = parse_ssh_config(&cfg); + assert!(!hosts[0].forward_agent, "{general:?} did not win"); + } + } + + #[test] + fn test_a_later_general_no_leaves_an_earlier_yes() { + let cfg = "\ +Host web + ForwardAgent yes + +Host * + ForwardAgent no +"; + let hosts = parse_ssh_config(cfg); + assert!(hosts[0].forward_agent); + } + #[test] fn test_equals_separator() { // Some configs use '=' instead of space. diff --git a/crates/omnyssh-core/src/ssh/client.rs b/crates/omnyssh-core/src/ssh/client.rs index a657d44..208af2a 100644 --- a/crates/omnyssh-core/src/ssh/client.rs +++ b/crates/omnyssh-core/src/ssh/client.rs @@ -94,6 +94,10 @@ pub struct Host { /// Start the tunnel when OmnySSH starts. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub tunnel_autostart: bool, + /// Lend the local SSH agent to this host's terminals (`ssh -A`). Anyone with + /// root on the host can use it while a terminal is open, so it is opt-in. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub forward_agent: bool, // ----------------------------------------------------------------------- // Auto SSH Key Setup metadata @@ -147,6 +151,7 @@ impl Default for Host { monitor_port: None, local_forwards: Vec::new(), tunnel_autostart: false, + forward_agent: false, key_setup_date: None, password_auth_disabled: None, } diff --git a/crates/omnyssh-core/src/ssh/pty.rs b/crates/omnyssh-core/src/ssh/pty.rs index 2b0a6b8..8bdb195 100644 --- a/crates/omnyssh-core/src/ssh/pty.rs +++ b/crates/omnyssh-core/src/ssh/pty.rs @@ -22,7 +22,9 @@ use crate::event::CoreEvent; use crate::ssh::client::Host; use crate::ssh::identity; use crate::ssh::password::{AskPassword, NoAnswer, Prompt}; -use crate::ssh::session::{connect_and_auth, passphrase_required, Passwords, SshConnection}; +use crate::ssh::session::{ + connect_for_shell, forwards_agent, passphrase_required, Passwords, SshConnection, +}; /// Stable numeric identifier for a PTY session (mirrors [`crate::event::SessionId`]). pub type SessionId = u64; @@ -143,6 +145,7 @@ async fn forward_locale(channel: &russh::Channel) { /// Opens a channel and requests a remote PTY + shell (the `ssh -t` equivalent). async fn open_shell( handle: &SshConnection, + lends_agent: bool, cols: u16, rows: u16, ) -> Result> { @@ -152,6 +155,13 @@ async fn open_shell( .context("open terminal channel")?; // Sent before the shell starts so it inherits the locale. forward_locale(&channel).await; + // Likewise `SSH_AUTH_SOCK`. + if lends_agent { + channel + .agent_forward(false) + .await + .context("request agent forwarding")?; + } // IUTF8 tells the server's line discipline that input is UTF-8, so multibyte // (e.g. Cyrillic) editing works in canonical mode. Unknown modes are ignored. channel @@ -198,7 +208,10 @@ async fn session_task( asked: false, closed: false, }; - let connected = connect_and_auth(&host, Passwords::Ask(&mut prompt)).await; + // Asked once: the connection that takes agent channels and the request that + // invites them must agree, even if the agent comes or goes during the login. + let lends_agent = forwards_agent(&host); + let connected = connect_for_shell(&host, Passwords::Ask(&mut prompt), lends_agent).await; // Keys typed past a password prompt must not reach the new shell (a // password entered twice would be echoed there). Without a prompt they are // the user's first command, and stay queued. @@ -212,7 +225,9 @@ async fn session_task( return; } let result = match connected { - Ok(handle) => open_shell(&handle, cols, rows).await.map(|ch| (handle, ch)), + Ok(handle) => open_shell(&handle, lends_agent, cols, rows) + .await + .map(|ch| (handle, ch)), Err(e) => Err(e), }; let (_handle, mut channel) = match result { diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index 8e5e4eb..a666223 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -58,8 +58,12 @@ pub(crate) struct KnownHostsHandler { no_method: Arc, /// The fingerprint of a host key first seen, and recorded, on this connection. new_key: Arc>>, - /// Dropped with the handler when the session ends, which wakes [`Link::ended`]. - _ended: watch::Sender<()>, + /// Whether this connection lends the local agent (`ssh -A`). Only a + /// terminal's target does; any other gets its agent channels closed. + lends_agent: bool, + /// Dropped with the handler when the session ends, which wakes [`Link::ended`] + /// and any agent channel still being carried. + ended: watch::Sender<()>, } /// What a connection's [`KnownHostsHandler`] reports while it runs. @@ -163,6 +167,59 @@ impl client::Handler for KnownHostsHandler { } } } + + // russh has already confirmed the channel, so refusing means closing it. + // Never an Err: that would end the whole connection, terminal and all. + async fn server_channel_open_agent_forward( + &mut self, + channel: russh::Channel, + session: &mut client::Session, + ) -> Result<(), Self::Error> { + if self.lends_agent { + // The proxy needs the session loop this callback is holding up. + #[cfg(unix)] + tokio::spawn(lend_agent(channel, self.ended.subscribe())); + } else { + // ssh(1) refuses these too: a server that asks for an agent nobody + // offered may be after the keys in it. + tracing::warn!(host = %self.host, "server opened an agent channel that was not offered; closed it"); + session.close(channel.id()); + } + Ok(()) + } +} + +/// Carries one forwarded agent channel to the local agent, as `ssh -A` does, for +/// no longer than the session lasts: an agent that never answers would otherwise +/// hold the task and its socket until it quits. +/// +/// Ends with an EOF, never a close: the server closes once it has seen it, and +/// a close of ours racing its window adjust would end the whole connection. +#[cfg(unix)] +async fn lend_agent(mut channel: russh::Channel, mut ended: watch::Receiver<()>) { + let agent = match std::env::var_os("SSH_AUTH_SOCK") { + Some(path) => tokio::net::UnixStream::connect(path).await, + None => Err(std::io::ErrorKind::NotFound.into()), + }; + let carried = match agent { + Ok(mut agent) => { + let writer = channel.make_writer(); + let mut remote = tokio::io::join(channel.make_reader(), writer); + tokio::select! { + carried = tokio::io::copy_bidirectional(&mut remote, &mut agent) => { + carried.map(drop) + } + // The session is gone, and the channel with it. + _ = ended.changed() => return, + } + } + Err(e) => Err(e), + }; + // A clean copy has already sent its EOF. + if let Err(e) = carried { + tracing::debug!(error = %e, "could not lend the SSH agent"); + let _ = channel.eof().await; + } } // --------------------------------------------------------------------------- @@ -510,18 +567,57 @@ impl SshSession { const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Connect to `host`, verify its host key, and authenticate — through the -/// host's `ProxyJump` chain when it has one. -/// -/// Shared by [`SshSession::connect`] (metrics/SFTP) and the terminal so every -/// native SSH path honors the same keys, agent, passwords, known_hosts policy, -/// and bastions. +/// host's `ProxyJump` chain when it has one. Everything but the terminal comes +/// through here: [`SshSession::connect`] (metrics, SFTP, key setup) and tunnels. /// /// # Errors /// Connection timeout (> 10 s per hop), host-key rejection, authentication /// failure, or an unresolvable `ProxyJump` chain. pub(crate) async fn connect_and_auth( + host: &Host, + passwords: Passwords<'_>, +) -> anyhow::Result { + connect_chain(host, passwords, false).await +} + +/// [`connect_and_auth`] for an interactive shell, whose target lends the local +/// agent when `lends_agent` — decided once by the caller, which also offers it. +/// Bastions never do, as with `ssh -J -A`. +pub(crate) async fn connect_for_shell( + host: &Host, + passwords: Passwords<'_>, + lends_agent: bool, +) -> anyhow::Result { + connect_chain(host, passwords, lends_agent).await +} + +/// Whether a terminal to `host` lends the local agent. With no agent running there +/// is nothing to lend, and offering one anyway would leave the remote shell an +/// `SSH_AUTH_SOCK` that leads nowhere. +pub(crate) fn forwards_agent(host: &Host) -> bool { + host.forward_agent && agent_running() +} + +/// Whether an agent answers at `SSH_AUTH_SOCK`: the variable outlives an agent that +/// has stopped. A local connect, so it costs nothing to ask. +#[cfg(unix)] +fn agent_running() -> bool { + std::env::var_os("SSH_AUTH_SOCK") + .is_some_and(|path| std::os::unix::net::UnixStream::connect(path).is_ok()) +} + +/// Agent authentication is unix-only, and so is lending the agent. +#[cfg(not(unix))] +fn agent_running() -> bool { + false +} + +/// Both entry points share it, so every native SSH path honors the same keys, +/// agent, passwords, known_hosts policy and bastions. +async fn connect_chain( host: &Host, mut passwords: Passwords<'_>, + lends_agent: bool, ) -> anyhow::Result { let chain = jump_chain(host).await?; let config = client_config(); @@ -532,8 +628,8 @@ pub(crate) async fn connect_and_auth( for (i, hop) in chain.iter().enumerate() { let key = login_key(hop, &chain[..i]); let handle = match jumps.last() { - None => connect_direct(&config, hop, &key, &mut passwords).await, - Some(via) => connect_tunnelled(&config, via, hop, &key, &mut passwords).await, + None => connect_direct(&config, hop, &key, &mut passwords, false).await, + Some(via) => connect_tunnelled(&config, via, hop, &key, &mut passwords, false).await, } .map_err(|e| at_hop(e, format!("ProxyJump via '{}' failed", hop.name)))?; jumps.push(handle); @@ -541,10 +637,12 @@ pub(crate) async fn connect_and_auth( let key = login_key(host, &chain); let handle = match (jumps.last(), chain.last()) { - (Some(via), Some(last)) => connect_tunnelled(&config, via, host, &key, &mut passwords) - .await - .map_err(|e| at_hop(e, format!("connecting via '{}' failed", last.name)))?, - _ => connect_direct(&config, host, &key, &mut passwords).await?, + (Some(via), Some(last)) => { + connect_tunnelled(&config, via, host, &key, &mut passwords, lends_agent) + .await + .map_err(|e| at_hop(e, format!("connecting via '{}' failed", last.name)))? + } + _ => connect_direct(&config, host, &key, &mut passwords, lends_agent).await?, }; Ok(SshConnection { @@ -620,8 +718,9 @@ async fn connect_direct( host: &Host, key: &str, passwords: &mut Passwords<'_>, + lends_agent: bool, ) -> anyhow::Result> { - let dial = || dial_direct(config, host); + let dial = || dial_direct(config, host, lends_agent); finish_auth(dial().await?, host, key, dial, passwords).await } @@ -634,8 +733,9 @@ async fn connect_tunnelled( host: &Host, key: &str, passwords: &mut Passwords<'_>, + lends_agent: bool, ) -> anyhow::Result> { - let dial = || dial_tunnelled(config, via, host); + let dial = || dial_tunnelled(config, via, host, lends_agent); finish_auth(dial().await?, host, key, dial, passwords).await } @@ -671,9 +771,13 @@ impl Dialed { } /// Opens a TCP connection to `host` and verifies its host key. -async fn dial_direct(config: &Arc, host: &Host) -> anyhow::Result { +async fn dial_direct( + config: &Arc, + host: &Host, + lends_agent: bool, +) -> anyhow::Result { let addr = format!("{}:{}", host.hostname, host.port); - let (handler, link) = known_hosts_handler(host); + let (handler, link) = known_hosts_handler(host, lends_agent); let handle = time::timeout( CONNECT_TIMEOUT, client::connect(Arc::clone(config), addr, handler), @@ -695,6 +799,7 @@ async fn dial_tunnelled( config: &Arc, via: &Handle, host: &Host, + lends_agent: bool, ) -> anyhow::Result { // The originator address is informational; ssh(1) reports the loopback it // forwards from, and servers only log it. @@ -710,7 +815,7 @@ async fn dial_tunnelled( .map_err(|_| anyhow!("SSH connection timed out (10 s)"))? .with_context(|| format!("open tunnel to {}:{}", host.hostname, host.port))?; - let (handler, link) = known_hosts_handler(host); + let (handler, link) = known_hosts_handler(host, lends_agent); let handle = time::timeout( CONNECT_TIMEOUT, client::connect_stream(Arc::clone(config), channel.into_stream(), handler), @@ -729,7 +834,7 @@ async fn dial_tunnelled( /// The host-key verifier for `host`, and what it will report about the /// connection. The lookup uses the target's own hostname/port even over a /// tunnel, so `known_hosts` entries match what an `ssh -J` would record. -fn known_hosts_handler(host: &Host) -> (KnownHostsHandler, Link) { +fn known_hosts_handler(host: &Host, lends_agent: bool) -> (KnownHostsHandler, Link) { let (ended_tx, ended) = watch::channel(()); let link = Link { hung_up: Arc::new(AtomicBool::new(false)), @@ -743,7 +848,8 @@ fn known_hosts_handler(host: &Host) -> (KnownHostsHandler, Link) { hung_up: Arc::clone(&link.hung_up), no_method: Arc::clone(&link.no_method), new_key: Arc::clone(&link.new_key), - _ended: ended_tx, + lends_agent, + ended: ended_tx, }; (handler, link) } diff --git a/crates/omnyssh-core/tests/agent_forward.rs b/crates/omnyssh-core/tests/agent_forward.rs new file mode 100644 index 0000000..e090139 --- /dev/null +++ b/crates/omnyssh-core/tests/agent_forward.rs @@ -0,0 +1,315 @@ +//! Agent forwarding, end to end, against an in-process SSH server that opens an +//! agent channel whether or not the client offered one — the way a server after +//! the keys would. + +#![cfg(unix)] + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Once}; +use std::time::Duration; + +use russh::keys::key::KeyPair; +use russh::server::{self, Auth, Msg, Session}; +use russh::{Channel, ChannelId, ChannelMsg, CryptoVec}; +use tokio::net::TcpListener; +use tokio::sync::mpsc; + +use omnyssh_core::event::CoreEvent; +use omnyssh_core::ssh::client::Host; +use omnyssh_core::ssh::pty::PtyManager; +use omnyssh_core::ssh::session::SshSession; + +const PASSWORD: &str = "agent-test"; + +/// Keeps trust-on-first-use off the real `~/.ssh`, and starts an agent holding +/// one key for the server to ask about. +fn isolate_home() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + use std::process::Command; + let home = tempfile::tempdir().expect("tempdir").keep(); + std::env::set_var("HOME", &home); + let socket = home.join("agent.sock"); + let key = home.join("agent_key"); + let started = Command::new("ssh-agent") + .arg("-a") + .arg(&socket) + .output() + .expect("these tests need ssh-agent on PATH"); + assert!(started.status.success(), "ssh-agent failed to start"); + let keygen = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", ""]) + .arg("-f") + .arg(&key) + .status() + .expect("these tests need ssh-keygen on PATH"); + assert!(keygen.success()); + let added = Command::new("ssh-add") + .arg("-q") + .arg(&key) + .env("SSH_AUTH_SOCK", &socket) + .status() + .expect("these tests need ssh-add on PATH"); + assert!(added.success()); + std::env::set_var("SSH_AUTH_SOCK", &socket); + }); +} + +// --------------------------------------------------------------------------- +// SSH server +// --------------------------------------------------------------------------- + +/// What became of the server's "list your keys" on an agent channel. +#[derive(Debug, PartialEq)] +enum Reply { + /// An agent answered: its message type and key count. + Answered(u8, u32), + /// The client closed the channel without an answer. + Closed, + /// Nothing came back at all — neither refused nor answered. + Silent, +} + +#[derive(Clone)] +struct Server { + /// Whether the client offered its agent. + offered: Arc, + replies: mpsc::UnboundedSender, +} + +#[async_trait::async_trait] +impl server::Handler for Server { + type Error = russh::Error; + + async fn auth_password(&mut self, _user: &str, password: &str) -> Result { + Ok(if password == PASSWORD { + Auth::Accept + } else { + Auth::Reject { + proceed_with_methods: None, + } + }) + } + + // The agent's key is not the way in; the password is. + async fn auth_publickey( + &mut self, + _user: &str, + _key: &russh::keys::key::PublicKey, + ) -> Result { + Ok(Auth::Reject { + proceed_with_methods: None, + }) + } + + async fn channel_open_session( + &mut self, + _channel: Channel, + _session: &mut Session, + ) -> Result { + Ok(true) + } + + async fn agent_request( + &mut self, + _channel: ChannelId, + _session: &mut Session, + ) -> Result { + self.offered.store(true, Ordering::SeqCst); + Ok(true) + } + + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + session.data(channel, CryptoVec::from_slice(b"logged-in\r\n")); + self.ask_agent(session); + Ok(()) + } + + async fn data( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let mut echo = b"echo:".to_vec(); + echo.extend_from_slice(data); + session.data(channel, CryptoVec::from(echo)); + Ok(()) + } + + async fn exec_request( + &mut self, + channel: ChannelId, + _data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + self.ask_agent(session); + session.exit_status_request(channel, 0); + session.eof(channel); + session.close(channel); + Ok(()) + } +} + +impl Server { + /// Opens an agent channel back to the client and asks it for its keys. + /// Spawned: the session is busy running this handler until it returns. + fn ask_agent(&self, session: &Session) { + let handle = session.handle(); + let replies = self.replies.clone(); + tokio::spawn(async move { + let Ok(mut channel) = handle.channel_open_agent().await else { + let _ = replies.send(Reply::Closed); + return; + }; + // SSH_AGENTC_REQUEST_IDENTITIES, length-prefixed. A channel closed + // under it may already turn this down. + let _ = channel.data(&[0u8, 0, 0, 1, 11][..]).await; + let mut answer = Vec::new(); + let reply = loop { + if answer.len() >= 9 { + let count = u32::from_be_bytes([answer[5], answer[6], answer[7], answer[8]]); + break Reply::Answered(answer[4], count); + } + match tokio::time::timeout(Duration::from_secs(5), channel.wait()).await { + Ok(Some(ChannelMsg::Data { data })) => answer.extend_from_slice(&data), + Ok(Some(ChannelMsg::Eof | ChannelMsg::Close) | None) => break Reply::Closed, + Ok(Some(_)) => {} + Err(_) => break Reply::Silent, + } + }; + let _ = replies.send(reply); + }); + } +} + +/// Serves a fresh server on a loopback port; returns where, whether the client +/// offered its agent, and what each agent channel got back. +async fn serve() -> (SocketAddr, Arc, mpsc::UnboundedReceiver) { + let (replies, received) = mpsc::unbounded_channel(); + let server = Server { + offered: Arc::new(AtomicBool::new(false)), + replies, + }; + let offered = Arc::clone(&server.offered); + let config = Arc::new(server::Config { + keys: vec![KeyPair::generate_ed25519()], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + ..Default::default() + }); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + let _ = server::run_stream(Arc::clone(&config), socket, server.clone()).await; + } + }); + (addr, offered, received) +} + +fn host(name: &str, addr: SocketAddr, forward_agent: bool) -> Host { + Host { + name: name.to_string(), + hostname: addr.ip().to_string(), + port: addr.port(), + user: name.to_string(), + password: Some(PASSWORD.to_string()), + forward_agent, + ..Host::default() + } +} + +async fn next_reply(received: &mut mpsc::UnboundedReceiver) -> Reply { + tokio::time::timeout(Duration::from_secs(20), received.recv()) + .await + .expect("the server never asked the agent") + .expect("server gone") +} + +async fn screen_contains(pty: &PtyManager, id: u64, text: &str) -> bool { + for _ in 0..100 { + if let Some(parser) = pty.parser_for(id) { + if parser.lock().unwrap().screen().contents().contains(text) { + return true; + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +/// Opens a terminal to `host`; the manager is returned so the session lives until +/// the test is done with it. +async fn terminal(host: &Host) -> (PtyManager, u64) { + let (tx, mut rx) = mpsc::channel::(256); + tokio::spawn(async move { while rx.recv().await.is_some() {} }); + let mut pty = PtyManager::new(); + let id = pty.open(host, 80, 24, tx).expect("open"); + assert!(screen_contains(&pty, id, "logged-in").await, "no shell"); + (pty, id) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// A host set to forward the agent gets it in a terminal: the server can list +/// the local agent's key through the channel it opens. +#[tokio::test] +async fn a_terminal_lends_the_agent_to_a_host_set_to_forward_it() { + isolate_home(); + let (addr, offered, mut replies) = serve().await; + + let _pty = terminal(&host("lend", addr, true)).await; + + // SSH_AGENT_IDENTITIES_ANSWER, with the agent's one key. + assert_eq!(next_reply(&mut replies).await, Reply::Answered(12, 1)); + assert!(offered.load(Ordering::SeqCst), "the terminal never offered"); +} + +/// A host not set to forward it is neither offered the agent nor let into it +/// when it opens an agent channel anyway — and the refusal costs it nothing else: +/// the terminal carries on. +#[tokio::test] +async fn a_terminal_keeps_the_agent_from_any_other_host() { + isolate_home(); + let (addr, offered, mut replies) = serve().await; + + let (mut pty, id) = terminal(&host("keep", addr, false)).await; + + assert_eq!(next_reply(&mut replies).await, Reply::Closed); + assert!(!offered.load(Ordering::SeqCst)); + // The server writes to the shell after every write it gets back. + pty.write(id, b"still-here").expect("write"); + assert!( + screen_contains(&pty, id, "echo:still-here").await, + "the refusal took the terminal down" + ); +} + +/// Only terminals lend the agent: the monitoring connection to a host set to +/// forward it refuses an agent channel all the same. +#[tokio::test] +async fn a_monitoring_connection_never_lends_the_agent() { + isolate_home(); + let (addr, offered, mut replies) = serve().await; + + let session = SshSession::connect(&host("poll", addr, true)) + .await + .expect("login"); + session.run_command("true").await.expect("command"); + + assert_eq!(next_reply(&mut replies).await, Reply::Closed); + assert!(!offered.load(Ordering::SeqCst)); + session + .run_command("true") + .await + .expect("the refusal took the connection down"); + assert_eq!(next_reply(&mut replies).await, Reply::Closed); +} diff --git a/crates/omnyssh-core/tests/ssh_config_parser.rs b/crates/omnyssh-core/tests/ssh_config_parser.rs index 63a8993..6c86b3d 100644 --- a/crates/omnyssh-core/tests/ssh_config_parser.rs +++ b/crates/omnyssh-core/tests/ssh_config_parser.rs @@ -254,3 +254,31 @@ fn an_include_cycle_terminates() { assert_eq!(names, ["a", "b", "local-direct"]); } + +/// ssh(1) reads an included file in place, so a general `ForwardAgent no` ahead of +/// the `Include` still wins over a `yes` in a host the file brings in — and one in +/// the included file wins over a host after it. +#[test] +fn a_general_forward_agent_no_reaches_across_include() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ssh = tmp.path().join(".ssh"); + fs::create_dir_all(&ssh).expect("create .ssh"); + fs::write(ssh.join("hosts.conf"), "Host web\n ForwardAgent yes\n").expect("write"); + fs::write(ssh.join("defaults.conf"), "Host *\n ForwardAgent no\n").expect("write"); + + let config = ssh.join("config"); + fs::write(&config, "ForwardAgent no\nInclude hosts.conf\n").expect("write config"); + let hosts = load_from_file(&config).expect("parse config"); + assert!( + !hosts[0].forward_agent, + "the global no was lost across Include" + ); + + fs::write( + &config, + "Include defaults.conf\nHost db\n ForwardAgent yes\n", + ) + .expect("write config"); + let hosts = load_from_file(&config).expect("parse config"); + assert!(!hosts[0].forward_agent, "the included no was lost"); +} diff --git a/crates/omnyssh-gui/src/commands/hosts.rs b/crates/omnyssh-gui/src/commands/hosts.rs index 03db430..dfcc801 100644 --- a/crates/omnyssh-gui/src/commands/hosts.rs +++ b/crates/omnyssh-gui/src/commands/hosts.rs @@ -192,6 +192,7 @@ mod tests { monitor_port: None, local_forwards: vec![], tunnel_autostart: false, + forward_agent: false, } } @@ -393,4 +394,21 @@ mod tests { assert!(hosts[0].local_forwards.is_empty()); assert!(!hosts[0].tunnel_autostart); } + + #[test] + fn upsert_takes_agent_forwarding_from_the_form() { + // On the form, so switching it off must switch it off. + let mut hosts = vec![Host { + name: "lab".to_string(), + forward_agent: true, + ..Host::default() + }]; + upsert(&mut hosts, input("lab"), None); + assert!(!hosts[0].forward_agent); + + let mut on = input("lab"); + on.forward_agent = true; + upsert(&mut hosts, on, None); + assert!(hosts[0].forward_agent); + } } diff --git a/crates/omnyssh-gui/src/dto.rs b/crates/omnyssh-gui/src/dto.rs index 4c87ef3..a975fce 100644 --- a/crates/omnyssh-gui/src/dto.rs +++ b/crates/omnyssh-gui/src/dto.rs @@ -72,6 +72,7 @@ pub struct HostDto { pub monitor_port: Option, pub local_forwards: Vec, pub tunnel_autostart: bool, + pub forward_agent: bool, } /// One `ssh -L` rule (tech-gui.md §4.1): listen on `bindAddress:bindPort` here and @@ -129,6 +130,7 @@ pub struct HostInputDto { pub monitor_port: Option, pub local_forwards: Vec, pub tunnel_autostart: bool, + pub forward_agent: bool, } /// Live connection state for a host (tech-gui.md §4.1). Internally tagged so the @@ -327,6 +329,7 @@ impl From<&Host> for HostDto { monitor_port: host.monitor_port, local_forwards: host.local_forwards.iter().map(Into::into).collect(), tunnel_autostart: host.tunnel_autostart, + forward_agent: host.forward_agent, } } } @@ -414,6 +417,7 @@ impl From for Host { .filter(|&p| p != 0 && monitoring == MonitorMode::TcpPort), local_forwards: dto.local_forwards.into_iter().map(Into::into).collect(), tunnel_autostart: dto.tunnel_autostart, + forward_agent: dto.forward_agent, key_setup_date: None, password_auth_disabled: None, } @@ -665,6 +669,7 @@ mod tests { monitor_port: None, local_forwards: vec![], tunnel_autostart: false, + forward_agent: false, } } @@ -720,6 +725,7 @@ mod tests { monitor_port: None, local_forwards: vec![], tunnel_autostart: false, + forward_agent: false, }); assert!(host.identity_file.is_none()); assert!(host.password.is_none()); @@ -1088,6 +1094,16 @@ mod tests { assert!(host.tunnel_autostart); } + #[test] + fn forward_agent_crosses_both_ways() { + let mut input = full_input(); + input.forward_agent = true; + let host = Host::from(input); + assert!(host.forward_agent); + let json = serde_json::to_value(HostDto::from(&host)).expect("serialise HostDto"); + assert_eq!(json["forwardAgent"], true); + } + #[test] fn check_forwards_keeps_only_rules_hosts_toml_reads_back() { assert!(check_forwards(&[ diff --git a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts index fbf7231..e4a8c6c 100644 --- a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts @@ -6,8 +6,8 @@ import { expect, test, type Page } from '@playwright/test'; // `hosts-loaded` event through the same listener the app registers — so a save/delete // round-trips into the dashboard grid exactly as the real backend would drive it. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, - { name: 'imported', hostname: 'imported.example.com', user: 'root', port: 22, tags: [], source: 'sshConfig', hasKey: false, localForwards: [], tunnelAutostart: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false }, + { name: 'imported', hostname: 'imported.example.com', user: 'root', port: 22, tags: [], source: 'sshConfig', hasKey: false, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; async function boot(page: Page): Promise { @@ -48,7 +48,8 @@ async function boot(page: Page): Promise { source: 'manual', hasKey: !!h.identityFile, localForwards: h.localForwards, - tunnelAutostart: h.tunnelAutostart + tunnelAutostart: h.tunnelAutostart, + forwardAgent: h.forwardAgent }; const i = state.hosts.findIndex((x) => (x as { name: string }).name === view.name); if (i >= 0) state.hosts[i] = { ...state.hosts[i], ...view }; @@ -116,6 +117,39 @@ test('edits a manual host in place', async ({ page }) => { await expect(page.getByText('deploy@web-1b.example.com:22')).toBeVisible(); }); +test.describe('on Linux', () => { + test.use({ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)' }); + + test('agent forwarding is off until switched on, and stays on', async ({ page }) => { + await boot(page); + + await page.getByRole('button', { name: 'Edit web-1' }).click(); + let editor = page.getByRole('dialog', { name: 'Edit host' }); + const agent = editor.getByRole('switch', { name: 'Forward SSH agent' }); + await expect(agent).toHaveAttribute('aria-checked', 'false'); + await agent.click(); + await editor.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + + await page.getByRole('button', { name: 'Edit web-1' }).click(); + editor = page.getByRole('dialog', { name: 'Edit host' }); + await expect(editor.getByRole('switch', { name: 'Forward SSH agent' })).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); +}); + +// The Desktop Chrome device reports a Windows user agent. +test('agent forwarding says it is not on Windows yet', async ({ page }) => { + await boot(page); + + await page.getByRole('button', { name: 'Edit web-1' }).click(); + const editor = page.getByRole('dialog', { name: 'Edit host' }); + await expect(editor.getByRole('switch', { name: 'Forward SSH agent' })).toBeDisabled(); + await expect(editor.getByText('Not available on Windows yet.')).toBeVisible(); +}); + test('deletes a manual host after confirmation', async ({ page }) => { await boot(page); await expect(page.getByText('web-1', { exact: true })).toBeVisible(); diff --git a/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts b/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts index 6d35934..febf1d2 100644 --- a/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/keysetup.spec.ts @@ -6,7 +6,7 @@ import { expect, test, type Page } from '@playwright/test'; // the host's hasKey/passwordAuthDisabled so the follow-up `reload_hosts` replays a keyed // host — exactly how the real backend drives the panel and refreshes the card. const HOSTS = [ - { name: 'pw-host', hostname: 'pw.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } + { name: 'pw-host', hostname: 'pw.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; async function boot(page: Page): Promise { diff --git a/crates/omnyssh-gui/ui/e2e/palette.spec.ts b/crates/omnyssh-gui/ui/e2e/palette.spec.ts index 9da9751..4ed8419 100644 --- a/crates/omnyssh-gui/ui/e2e/palette.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/palette.spec.ts @@ -13,7 +13,8 @@ const HOSTS = [ source: 'manual', hasKey: true, localForwards: [], - tunnelAutostart: false + tunnelAutostart: false, + forwardAgent: false }, { name: 'db-1', @@ -24,7 +25,8 @@ const HOSTS = [ source: 'manual', hasKey: false, localForwards: [], - tunnelAutostart: false + tunnelAutostart: false, + forwardAgent: false } ]; diff --git a/crates/omnyssh-gui/ui/e2e/passphrase.spec.ts b/crates/omnyssh-gui/ui/e2e/passphrase.spec.ts index dfea5af..cfe2965 100644 --- a/crates/omnyssh-gui/ui/e2e/passphrase.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/passphrase.spec.ts @@ -5,8 +5,8 @@ import { expect, test, type Page } from '@playwright/test'; // (§6.4). The stub plays the core: `key-passphrase-required` names a locked key, // `unlock_identity` accepts only the right passphrase, and every call is recorded. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, - { name: 'web-2', hostname: 'web-2.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false }, + { name: 'web-2', hostname: 'web-2.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; const KEY = '/home/me/.ssh/id_ed25519'; const OTHER = '/home/me/.ssh/deploy_key'; diff --git a/crates/omnyssh-gui/ui/e2e/password.spec.ts b/crates/omnyssh-gui/ui/e2e/password.spec.ts index f45d85b..a5040ad 100644 --- a/crates/omnyssh-gui/ui/e2e/password.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/password.spec.ts @@ -6,7 +6,7 @@ import { expect, test, type Page } from '@playwright/test'; // `password-required`, `answer_password` hands the answer back, a wrong password // comes back as a new request marked `retry`, and every answer is recorded. const HOSTS = [ - { name: 'nas', hostname: 'nas.example.com', user: 'admin', port: 22, tags: [], source: 'sshConfig', hasKey: false, localForwards: [], tunnelAutostart: false } + { name: 'nas', hostname: 'nas.example.com', user: 'admin', port: 22, tags: [], source: 'sshConfig', hasKey: false, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; type Answer = { requestId: number; password: string | null }; diff --git a/crates/omnyssh-gui/ui/e2e/settings.spec.ts b/crates/omnyssh-gui/ui/e2e/settings.spec.ts index c3044f7..804f2f4 100644 --- a/crates/omnyssh-gui/ui/e2e/settings.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/settings.spec.ts @@ -6,7 +6,7 @@ import { expect, test, type Page } from '@playwright/test'; // is fired after `reload_hosts` (which the layout calls once its listeners are attached), // mirroring the startup check. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; const UPDATE = { diff --git a/crates/omnyssh-gui/ui/e2e/sftp.spec.ts b/crates/omnyssh-gui/ui/e2e/sftp.spec.ts index 9fbfcc9..2d8e7e7 100644 --- a/crates/omnyssh-gui/ui/e2e/sftp.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/sftp.spec.ts @@ -8,8 +8,8 @@ import { expect, test, type Page } from '@playwright/test'; // the live progress bar is deterministically observable. Both spawn paths (a card's // `files`, and the SFTP spawner via the host picker) are load-bearing for the stage. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, - { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false }, + { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; async function boot(page: Page): Promise { diff --git a/crates/omnyssh-gui/ui/e2e/snippets.spec.ts b/crates/omnyssh-gui/ui/e2e/snippets.spec.ts index a626cf8..cd2b931 100644 --- a/crates/omnyssh-gui/ui/e2e/snippets.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/snippets.spec.ts @@ -6,8 +6,8 @@ import { expect, test, type Page } from '@playwright/test'; // back (so CRUD round-trips are observable), and `execute_snippet` delivers a // `snippet-result` event per host through the same listener the app registers. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, - { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false }, + { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; const SNIPPETS = [ diff --git a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts index 2fc958f..14612f9 100644 --- a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts @@ -7,8 +7,8 @@ import { expect, test, type Page } from '@playwright/test'; // on Enter (proving input round-trips). The host-first path (a Dashboard card's `sh`, // no picker) is the load-bearing flow the stage requires. const HOSTS = [ - { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false }, - { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false } + { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: ['prod'], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false }, + { name: 'db-1', hostname: 'db-1.example.com', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; async function boot(page: Page): Promise { diff --git a/crates/omnyssh-gui/ui/e2e/tunnels.spec.ts b/crates/omnyssh-gui/ui/e2e/tunnels.spec.ts index d4664ce..32142cf 100644 --- a/crates/omnyssh-gui/ui/e2e/tunnels.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/tunnels.spec.ts @@ -11,7 +11,8 @@ const BASE = { hasKey: true, monitoring: 'ssh', localForwards: [], - tunnelAutostart: false + tunnelAutostart: false, + forwardAgent: false }; const HOSTS = [ { @@ -64,7 +65,8 @@ async function boot(page: Page, options: { rejectStart?: string } = {}): Promise ...state.hosts[i], hostname: h.hostname, localForwards: h.localForwards, - tunnelAutostart: h.tunnelAutostart + tunnelAutostart: h.tunnelAutostart, + forwardAgent: h.forwardAgent }; state.hosts[i] = view; return Promise.resolve(null); diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index 71643ac..92213a7 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -493,7 +493,7 @@ export type FilePreview = { sessionId: number; path: string; content: string } * (tech-gui.md §3.4). `hasKey` reports whether an identity file is configured; * the key path itself never crosses the boundary. */ -export type HostDto = { name: string; hostname: string; user: string; port: number; tags: string[]; notes?: string | null; source: HostSourceDto; hasKey: boolean; passwordAuthDisabled?: boolean | null; monitoring: MonitorModeDto; monitorPort?: number | null; localForwards: LocalForwardDto[]; tunnelAutostart: boolean } +export type HostDto = { name: string; hostname: string; user: string; port: number; tags: string[]; notes?: string | null; source: HostSourceDto; hasKey: boolean; passwordAuthDisabled?: boolean | null; monitoring: MonitorModeDto; monitorPort?: number | null; localForwards: LocalForwardDto[]; tunnelAutostart: boolean; forwardAgent: boolean } /** * Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Always * builds a **manual** `Host`: editing an SSH-config import saves a copy that shadows @@ -502,7 +502,7 @@ export type HostDto = { name: string; hostname: string; user: string; port: numb * travel back out: the outbound `HostDto` omits both (§3.4). Inbound only, so it * derives `Deserialize` (not `Serialize`). */ -export type HostInputDto = { name: string; hostname: string; user: string; port: number; identityFile?: string | null; password?: string | null; proxyJump?: string | null; tags: string[]; notes?: string | null; monitoring?: MonitorModeDto | null; monitorPort?: number | null; localForwards: LocalForwardDto[]; tunnelAutostart: boolean } +export type HostInputDto = { name: string; hostname: string; user: string; port: number; identityFile?: string | null; password?: string | null; proxyJump?: string | null; tags: string[]; notes?: string | null; monitoring?: MonitorModeDto | null; monitorPort?: number | null; localForwards: LocalForwardDto[]; tunnelAutostart: boolean; forwardAgent: boolean } /** * Host origin, mirrors `omnyssh_core::ssh::client::HostSource`. */ diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts b/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts index 1ef2b01..770b0ca 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts @@ -52,7 +52,8 @@ describe('ipc event router', () => { hasKey: false, monitoring: 'ssh', localForwards: [], - tunnelAutostart: false + tunnelAutostart: false, + forwardAgent: false } ]; @@ -119,7 +120,7 @@ describe('ipc event router', () => { applyServicesDetected({ hostName: 'web-2', services: [{ kind: 'docker', metrics: [] }] }); applyHostsLoaded([ - { name: 'web-1', hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false } + { name: 'web-1', hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false, forwardAgent: false } ]); expect(get(statuses).has('web-2')).toBe(false); diff --git a/crates/omnyssh-gui/ui/src/lib/platform.ts b/crates/omnyssh-gui/ui/src/lib/platform.ts index bce2ee5..45c438d 100644 --- a/crates/omnyssh-gui/ui/src/lib/platform.ts +++ b/crates/omnyssh-gui/ui/src/lib/platform.ts @@ -2,3 +2,4 @@ // WebView2 Windows NT, WebKitGTK X11; Linux. app.html makes the same macOS test for the // title-bar inset, and the e2e suite emulates a platform by its user agent alone. export const isMac = /Mac/.test(navigator.userAgent); +export const isWindows = /Windows/.test(navigator.userAgent); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte index c4bece4..b7740d4 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte @@ -8,6 +8,7 @@ import { Button, Icon } from '$lib/theme'; import Modal from '$lib/components/Modal.svelte'; import Select from '$lib/components/Select.svelte'; + import { isWindows } from '$lib/platform'; import { emptyForwardRow, formToInput, type HostFormFields } from './hostForm'; let { @@ -175,6 +176,38 @@

Checks the port only — no login, and no metrics on the card.

{/if} +
+
+

Forward SSH agent

+

+ {#if isWindows} + Not available on Windows yet. + {:else} + Terminals here can use your local agent's keys, like ssh -A + — for sudo or hopping on. Anyone with root on this server can use them while a + terminal is open. + {/if} +

+
+ +
+
diff --git a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts index 42c8f70..5638a0c 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.test.ts @@ -18,6 +18,7 @@ function host(partial: Partial): HostDto { monitoring: 'ssh', localForwards: [], tunnelAutostart: false, + forwardAgent: false, ...partial }; } @@ -131,11 +132,24 @@ describe('formFromHost', () => { monitoring: 'ssh', localForwards: [], tunnelAutostart: false, + forwardAgent: false, monitorPort: undefined }); }); }); +describe('formToInput — agent forwarding', () => { + it('sends the switch as set, with no forwards needed', () => { + const r = formToInput(fields({ name: 'lab', hostname: 'h', forwardAgent: true })); + expect(r.ok && r.input.forwardAgent).toBe(true); + }); + + it('seeds the edit form from the host, an ssh-config import included', () => { + expect(formFromHost(host({ source: 'sshConfig', forwardAgent: true })).forwardAgent).toBe(true); + expect(emptyForm().forwardAgent).toBe(false); + }); +}); + describe('formToInput — monitoring mode', () => { it('defaults to ssh and sends no probe port', () => { const result = formToInput({ ...emptyForm(), name: 'web', hostname: '10.0.0.1' }); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts index 6df2e6b..d76f569 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/hostForm.ts @@ -28,6 +28,7 @@ export interface HostFormFields { monitorPort: string; forwards: ForwardRow[]; tunnelAutostart: boolean; + forwardAgent: boolean; } export function emptyForm(): HostFormFields { @@ -45,7 +46,8 @@ export function emptyForm(): HostFormFields { monitoring: 'ssh', monitorPort: '', forwards: [], - tunnelAutostart: false + tunnelAutostart: false, + forwardAgent: false }; } @@ -80,7 +82,8 @@ export function formFromHost(h: HostDto): HostFormFields { monitoring: h.monitoring, monitorPort: h.monitorPort == null ? '' : String(h.monitorPort), forwards: h.localForwards.map(rowFromForward), - tunnelAutostart: h.tunnelAutostart + tunnelAutostart: h.tunnelAutostart, + forwardAgent: h.forwardAgent }; } @@ -203,7 +206,8 @@ export function formToInput(f: HostFormFields): HostFormResult { monitorPort, localForwards, // The switch hides with the last row; a flag nobody can see must not linger. - tunnelAutostart: f.tunnelAutostart && localForwards.length > 0 + tunnelAutostart: f.tunnelAutostart && localForwards.length > 0, + forwardAgent: f.forwardAgent } }; } diff --git a/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts b/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts index 8b86f01..d10299e 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/serverCard.test.ts @@ -13,7 +13,7 @@ import { } from './serverCard'; function host(name = 'web-1'): HostDto { - return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false }; + return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false, forwardAgent: false }; } function tcpHost(name = 'fw-1'): HostDto { diff --git a/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts index a626a5d..82ecac7 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/hostSummary.test.ts @@ -3,7 +3,7 @@ import type { ConnectionStatusDto, HostDto, MetricsDto } from '$lib/bindings'; import { deriveHostSummary } from './hostSummary'; function host(name: string): HostDto { - return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false }; + return { name, hostname: '10.0.0.1', user: 'root', port: 22, tags: [], source: 'manual', hasKey: false, monitoring: 'ssh', localForwards: [], tunnelAutostart: false, forwardAgent: false }; } function metrics(partial: Partial): MetricsDto { diff --git a/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts index d98ce60..c7d6915 100644 --- a/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/stores/palette.test.ts @@ -16,6 +16,7 @@ function host(name: string, extra: Partial = {}): HostDto { monitoring: 'ssh', localForwards: [], tunnelAutostart: false, + forwardAgent: false, ...extra }; } diff --git a/crates/omnyssh/src/app/host.rs b/crates/omnyssh/src/app/host.rs index cbfd145..aa56eaa 100644 --- a/crates/omnyssh/src/app/host.rs +++ b/crates/omnyssh/src/app/host.rs @@ -24,6 +24,11 @@ pub const FORM_FIELD_LABELS: &[&str] = &[ "Monitoring (ssh | tcp | tcp:PORT)", "Port forwards (port:host:hostport, ...)", "Start tunnel on launch (y/n)", + #[cfg(unix)] + "Forward SSH agent (y/n)", + // Kept so a hosts.toml shared with another machine round-trips it. + #[cfg(not(unix))] + "Forward SSH agent (y/n, not on Windows yet)", ]; /// Whether an edit changed anything a running poller reads. Everything else on @@ -101,24 +106,22 @@ fn parse_forwards(value: &str) -> Result, String> { Ok(forwards) } -/// Renders the autostart flag into its form field; off stays blank like the -/// other optional fields. -fn autostart_value(host: &Host) -> &'static str { - if host.tunnel_autostart { +/// Renders a y/n flag into its form field; off stays blank like the other +/// optional fields. +fn yes_no_value(on: bool) -> &'static str { + if on { "y" } else { "" } } -fn parse_autostart(value: &str) -> Result { +/// Parses a y/n field; `label` names it in the error. +fn parse_yes_no(label: &str, value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "" | "n" | "no" => Ok(false), "y" | "yes" => Ok(true), - _ => Err(format!( - "Start tunnel on launch must be y or n, got '{}'", - value.trim() - )), + _ => Err(format!("{label} must be y or n, got '{}'", value.trim())), } } @@ -193,7 +196,8 @@ impl HostForm { form.fields[7] = FormField::with_value(host.notes.as_deref().unwrap_or("")); form.fields[8] = FormField::with_value(monitoring_value(host)); form.fields[9] = FormField::with_value(forwards_value(host)); - form.fields[10] = FormField::with_value(autostart_value(host)); + form.fields[10] = FormField::with_value(yes_no_value(host.tunnel_autostart)); + form.fields[11] = FormField::with_value(yes_no_value(host.forward_agent)); form } @@ -268,7 +272,8 @@ impl HostForm { let (monitoring, monitor_port) = parse_monitoring(self.fields[8].value.trim())?; let local_forwards = parse_forwards(&self.fields[9].value)?; - let tunnel_autostart = parse_autostart(&self.fields[10].value)?; + let tunnel_autostart = parse_yes_no("Start tunnel on launch", &self.fields[10].value)?; + let forward_agent = parse_yes_no("Forward SSH agent", &self.fields[11].value)?; Ok(Host { name, @@ -286,6 +291,7 @@ impl HostForm { monitor_port, local_forwards, tunnel_autostart, + forward_agent, key_setup_date: None, password_auth_disabled: None, }) @@ -878,18 +884,26 @@ mod tests { ("y", true), ("YES", true), ] { - assert_eq!(parse_autostart(text), Ok(on), "parsing '{text}'"); + assert_eq!( + parse_yes_no("Start tunnel on launch", text), + Ok(on), + "parsing '{text}'" + ); } for on in [false, true] { let host = Host { tunnel_autostart: on, ..Host::default() }; - assert_eq!(parse_autostart(autostart_value(&host)), Ok(on)); + let form = HostForm::from_host(&host); + assert_eq!( + parse_yes_no("Start tunnel on launch", &form.fields[10].value), + Ok(on) + ); } for text in ["maybe", "1", "true"] { assert_eq!( - parse_autostart(text), + parse_yes_no("Start tunnel on launch", text), Err(format!( "Start tunnel on launch must be y or n, got '{text}'" )) @@ -897,6 +911,27 @@ mod tests { } } + #[test] + fn the_agent_field_round_trips_and_edits() { + let host = Host { + name: String::from("lab"), + hostname: String::from("10.0.0.7"), + forward_agent: true, + ..Host::default() + }; + let parsed = |form: &HostForm| form.to_host(HostSource::Manual).map(|h| h.forward_agent); + let mut form = HostForm::from_host(&host); + assert_eq!(form.fields[11].value, "y"); + assert_eq!(parsed(&form), Ok(true)); + + form.fields[11] = FormField::with_value("n"); + assert_eq!(parsed(&form), Ok(false)); + + form.fields[11] = FormField::with_value("sometimes"); + let error = "Forward SSH agent must be y or n, got 'sometimes'"; + assert_eq!(parsed(&form), Err(String::from(error))); + } + #[test] fn an_edit_keeps_the_forwards_and_autostart() { // Forwards set in the GUI or read from ~/.ssh/config have to survive an diff --git a/crates/omnyssh/src/ui/popup.rs b/crates/omnyssh/src/ui/popup.rs index ffce46f..fcf20db 100644 --- a/crates/omnyssh/src/ui/popup.rs +++ b/crates/omnyssh/src/ui/popup.rs @@ -1893,12 +1893,12 @@ mod tests { #[test] fn the_form_window_scrolls_only_past_the_last_slot() { - // 11 fields at the 80x24 minimum leave room for 9. - assert_eq!(field_window(11, 0, 9), 0..9); - assert_eq!(field_window(11, 8, 9), 0..9); - assert_eq!(field_window(11, 9, 9), 1..10); - assert_eq!(field_window(11, 10, 9), 2..11); - assert_eq!(field_window(11, 10, 20), 0..11); + // 12 fields at the 80x24 minimum leave room for 9. + assert_eq!(field_window(12, 0, 9), 0..9); + assert_eq!(field_window(12, 8, 9), 0..9); + assert_eq!(field_window(12, 9, 9), 1..10); + assert_eq!(field_window(12, 11, 9), 3..12); + assert_eq!(field_window(12, 11, 20), 0..12); } #[test] From f0751145831223f21457f7ba5cf94c5e0784d433 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 26 Sep 2026 18:29:18 +0400 Subject: [PATCH 3/7] feat(gui): minimize and close to the system tray --- Cargo.lock | 20 ++ crates/omnyssh-gui/Cargo.toml | 20 +- crates/omnyssh-gui/src/commands/mod.rs | 1 + crates/omnyssh-gui/src/commands/tray.rs | 42 +++ crates/omnyssh-gui/src/dto.rs | 9 + crates/omnyssh-gui/src/main.rs | 66 +++- crates/omnyssh-gui/src/tray.rs | 336 ++++++++++++++++++ crates/omnyssh-gui/tests/startup_contract.rs | 23 ++ crates/omnyssh-gui/ui/e2e/settings.spec.ts | 83 ++++- crates/omnyssh-gui/ui/src/lib/bindings.ts | 18 + crates/omnyssh-gui/ui/src/lib/ipc/commands.ts | 11 + .../ui/src/lib/screens/Settings.svelte | 77 +++- .../ui/src/lib/stores/tray.test.ts | 85 +++++ crates/omnyssh-gui/ui/src/lib/stores/tray.ts | 113 ++++++ .../omnyssh-gui/ui/src/routes/+layout.svelte | 11 +- 15 files changed, 889 insertions(+), 26 deletions(-) create mode 100644 crates/omnyssh-gui/src/commands/tray.rs create mode 100644 crates/omnyssh-gui/src/tray.rs create mode 100644 crates/omnyssh-gui/ui/src/lib/stores/tray.test.ts create mode 100644 crates/omnyssh-gui/ui/src/lib/stores/tray.ts diff --git a/Cargo.lock b/Cargo.lock index 06417d9..88e98bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3221,6 +3221,7 @@ name = "omnyssh-gui" version = "1.1.2" dependencies = [ "chrono", + "gtk", "libc", "omnyssh-core", "proptest", @@ -3231,11 +3232,14 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", "tauri-specta", "tokio", + "x11-dl", + "zbus", ] [[package]] @@ -5427,6 +5431,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-store" version = "2.4.3" diff --git a/crates/omnyssh-gui/Cargo.toml b/crates/omnyssh-gui/Cargo.toml index 25ef1f6..9a4fe51 100644 --- a/crates/omnyssh-gui/Cargo.toml +++ b/crates/omnyssh-gui/Cargo.toml @@ -20,7 +20,8 @@ tauri-build = { version = "2", features = [] } [dependencies] # The `specta` feature makes `tauri::ipc::Channel` a `specta::Type`, so the terminal # stream channel (§3.3/§3.6) exports through tauri-specta like any other DTO. -tauri = { version = "2", features = ["specta"] } +# `tray-icon` also makes the bundler depend on libayatana-appindicator3 on Linux. +tauri = { version = "2", features = ["specta", "tray-icon"] } omnyssh-core = { path = "../omnyssh-core" } serde = { workspace = true } tokio = { workspace = true } @@ -43,14 +44,27 @@ tauri-plugin-opener = "2" # Restores the window's size and position between launches. Registered Rust-side only, # so none of its commands are granted to the frontend. tauri-plugin-window-state = "2" +# A second launch shows the running window instead: one hidden in the tray could not be +# reached otherwise, and two instances would fight over the tunnels' ports. 2.x only — +# 3.0 alphas are published. +tauri-plugin-single-instance = "2.4" # `generate_context!` embeds the `plugins.updater` config and expands to serde_json at # runtime; also asserts the wire form of DTOs in tests (e.g. HostDto never carries a password). serde_json = "1" -# Only to restore SIGPIPE after a failed `exec` in the AppImage render retry — `std` -# resets it as part of the child setup it runs in this process and never puts it back. +# To restore SIGPIPE after a failed `exec` in the AppImage render retry — `std` resets +# it as part of the child setup it runs in this process and never puts it back — and to +# look for the tray library before tray-icon panics over a missing one. [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" +# The session bus: its address, read the way the single-instance plugin will (it panics +# on one it cannot parse), and whether a StatusNotifier tray is running. Already built +# for that plugin. +zbus = "5" +# Whether an X server runs an XEmbed tray; loaded at runtime, like tao loads it. +x11-dl = "2" +# Whether the window is native Wayland, which never reports being minimized. +gtk = "0.18" [dev-dependencies] # Property-based coverage of snippet param substitution (tech-gui.md §6.4, §7 Stage 2.2). diff --git a/crates/omnyssh-gui/src/commands/mod.rs b/crates/omnyssh-gui/src/commands/mod.rs index e386c76..d0f55b7 100644 --- a/crates/omnyssh-gui/src/commands/mod.rs +++ b/crates/omnyssh-gui/src/commands/mod.rs @@ -7,5 +7,6 @@ pub mod keysetup; pub mod sftp; pub mod snippets; pub mod terminal; +pub mod tray; pub mod tunnels; pub mod update; diff --git a/crates/omnyssh-gui/src/commands/tray.rs b/crates/omnyssh-gui/src/commands/tray.rs new file mode 100644 index 0000000..ab72e2e --- /dev/null +++ b/crates/omnyssh-gui/src/commands/tray.rs @@ -0,0 +1,42 @@ +//! The system tray (tech-gui.md §4.2): the frontend owns the preference and hands +//! it over here, where the window events it governs are handled. + +use tauri::AppHandle; + +use crate::dto::TraySupportDto; +use crate::error::CommandError; + +/// Minimize and close to the tray, or not. Resolves to what this desktop allows — +/// a tray at all, and minimizing into it; what it does not, the window keeps doing +/// as before. +#[tauri::command] +#[specta::specta] +pub async fn set_tray_behavior( + app: AppHandle, + minimize_to_tray: bool, + close_to_tray: bool, +) -> Result { + let error = |e: &dyn std::fmt::Display| CommandError { + message: e.to_string(), + }; + // Other processes answer these, so they are asked before the main thread is. + let hosts = tokio::task::spawn_blocking(crate::tray::find_hosts) + .await + .map_err(|e| error(&e))?; + let (tx, rx) = tokio::sync::oneshot::channel(); + let handle = app.clone(); + // The icon is built and shown on the main thread; a Linux build that panics there + // would take the event loop with it, which is why `apply` probes first. + app.run_on_main_thread(move || { + let _ = tx.send(crate::tray::apply( + &handle, + minimize_to_tray, + close_to_tray, + hosts, + )); + }) + .map_err(|e| error(&e))?; + rx.await + .map_err(|e| error(&e))? + .map_err(|message| CommandError { message }) +} diff --git a/crates/omnyssh-gui/src/dto.rs b/crates/omnyssh-gui/src/dto.rs index a975fce..f7db400 100644 --- a/crates/omnyssh-gui/src/dto.rs +++ b/crates/omnyssh-gui/src/dto.rs @@ -133,6 +133,15 @@ pub struct HostInputDto { pub forward_agent: bool, } +/// What this desktop allows the tray (tech-gui.md §4.2 `set_tray_behavior`): an icon +/// at all, and hiding a minimized window into it. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TraySupportDto { + pub available: bool, + pub minimize: bool, +} + /// Live connection state for a host (tech-gui.md §4.1). Internally tagged so the /// frontend consumes a discriminated union keyed on `kind`. #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 2c9b617..4b72c97 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -12,6 +12,7 @@ mod dto; mod error; mod events; mod state; +mod tray; use commands::auth::{answer_password, unlock_identity}; use commands::hosts::{delete_host, list_hosts, refresh_metrics, reload_hosts, save_host}; @@ -22,6 +23,7 @@ use commands::sftp::{ }; use commands::snippets::{delete_snippet, execute_snippet, list_snippets, save_snippet}; use commands::terminal::{terminal_close, terminal_open, terminal_resize, terminal_write}; +use commands::tray::set_tray_behavior; use commands::tunnels::{tunnel_start, tunnel_stop}; use commands::update::{check_update, install_update, load_update_config, save_update_config}; use omnyssh_core::event::{CoreEvent, SessionId}; @@ -36,8 +38,8 @@ use tauri_specta::{collect_commands, collect_events, Builder}; const BINDINGS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui/src/lib/bindings.ts"); /// How long the hidden window may wait for the page before it is revealed anyway. -/// The app has no tray icon, so a frontend that never loads must not leave a -/// running process the user cannot see or reach. +/// The tray is off until the page turns it on, so a frontend that never loads must +/// not leave a running process the user cannot see or reach. const REVEAL_FALLBACK: std::time::Duration = std::time::Duration::from_secs(3); /// Set once the document is up. `is_visible()` stops answering that question the moment @@ -73,8 +75,8 @@ const RETRY_MARKER: &str = "OMNYSSH_SOFTWARE_RENDER_RETRY"; /// maximised. Such a window reopens at its own size in the corner of the display. const WINDOW_STATE_FLAGS: StateFlags = StateFlags::SIZE.union(StateFlags::POSITION); -// The reveal is the only path to a visible window — the app has no tray icon — so the -// exclusions above are too load-bearing to live in prose alone. +// The reveal is the only path to a visible window until the page turns the tray on, so +// the exclusions above are too load-bearing to live in prose alone. const _: () = assert!(!WINDOW_STATE_FLAGS.intersects( StateFlags::VISIBLE .union(StateFlags::MAXIMIZED) @@ -116,6 +118,7 @@ fn specta_builder() -> Builder { refresh_metrics, unlock_identity, answer_password, + set_tray_behavior, check_update, install_update, load_update_config, @@ -178,6 +181,20 @@ fn should_retry_software_rendering( !page_loaded && !already_retried && !dmabuf_disabled } +/// Whether the single-instance plugin can start. On Linux it unwraps the session bus +/// address, so one zbus cannot read — `disabled:`, as some sandboxes set — would abort +/// the launch. With no bus at all it just stands down, and so does this app: each launch +/// is then its own. +#[cfg(target_os = "linux")] +fn single_instance_can_start() -> bool { + zbus::Address::session().is_ok() +} + +#[cfg(not(target_os = "linux"))] +fn single_instance_can_start() -> bool { + true +} + /// Re-exec ourselves with WebKit's software renderer. Returns only on failure. /// /// `/proc/self/exe`, not `$APPIMAGE`: inside an AppImage the runtime already put the @@ -216,7 +233,15 @@ fn main() { #[cfg(debug_assertions)] export_bindings(BINDINGS_PATH); - tauri::Builder::default() + let mut app = tauri::Builder::default(); + // First, so a second launch exits before anything else starts; it brings the running + // window forward, from the tray too. + if single_instance_can_start() { + app = app.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + tray::reveal(app) + })); + } + app // Persists UI prefs (theme, sidebar collapse, refresh interval) from the // frontend JS API — no bespoke command (tech-gui.md §4.2, §5.1). .plugin(tauri_plugin_store::Builder::new().build()) @@ -232,16 +257,18 @@ fn main() { .build(), ) .invoke_handler(builder.invoke_handler()) + .on_menu_event(tray::on_menu_event) + .on_window_event(tray::on_window_event) // The window is created hidden (tauri.conf.json `visible: false`) so the // launch never shows the webview's blank base colour; reveal it once the // document — stylesheet included — is up. .on_page_load(|webview, payload| { if matches!(payload.event(), PageLoadEvent::Finished) { PAGE_LOADED.store(true, std::sync::atomic::Ordering::Release); - let window = webview.window(); // Reveal once: a later page load must not raise the window over - // whatever the user is doing. - if !window.is_visible().unwrap_or(false) { + // whatever the user is doing, nor out of the tray. + if !tray::REVEALED.swap(true, std::sync::atomic::Ordering::AcqRel) { + let window = webview.window(); let _ = window.show(); // A window shown after build does not become key on its own everywhere. let _ = window.set_focus(); @@ -254,11 +281,11 @@ fn main() { let reveal = app.handle().clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(REVEAL_FALLBACK).await; - if let Some(window) = reveal.get_webview_window("main") { - // Only when the page never got there: on macOS showing an - // already-visible window raises it over whatever the user - // switched to meanwhile. - if !window.is_visible().unwrap_or(false) { + // Only when the page never got there: on macOS showing an + // already-visible window raises it over whatever the user switched to + // meanwhile. + if !tray::REVEALED.swap(true, std::sync::atomic::Ordering::AcqRel) { + if let Some(window) = reveal.get_webview_window("main") { let _ = window.show(); } } @@ -270,7 +297,9 @@ fn main() { // alone. Kept out of debug builds because `tauri dev` waits on a dev server, // and a slow one starting is not a broken graphics stack. #[cfg(all(target_os = "linux", not(debug_assertions)))] - tauri::async_runtime::spawn(async { + let heal = app.handle().clone(); + #[cfg(all(target_os = "linux", not(debug_assertions)))] + tauri::async_runtime::spawn(async move { tokio::time::sleep(RENDER_HEAL_DEADLINE).await; if should_retry_software_rendering( PAGE_LOADED.load(std::sync::atomic::Ordering::Acquire), @@ -282,6 +311,10 @@ fn main() { eprintln!( "OmnySSH: the interface never loaded — restarting once with software rendering" ); + // The restarted image claims the single-instance name again, and + // finding it still held it would take itself for a second launch + // and quit. + tauri_plugin_single_instance::destroy(&heal); // `exec` returns only on failure; carry on with this process. let err = exec_software_render_retry(); eprintln!("OmnySSH: the restart failed ({err}) — continuing as is"); @@ -315,8 +348,9 @@ fn main() { // `UpdateAvailable` before the webview can receive them (§3.4). Ok(()) }) - .run(tauri::generate_context!()) - .expect("failed to launch OmnySSH Desktop"); + .build(tauri::generate_context!()) + .expect("failed to launch OmnySSH Desktop") + .run(tray::on_run_event); } #[cfg(test)] diff --git a/crates/omnyssh-gui/src/tray.rs b/crates/omnyssh-gui/src/tray.rs new file mode 100644 index 0000000..a4cbaa6 --- /dev/null +++ b/crates/omnyssh-gui/src/tray.rs @@ -0,0 +1,336 @@ +//! The system tray: an opt-in place to keep OmnySSH — every terminal, file browser +//! and tunnel in it — running with no window on screen. Off until the user turns it +//! on, and the window only ever hides into an icon that exists: on a desktop without +//! a tray it would have nowhere to come back from. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use tauri::menu::{Menu, MenuEvent, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Manager, RunEvent, Window, WindowEvent}; + +use crate::dto::TraySupportDto; + +const TRAY_ID: &str = "main"; +const SHOW_ID: &str = "tray-show"; +const QUIT_ID: &str = "tray-quit"; + +/// What closing and minimizing the window do, and whether the icon is up. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Behavior { + minimize: bool, + close: bool, + /// The icon exists and is shown. + live: bool, +} + +impl Behavior { + const OFF: Self = Self { + minimize: false, + close: false, + live: false, + }; + + fn closes_to_tray(self) -> bool { + self.live && self.close + } + + fn minimizes_to_tray(self) -> bool { + self.live && self.minimize + } +} + +// Read on every window event, so it lives where the event handler can reach it +// without the app's managed state. +static BEHAVIOR: Mutex = Mutex::new(Behavior::OFF); + +/// Set by whichever reveals the window first at startup — not `is_visible()`, which +/// takes a window hidden in the tray for one never shown. Until then there is nothing +/// to bring back: the window is still coming up over a blank webview. +pub static REVEALED: AtomicBool = AtomicBool::new(false); + +/// Whether the window was minimized at the last resize. It hides only on the way +/// down, so a restore that lands while the window manager still calls it minimized +/// does not send it straight back. +static MINIMIZED: AtomicBool = AtomicBool::new(false); + +fn behavior() -> Behavior { + *BEHAVIOR.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn set_behavior(behavior: Behavior) { + *BEHAVIOR.lock().unwrap_or_else(PoisonError::into_inner) = behavior; +} + +/// Applies the user's choice as far as this desktop allows, and says how far that is. +/// Whatever it cannot do, the window keeps doing as before. +/// +/// Main thread only: that is where the icon is built, shown and hidden. +pub fn apply( + app: &AppHandle, + minimize: bool, + close: bool, + hosts: TrayHosts, +) -> Result { + let support = support(app, hosts); + let minimize = minimize && support.minimize; + let mut behavior = Behavior { + minimize, + close, + live: false, + }; + if !support.available { + set_behavior(behavior); + return Ok(support); + } + let shown = if minimize || close { + show_icon(app) + } else { + hide_icon(app); + Ok(()) + }; + behavior.live = (minimize || close) && shown.is_ok(); + set_behavior(behavior); + shown.map(|()| support) +} + +/// The trays a Linux desktop runs. Asked off the main thread: both answers come +/// from another process. +#[derive(Debug, Clone, Copy, Default)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub struct TrayHosts { + /// A StatusNotifier watcher (KDE, GNOME's AppIndicator extension, waybar). + status_notifier: bool, + /// An XEmbed system tray on the X server (i3bar, stalonetray, older panels). + xembed: bool, +} + +#[cfg(target_os = "linux")] +pub fn find_hosts() -> TrayHosts { + TrayHosts { + status_notifier: status_notifier_watcher(), + xembed: xembed_tray(), + } +} + +#[cfg(not(target_os = "linux"))] +pub fn find_hosts() -> TrayHosts { + TrayHosts::default() +} + +/// On Linux an icon shows only in a tray something is running — the library is +/// there on every packaged install, a tray is not: stock GNOME has none, and there +/// the window would hide into nothing. A native Wayland window is also never told it +/// was minimized, and cannot use an XEmbed tray. +#[cfg(target_os = "linux")] +fn support(app: &AppHandle, hosts: TrayHosts) -> TraySupportDto { + let wayland = wayland(app); + let available = tray_library_present() && (hosts.status_notifier || (hosts.xembed && !wayland)); + TraySupportDto { + available, + minimize: available && !wayland, + } +} + +/// macOS has no minimize event to act on, and keeps minimized windows in the Dock. +#[cfg(not(target_os = "linux"))] +fn support(_app: &AppHandle, _hosts: TrayHosts) -> TraySupportDto { + TraySupportDto { + available: true, + minimize: !cfg!(target_os = "macos"), + } +} + +#[cfg(target_os = "linux")] +fn wayland(app: &AppHandle) -> bool { + use gtk::prelude::*; + app.get_webview_window("main") + .and_then(|window| window.gtk_window().ok()) + .is_some_and(|window| window.display().type_().name() == "GdkWaylandDisplay") +} + +#[cfg(target_os = "linux")] +fn status_notifier_watcher() -> bool { + let Ok(bus) = zbus::blocking::Connection::session() else { + return false; + }; + let Ok(dbus) = zbus::blocking::fdo::DBusProxy::new(&bus) else { + return false; + }; + zbus::names::BusName::try_from("org.kde.StatusNotifierWatcher") + .is_ok_and(|name| dbus.name_has_owner(name).unwrap_or(false)) +} + +#[cfg(target_os = "linux")] +fn xembed_tray() -> bool { + let Ok(xlib) = x11_dl::xlib::Xlib::open() else { + return false; + }; + // SAFETY: a connection of our own, used on this thread only and closed before + // returning; nothing read from it outlives it. + unsafe { + let display = (xlib.XOpenDisplay)(std::ptr::null()); + if display.is_null() { + return false; + } + let screen = (xlib.XDefaultScreen)(display); + let name = std::ffi::CString::new(format!("_NET_SYSTEM_TRAY_S{screen}")) + .expect("no NUL in the atom name"); + let atom = (xlib.XInternAtom)(display, name.as_ptr(), x11_dl::xlib::True); + let owned = atom != 0 && (xlib.XGetSelectionOwner)(display, atom) != 0; + (xlib.XCloseDisplay)(display); + owned + } +} + +/// Built once, then only shown and hidden: on Linux an icon can never really be +/// removed, so rebuilding would stack up dead ones. +fn show_icon(app: &AppHandle) -> Result<(), String> { + if let Some(tray) = app.tray_by_id(TRAY_ID) { + return tray.set_visible(true).map_err(|e| e.to_string()); + } + build_icon(app).map_err(|e| format!("Could not add the tray icon: {e}")) +} + +fn hide_icon(app: &AppHandle) { + if let Some(tray) = app.tray_by_id(TRAY_ID) { + let _ = tray.set_visible(false); + } +} + +fn build_icon(app: &AppHandle) -> tauri::Result<()> { + let show = MenuItem::with_id(app, SHOW_ID, "Show OmnySSH", true, None::<&str>)?; + let quit = MenuItem::with_id(app, QUIT_ID, "Quit OmnySSH", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + let mut builder = TrayIconBuilder::with_id(TRAY_ID) + .tooltip("OmnySSH") + .menu(&menu) + // A menu bar extra opens its menu on click; a Windows tray icon brings the + // window back and keeps its menu for the right button. + .show_menu_on_left_click(cfg!(target_os = "macos")) + .on_tray_icon_event(|tray, event| { + if cfg!(target_os = "macos") { + return; + } + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + reveal(tray.app_handle()); + } + }); + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + builder.build(app)?; + Ok(()) +} + +/// Whether the library a Linux tray icon needs is installed. libappindicator panics +/// when it cannot load one of these — in this order — instead of returning an error. +#[cfg(target_os = "linux")] +fn tray_library_present() -> bool { + static PRESENT: std::sync::OnceLock = std::sync::OnceLock::new(); + *PRESENT.get_or_init(|| { + [ + c"libayatana-appindicator3.so.1", + c"libappindicator3.so.1", + c"libayatana-appindicator3.so", + c"libappindicator3.so", + ] + .iter() + // SAFETY: loads a library by name with the flags libappindicator itself uses. + // The handle is never closed: the library is about to be loaded for good, + // and unloading GTK code that may have registered types is unsafe. + .any(|name| { + !unsafe { libc::dlopen(name.as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL) }.is_null() + }) + }) +} + +/// Brings the window back from the tray, the Dock or a second launch. +pub fn reveal(app: &AppHandle) { + if !REVEALED.load(Ordering::Acquire) { + return; + } + if let Some(window) = app.get_webview_window("main") { + // In this order: `show` alone leaves a minimized window minimized on + // Windows, and a minimized window ignores focus. + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +pub fn on_menu_event(app: &AppHandle, event: MenuEvent) { + match event.id().as_ref() { + SHOW_ID => reveal(app), + // Never `prevent_exit` anywhere: it would stop this too. + QUIT_ID => app.exit(0), + _ => {} + } +} + +pub fn on_window_event(window: &Window, event: &WindowEvent) { + let behavior = behavior(); + match event { + WindowEvent::CloseRequested { api, .. } if behavior.closes_to_tray() => { + api.prevent_close(); + let _ = window.hide(); + } + // Minimizing arrives as a resize, on GTK with the size unchanged. + WindowEvent::Resized(_) => { + let minimized = window.is_minimized().unwrap_or(false); + let was = MINIMIZED.swap(minimized, Ordering::AcqRel); + if minimized && !was && behavior.minimizes_to_tray() { + let _ = window.hide(); + } + } + _ => {} + } +} + +/// On macOS the Dock icon brings back a window hidden in the tray. +pub fn on_run_event(app: &AppHandle, event: RunEvent) { + #[cfg(target_os = "macos")] + if let RunEvent::Reopen { + has_visible_windows: false, + .. + } = event + { + reveal(app); + } + #[cfg(not(target_os = "macos"))] + let _ = (app, event); +} + +#[cfg(test)] +mod tests { + use super::Behavior; + + /// The window hides only into an icon that is up: an unavailable or failed tray + /// must leave close and minimize as they were. + #[test] + fn the_window_hides_only_into_a_live_icon() { + let on = Behavior { + minimize: true, + close: true, + live: true, + }; + assert!(on.closes_to_tray() && on.minimizes_to_tray()); + + let no_icon = Behavior { live: false, ..on }; + assert!(!no_icon.closes_to_tray() && !no_icon.minimizes_to_tray()); + + let close_only = Behavior { + minimize: false, + ..on + }; + assert!(close_only.closes_to_tray() && !close_only.minimizes_to_tray()); + + assert!(!Behavior::OFF.closes_to_tray() && !Behavior::OFF.minimizes_to_tray()); + } +} diff --git a/crates/omnyssh-gui/tests/startup_contract.rs b/crates/omnyssh-gui/tests/startup_contract.rs index c4bb5de..7fe759e 100644 --- a/crates/omnyssh-gui/tests/startup_contract.rs +++ b/crates/omnyssh-gui/tests/startup_contract.rs @@ -96,6 +96,29 @@ fn the_render_retry_marks_the_child_it_starts() { ); } +/// A second launch has to exit before anything else starts, and only the first plugin +/// runs before all the others. Moved down the list, the second copy would open the +/// settings store and the updater — and, with the tray, a second icon — before quitting. +#[test] +fn the_single_instance_plugin_is_registered_first() { + let first = MAIN_RS.find(".plugin(").expect("main.rs registers plugins"); + assert!( + MAIN_RS[first..].starts_with(".plugin(tauri_plugin_single_instance::init("), + "tauri_plugin_single_instance is no longer the first plugin registered" + ); +} + +/// The window hides into the tray only; `prevent_exit` would also swallow the tray's +/// own Quit, leaving an app with no window that cannot be closed. +#[test] +fn nothing_prevents_the_app_from_exiting() { + let tray = read(Path::new(MANIFEST_DIR).join("src/tray.rs")); + assert!( + !MAIN_RS.contains("prevent_exit(") && !tray.contains("prevent_exit("), + "prevent_exit would block the tray's Quit" + ); +} + /// Window geometry is restored by a plugin whose default flag set includes `VISIBLE`, /// which it applies from `on_window_ready` — before the page exists. Fall back to those /// defaults and every launch shows the window over a blank webview again, undoing the diff --git a/crates/omnyssh-gui/ui/e2e/settings.spec.ts b/crates/omnyssh-gui/ui/e2e/settings.spec.ts index 804f2f4..80da25a 100644 --- a/crates/omnyssh-gui/ui/e2e/settings.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/settings.spec.ts @@ -16,9 +16,15 @@ const UPDATE = { canSelfUpdate: true }; -async function boot(page: Page, opts: { fireUpdateOnBoot: boolean }): Promise { +async function boot( + page: Page, + opts: { + fireUpdateOnBoot: boolean; + traySupport?: { available: boolean; minimize: boolean }; + } +): Promise { await page.addInitScript( - ({ hosts, update, fireUpdateOnBoot }) => { + ({ hosts, update, fireUpdateOnBoot, traySupport }) => { let cbid = 0; const listeners: Record = {}; const state = { @@ -55,6 +61,9 @@ async function boot(page: Page, opts: { fireUpdateOnBoot: boolean }): Promise + page.evaluate(() => (window as unknown as { __tray?: unknown[] }).__tray ?? []); + +// The Desktop Chrome device reports a Windows user agent. +test('the tray settings reach the backend and survive a restart', async ({ page }) => { + await boot(page, { fireUpdateOnBoot: false }); + // Off by default, and the backend is told so on start. + await expect.poll(() => trayCalls(page)).toEqual([{ minimizeToTray: false, closeToTray: false }]); + + await page.getByRole('button', { name: 'Settings' }).click(); + const close = page.getByRole('switch', { name: 'Close to tray' }); + await expect(page.getByRole('switch', { name: 'Minimize to tray' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + await close.click(); + await expect(close).toHaveAttribute('aria-checked', 'true'); + await expect + .poll(async () => (await trayCalls(page)).at(-1)) + .toEqual({ minimizeToTray: false, closeToTray: true }); + + await page.reload(); + await expect(page.getByText('web-1', { exact: true })).toBeVisible(); + await expect.poll(() => trayCalls(page)).toEqual([{ minimizeToTray: false, closeToTray: true }]); +}); + +test('without a system tray the settings say so and stay off', async ({ page }) => { + await boot(page, { fireUpdateOnBoot: false, traySupport: { available: false, minimize: false } }); + + await page.getByRole('button', { name: 'Settings' }).click(); + await expect(page.getByText('This desktop has no system tray')).toBeVisible(); + await expect(page.getByRole('switch', { name: 'Close to tray' })).toBeDisabled(); + await expect(page.getByRole('switch', { name: 'Minimize to tray' })).toBeDisabled(); +}); + +test('under Wayland the window closes to the tray but cannot minimize into it', async ({ + page +}) => { + await boot(page, { fireUpdateOnBoot: false, traySupport: { available: true, minimize: false } }); + + await page.getByRole('button', { name: 'Settings' }).click(); + await expect(page.getByRole('switch', { name: 'Minimize to tray' })).toBeDisabled(); + await expect(page.getByText(/Not on Wayland/)).toBeVisible(); + await expect(page.getByRole('switch', { name: 'Close to tray' })).toBeEnabled(); +}); + +test.describe('on macOS', () => { + test.use({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)' + }); + + test('the window closes to the menu bar, and minimizing stays with the Dock', async ({ + page + }) => { + await boot(page, { fireUpdateOnBoot: false }); + + await page.getByRole('button', { name: 'Settings' }).click(); + await expect(page.getByRole('switch', { name: 'Close to the menu bar' })).toBeVisible(); + await expect(page.getByRole('switch', { name: 'Minimize to tray' })).toHaveCount(0); + }); +}); diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index 92213a7..d5a7bcd 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -360,6 +360,19 @@ async answerPassword(requestId: number, password: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("set_tray_behavior", { minimizeToTray, closeToTray }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Query GitHub for a newer release (tech-gui.md §4.2). `None` means up to date — the * core swallows network/parse errors so a failed check never disrupts. @@ -669,6 +682,11 @@ export type TransferProgress = TransferProgressDto * remote size could not be determined). */ export type TransferProgressDto = { sessionId: number; transferId: number; done: number; total: number } +/** + * What this desktop allows the tray (tech-gui.md §4.2 `set_tray_behavior`): an icon + * at all, and hiding a minimized window into it. + */ +export type TraySupportDto = { available: boolean; minimize: boolean } /** * A host's port-forwarding tunnel changed state (tech-gui.md §4.3). */ diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts index b5e6eaf..a516b35 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts @@ -9,6 +9,7 @@ import type { HostInputDto, SnippetDto, TerminalBytes, + TraySupportDto, UpdateConfigDto, UpdateInfoDto } from '$lib/bindings'; @@ -191,6 +192,16 @@ export async function tunnelStop(hostName: string): Promise { if (res.status === 'error') throw new Error(res.error.message); } +/** Minimize and close to the tray, or not; resolves to what this desktop allows. */ +export async function setTrayBehavior( + minimizeToTray: boolean, + closeToTray: boolean +): Promise { + const res = await commands.setTrayBehavior(minimizeToTray, closeToTray); + if (res.status === 'error') throw new Error(res.error.message); + return res.data; +} + /** Force an immediate metric poll of every host (tech-gui.md §4.2). */ export async function refreshMetrics(): Promise { const res = await commands.refreshMetrics(); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte index 55933b2..bc2e198 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte @@ -1,14 +1,16 @@ From 110f01b1656429302f6585141a99a7a66594de1b Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 26 Sep 2026 18:29:18 +0400 Subject: [PATCH 4/7] docs(changelog): note terminal copy, agent forwarding and the tray --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2546426..70892e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Features - **OmnySSH asks for the login password when no key gets in.** A host without a working key and without a saved password — typically one imported from `~/.ssh/config` — failed with "SSH authentication failed" and offered no way in short of saving its password to disk. A terminal now asks the way `ssh` does, right in the tab (`user@host's password:`), and a file session asks in a dialog. Three tries, then the login fails; Ctrl+C or Cancel ends it. The password is kept in memory until you quit, never written to disk, and only once the server has accepted it; the dashboard, and any tunnel on that host, pick it up and connect on their own — they never ask themselves. The first time OmnySSH meets a server, the prompt shows the host key it just recorded, so you can check it before typing. A saved password is still tried first, and a key whose passphrase is needed still comes before any password when the host names it as its identity file. +- **Copy from the desktop terminal with Ctrl+Shift+C.** On Windows and Linux there was no key that copied: Ctrl+C sent the shell an interrupt, as it must, and the only way to copy was the right-click menu. Ctrl+Shift+C now copies the selection, as in GNOME Terminal and Windows Terminal, and Ctrl+Shift+V keeps pasting. Ctrl+C and Ctrl+V still reach the shell as ^C and ^V, which vim and readline rely on. On macOS Cmd+C and Cmd+V already worked and are unchanged. +- **Forward your SSH agent to a host, like `ssh -A`.** A host can now lend its terminals your local SSH agent, so `sudo` through `pam_ssh_agent_auth`, `git` over SSH and hopping on to another server all work with the keys on your machine, and `SSH_AUTH_SOCK` is set on the remote side. It is off for every host until you turn it on — "Forward SSH agent" in the host form of both apps — or the host has `ForwardAgent yes` in `~/.ssh/config`. Only terminals get the agent, only on the host itself and not its `ProxyJump` bastions, and only while an agent is running here; the dashboard, file sessions and tunnels never do. Anyone with root on that server can use your keys while a terminal is open, so keep it to servers you trust. A server that opens an agent channel it was not offered is refused, as `ssh` does. A `ForwardAgent yes` under `Host *` or `Match`, or one pointing at another agent's socket, is not read, and `IdentityAgent` is not followed; a `ForwardAgent no` anywhere above a host keeps its agent home. Not available on Windows yet, where OmnySSH does not use the agent for logins either. +- **Minimize or close the desktop app to the system tray.** Closing the window quit the app, and with it every open terminal, file transfer and tunnel. Two settings under Window now keep it running in the tray instead — one for minimizing, one for closing — and the tray icon brings the window back or quits. Both are off until you turn them on. On macOS the icon lives in the menu bar and minimizing stays with the Dock. Launching OmnySSH again while it runs now brings the running window forward rather than starting a second copy. On Linux the tray needs `libayatana-appindicator3` — the `.deb` and `.rpm` now depend on it, and the AppImage carries its own copy — and a panel that shows tray icons, which stock GNOME does not without the AppIndicator extension; where either is missing, the settings say so and the window keeps closing as before. Minimizing to the tray needs X11: Wayland never tells an app its window was minimized. + ### Bug Fixes - **Devices that only take the password by keyboard-interactive log in.** UniFi consoles such as the Dream Machine Pro, and other servers with `PasswordAuthentication no`, accept a password only through keyboard-interactive — which is what `ssh` and PuTTY fall back to without telling you. OmnySSH sent the saved password by the password method alone, so these hosts showed as offline with "SSH authentication failed". It now offers the password the other way too, and remembers which one a server takes, so a wrong password costs one failed login, not two. A server that asks for a one-time code instead of a password is told so rather than sent the password. - **A silent or refusing SSH agent no longer leaves every host stuck on "connecting".** Every login asks the agent first, and nothing bounded that: an agent that accepts connections but never answers — as the launchd agent does on some macOS Tahoe setups — or one that turns a signature down (a declined 1Password or Secretive approval, a key added with `ssh-add -c`) held the login forever, password hosts included. The agent now gets five seconds to list its keys and a minute to sign, a refused signature moves on to the next method, and a signature you turned down is not asked for again by background reconnects. From 1b374e106df64ecae74cfb50e419c66f49948e8c Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 26 Sep 2026 19:07:02 +0400 Subject: [PATCH 5/7] fix(gui): keep terminal Ctrl keys working on non-Latin layouts --- Cargo.lock | 1 + crates/omnyssh-gui/Cargo.toml | 2 + crates/omnyssh-gui/src/commands/terminal.rs | 21 ++++++ crates/omnyssh-gui/src/main.rs | 5 +- crates/omnyssh-gui/ui/e2e/terminal.spec.ts | 33 +++++++++ crates/omnyssh-gui/ui/src/lib/bindings.ts | 14 ++++ crates/omnyssh-gui/ui/src/lib/ipc/commands.ts | 7 ++ .../ui/src/lib/screens/TerminalView.svelte | 33 +++++++-- .../ui/src/lib/screens/terminalInput.test.ts | 67 ++++++++++++++++++- .../ui/src/lib/screens/terminalInput.ts | 45 +++++++++++-- 10 files changed, 213 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88e98bc..1dd991d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3238,6 +3238,7 @@ dependencies = [ "tauri-plugin-window-state", "tauri-specta", "tokio", + "webkit2gtk", "x11-dl", "zbus", ] diff --git a/crates/omnyssh-gui/Cargo.toml b/crates/omnyssh-gui/Cargo.toml index 9a4fe51..36a279b 100644 --- a/crates/omnyssh-gui/Cargo.toml +++ b/crates/omnyssh-gui/Cargo.toml @@ -65,6 +65,8 @@ zbus = "5" x11-dl = "2" # Whether the window is native Wayland, which never reports being minimized. gtk = "0.18" +# The webview's own paste, for the chord WebKitGTK cannot see under a non-Latin layout. +webkit2gtk = "2" [dev-dependencies] # Property-based coverage of snippet param substitution (tech-gui.md §6.4, §7 Stage 2.2). diff --git a/crates/omnyssh-gui/src/commands/terminal.rs b/crates/omnyssh-gui/src/commands/terminal.rs index af434a0..9b65e8d 100644 --- a/crates/omnyssh-gui/src/commands/terminal.rs +++ b/crates/omnyssh-gui/src/commands/terminal.rs @@ -63,3 +63,24 @@ pub fn terminal_close(state: State<'_, GuiState>, session_id: u64) -> Result<(), state.close_terminal(session_id); Ok(()) } + +/// Paste into the focused terminal the way the webview's own Ctrl+Shift+V does. +/// WebKitGTK binds that chord by its key symbol, so under a non-Latin layout it never +/// fires and the frontend asks here instead. The other webviews bind it by the +/// physical key and never need this. +#[tauri::command] +#[specta::specta] +pub fn terminal_paste(webview: tauri::Webview) -> Result<(), CommandError> { + #[cfg(target_os = "linux")] + webview + .with_webview(|platform| { + use webkit2gtk::WebViewExt; + platform.inner().execute_editing_command("PasteAsPlainText"); + }) + .map_err(|e| CommandError { + message: e.to_string(), + })?; + #[cfg(not(target_os = "linux"))] + let _ = webview; + Ok(()) +} diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 4b72c97..ade01de 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -22,7 +22,9 @@ use commands::sftp::{ sftp_mkdir, sftp_open, sftp_preview, sftp_rename, sftp_upload, }; use commands::snippets::{delete_snippet, execute_snippet, list_snippets, save_snippet}; -use commands::terminal::{terminal_close, terminal_open, terminal_resize, terminal_write}; +use commands::terminal::{ + terminal_close, terminal_open, terminal_paste, terminal_resize, terminal_write, +}; use commands::tray::set_tray_behavior; use commands::tunnels::{tunnel_start, tunnel_stop}; use commands::update::{check_update, install_update, load_update_config, save_update_config}; @@ -101,6 +103,7 @@ fn specta_builder() -> Builder { terminal_write, terminal_resize, terminal_close, + terminal_paste, sftp_open, sftp_list, sftp_upload, diff --git a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts index 14612f9..5ef923b 100644 --- a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts @@ -68,6 +68,9 @@ async function boot(page: Page): Promise { } return Promise.resolve(null); } + case 'terminal_paste': + win.__pasted = ((win.__pasted as number | undefined) ?? 0) + 1; + return Promise.resolve(null); case 'terminal_resize': case 'terminal_close': return Promise.resolve(null); @@ -229,6 +232,36 @@ test('Ctrl+Shift+C with nothing selected copies nothing', async ({ page }) => { expect(await writes(page)).toEqual([]); }); +// WebKitGTK under a Russian layout reports keyCode 0 for letter keys, which is also +// what a synthetic keydown carries unless told otherwise — so this is the key event +// xterm gets there: without the fallback, Ctrl+C would send nothing at all. +test('under a non-Latin layout Ctrl+C still interrupts and Ctrl+Shift+V still pastes', async ({ + page +}) => { + await bootWithClipboard(page); + const press = (code: string, shiftKey: boolean, keyCode = 0) => + page.locator('.xterm-helper-textarea').evaluate( + (el, init) => { + el.dispatchEvent(new KeyboardEvent('keydown', { ...init, ctrlKey: true, bubbles: true })); + }, + { key: '\u0441', code, shiftKey, keyCode } + ); + + await press('KeyC', false); + await expect.poll(() => writes(page)).toEqual([[3]]); + + // Where the webview does report the key (WebView2 under the same layout), xterm + // sends ^C itself and the fallback stays out: one ^C, not two. + await press('KeyC', false, 67); + await expect.poll(() => writes(page)).toEqual([[3], [3]]); + + await press('KeyV', true); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __pasted?: number }).__pasted)) + .toBe(1); + expect(await writes(page)).toEqual([[3], [3]]); +}); + test.describe('on macOS', () => { test.use({ userAgent: diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index d5a7bcd..ee438ed 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -157,6 +157,20 @@ async terminalClose(sessionId: number) : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Paste into the focused terminal the way the webview's own Ctrl+Shift+V does. + * WebKitGTK binds that chord by its key symbol, so under a non-Latin layout it never + * fires and the frontend asks here instead. The other webviews bind it by the + * physical key and never need this. + */ +async terminalPaste() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("terminal_paste") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Open an SFTP session for `host_name` (tech-gui.md §4.2). Awaits the core connect, * registers the manager under a fresh public id, and spawns the per-session diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts index a516b35..b559822 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts @@ -192,6 +192,13 @@ export async function tunnelStop(hostName: string): Promise { if (res.status === 'error') throw new Error(res.error.message); } +/** Paste into the focused terminal through the webview's own paste, for the Ctrl+Shift+V + * WebKitGTK misses under a non-Latin layout. */ +export async function terminalPaste(): Promise { + const res = await commands.terminalPaste(); + if (res.status === 'error') throw new Error(res.error.message); +} + /** Minimize and close to the tray, or not; resolves to what this desktop allows. */ export async function setTrayBehavior( minimizeToTray: boolean, diff --git a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte index 6bd33ec..0ddea94 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte @@ -17,9 +17,15 @@ import { terminalDidExit } from '$lib/ipc/router'; import { lastError } from '$lib/stores/notifications'; import { dialogs } from '$lib/stores/dialogs'; - import { terminalOpen, terminalWrite, terminalResize, terminalClose } from '$lib/ipc/commands'; + import { + terminalOpen, + terminalWrite, + terminalResize, + terminalClose, + terminalPaste + } from '$lib/ipc/commands'; import { shouldFadeTop } from './terminalFade'; - import { chunkBytes, isCopyShortcut } from './terminalInput'; + import { chunkBytes, isCopyShortcut, layoutFallback } from './terminalInput'; import { isMac } from '$lib/platform'; import type { TerminalBytes } from '$lib/bindings'; @@ -159,11 +165,26 @@ // reaches the shell. Returning false only keeps xterm out of it; the default is // ours to stop. The write happens inside the keydown, which WebKit requires. term.attachCustomKeyEventHandler((e) => { - if (!isCopyShortcut(e, isMac)) return true; + if (isCopyShortcut(e, isMac)) { + e.preventDefault(); + if (term?.hasSelection()) { + navigator.clipboard.writeText(term.getSelection()).catch((err) => { + lastError.set(`Copy failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } + return false; + } + // Under a non-Latin layout WebKitGTK names no key; the physical one stands in. + const fallback = layoutFallback(e); + if (!fallback) return true; + // As xterm does with a key it handles: nothing else acts on it. e.preventDefault(); - if (term?.hasSelection()) { - navigator.clipboard.writeText(term.getSelection()).catch((err) => { - lastError.set(`Copy failed: ${err instanceof Error ? err.message : String(err)}`); + e.stopPropagation(); + if (fallback.kind === 'control') { + term?.input(fallback.data); + } else { + terminalPaste().catch((err) => { + lastError.set(`Paste failed: ${err instanceof Error ? err.message : String(err)}`); }); } return false; diff --git a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts index f4354da..8d180ef 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { chunkBytes, INPUT_CHUNK, isCopyShortcut, type KeyPress } from './terminalInput'; +import { + chunkBytes, + INPUT_CHUNK, + isCopyShortcut, + layoutFallback, + type KeyPress +} from './terminalInput'; const seq = (n: number) => new Uint8Array(Array.from({ length: n }, (_, i) => i & 0xff)); @@ -30,6 +36,8 @@ describe('isCopyShortcut — Ctrl+Shift+C copies on Windows and Linux', () => { it('follows the letter, not the key, on a Latin layout like Dvorak', () => { expect(isCopyShortcut(press({ key: 'J', code: 'KeyC' }), false)).toBe(false); expect(isCopyShortcut(press({ key: 'C', code: 'KeyI' }), false)).toBe(true); + // A Latin letter with a diacritic on the C key is still that letter. + expect(isCopyShortcut(press({ key: '\u00e7', code: 'KeyC' }), false)).toBe(false); }); it('leaves bare Ctrl+C to the shell as ^C', () => { @@ -54,6 +62,63 @@ describe('isCopyShortcut — Ctrl+Shift+C copies on Windows and Linux', () => { }); }); +// WebKitGTK under a Russian layout: the key is Cyrillic, the keyCode 0, the code Latin. +const cyrillic = (code: string, over: Partial = {}): KeyPress => + press({ key: '\u0441', code, keyCode: 0, shiftKey: false, ...over }); + +describe('layoutFallback — Ctrl chords under a non-Latin layout on WebKitGTK', () => { + it('sends the control character of the physical letter key', () => { + expect(layoutFallback(cyrillic('KeyC'))).toEqual({ kind: 'control', data: '\x03' }); + expect(layoutFallback(cyrillic('KeyD'))).toEqual({ kind: 'control', data: '\x04' }); + expect(layoutFallback(cyrillic('KeyZ'))).toEqual({ kind: 'control', data: '\x1a' }); + expect(layoutFallback(cyrillic('KeyA'))).toEqual({ kind: 'control', data: '\x01' }); + }); + + it("covers Ctrl+[ (vi's escape) and the other punctuation chords xterm knows", () => { + expect(layoutFallback(cyrillic('BracketLeft'))).toEqual({ kind: 'control', data: '\x1b' }); + expect(layoutFallback(cyrillic('Backslash'))).toEqual({ kind: 'control', data: '\x1c' }); + expect(layoutFallback(cyrillic('BracketRight'))).toEqual({ kind: 'control', data: '\x1d' }); + }); + + it('pastes on Ctrl+Shift+V, the chord the webview no longer sees', () => { + expect(layoutFallback(cyrillic('KeyV', { shiftKey: true }))).toEqual({ kind: 'paste' }); + expect(layoutFallback(cyrillic('KeyC', { shiftKey: true }))).toBeNull(); + }); + + it('stays out of the way wherever the keyCode is known', () => { + // Latin layouts, and every other webview even under Cyrillic, report one. + expect(layoutFallback(press({ key: 'c', shiftKey: false, keyCode: 67 }))).toBeNull(); + expect(layoutFallback(cyrillic('KeyV', { shiftKey: true, keyCode: 86 }))).toBeNull(); + }); + + it('leaves a Latin layout its own letters, even the ones WebKitGTK cannot name', () => { + // German Ü on the [ key, Spanish Ç on the \ key, a dead key, Turkish dotless i: + // keyCode 0 there too, but the chord must not become ESC, SIGQUIT's FS or ^I. + expect(layoutFallback(cyrillic('BracketLeft', { key: '\u00fc' }))).toBeNull(); + expect(layoutFallback(cyrillic('Backslash', { key: '\u00e7' }))).toBeNull(); + expect(layoutFallback(cyrillic('BracketRight', { key: 'Dead' }))).toBeNull(); + expect(layoutFallback(cyrillic('KeyI', { key: '\u0131' }))).toBeNull(); + expect(layoutFallback(cyrillic('KeyV', { key: '\u00dc', shiftKey: true }))).toBeNull(); + }); + + it('works for any non-Latin script, not just Cyrillic', () => { + // Greek sigma on the C key. + expect(layoutFallback(cyrillic('KeyC', { key: '\u03c3' }))).toEqual({ + kind: 'control', + data: '\x03' + }); + }); + + it('leaves plain keys, other modifiers, key-up and compositions alone', () => { + expect(layoutFallback(cyrillic('KeyC', { ctrlKey: false }))).toBeNull(); + expect(layoutFallback(cyrillic('KeyC', { altKey: true }))).toBeNull(); + expect(layoutFallback(cyrillic('KeyC', { metaKey: true }))).toBeNull(); + expect(layoutFallback(cyrillic('KeyC', { type: 'keyup' }))).toBeNull(); + expect(layoutFallback(cyrillic('KeyC', { isComposing: true }))).toBeNull(); + expect(layoutFallback(cyrillic('Digit1'))).toBeNull(); + }); +}); + describe('chunkBytes — bounded terminal input', () => { it('yields nothing for empty input', () => { expect(chunkBytes(new Uint8Array(0))).toEqual([]); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts index f5c910c..142c445 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts @@ -1,14 +1,15 @@ // Terminal input is sent over `terminal_write` as a `number[]` (§4.2). A single huge // paste would serialize as one giant array on the main thread and freeze the UI, so the // view splits it into bounded chunks and awaits each (yielding between). This is the -// pure split; the view owns the ordered dispatch. The copy shortcut is decided here too, -// before xterm turns the key into input. +// pure split; the view owns the ordered dispatch. The Ctrl chords xterm cannot handle +// itself — the copy shortcut, and those WebKitGTK leaves unnamed under a non-Latin +// layout — are decided here too, before xterm turns the key into input. /** The per-write byte cap. Small enough that one chunk's `number[]` serialization is * imperceptible, so a multi-MB paste streams without a visible stall (§9). */ export const INPUT_CHUNK = 8192; -/** The fields of a key event the copy shortcut reads, so tests can pass plain objects. */ +/** The fields of a key event these chords read, so tests can pass plain objects. */ export type KeyPress = Pick< KeyboardEvent, | 'type' @@ -22,17 +23,47 @@ export type KeyPress = Pick< | 'isComposing' >; +/** A letter of a non-Latin script (Cyrillic, Greek, Hebrew…): the one case where the + * physical key, not the letter, names a Ctrl chord. A Latin letter with a diacritic + * (ü, å, ç) or a dead key keeps its own meaning. */ +function nonLatinLetter(key: string): boolean { + return /^\p{L}$/u.test(key) && !/\p{Script=Latin}/u.test(key); +} + /** Ctrl+Shift+C on Windows and Linux, as in GNOME Terminal and Windows Terminal: a bare * Ctrl+C has to stay ^C. macOS needs none — Cmd+C copies there through the Edit menu. - * Ctrl+Shift+V needs no twin — xterm makes no input of it, so the webview pastes - * natively. */ + * The webview pastes Ctrl+Shift+V natively, except where `layoutFallback` steps in. */ export function isCopyShortcut(e: KeyPress, mac: boolean): boolean { // keyCode 229 marks the keydown that starts an IME composition. if (mac || e.type !== 'keydown' || e.isComposing || e.keyCode === 229) return false; if (e.altKey || e.metaKey || !e.ctrlKey || !e.shiftKey) return false; - // The physical key stands in only where the layout puts no Latin letter on it, so + // The physical key stands in only where the layout puts a non-Latin letter on it, so // a Cyrillic layout copies with the same keys while Dvorak's J there stays a J. - return e.key === 'c' || e.key === 'C' || (e.code === 'KeyC' && !/^[a-z]$/i.test(e.key)); + return e.key === 'c' || e.key === 'C' || (e.code === 'KeyC' && nonLatinLetter(e.key)); +} + +/** What a Ctrl chord means when WebKitGTK cannot say. Under a non-Latin layout it + * reports keyCode 0 for the letters, so xterm sends nothing for Ctrl+C and the + * webview's own Ctrl+Shift+V never fires; the physical key decides instead, as in + * GNOME Terminal. Every other webview reports a Latin keyCode and needs none of this. */ +export type LayoutFallback = { kind: 'control'; data: string } | { kind: 'paste' } | null; + +// The punctuation Ctrl chords xterm maps by keyCode: ESC (Ctrl+[, vi's escape), FS, GS. +// A non-Latin layout puts letters on these keys too (х, ъ on a Russian one). +const CONTROL_PUNCTUATION: Record = { + BracketLeft: '\x1b', + Backslash: '\x1c', + BracketRight: '\x1d' +}; + +export function layoutFallback(e: KeyPress): LayoutFallback { + if (e.type !== 'keydown' || e.keyCode !== 0 || e.isComposing) return null; + if (!e.ctrlKey || e.altKey || e.metaKey || !nonLatinLetter(e.key)) return null; + if (e.shiftKey) return e.code === 'KeyV' ? { kind: 'paste' } : null; + const letter = /^Key([A-Z])$/.exec(e.code); + if (letter) return { kind: 'control', data: String.fromCharCode(letter[1].charCodeAt(0) - 64) }; + const punctuation = CONTROL_PUNCTUATION[e.code]; + return punctuation ? { kind: 'control', data: punctuation } : null; } /** Split `data` into <=`size` slices, in order. Empty input yields nothing; input at or From 5da3ab00e5f55667ca7e628a29c3ed14e1bb4f50 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 26 Sep 2026 19:07:02 +0400 Subject: [PATCH 6/7] docs(changelog): note terminal Ctrl keys on non-Latin layouts --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70892e9..331df78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Versions follow [Semantic Versioning](https://semver.org/). - **Minimize or close the desktop app to the system tray.** Closing the window quit the app, and with it every open terminal, file transfer and tunnel. Two settings under Window now keep it running in the tray instead — one for minimizing, one for closing — and the tray icon brings the window back or quits. Both are off until you turn them on. On macOS the icon lives in the menu bar and minimizing stays with the Dock. Launching OmnySSH again while it runs now brings the running window forward rather than starting a second copy. On Linux the tray needs `libayatana-appindicator3` — the `.deb` and `.rpm` now depend on it, and the AppImage carries its own copy — and a panel that shows tray icons, which stock GNOME does not without the AppIndicator extension; where either is missing, the settings say so and the window keeps closing as before. Minimizing to the tray needs X11: Wayland never tells an app its window was minimized. ### Bug Fixes +- **Ctrl keys work in the desktop terminal on a non-Latin keyboard layout (Linux).** With a Russian, Ukrainian, Greek or other non-Latin layout active, Ctrl+C, Ctrl+D, Ctrl+Z and the other Ctrl chords sent nothing to the shell, and Ctrl+Shift+V did not paste, because the Linux webview reports no key code for those letters. The physical key now decides, as in GNOME Terminal. Windows, macOS and the terminal app were not affected. - **Devices that only take the password by keyboard-interactive log in.** UniFi consoles such as the Dream Machine Pro, and other servers with `PasswordAuthentication no`, accept a password only through keyboard-interactive — which is what `ssh` and PuTTY fall back to without telling you. OmnySSH sent the saved password by the password method alone, so these hosts showed as offline with "SSH authentication failed". It now offers the password the other way too, and remembers which one a server takes, so a wrong password costs one failed login, not two. A server that asks for a one-time code instead of a password is told so rather than sent the password. - **A silent or refusing SSH agent no longer leaves every host stuck on "connecting".** Every login asks the agent first, and nothing bounded that: an agent that accepts connections but never answers — as the launchd agent does on some macOS Tahoe setups — or one that turns a signature down (a declined 1Password or Secretive approval, a key added with `ssh-add -c`) held the login forever, password hosts included. The agent now gets five seconds to list its keys and a minute to sign, a refused signature moves on to the next method, and a signature you turned down is not asked for again by background reconnects. - **The dashboard card says why a host is down.** A failed host showed a grey "offline" with the reason nowhere on screen. The card now shows it — "SSH connection failed: Connection refused", an authentication failure, a timeout — hidden in streamer mode like the tunnel's reason. The terminal app keeps the whole cause in the host's detail view, and a terminal that fails to open no longer replaces its reason with "SSH session closed.". From 3c11d1066f2832b33b91966f8be301eb6f3a2fb7 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 26 Sep 2026 19:11:04 +0400 Subject: [PATCH 7/7] fix(ssh): allow the unix-only session-end field on Windows --- crates/omnyssh-core/src/ssh/session.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index a666223..007f090 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -62,7 +62,9 @@ pub(crate) struct KnownHostsHandler { /// terminal's target does; any other gets its agent channels closed. lends_agent: bool, /// Dropped with the handler when the session ends, which wakes [`Link::ended`] - /// and any agent channel still being carried. + /// and any agent channel still being carried. Only unix lends the agent, so + /// elsewhere it is only ever dropped. + #[cfg_attr(not(unix), allow(dead_code))] ended: watch::Sender<()>, }