// Terminal panes of the workspace: a shell on a machine, or a shell the // machine opens by SSH. Also the touch helpers for typing in them (extra // keys, long-press copy). The workspace (workspace.js) holds the tabs. // Touch helpers, for the terminal being typed in: the extra keys with their // CTRL / ALT, and selecting and copying with a long press. window.TermKeys = (function () { const touch = window.matchMedia('(pointer: coarse)').matches; const cur = () => window.TermPane && window.TermPane.active(); // CTRL and ALT: a tap arms the key for the next key press, a long press // keeps it on until tapped again. const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked const modBtn = {}; document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; }); function showMods() { for (const m in mods) { modBtn[m].classList.toggle('on', mods[m] === 1); modBtn[m].classList.toggle('lock', mods[m] === 2); } } function useMods() { const used = { ctrl: !!mods.ctrl, alt: !!mods.alt }; for (const m in mods) if (mods[m] === 1) mods[m] = 0; showMods(); return used; } function ctrlChar(c) { if (c === ' ' || c === '@') return '\x00'; if (c === '?') return '\x7f'; const code = c.toUpperCase().charCodeAt(0); return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c; } function withMods(data) { if (!mods.ctrl && !mods.alt) return data; const m = useMods(); if (m.ctrl && data.length === 1) data = ctrlChar(data); return m.alt ? '\x1b' + data : data; } // xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4). const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' }; const TILDE = { pgup: 5, pgdn: 6 }; function keySeq(k, term) { const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0); if (CSI[k]) { if (p > 1) return '\x1b[1;' + p + CSI[k]; return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k]; } if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~'; let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k; if (m.ctrl && s.length === 1) s = ctrlChar(s); return m.alt ? '\x1b' + s : s; } let press = null; document.querySelectorAll('#keys button').forEach(function (b) { // Keep the focus (and the on-screen keyboard) on the terminal. b.addEventListener('pointerdown', function (e) { e.preventDefault(); const mod = b.dataset.mod; if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) }; }); b.addEventListener('pointerup', function (e) { e.preventDefault(); const mod = b.dataset.mod; if (mod) { if (press && press.mod === mod) { clearTimeout(press.timer); press = null; mods[mod] = mods[mod] ? 0 : 1; showMods(); } return; } const s = cur(); if (s) s.send(keySeq(b.dataset.k, s.term)); }); b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } }); b.addEventListener('contextmenu', function (e) { e.preventDefault(); }); }); // --- Selecting and copying on touch screens --- // xterm.js has no touch selection of its own. A long press selects the word // under the finger and dragging on from there stretches the selection; a // menu then copies it or pastes. A plain tap or swipe still types and scrolls. const menu = document.getElementById('selmenu'); let selecting = false; function showMenu() { const s = cur(); const keys = document.getElementById('keys').getBoundingClientRect(); menu.style.top = ''; menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px'; menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection()); menu.hidden = false; } function selectionChanged(s) { if (!touch || s !== cur()) return; if (s.term.hasSelection()) showMenu(); else if (!selecting) menu.hidden = true; } if (touch) { const box = document.getElementById('area'); let timer = null, start = null, anchor = null; function cellAt(term, el, x, y) { const r = el.querySelector('.xterm-screen').getBoundingClientRect(); const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols)))); const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows)))); return { col: col, row: row + term.buffer.active.viewportY }; } function selectWord(term, c) { const line = term.buffer.active.getLine(c.row); const text = line ? line.translateToString(false) : ''; const word = ch => ch && !/\s/.test(ch); let a = c.col, b = c.col; if (word(text[c.col])) { while (a > 0 && word(text[a - 1])) a--; while (b < text.length - 1 && word(text[b + 1])) b++; } term.select(a, c.row, b - a + 1); anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b }; } function stretch(term, c) { const p = c.row * term.cols + c.col; const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p); term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1); } function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; } box.addEventListener('touchstart', function (e) { if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; } const t = e.touches[0]; start = { x: t.clientX, y: t.clientY }; if (!menu.hidden) hideMenu(); clearTimeout(timer); timer = setTimeout(function () { const s = cur(); if (!s) return; selecting = true; selectWord(s.term, cellAt(s.term, s.el, start.x, start.y)); showMenu(); if (navigator.vibrate) navigator.vibrate(15); }, 450); }, { passive: true, capture: true }); box.addEventListener('touchmove', function (e) { const t = e.touches[0], s = cur(); if (selecting && s) { e.preventDefault(); // no scrolling while the selection stretches e.stopPropagation(); stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY)); } else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) { clearTimeout(timer); // a swipe: let it scroll } }, { passive: false, capture: true }); box.addEventListener('touchend', function (e) { clearTimeout(timer); if (!selecting) return; selecting = false; e.preventDefault(); // no tap: the keyboard stays as it was e.stopPropagation(); showMenu(); }, { passive: false, capture: true }); box.addEventListener('contextmenu', function (e) { e.preventDefault(); }); const statusEl = document.getElementById('status'); function flash(t, cls) { statusEl.textContent = t; statusEl.className = cls; setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500); } function copyText(text) { if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); // Plain http: the old way, through a field that is there for a moment. const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу')); } menu.addEventListener('pointerdown', function (e) { e.preventDefault(); }); menu.addEventListener('click', function (e) { const b = e.target.closest('button'), s = cur(); if (!b || !s) return; const a = b.dataset.a; if (a === 'copy') { const text = s.term.getSelection(); hideMenu(); copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); }); } else if (a === 'paste') { hideMenu(); if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; } navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); }); } else if (a === 'all') { s.term.selectAll(); showMenu(); } else { hideMenu(); } }); } return { withMods: withMods, selectionChanged: selectionChanged }; })(); (function () { 'use strict'; const W = window.Workspace; const touch = W.touch; const statusEl = document.getElementById('status'); const base = '/api/panel/term/'; const X = ''; function bytesToB64(bytes) { let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function b64ToBytes(b64) { const bin = atob(b64), u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u; } // ---------- copy and paste by keys ---------- // Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and // interrupts as ever when there is none; Ctrl+Shift+C always copies (and is // kept from the browser, whose developer tools it would open); Ctrl+V and // Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too. const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); function copyText(text) { if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); // Plain http: the old way, through a field that is there for a moment. const ta = document.createElement('textarea'), back = document.activeElement; ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); if (back && back.focus) back.focus(); return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу')); } function clipKeys(s, ev) { if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true; const c = ev.code === 'KeyC', v = ev.code === 'KeyV'; if (!c && !v) return true; if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell // From here the key is ours: xterm does not turn it into a control code. if (ev.type === 'keydown' && c) { ev.preventDefault(); if (s.term.hasSelection()) { const text = s.term.getSelection(); s.term.clearSelection(); copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err')); } } // Ctrl+V: the browser's paste goes on into the terminal's text field. return false; } function flashStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls; clearTimeout(flashStatus.t); flashStatus.t = setTimeout(W.showStatus, 1500); } // ---------- one shell ---------- class Session { // ssh, when given, is where the agent connects by SSH for this shell: // {host, port, user} and the secret to sign in with, sent once on open. constructor(agent, ssh) { this.agent = agent; this.ssh = ssh || null; this.kind = ssh ? 'ssh' : 'term'; // Where its files are, for dropped paths: the machine, or the SSH host // through it. this.source = ssh ? {kind: 'ssh', id: agent.id + '|' + ssh.user + '@' + ssh.host + ':' + (ssh.port || 22), label: ssh.user + '@' + ssh.host} : {kind: 'agent', id: agent.id, label: agent.name}; this.sid = null; this.closed = false; this.connected = false; this.state = 'wait'; this.text = 'подключение…'; this.inSeq = 0; this.inBusy = false; this.inQueue = []; this.el = document.createElement('div'); this.el.className = 'pane'; this.el.innerHTML = '
' + '
' + '
'; this.el.querySelector('.pane-title').textContent = who(this); this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name; if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', 'SSH'); this.term = new Terminal({ cursorBlink: true, fontSize: touch ? 13 : 14, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' } }); this.fitter = new FitAddon.FitAddon(); this.term.loadAddon(this.fitter); this.term.open(this.el.querySelector('.pane-body')); this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev)); this.term.onData(d => this.send(TermKeys.withMods(d))); this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows })); this.term.onSelectionChange(() => TermKeys.selectionChanged(this)); this.el.addEventListener('pointerdown', () => W.setActivePane(this), true); this.term.textarea.addEventListener('focus', () => W.setActivePane(this)); this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); W.closePane(this); }; if (W.desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => W.startDrag(ev, 'pane', this)); this.el.querySelector('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); }); this.open(); } setState(state, text) { this.state = state; this.text = text; this.el.querySelector('.dot').className = 'dot ' + state; const t = W.tabOf(this); if (t) W.renderTab(t); W.showStatus(); } async open() { try { let opt = { method: 'POST' }; if (this.ssh) { opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) }; // The password or key goes once, to the agent; the page keeps no copy. this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user }; } const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt); if (!r.ok) { let why = 'HTTP ' + r.status; try { why = (await r.json()).error || why; } catch (e) { /* plain text */ } this.setState('err', 'ошибка открытия: ' + why); this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n'); return; } this.sid = (await r.json()).session_id; this.setState('wait', 'ожидание агента…'); // An agent from before SSH terminals does not answer the request. if (this.ssh) setTimeout(() => { if (!this.closed && !this.ended && this.text === 'ожидание агента…') { this.setState('err', 'агент не начал SSH-подключение'); this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n'); } }, 20000); this.fit(); // A WebSocket if it connects; the long polls otherwise, and from a // dropped WebSocket on for the rest of this session. if (!(await this.openWS())) this.poll(); this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }); } catch (e) { this.setState('err', 'ошибка сети'); this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n'); } } openWS() { return new Promise(resolve => { if (!window.WebSocket) { resolve(false); return; } const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws'); let opened = false; const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000); ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); }; ws.onmessage = ev => { let j; try { j = JSON.parse(ev.data); } catch (e) { return; } this.take(j); }; ws.onclose = () => { clearTimeout(timer); if (!opened) { resolve(false); return; } this.ws = null; if (!this.closed && !this.ended) this.poll(); }; }); } // take applies one answer of the output channel, either transport's. take(j) { if (j.closed) { this.finish('[сессия завершена]'); return false; } for (const f of j.frames || []) { if (f.status) this.sshStatus(f.status, f.note || ''); if (f.exit) { this.finish('[сессия завершена]'); return false; } if (f.data) { if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); } this.term.write(b64ToBytes(f.data)); } } return true; } // sshStatus shows how the agent's SSH connection goes. sshStatus(st, note) { if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…'); else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…'); else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); } else if (st === 'err') this.setState('err', 'SSH: ' + note); } async poll() { while (!this.closed && this.sid) { let r; try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); } catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; } if (this.closed) return; if (!r.ok) { this.finish('[соединение закрыто]'); return; } if (!this.take(await r.json())) return; } } finish(msg) { if (this.ended) return; this.ended = true; if (this.state !== 'err') this.setState('off', 'соединение закрыто'); this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n'); } // Input goes out one request at a time, so on a slow link a keystroke can // never overtake the one before it; a request that fails goes again under // the same number, and the server drops one it already has. queueIn(m) { if (this.closed || !this.sid) return; const q = this.inQueue, last = q[q.length - 1]; if (last && last.t === 'i' && m.t === 'i') last.text += m.text; else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m; else q.push(m); this.pumpIn(); } async pumpIn() { if (this.inBusy || !this.inQueue.length) return; this.inBusy = true; while (this.inQueue.length && !this.closed) { const m = this.inQueue.shift(); const body = m.t === 'i' ? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq } : { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq }; // On the WebSocket a message goes out in order with no answer to wait // for; numbered as well, so a repeat by the long polls is dropped. if (this.ws && this.ws.readyState === 1) { try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ } } for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) { let r = null; try { r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (e) { /* no answer: send it again */ } if (r && r.status < 500) break; await new Promise(res => setTimeout(res, wait)); } } this.inBusy = false; } send(data) { this.queueIn({ t: 'i', text: data }); } fit() { const b = this.el.querySelector('.pane-body'); if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } } } title() { return this.ssh ? this.ssh.host : this.agent.name; } tooltip() { return who(this); } live() { return !!this.sid && !this.ended && !this.closed; } focus() { this.term.focus(); } dropTarget() { return {kind: 'term', source: this.source}; } // Files dropped here from the same place: their paths, quoted, typed in. drop(item) { this.send(WSUtil.shellQuoteAll(item.paths) + ' '); this.term.focus(); } close() { if (this.closed) return; this.closed = true; if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } } if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {}); this.term.dispose(); this.el.remove(); } } // who is what a shell is signed in as, for titles. function who(s) { return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host; } W.paneTypes.term = agent => new Session(agent); W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh); // For the touch helpers: the terminal being typed in. window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }}; })();