diff --git a/src/i18n.js b/src/i18n.js index b50e4a3..1feb7cb 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -156,6 +156,8 @@ 'sudo password': 'sudo-Passwort', 'sudo password for {user}': 'sudo-Passwort für {user}', 'Wrong password. sudo password for {user}': 'Falsches Passwort. sudo-Passwort für {user}', + 'Open in new window': 'In neuem Fenster öffnen', + 'Back to main window': 'Zurück ins Hauptfenster', // Support-Assistent 'Delete chat': 'Chat löschen', 'Delete chat?': 'Chat löschen?', diff --git a/src/main/main.js b/src/main/main.js index cab4df8..815ba1c 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -43,8 +43,19 @@ process.on('uncaughtException', (e) => { }); process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e)); +// Abgelöste Tabs laufen in eigenen Fenstern. Ereignisse gehen an alle Fenster (die Oberfläche filtert nach Sitzungs-ID), +// Passwortabfragen nur an das Fenster, das die Sitzung zeigt; Kopplung/Updates nur an das Hauptfenster. +const tabWins = new Set(); +const sessionOwner = new Map(); // Sitzungs-ID -> BrowserWindow (nur für abgelöste Fenster) +const MAIN_ONLY = new Set(['sync:pairPrompt', 'update:available']); +const alive = (w) => w && !w.isDestroyed(); function send(channel, ...args) { - if (win && !win.isDestroyed()) win.webContents.send(channel, ...args); + if (channel === 'secret:request') { + const w = sessionOwner.get(args[0]?.sessionId); + if (alive(w)) return w.webContents.send(channel, ...args); + } + if (alive(win)) win.webContents.send(channel, ...args); + if (!MAIN_ONLY.has(channel)) for (const w of tabWins) if (alive(w)) w.webContents.send(channel, ...args); } async function confirmHostKey(target, fingerprint, known) { @@ -91,12 +102,13 @@ const sync = new SyncService(store, send, (req) => new Promise((resolve) => { ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); }); store.onChange = () => sync.schedule(); -function createWindow() { - win = new BrowserWindow({ +function newWindow(opts = {}) { + const w = new BrowserWindow({ width: 1360, height: 860, - minWidth: 900, - minHeight: 560, + minWidth: 600, + minHeight: 400, + ...opts, backgroundColor: '#14161d', icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'), frame: false, @@ -108,18 +120,57 @@ function createWindow() { sandbox: true, }, }); + w.on('maximize', () => w.webContents.send('win:state', true)); + w.on('unmaximize', () => w.webContents.send('win:state', false)); + w.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); + return w; +} + +function createWindow() { + win = newWindow({ minWidth: 900, minHeight: 560 }); Menu.setApplicationMenu(null); win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); - win.on('maximize', () => send('win:state', true)); + // Hauptfenster zu = MrTerm beenden, auch abgelöste Tab-Fenster schließen + win.on('closed', () => { for (const w of tabWins) if (alive(w)) w.close(); }); // Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } }; win.on('move', followRdp); win.on('resize', followRdp); win.on('restore', followRdp); - win.on('unmaximize', () => send('win:state', false)); - win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); } +// Tab in ein eigenes Fenster lösen: data = { kind, host, id, title, … } wird vom neuen Fenster per win:handoff abgeholt +const handoffs = new Map(); +function openTabWindow(data, pos) { + const token = crypto.randomUUID(); + handoffs.set(token, data); + const w = newWindow({ width: 1000, height: 650, ...(pos ? { x: Math.round(pos.x - 120), y: Math.round(pos.y - 20) } : {}) }); + tabWins.add(w); + w.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'), { query: { detached: token } }); + // Fenster zu: dort noch offene Sitzungen beenden + w.on('closed', () => { + tabWins.delete(w); + for (const [id, o] of sessionOwner) { + if (o !== w) continue; + sessionOwner.delete(id); + for (const m of [ssh, docker, firewall, network]) { try { m.close(id); } catch {} } + } + }); +} +ipcMain.handle('win:detach', (_e, data, pos) => { openTabWindow(data, pos); return true; }); +ipcMain.handle('win:handoff', (_e, token) => { const d = handoffs.get(token); handoffs.delete(token); return d || null; }); +// Sitzung gehört (jetzt) dem aufrufenden Fenster; im Hauptfenster gilt keine Zuordnung +ipcMain.on('win:claim', (e, id) => { + const w = BrowserWindow.fromWebContents(e.sender); + if (w === win) sessionOwner.delete(id); else sessionOwner.set(id, w); +}); +// Tab ins Hauptfenster zurück +ipcMain.handle('win:attach', (_e, data) => { if (alive(win)) { win.webContents.send('win:adopt', data); win.focus(); } return true; }); +// Ein anderes Fenster hat den Tab übernommen (Drag & Drop): beim bisherigen Fenster ohne Trennen entfernen +ipcMain.on('win:released', (e, id) => { + for (const w of [win, ...tabWins]) if (alive(w) && w.webContents !== e.sender) w.webContents.send('win:released', id); +}); + // Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar const OPEN_WHILE_LOCKED = new Set(['app:version']); function handle(channel, fn) { @@ -133,9 +184,10 @@ function handle(channel, fn) { } // ---------- Fenster ---------- -ipcMain.on('win:min', () => win.minimize()); -ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize())); -ipcMain.on('win:close', () => win.close()); +const senderWin = (e) => BrowserWindow.fromWebContents(e.sender); +ipcMain.on('win:min', (e) => senderWin(e)?.minimize()); +ipcMain.on('win:max', (e) => { const w = senderWin(e); if (w) w.isMaximized() ? w.unmaximize() : w.maximize(); }); +ipcMain.on('win:close', (e) => senderWin(e)?.close()); // ---------- App-Sperre (Passwort / FIDO2) ---------- const kdf = (pw, salt, N) => new Promise((resolve, reject) => diff --git a/src/preload.js b/src/preload.js index d3e5a99..fcbe7a7 100644 --- a/src/preload.js +++ b/src/preload.js @@ -20,6 +20,12 @@ contextBridge.exposeInMainWorld('api', { min: () => ipcRenderer.send('win:min'), max: () => ipcRenderer.send('win:max'), close: () => ipcRenderer.send('win:close'), + // Tabs lösen / zurückholen + detach: (data, pos) => ipcRenderer.invoke('win:detach', data, pos), + attach: (data) => ipcRenderer.invoke('win:attach', data), + handoff: (token) => ipcRenderer.invoke('win:handoff', token), + claim: (id) => ipcRenderer.send('win:claim', id), + released: (id) => ipcRenderer.send('win:released', id), }, ssh: { write: (id, d) => ipcRenderer.send('ssh:write', id, d), diff --git a/src/renderer/app.js b/src/renderer/app.js index 90d6817..c1b4c0f 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -159,6 +159,12 @@ async function reload() { S.activeForwards = new Set(await call('fw:active')); if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({})); render(); + // Abgelöstes Fenster: übergebenen Tab erst nach dem Entsperren übernehmen (vorher fehlen Tresor und Einstellungen) + if (DETACHED && !S.handoffDone) { + S.handoffDone = true; + const d = await api.win.handoff(DETACHED); + if (d) adoptTab(d); else api.win.close(); + } } // Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title) @@ -1547,10 +1553,14 @@ function addTab(session) { ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []), ...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []), { label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, + ...(DETACHABLE.has(session.kind) ? ['-', { label: T('Open in new window'), icon: 'dup', run: () => detachTab(session) }, + ...(DETACHED ? [{ label: T('Back to main window'), icon: 'home', run: () => attachTab(session) }] : [])] : []), '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) }, { label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) }, ]); session.tabEl = tab; + tabDrag(tab, session); + if (!WEB) api.win.claim(session.id); $('#tabs').append(tab); $('#sessions').append(session.el); activateTab(session.id); @@ -1578,17 +1588,101 @@ function closeTab(id) { t.dispose(); t.el.remove(); t.tabEl.remove(); S.tabs.splice(i, 1); + if (DETACHED && !S.tabs.length) return api.win.close(); if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home'); } $('.tab.home').onclick = () => activateTab('home'); +// ============================================================ Tabs verschieben, lösen und zurückholen (Desktop) +// Ziehen in der Tableiste = Reihenfolge ändern; aus dem Fenster ziehen = eigenes Fenster; +// in die Tableiste eines anderen MrTerm-Fensters ziehen = dorthin verschieben. Terminals behalten dabei ihre SSH-Verbindung. +const DETACHED = !WEB && new URLSearchParams(location.search).get('detached'); +const DETACHABLE = new Set(WEB ? [] : ['ssh', 'sftp', 'docker', 'firewall', 'network']); +const TAB_MIME = 'application/x-mrterm-tab'; + +function tabHandoff(t) { + const d = { kind: t.kind, host: t.host, id: t.id, title: t.title }; + if (t.kind === 'ssh') Object.assign(d, { text: t.bufferText(), alive: t.alive }); + return d; +} +// Tab hier entfernen, ohne die SSH-Verbindung zu trennen (andere Tabarten verbinden im neuen Fenster neu) +function releaseTab(t) { t.keepAlive = true; closeTab(t.id); } +function adoptTab(d) { + if (d.kind === 'ssh') return new TerminalSession(d.host, d); + return ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[d.kind])?.(d.host); +} +async function detachTab(t, pos) { await api.win.detach(tabHandoff(t), pos); releaseTab(t); } +async function attachTab(t) { await api.win.attach(tabHandoff(t)); releaseTab(t); } + +function tabDrag(tab, session) { + tab.draggable = true; + tab.addEventListener('dragstart', (e) => { + S.dragTab = session; + e.dataTransfer.effectAllowed = 'move'; + if (DETACHABLE.has(session.kind)) e.dataTransfer.setData(TAB_MIME, JSON.stringify(tabHandoff(session))); + else e.dataTransfer.setData('text/plain', session.title); + }); + tab.addEventListener('dragend', (e) => { + const t = S.dragTab; S.dragTab = null; + $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); + if (!t || !DETACHABLE.has(t.kind) || e.dataTransfer.dropEffect !== 'none') return; + // Außerhalb des Fensters losgelassen → eigenes Fenster an dieser Stelle + const out = e.screenX < window.screenX || e.screenX > window.screenX + window.outerWidth || e.screenY < window.screenY || e.screenY > window.screenY + window.outerHeight; + if (out) detachTab(t, { x: e.screenX, y: e.screenY }); + }); +} +function tabDropTarget(e) { + const tabs = $$('#tabs .tab:not(.home)'); + const over = tabs.find((x) => { const r = x.getBoundingClientRect(); return e.clientX >= r.left && e.clientX <= r.right; }); + if (!over) return null; + const r = over.getBoundingClientRect(); + return { el: over, after: e.clientX > r.left + r.width / 2 }; +} +const tabsBar = $('#tabs'); +tabsBar.addEventListener('dragover', (e) => { + const local = !!S.dragTab, foreign = !local && [...e.dataTransfer.types].includes(TAB_MIME); + if (!local && !foreign) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); + const t = tabDropTarget(e); + if (t && t.el !== S.dragTab?.tabEl) t.el.classList.add(t.after ? 'drop-after' : 'drop-before'); +}); +tabsBar.addEventListener('dragleave', (e) => { if (!tabsBar.contains(e.relatedTarget)) $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); }); +tabsBar.addEventListener('drop', (e) => { + const target = tabDropTarget(e); + $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); + let moved = S.dragTab; + if (!moved) { + // Tab aus einem anderen Fenster + const raw = e.dataTransfer.getData(TAB_MIME); + if (!raw) return; + e.preventDefault(); + const d = JSON.parse(raw); + api.win.released(d.id); + moved = adoptTab(d); + if (!moved) return; + } else e.preventDefault(); + if (target && target.el !== moved.tabEl) target.el.insertAdjacentElement(target.after ? 'afterend' : 'beforebegin', moved.tabEl); + else if (!target) tabsBar.append(moved.tabEl); + // Reihenfolge für Strg+Tab / Strg+1..9 übernehmen + const order = $$('#tabs .tab[data-tab]').map((x) => x.dataset.tab); + S.tabs.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + activateTab(moved.id); +}); +if (!WEB) { + api.on('win:adopt', (d) => { adoptTab(d); }); + api.on('win:released', (id) => { const t = S.tabs.find((x) => x.id === id); if (t) releaseTab(t); }); +} + // ============================================================ Terminal-Session class TerminalSession { - constructor(host) { + // attach: { id, title, text, alive } – bestehende SSH-Sitzung aus einem anderen Fenster übernehmen + constructor(host, attach) { this.kind = 'ssh'; - this.id = uid(); + this.id = attach?.id || uid(); this.host = host; - this.title = host.label || host.address; + this.title = attach?.title || host.label || host.address; this.el = h(`