Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/wiki/Terminal-and-Persistent-tmux-Sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ On macOS, use the platform's Command equivalents for copy and paste.
`Ctrl+C` copies selected text. With no selection, it sends the normal interrupt
character to the remote process.

When tmux mouse mode owns the selection, WebSSH accepts tmux's bounded OSC 52
clipboard update for that persistent session. Browser clipboard permissions
still apply.

## Broadcast input

Broadcast mode sends the same input to every open SSH session. Treat it as a
Expand Down
8 changes: 7 additions & 1 deletion static/js/session-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const SessionManager = {
via_jump: data.via_jump,
display_name: data.display_name,
file_source: data.file_source,
use_tmux: data.use_tmux,
tmux_session_name: data.tmux_session_name,
restored: true,
};

Expand Down Expand Up @@ -133,7 +135,11 @@ const SessionManager = {
terminalContainer.className = 'terminal-wrapper unassigned';
document.getElementById('terminalsContainer').appendChild(terminalContainer);

TerminalManager.createTerminal(session_id);
TerminalManager.createTerminal(session_id, null, {
allowOsc52Clipboard: Boolean(
sessionData.use_tmux && sessionData.tmux_session_name
Comment thread
bifrost0x marked this conversation as resolved.
),
});
TerminalManager.attachTerminal(session_id, terminalId);
TerminalManager.setupInputHandler(session_id, (data) => {
if (window.socket) {
Expand Down
149 changes: 143 additions & 6 deletions static/js/terminal-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const TerminalManager = {
lastOutputSequences: {},
syncedSizes: {},
scrollbarDisposers: {},
compositionDisposers: {},
clipboardDisposers: {},
osc52ClipboardAllowed: {},

isVirtualKeyboardVisible(visualViewportHeight, layoutViewportHeight) {
if (visualViewportHeight <= 0 || layoutViewportHeight <= 0) {
Expand All @@ -30,6 +33,117 @@ const TerminalManager = {
return /mac|iphone|ipad|ipod/i.test(platform);
},

isAndroidPlatform() {
const platform = navigator.userAgentData?.platform || '';
return /android/i.test(`${platform} ${navigator.userAgent || ''}`);
},

setupAndroidCompositionGuard(terminal, isAndroid = this.isAndroidPlatform()) {
const textarea = terminal?.textarea;
if (!isAndroid || !textarea?.addEventListener || terminal.options?.screenReaderMode) {
return () => {};
}

const resetStaleInput = () => {
// Some Android IMEs replace xterm's accumulated helper value when a
// composition starts. Starting from that stale offset truncates the
// same number of characters from the committed terminal input.
textarea.value = '';
textarea.setSelectionRange?.(0, 0);
};

// Capture runs before xterm records the composition's start offset.
textarea.addEventListener('compositionstart', resetStaleInput, true);
return () => {
textarea.removeEventListener('compositionstart', resetStaleInput, true);
};
},

decodeOsc52Clipboard(data, maxBytes = 1024 * 1024) {
if (typeof data !== 'string') return null;
const separator = data.indexOf(';');
if (separator < 0) return null;

const selection = data.slice(0, separator);
if (selection && (!/^[cps0-7]+$/.test(selection) || !selection.includes('c'))) {
return null;
}

const encoded = data.slice(separator + 1);
if (
!encoded
|| encoded === '?'
|| encoded.length > Math.ceil(maxBytes / 3) * 4
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)
) {
return null;
}

const remainder = encoded.length % 4;
if (remainder === 1) return null;

try {
const padded = encoded + '='.repeat((4 - remainder) % 4);
const binary = atob(padded);
if (binary.length > maxBytes) return null;
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
return new TextDecoder('utf-8', {fatal: true}).decode(bytes);
} catch {
return null;
}
},

registerOsc52ClipboardHandler(terminal) {
if (!terminal?.parser?.registerOscHandler) return null;
let failureReported = false;

return terminal.parser.registerOscHandler(52, data => {
const text = this.decodeOsc52Clipboard(data);
if (text === null) return true;

const clipboard = navigator.clipboard;
if (!clipboard || typeof clipboard.writeText !== 'function') {
if (!failureReported) {
failureReported = true;
window.showNotification?.('Clipboard access denied', 'error');
}
return true;
}

try {
Promise.resolve(clipboard.writeText(text)).then(() => {
failureReported = false;
}).catch(() => {
if (!failureReported) {
failureReported = true;
window.showNotification?.('Clipboard access denied', 'error');
}
});
} catch {
if (!failureReported) {
failureReported = true;
window.showNotification?.('Clipboard access denied', 'error');
}
}
return true;
});
},

activateOsc52ClipboardHandler(terminalKey, expectedTerminal) {
const terminal = this.terminals[terminalKey];
if (
terminal !== expectedTerminal
|| !this.osc52ClipboardAllowed[terminalKey]
|| this.clipboardDisposers[terminalKey]
) {
return;
}
const disposable = this.registerOsc52ClipboardHandler(terminal);
if (disposable) {
this.clipboardDisposers[terminalKey] = disposable;
}
},

shouldProcessClipboardKeyEvent(event, terminal, isMac) {
if (event.type !== 'keydown' || event.altKey || event.shiftKey) {
return true;
Expand Down Expand Up @@ -152,7 +266,7 @@ const TerminalManager = {
return Math.min(10000, Math.max(50, parsed));
},

createTerminal(sessionId, terminalKey = null) {
createTerminal(sessionId, terminalKey = null, options = {}) {
const key = terminalKey || sessionId;
const monoFont = this.getMonoFont();
const theme = this.buildTheme();
Expand All @@ -174,6 +288,8 @@ const TerminalManager = {
this.handleClipboardKeyEvent(event, terminal, isMac)
));

this.osc52ClipboardAllowed[key] = options.allowOsc52Clipboard === true;

const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);

Expand Down Expand Up @@ -221,6 +337,9 @@ const TerminalManager = {

terminal.open(container);

this.compositionDisposers[key]?.();
this.compositionDisposers[key] = this.setupAndroidCompositionGuard(terminal);

// Add custom scrollbar on the right side of the terminal
this.setupScrollbar(container, terminal, key);

Expand All @@ -234,8 +353,20 @@ const TerminalManager = {
this.pendingOutput[key] = [];
this.terminalReady[key] = true;

existingOutput.concat(pendingOutput).forEach(data => {
this.writeOutputToTerminal(key, data);
const replayOutput = existingOutput.concat(pendingOutput);
if (replayOutput.length === 0) {
this.activateOsc52ClipboardHandler(key, terminal);
return;
}

let remainingWrites = replayOutput.length;
replayOutput.forEach(data => {
this.writeOutputToTerminal(key, data, () => {
remainingWrites -= 1;
if (remainingWrites === 0) {
this.activateOsc52ClipboardHandler(key, terminal);
}
});
});
}, 50);
});
Expand Down Expand Up @@ -281,7 +412,7 @@ const TerminalManager = {
});
},

writeOutputToTerminal(terminalKey, data) {
writeOutputToTerminal(terminalKey, data, onWritten = null) {
const terminal = this.terminals[terminalKey];
if (!terminal) {
return;
Expand All @@ -293,7 +424,7 @@ const TerminalManager = {
data = data.replace(/\x1b\[[?>]?[0-9;]*c/g, '');

if (this.terminalReady[terminalKey]) {
this.writeToTerminalWithScroll(terminal, data);
this.writeToTerminalWithScroll(terminal, data, onWritten);
} else {
if (!this.pendingOutput[terminalKey]) {
this.pendingOutput[terminalKey] = [];
Expand All @@ -310,12 +441,13 @@ const TerminalManager = {
return buffer.viewportY >= buffer.baseY;
},

writeToTerminalWithScroll(terminal, data) {
writeToTerminalWithScroll(terminal, data, onWritten = null) {
const shouldScroll = this.isTerminalAtBottom(terminal);
terminal.write(data, () => {
if (shouldScroll) {
terminal.scrollToBottom();
}
onWritten?.();
});
},

Expand Down Expand Up @@ -766,6 +898,8 @@ const TerminalManager = {
destroyTerminalKey(terminalKey, sessionId) {
const terminal = this.terminals[terminalKey];
this.scrollbarDisposers[terminalKey]?.();
this.compositionDisposers[terminalKey]?.();
this.clipboardDisposers[terminalKey]?.dispose?.();
if (terminal) {
terminal.dispose();
}
Expand All @@ -774,6 +908,9 @@ const TerminalManager = {
delete this.searchAddons[terminalKey];
delete this.terminalReady[terminalKey];
delete this.pendingOutput[terminalKey];
delete this.compositionDisposers[terminalKey];
delete this.clipboardDisposers[terminalKey];
delete this.osc52ClipboardAllowed[terminalKey];

if (sessionId && this.sessionTerminals[sessionId]) {
this.sessionTerminals[sessionId] = this.sessionTerminals[sessionId].filter(key => key !== terminalKey);
Expand Down
88 changes: 88 additions & 0 deletions tests/e2e/session-workspace.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,24 @@ async function seedLinuxSession(page, options = {}) {
};

window.__createWorkspaceSession = function createWorkspaceSession() {
if (seedOptions.bufferedOutput) {
TerminalManager.seedRestoredOutput(
'workspace-linux',
seedOptions.bufferedOutput,
1,
);
}
SessionManager.createSession({
session_id: 'workspace-linux',
host: 'edge-01.example',
port: 22,
username: 'ops',
display_name: 'Production Edge',
file_source: linuxFileSource,
use_tmux: seedOptions.useTmux === true,
tmux_session_name: seedOptions.useTmux
? 'webssh_ops_edge_01'
: null,
});
SessionManager.assignSessionToPane('workspace-linux', 0);
};
Expand Down Expand Up @@ -349,6 +360,83 @@ test('terminal selections copy through keyboard and command palette actions', as
await assertNoExternalRequests(page);
});

test('Android IME composition sends the complete value without its stale prefix', async ({ page }) => {
await login(page);
await seedLinuxSession(page);
await page.evaluate(() => {
const terminalKey = TerminalManager.sessionTerminals['workspace-linux'][0];
const terminal = TerminalManager.terminals[terminalKey];
const textarea = terminal.textarea;
window.__androidCompositionDispose = TerminalManager.setupAndroidCompositionGuard(
terminal,
true,
);

textarea.value = '1';
textarea.setSelectionRange(1, 1);
textarea.dispatchEvent(new CompositionEvent('compositionstart', {
bubbles: true,
}));
textarea.value = '12345';
textarea.setSelectionRange(5, 5);
textarea.dispatchEvent(new CompositionEvent('compositionupdate', {
bubbles: true,
data: '12345',
}));
textarea.dispatchEvent(new CompositionEvent('compositionend', {
bubbles: true,
data: '12345',
}));
});

await expect.poll(() => page.evaluate(() => window.__workspaceEvents
.filter(event => event.event === 'ssh_input')
.map(event => event.payload.data))).toContain('12345');
expect(await page.evaluate(() => window.__workspaceEvents
.filter(event => event.event === 'ssh_input')
.map(event => event.payload.data))).not.toContain('2345');
await page.evaluate(() => window.__androidCompositionDispose());
await assertNoExternalRequests(page);
});

test('tmux ignores replayed OSC 52 and accepts a live clipboard selection', async ({ page }) => {
await login(page);
await seedLinuxSession(page, {
useTmux: true,
bufferedOutput: '\u001b]52;;c3RhbGUgdG11eCBzZWxlY3Rpb24=\u0007',
});

await page.waitForTimeout(100);
expect(await page.evaluate(() => window.__workspaceClipboard)).toBeNull();

await page.evaluate(() => {
const terminalKey = TerminalManager.sessionTerminals['workspace-linux'][0];
TerminalManager.terminals[terminalKey].write(
'\u001b]52;;dG11eCBzZWxlY3Rpb24=\u0007',
);
});

await expect.poll(() => page.evaluate(() => window.__workspaceClipboard))
.toBe('tmux selection');
await assertNoExternalRequests(page);
});

test('plain SSH sessions ignore remote OSC 52 clipboard writes', async ({ page }) => {
await login(page);
await seedLinuxSession(page);

await page.evaluate(() => {
const terminalKey = TerminalManager.sessionTerminals['workspace-linux'][0];
TerminalManager.terminals[terminalKey].write(
'\u001b]52;;dW50cnVzdGVkIHJlbW90ZSBvdXRwdXQ=\u0007',
);
});
await page.waitForTimeout(100);

expect(await page.evaluate(() => window.__workspaceClipboard)).toBeNull();
await assertNoExternalRequests(page);
});

test('single-session workspace keeps terminal primary with on-demand Files, Diagnostics, and Notes', async ({ page }, testInfo) => {
await login(page);
await seedLinuxSession(page);
Expand Down
Loading
Loading