diff --git a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md index 7ea686f..54c2b2c 100644 --- a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md +++ b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md @@ -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 diff --git a/static/js/session-manager.js b/static/js/session-manager.js index e22675b..35a6223 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -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, }; @@ -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 + ), + }); TerminalManager.attachTerminal(session_id, terminalId); TerminalManager.setupInputHandler(session_id, (data) => { if (window.socket) { diff --git a/static/js/terminal-manager.js b/static/js/terminal-manager.js index 87905fb..454f087 100644 --- a/static/js/terminal-manager.js +++ b/static/js/terminal-manager.js @@ -12,6 +12,9 @@ const TerminalManager = { lastOutputSequences: {}, syncedSizes: {}, scrollbarDisposers: {}, + compositionDisposers: {}, + clipboardDisposers: {}, + osc52ClipboardAllowed: {}, isVirtualKeyboardVisible(visualViewportHeight, layoutViewportHeight) { if (visualViewportHeight <= 0 || layoutViewportHeight <= 0) { @@ -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; @@ -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(); @@ -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); @@ -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); @@ -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); }); @@ -281,7 +412,7 @@ const TerminalManager = { }); }, - writeOutputToTerminal(terminalKey, data) { + writeOutputToTerminal(terminalKey, data, onWritten = null) { const terminal = this.terminals[terminalKey]; if (!terminal) { return; @@ -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] = []; @@ -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?.(); }); }, @@ -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(); } @@ -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); diff --git a/tests/e2e/session-workspace.spec.js b/tests/e2e/session-workspace.spec.js index 3be515e..69fdc88 100644 --- a/tests/e2e/session-workspace.spec.js +++ b/tests/e2e/session-workspace.spec.js @@ -271,6 +271,13 @@ 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', @@ -278,6 +285,10 @@ async function seedLinuxSession(page, options = {}) { 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); }; @@ -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); diff --git a/tests/js/session-manager-close.test.js b/tests/js/session-manager-close.test.js index 95201ba..3def432 100644 --- a/tests/js/session-manager-close.test.js +++ b/tests/js/session-manager-close.test.js @@ -255,7 +255,13 @@ test('seeds restored output before creating and attaching the terminal', () => { calls.push(['seed', sessionId, output, sequence]); }; manager.createSession = data => { - calls.push(['create', data.session_id, data.restored]); + calls.push([ + 'create', + data.session_id, + data.restored, + data.use_tmux, + data.tmux_session_name, + ]); return data.session_id; }; manager.getFirstEmptyPaneIndex = () => 0; @@ -268,11 +274,13 @@ test('seeds restored output before creating and attaching the terminal', () => { username: 'admin', buffered_output: 'switch# ', output_sequence: 14, + use_tmux: true, + tmux_session_name: 'webssh_admin_switch', }); assert.deepEqual(calls, [ ['seed', 'restored', 'switch# ', 14], - ['create', 'restored', true], + ['create', 'restored', true, true, 'webssh_admin_switch'], ['assign', 'restored'], ]); }); diff --git a/tests/js/terminal-manager-layout.test.js b/tests/js/terminal-manager-layout.test.js index cdec868..ad1ee82 100644 --- a/tests/js/terminal-manager-layout.test.js +++ b/tests/js/terminal-manager-layout.test.js @@ -39,6 +39,92 @@ test('virtual keyboard detection follows visual viewport occlusion, not browser assert.equal(TerminalManager.isVirtualKeyboardVisible(0, 640), false); }); +test('Android compositions discard stale helper input before xterm records the offset', () => { + const listeners = new Map(); + const textarea = { + value: '1', + addEventListener(name, listener, capture) { + listeners.set(`${name}:${capture}`, listener); + }, + removeEventListener(name, listener, capture) { + const key = `${name}:${capture}`; + if (listeners.get(key) === listener) listeners.delete(key); + }, + setSelectionRange(start, end) { + this.selectionStart = start; + this.selectionEnd = end; + }, + }; + + const dispose = TerminalManager.setupAndroidCompositionGuard({ + textarea, + options: {screenReaderMode: false}, + }, true); + listeners.get('compositionstart:true')(); + + assert.equal(textarea.value, ''); + assert.equal(textarea.selectionStart, 0); + assert.equal(textarea.selectionEnd, 0); + + dispose(); + assert.equal(listeners.size, 0); +}); + +test('Android composition guard preserves screen-reader helper input', () => { + let listenerAdded = false; + const textarea = { + value: 'screen reader context', + addEventListener() { listenerAdded = true; }, + }; + + TerminalManager.setupAndroidCompositionGuard({ + textarea, + options: {screenReaderMode: true}, + }, true); + + assert.equal(listenerAdded, false); + assert.equal(textarea.value, 'screen reader context'); +}); + +test('OSC 52 clipboard payloads are bounded, targeted, and decoded as UTF-8', () => { + assert.equal( + TerminalManager.decodeOsc52Clipboard('c;dG11eCDinJM='), + 'tmux ✓', + ); + assert.equal(TerminalManager.decodeOsc52Clipboard(';dGVybWluYWw='), 'terminal'); + assert.equal(TerminalManager.decodeOsc52Clipboard('p;dGVybWluYWw='), null); + assert.equal(TerminalManager.decodeOsc52Clipboard('c;?'), null); + assert.equal(TerminalManager.decodeOsc52Clipboard('c;%%%'), null); + assert.equal(TerminalManager.decodeOsc52Clipboard('c;dG9vIGxhcmdl', 4), null); +}); + +test('OSC 52 handler writes valid tmux selections to the browser clipboard', async () => { + const writes = []; + let handler; + global.navigator.clipboard = { + writeText(text) { + writes.push(text); + return Promise.resolve(); + }, + }; + const disposable = {dispose() {}}; + const terminal = { + parser: { + registerOscHandler(identifier, callback) { + assert.equal(identifier, 52); + handler = callback; + return disposable; + }, + }, + }; + + assert.equal(TerminalManager.registerOsc52ClipboardHandler(terminal), disposable); + assert.equal(handler(';dG11eCBzZWxlY3Rpb24='), true); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(writes, ['tmux selection']); + delete global.navigator.clipboard; +}); + test('copy shortcuts write xterm selection directly to the clipboard', async () => { const writes = []; global.navigator.clipboard = { @@ -410,15 +496,18 @@ test('attachTerminal replays output received before and during terminal attachme const originalRequestAnimationFrame = global.requestAnimationFrame; const originalSetupScrollbar = TerminalManager.setupScrollbar; const originalFitTerminal = TerminalManager.fitTerminal; + const originalRegisterOsc52ClipboardHandler = TerminalManager.registerOsc52ClipboardHandler; const originalConsoleError = console.error; const writes = []; + const writeCallbacks = []; + const clipboardActivations = []; const terminal = { buffer: { active: { viewportY: 0, baseY: 0 } }, open() {}, clear() {}, write(data, callback) { writes.push(data); - callback?.(); + writeCallbacks.push(callback); }, scrollToBottom() {}, }; @@ -428,6 +517,8 @@ test('attachTerminal replays output received before and during terminal attachme TerminalManager.sessionTerminals = {}; TerminalManager.pendingOutput = {}; TerminalManager.terminalReady = {}; + TerminalManager.osc52ClipboardAllowed = {switchTerminal: true}; + TerminalManager.clipboardDisposers = {}; TerminalManager.transcripts = {}; TerminalManager.transcriptSizes = {}; console.error = () => {}; @@ -440,6 +531,10 @@ test('attachTerminal replays output received before and during terminal attachme global.requestAnimationFrame = callback => callback(); TerminalManager.setupScrollbar = () => {}; TerminalManager.fitTerminal = () => {}; + TerminalManager.registerOsc52ClipboardHandler = target => { + clipboardActivations.push(target); + return {dispose() {}}; + }; assert.equal( TerminalManager.attachTerminal('switch-session', 'terminal-container', 'switchTerminal'), @@ -450,11 +545,17 @@ test('attachTerminal replays output received before and during terminal attachme await new Promise(resolve => setTimeout(resolve, 80)); assert.deepEqual(writes, ['Switch#', ' ready']); + assert.deepEqual(clipboardActivations, []); + writeCallbacks[0](); + assert.deepEqual(clipboardActivations, []); + writeCallbacks[1](); + assert.deepEqual(clipboardActivations, [terminal]); } finally { global.document.getElementById = originalGetElementById; global.requestAnimationFrame = originalRequestAnimationFrame; TerminalManager.setupScrollbar = originalSetupScrollbar; TerminalManager.fitTerminal = originalFitTerminal; + TerminalManager.registerOsc52ClipboardHandler = originalRegisterOsc52ClipboardHandler; console.error = originalConsoleError; } });