Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Versions follow [Semantic Versioning](https://semver.org/).
- **macOS: a host on your local network that fails with "No route to host" says macOS may be blocking it.** macOS keeps an app away from devices on the local network until you allow it, and a blocked connection fails at once with "No route to host (os error 65)", while the router usually stays reachable because it is also your DNS server, so it looked like a fault on that one machine. The error now adds that macOS may be blocking the app and where to allow it: System Settings, Privacy & Security, Local Network, then reopen the app. The desktop app also now carries the explanation macOS shows when it asks for that permission. The terminal app needs the permission for the terminal it runs in; Apple's Terminal has it already.
- **A terminal the server closes right after login stays open and shows why.** A Synology NAS gives a shell only to administrators: any other account logs in, gets DSM's "Permission denied, please try again." and the session ends. `ssh` shows that message, but OmnySSH closed the tab the moment the session ended, so the terminal seemed to vanish right after the password while file sessions to the same NAS worked. In both apps, a terminal tab that showed anything now stays open when the remote side ends its session, including after `exit` or a dropped connection, with "[Connection closed. Press Enter to close this tab.]" under the last output. Enter or Esc closes it, and it takes no other input. A terminal that fails before showing anything still closes, with the reason in the status bar. The desktop app could also drop the last lines a session printed just before it ended; they now always arrive.

### Features
- **Copy and paste in the desktop terminal with Ctrl+C and Ctrl+V (Windows and Linux).** Copying needed Ctrl+Shift+C or the right-click menu. Now Ctrl+C copies the selected text and clears the selection. With nothing selected it still sends the shell an interrupt (^C), as in Windows Terminal. Ctrl+V pastes, using bracketed paste when the remote program asks for it. Vim's visual-block mode and readline's quoted-insert need a literal ^V, so a Terminal setting turns both keys off and sends ^C and ^V to the shell again. Ctrl+Shift+C and Ctrl+Shift+V work either way. On macOS Cmd+C and Cmd+V already worked and are unchanged.

---

## 1.1.4 — 2026-09-28
Expand Down
46 changes: 39 additions & 7 deletions crates/omnyssh-gui/ui/e2e/terminal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,41 @@ test('Ctrl+Shift+C copies the selection and sends the shell nothing', async ({ p
await expect.poll(() => copied(page)).toEqual(['omnyssh-ready>']);
expect(await writes(page)).toEqual([]);

// Bare Ctrl+C stays the interrupt, selection or not.
// 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([]);
});

test('bare Ctrl+C copies a selection, then interrupts once it is cleared', async ({ page }) => {
await bootWithClipboard(page);
await selectPrompt(page);

await page.keyboard.press('Control+C');
await expect.poll(() => copied(page)).toEqual(['omnyssh-ready>']);
expect(await writes(page)).toEqual([]);

// The copy cleared the selection, so the next Ctrl+C is the interrupt.
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]]);
// Bare Ctrl+V is the webview's own paste too: never a ^V to the shell.
await page.keyboard.press('Control+V');
expect(await writes(page)).not.toContainEqual([22]);
});

test('with the Ctrl clipboard pref off, Ctrl+C and Ctrl+V reach the shell', async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('omnyssh-terminal-ctrl-clipboard', 'false')
);
await bootWithClipboard(page);
await selectPrompt(page);

await page.keyboard.press('Control+C');
await expect.poll(() => writes(page)).toEqual([[3]]);
await page.keyboard.press('Control+V');
await expect.poll(() => writes(page)).toEqual([[3], [22]]);
expect(await copied(page)).toEqual([]);
});

test('Ctrl+Shift+C with nothing selected copies nothing', async ({ page }) => {
Expand Down Expand Up @@ -359,10 +386,15 @@ test('under a non-Latin layout Ctrl+C still interrupts and Ctrl+Shift+V still pa
await press('KeyC', false, 67);
await expect.poll(() => writes(page)).toEqual([[3], [3]]);

const pasted = () =>
page.evaluate(() => (window as unknown as { __pasted?: number }).__pasted);
await press('KeyV', true);
await expect
.poll(() => page.evaluate(() => (window as unknown as { __pasted?: number }).__pasted))
.toBe(1);
await expect.poll(pasted).toBe(1);
expect(await writes(page)).toEqual([[3], [3]]);

// Bare Ctrl+V pastes the same way there, rather than falling back to ^V.
await press('KeyV', false);
await expect.poll(pasted).toBe(2);
expect(await writes(page)).toEqual([[3], [3]]);
});

Expand Down
33 changes: 33 additions & 0 deletions crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { Surface, Icon } from '$lib/theme';
import { theme } from '$lib/stores/theme';
import { streamerMode } from '$lib/stores/streamer';
import { terminalCtrlClipboard } from '$lib/stores/terminalClipboard';
import { refreshInterval, REFRESH_OPTIONS } from '$lib/stores/settings';
import { traySupport, trayBehavior } from '$lib/stores/tray';
import { isMac } from '$lib/platform';
Expand Down Expand Up @@ -138,6 +139,38 @@
</div>
</Surface>

<!-- Terminal (macOS copies and pastes with Cmd, so it has nothing to switch) -->
{#if !isMac}
<Surface class="p-5">
<h2 class="mb-3 text-sm font-semibold">Terminal</h2>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<p class="text-sm">Copy and paste with Ctrl+C / Ctrl+V</p>
<p class="text-xs text-muted">
Ctrl+C copies the selection, or interrupts when nothing is selected. Turn off to
send ^V to the remote. Ctrl+Shift+C / Ctrl+Shift+V always work.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={$terminalCtrlClipboard}
aria-label="Copy and paste with Ctrl+C / Ctrl+V"
onclick={() => terminalCtrlClipboard.toggle()}
class="relative h-6 w-11 shrink-0 rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus {$terminalCtrlClipboard
? 'bg-accent'
: 'bg-surface-inset'}"
>
<span
class="absolute top-0.5 h-5 w-5 rounded-full bg-surface shadow-soft transition-[left] {$terminalCtrlClipboard
? 'left-[1.375rem]'
: 'left-0.5'}"
></span>
</button>
</div>
</Surface>
{/if}

<!-- Dashboard -->
<Surface class="p-5">
<h2 class="mb-3 text-sm font-semibold">Dashboard</h2>
Expand Down
41 changes: 37 additions & 4 deletions crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,16 @@
terminalPaste
} from '$lib/ipc/commands';
import { shouldFadeTop } from './terminalFade';
import { chunkBytes, closesEndedTab, isCopyShortcut, layoutFallback } from './terminalInput';
import {
chunkBytes,
isBareCopy,
isBarePaste,
closesEndedTab,
isCopyShortcut,
layoutFallback
} from './terminalInput';
import { isMac } from '$lib/platform';
import { terminalCtrlClipboard } from '$lib/stores/terminalClipboard';
import type { TerminalBytes } from '$lib/bindings';

let { session, active }: { session: Session; active: boolean } = $props();
Expand Down Expand Up @@ -174,12 +182,37 @@
// 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.
// With the Ctrl clipboard pref on, a bare Ctrl+C copies too, but only a selection
// (and clears it, so the next Ctrl+C interrupts); with nothing selected it stays ^C.
const copySelection = (): void => {
if (!term?.hasSelection()) return;
navigator.clipboard.writeText(term.getSelection()).catch((err) => {
lastError.set(`Copy failed: ${err instanceof Error ? err.message : String(err)}`);
});
};
term.attachCustomKeyEventHandler((e) => {
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)}`);
copySelection();
return false;
}
const ctrlClipboard = get(terminalCtrlClipboard);
if (ctrlClipboard && isBareCopy(e, isMac) && term?.hasSelection()) {
e.preventDefault();
e.stopPropagation();
copySelection();
term.clearSelection();
return false;
}
// A bare Ctrl+V pastes like Ctrl+Shift+V: xterm stays out and the webview's own
// paste lands in xterm's textarea, so bracketed paste and chunking still apply.
// WebKitGTK under a non-Latin layout (keyCode 0) runs no native paste; ask it.
if (ctrlClipboard && isBarePaste(e, isMac)) {
if (e.keyCode === 0) {
e.preventDefault();
e.stopPropagation();
terminalPaste().catch((err) => {
lastError.set(`Paste failed: ${err instanceof Error ? err.message : String(err)}`);
});
}
return false;
Expand Down
41 changes: 41 additions & 0 deletions crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
chunkBytes,
closesEndedTab,
INPUT_CHUNK,
isBareCopy,
isBarePaste,
isCopyShortcut,
layoutFallback,
type KeyPress
Expand Down Expand Up @@ -89,6 +91,45 @@ describe('closesEndedTab — Enter or Esc dismisses a tab whose session ended',
});
});

// The bare chords: the view copies on Ctrl+C only with a selection and the pref on.
const bare = (over: Partial<KeyPress> = {}): KeyPress => press({ key: 'c', shiftKey: false, ...over });
const bareV = (over: Partial<KeyPress> = {}): KeyPress =>
bare({ key: 'v', code: 'KeyV', keyCode: 86, ...over });

describe('isBareCopy / isBarePaste — plain Ctrl+C and Ctrl+V', () => {
it('matches Ctrl+C and Ctrl+V without Shift, whichever case the key reports', () => {
expect(isBareCopy(bare(), false)).toBe(true);
expect(isBareCopy(bare({ key: 'C' }), false)).toBe(true);
expect(isBarePaste(bareV(), false)).toBe(true);
expect(isBarePaste(bareV({ key: 'V' }), false)).toBe(true);
});

it('leaves the Shift chords to isCopyShortcut and the native paste', () => {
expect(isBareCopy(bare({ shiftKey: true }), false)).toBe(false);
expect(isBarePaste(bareV({ shiftKey: true }), false)).toBe(false);
});

it('does not mix up the two letters', () => {
expect(isBareCopy(bareV(), false)).toBe(false);
expect(isBarePaste(bare(), false)).toBe(false);
});

it('follows the physical key only under a non-Latin layout', () => {
expect(isBareCopy(bare({ key: 'с', keyCode: 0 }), false)).toBe(true);
expect(isBarePaste(bareV({ key: 'м', keyCode: 0 }), false)).toBe(true);
expect(isBareCopy(bare({ key: 'j' }), false)).toBe(false);
});

it('ignores extra modifiers, non-keydown events, composition and macOS', () => {
expect(isBareCopy(bare({ altKey: true }), false)).toBe(false);
expect(isBarePaste(bareV({ metaKey: true }), false)).toBe(false);
expect(isBareCopy(bare({ type: 'keyup' }), false)).toBe(false);
expect(isBarePaste(bareV({ isComposing: true }), false)).toBe(false);
expect(isBareCopy(bare(), true)).toBe(false);
expect(isBarePaste(bareV(), true)).toBe(false);
});
});

// WebKitGTK under a Russian layout: the key is Cyrillic, the keyCode 0, the code Latin.
const cyrillic = (code: string, over: Partial<KeyPress> = {}): KeyPress =>
press({ key: '\u0441', code, keyCode: 0, shiftKey: false, ...over });
Expand Down
34 changes: 26 additions & 8 deletions crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// 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 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.
// itself — the copy and paste shortcuts, 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). */
Expand All @@ -30,16 +30,34 @@ 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.
* The webview pastes Ctrl+Shift+V natively, except where `layoutFallback` steps in. */
export function isCopyShortcut(e: KeyPress, mac: boolean): boolean {
/** Ctrl+`letter` (with Shift exactly when `shift`) on Windows and Linux. macOS needs none
* of these — Cmd+C and Cmd+V work there through the Edit menu. */
function ctrlChord(e: KeyPress, mac: boolean, letter: 'C' | 'V', shift: 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;
if (e.altKey || e.metaKey || !e.ctrlKey || e.shiftKey !== shift) return false;
// 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' && nonLatinLetter(e.key));
return (
e.key.toUpperCase() === letter || (e.code === `Key${letter}` && nonLatinLetter(e.key))
);
}

/** Ctrl+Shift+C, as in GNOME Terminal and Windows Terminal: always a copy, never ^C.
* The webview pastes Ctrl+Shift+V natively, except where `layoutFallback` steps in. */
export function isCopyShortcut(e: KeyPress, mac: boolean): boolean {
return ctrlChord(e, mac, 'C', true);
}

/** A bare Ctrl+C: a copy only when the Ctrl clipboard pref is on and text is selected
* (the view decides both), ^C otherwise, as in Windows Terminal. */
export function isBareCopy(e: KeyPress, mac: boolean): boolean {
return ctrlChord(e, mac, 'C', false);
}

/** A bare Ctrl+V: a paste when the Ctrl clipboard pref is on, ^V otherwise. */
export function isBarePaste(e: KeyPress, mac: boolean): boolean {
return ctrlChord(e, mac, 'V', false);
}

/** Enter or Esc, unmodified and outside an IME composition: what closes a tab whose
Expand Down
59 changes: 59 additions & 0 deletions crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { get } from 'svelte/store';

// Same shape as the streamer-mode pref: a fake Tauri store, a fresh module per test to
// reset the singleton, and the localStorage mirror seeding the initial value.
const backend = { get: vi.fn(), set: vi.fn(), save: vi.fn() };
vi.mock('@tauri-apps/plugin-store', () => ({ load: vi.fn(async () => backend) }));

async function fresh() {
vi.resetModules();
return (await import('./terminalClipboard')).terminalCtrlClipboard;
}

describe('terminal Ctrl clipboard pref', () => {
beforeEach(() => {
localStorage.clear();
backend.get.mockReset();
backend.set.mockReset().mockResolvedValue(undefined);
backend.save.mockReset().mockResolvedValue(undefined);
});

it('defaults to on and mirrors a toggle to localStorage', async () => {
const pref = await fresh();
expect(get(pref)).toBe(true);
pref.toggle();
expect(get(pref)).toBe(false);
expect(localStorage.getItem('omnyssh-terminal-ctrl-clipboard')).toBe('false');
});

it('starts off when the mirror says so', async () => {
localStorage.setItem('omnyssh-terminal-ctrl-clipboard', 'false');
expect(get(await fresh())).toBe(false);
});

it('writes the canonical tauri-plugin-store on a user flip', async () => {
const pref = await fresh();
pref.set(false);
await vi.waitFor(() => {
expect(backend.set).toHaveBeenCalledWith('terminalCtrlClipboard', false);
expect(backend.save).toHaveBeenCalled();
});
});

it('hydrate applies the stored value without clobbering a fresh user flip', async () => {
backend.get.mockResolvedValue(true); // stale persisted value
const pref = await fresh();
pref.set(false); // user acts before hydrate resolves
await pref.hydrate();
expect(get(pref)).toBe(false);
});

it('hydrate applies the stored value when the user has not acted', async () => {
backend.get.mockResolvedValue(false);
const pref = await fresh();
await pref.hydrate();
expect(get(pref)).toBe(false);
});
});
Loading