Compare commits

...
13 Commits
Author SHA1 Message Date
MrBlake ea8d73dd60 Version 0.22.0 2026-10-14 21:54:01 +02:00
MrBlakeandClaude Opus 5.5 d87feb63af Tabs: reorder by dragging; detach tabs into their own window (drag out of the window or "Open in new window") and move them back (drag into another window's tab bar or "Back to main window"); terminal tabs keep their SSH connection and output, SFTP/Docker/firewall/network reconnect; events go to all windows, password prompts to the window showing the session; closing a detached window ends its sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:54:00 +02:00
MrBlake ec5ca37356 Version 0.21.1 2026-10-14 21:45:08 +02:00
MrBlakeandClaude Opus 5.5 5c974f31ce Title bar: center tabs vertically as rounded pills (were bottom-aligned while logo, + and buttons were centered), frame the active tab, draw + as an icon and center all title bar icons at the same size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:45:05 +02:00
MrBlake 25115f8747 Version 0.21.0 2026-10-14 21:36:00 +02:00
MrBlakeandClaude Opus 5.5 c3a4d06310 sudo for firewall, network and Docker: try passwordless sudo first, then the saved host password, otherwise ask for the sudo password (up to 3 attempts, kept only for the open tab) instead of failing with "root privileges required"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:35:59 +02:00
MrBlake b4eedc77bb Version 0.20.0 2026-10-14 21:29:50 +02:00
MrBlakeandClaude Opus 5.5 93123c5c60 SFTP: upload button on the server pane (desktop: system file dialog with multi-select, uploads into the current folder); fix the backend switch (UFW/iptables) sitting too high in the firewall tab bar (.seg bottom margin was only reset for Docker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:29:49 +02:00
MrBlake 3714205dab Version 0.19.1 2026-10-13 21:53:46 +02:00
MrBlake 4818523b29 Version 0.18.1 2026-10-13 21:50:56 +02:00
MrBlakeandClaude Opus 5.5 6f52873b69 Support assistant: fix the delete-chat confirmation opening twice (panel click handler was added again on every reload)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:50:55 +02:00
MrBlakeandClaude Opus 5.5 51bd769d6e Version 0.19.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:34 +02:00
MrBlakeandClaude Opus 5.5 136e4b4e2c RDP tabs on Windows: no visible mstsc windows and resolution follows the tab size. mstsc is started with /v: instead of an unsigned .rdp file (avoids the mandatory "unknown publisher" security warning; a file is still used when the host needs drive redirection, clipboard/audio off or scaling); a WinEvent hook adopts the session window while it is still hidden (borderless, parked off-screen until logged in), keeps the "connecting" progress dialog invisible and shows real prompts (certificate, errors) centered over the tab; servers without dynamic resolution (mstsc clamps the window to the session size) are detected and reconnected with the new size after resizing, re-confirming a certificate the user already accepted for this session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:30 +02:00
14 changed files with 455 additions and 97 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.18.1", "version": "0.22.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.18.1", "version": "0.22.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.18.1", "version": "0.22.0",
"description": "Moderner SSH-, SFTP- und RDP-Client", "description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js", "main": "src/main/main.js",
"author": "MrBlake", "author": "MrBlake",
+8 -1
View File
@@ -151,6 +151,13 @@
'Send Enter automatically': 'Enter automatisch senden', 'Send Enter automatically': 'Enter automatisch senden',
'Command is missing.': 'Befehl fehlt.', 'Command is missing.': 'Befehl fehlt.',
'No open terminal': 'Kein offenes Terminal', 'No open terminal': 'Kein offenes Terminal',
'Upload files …': 'Dateien hochladen …',
'Root privileges are required. Log in as root or enter the password of a user with sudo rights.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder gib das Passwort eines Benutzers mit sudo-Rechten ein.',
'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 // Support-Assistent
'Delete chat': 'Chat löschen', 'Delete chat': 'Chat löschen',
'Delete chat?': 'Chat löschen?', 'Delete chat?': 'Chat löschen?',
@@ -306,6 +313,7 @@
'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.', 'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.',
'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.', 'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
'RDP session ended.': 'RDP-Sitzung beendet.', 'RDP session ended.': 'RDP-Sitzung beendet.',
'Adjusting resolution …': 'Auflösung wird angepasst …',
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).', 'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter', 'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter',
@@ -577,7 +585,6 @@
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}', 'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
'Firewall session not found': 'Firewall-Sitzung nicht gefunden', 'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}', 'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}',
'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.',
'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.', 'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.',
'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.', 'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.',
'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).', 'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).',
+13 -6
View File
@@ -2,6 +2,7 @@
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus. // Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht. // Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
const i18n = require('../i18n'); const i18n = require('../i18n');
const { acquireSudo } = require('./sudo');
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' }; const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
@@ -37,12 +38,18 @@ class DockerManager {
async open(id, host, onClose) { async open(id, host, onClose) {
const { conn, jumps } = await this.ssh.connect(host, id); const { conn, jumps } = await this.ssh.connect(host, id);
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false }); this.sessions.set(id, { id, conn, jumps, host, runtime: null, sudo: false });
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } }); conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
conn.on('error', () => {}); conn.on('error', () => {});
return this.list(id); return this.list(id);
} }
// sudo-Passwort einmal pro Sitzung ermitteln (ohne Passwort, gespeichertes Host-Passwort oder Nachfrage)
async sudo(id, s) {
if (s.sudoPw === undefined) s.sudoPw = await acquireSudo(this.ssh, id, s.host, (c, stdin) => execOn(s.conn, c, stdin));
return s.sudoPw != null;
}
get(id) { get(id) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Docker session not found')); if (!s) throw new Error(i18n.t('Docker session not found'));
@@ -51,7 +58,7 @@ class DockerManager {
run(s, args) { run(s, args) {
const sudo = s.sudo ? "sudo -S -p '' " : ''; const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : ''); return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
} }
async list(id) { async list(id) {
@@ -62,7 +69,7 @@ class DockerManager {
s.runtime = r.out.trim(); s.runtime = r.out.trim();
} }
let r = await this.run(s, `ps -a --format ${LIST_FMT}`); let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code && denied(r.err) && !s.sudo) { if (r.code && denied(r.err) && !s.sudo && await this.sudo(id, s)) {
s.sudo = true; s.sudo = true;
r = await this.run(s, `ps -a --format ${LIST_FMT}`); r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code) s.sudo = false; if (r.code) s.sudo = false;
@@ -124,8 +131,8 @@ class DockerManager {
// Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort) // Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort)
async sh(s, script) { async sh(s, script) {
let r = await execOn(s.conn, `sh -c ${q(script)}`); let r = await execOn(s.conn, `sh -c ${q(script)}`);
if (r.code && /permission denied|operation not permitted/i.test(r.err) && s.host.password) { if (r.code && /permission denied|operation not permitted/i.test(r.err) && await this.sudo(s.id, s)) {
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.host.password}\n`); r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.sudoPw}\n`);
} }
return r; return r;
} }
@@ -158,7 +165,7 @@ class DockerManager {
runCompose(s, compose, args) { runCompose(s, compose, args) {
const sudo = s.sudo ? "sudo -S -p '' " : ''; const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.host.password || ''}\n` : ''); return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
} }
async readStack(id, file) { async readStack(id, file) {
+4 -3
View File
@@ -3,6 +3,7 @@
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen. // Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
const i18n = require('../i18n'); const i18n = require('../i18n');
const { execOn } = require('./docker'); const { execOn } = require('./docker');
const { acquireSudo } = require('./sudo');
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || ''; const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const RX = { const RX = {
@@ -58,7 +59,7 @@ class FirewallManager {
// Befehl mit root-Rechten ausführen // Befehl mit root-Rechten ausführen
run(s, cmd) { run(s, cmd) {
if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`); if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`);
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`); return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
} }
async runOk(s, cmd) { async runOk(s, cmd) {
const r = await this.run(s, cmd); const r = await this.run(s, cmd);
@@ -75,8 +76,8 @@ class FirewallManager {
const uid = (await execOn(conn, 'id -u')).out.trim(); const uid = (await execOn(conn, 'id -u')).out.trim();
if (uid !== '0') { if (uid !== '0') {
s.sudo = true; s.sudo = true;
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`); s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.')); if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
} }
const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\''); const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\'');
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean); s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
+93 -20
View File
@@ -43,8 +43,19 @@ process.on('uncaughtException', (e) => {
}); });
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', 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) { 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) { 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); }); ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
store.onChange = () => sync.schedule(); store.onChange = () => sync.schedule();
function createWindow() { function newWindow(opts = {}) {
win = new BrowserWindow({ const w = new BrowserWindow({
width: 1360, width: 1360,
height: 860, height: 860,
minWidth: 900, minWidth: 600,
minHeight: 560, minHeight: 400,
...opts,
backgroundColor: '#14161d', backgroundColor: '#14161d',
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'), icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
frame: false, frame: false,
@@ -108,18 +120,57 @@ function createWindow() {
sandbox: true, 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); Menu.setApplicationMenu(null);
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); 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 // 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 {} } }; const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } };
win.on('move', followRdp); win.on('move', followRdp);
win.on('resize', followRdp); win.on('resize', followRdp);
win.on('restore', 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 // Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
const OPEN_WHILE_LOCKED = new Set(['app:version']); const OPEN_WHILE_LOCKED = new Set(['app:version']);
function handle(channel, fn) { function handle(channel, fn) {
@@ -133,9 +184,10 @@ function handle(channel, fn) {
} }
// ---------- Fenster ---------- // ---------- Fenster ----------
ipcMain.on('win:min', () => win.minimize()); const senderWin = (e) => BrowserWindow.fromWebContents(e.sender);
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize())); ipcMain.on('win:min', (e) => senderWin(e)?.minimize());
ipcMain.on('win:close', () => win.close()); 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) ---------- // ---------- App-Sperre (Passwort / FIDO2) ----------
const kdf = (pw, salt, N) => new Promise((resolve, reject) => const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
@@ -408,6 +460,11 @@ handle('sftp:open', async (sessionId, hostRef) => {
}); });
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir)); handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b)); handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
// Dateien für den SFTP-Upload auswählen (Mehrfachauswahl)
handle('sftp:pickUpload', async () => {
const r = await dialog.showOpenDialog(win, { title: i18n.t('Upload'), properties: ['openFile', 'multiSelections', 'showHiddenFiles'] });
return r.canceled ? [] : r.filePaths.map((p) => ({ path: p, name: path.basename(p) }));
});
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => { handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
let last = 0; let last = 0;
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => { await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
@@ -473,30 +530,46 @@ handle('rdp:open', async (id, hostRef, bounds) => {
const host = hostWithOverrides(hostRef); const host = hostWithOverrides(hostRef);
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
if (await vpn.ensureForHost(host)) send('vpn:changed'); if (await vpn.ensureForHost(host)) send('vpn:changed');
const emb = createEmbed(win.getNativeWindowHandle()); const sess = { cancelled: false };
const sess = { emb, cancelled: false };
rdpSessions.set(id, sess); rdpSessions.set(id, sess);
return startRdpEmbed(id, sess, host, bounds);
});
async function startRdpEmbed(id, sess, host, bounds, acceptCert = false) {
const emb = createEmbed(win.getNativeWindowHandle());
emb.acceptCert = acceptCert;
sess.emb = emb;
const size = { width: bounds.width, height: bounds.height }; const size = { width: bounds.width, height: bounds.height };
let proc; let proc;
if (process.platform === 'win32') { if (process.platform === 'win32') {
const file = rdp.writeRdpFile(host, size); const args = rdp.mstscArgs(host, size);
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ rdpFile: file })))); proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ args }))));
} else { } else {
proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password }); proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password });
} }
proc.on('error', (e) => send('rdp:event', id, 'exit', { code: -1, message: e.message })); const current = () => rdpSessions.get(id) === sess && sess.emb === emb;
proc.on('error', (e) => { if (current()) send('rdp:event', id, 'exit', { code: -1, message: e.message }); });
proc.on('exit', (code) => { proc.on('exit', (code) => {
if (!current()) return; // durch Neuverbindung ersetzt
const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || ''; const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || '';
if (rdpSessions.get(id) === sess) send('rdp:event', id, 'exit', { code, message: err }); send('rdp:event', id, 'exit', { code, message: err });
rdpSessions.delete(id); rdpSessions.delete(id);
}); });
const ok = await emb.attach(bounds, () => sess.cancelled); // Server ohne dynamische Auflösung: mit neuer Größe neu verbinden (die Sitzung auf dem Server bleibt erhalten)
if (ok) { emb.onResizeNeeded = (b) => {
if (!current()) return;
send('rdp:event', id, 'resizing');
emb.kill();
startRdpEmbed(id, sess, host, b, emb.certAccepted).then((ok) => {
if (!ok && rdpSessions.get(id) === sess && !sess.cancelled) send('rdp:event', id, 'exit', { code: -1, message: '' });
});
};
const ok = await emb.attach(bounds, () => sess.cancelled || !current());
if (ok && current()) {
if (sess.hidden) emb.hide(); if (sess.hidden) emb.hide();
send('rdp:event', id, 'attached'); send('rdp:event', id, 'attached');
} }
return ok; return ok;
}); }
ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} }); ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} });
ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } }); ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } });
ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } }); ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
+4 -3
View File
@@ -4,6 +4,7 @@
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann. // nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
const i18n = require('../i18n'); const i18n = require('../i18n');
const { execOn } = require('./docker'); const { execOn } = require('./docker');
const { acquireSudo } = require('./sudo');
const nc = require('./netconf'); const nc = require('./netconf');
const STATE = '/var/lib/mrterm-net'; const STATE = '/var/lib/mrterm-net';
@@ -127,7 +128,7 @@ class NetworkConfigManager {
root(s, script, conn = s.conn) { root(s, script, conn = s.conn) {
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`; const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
if (!s.sudo) return execOn(conn, cmd); if (!s.sudo) return execOn(conn, cmd);
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`); return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
} }
async rootOk(s, script) { async rootOk(s, script) {
const r = await this.root(s, script); const r = await this.root(s, script);
@@ -142,8 +143,8 @@ class NetworkConfigManager {
this.watch(id, s); this.watch(id, s);
if ((await execOn(conn, 'id -u')).out.trim() !== '0') { if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
s.sudo = true; s.sudo = true;
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`); s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.')); if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
} }
return this.read(id); return this.read(id);
} }
+142 -43
View File
@@ -1,5 +1,5 @@
// Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab). // Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab).
// Windows: mstsc.exe wird gestartet, sein Hauptfenster (TscShellContainerClass) per SetParent eingehängt. // Windows: mstsc.exe wird gestartet, sein Sitzungsfenster (TscShellContainerClass) rahmenlos über den Tab gelegt.
// Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland). // Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland).
// Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters. // Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters.
const { spawn } = require('child_process'); const { spawn } = require('child_process');
@@ -12,13 +12,25 @@ const i18n = require('../i18n');
// Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes // Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes
// mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window" // mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window"
// von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag. // von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag.
// Mit "dynamic resolution" passt mstsc die Auflösung der Sitzung an, sobald sich die Fenstergröße ändert.
function winApi() { function winApi() {
if (winApi.api) return winApi.api; if (winApi.api) return winApi.api;
const u = koffi.load('user32.dll'); const u = koffi.load('user32.dll');
const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)'); const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)');
const WinEventProc = koffi.proto('void __stdcall MrTermWinEventProc(intptr hook, uint32 event, intptr hwnd, long idObject, long idChild, uint32 thread, uint32 time)');
const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' }); const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' });
const RECT = koffi.struct('MrTermRECT', { left: 'int', top: 'int', right: 'int', bottom: 'int' });
winApi.api = { winApi.api = {
EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'), EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'),
EnumChildWindows: u.func('bool __stdcall EnumChildWindows(intptr hwnd, MrTermEnumProc *cb, intptr lParam)'),
SetWinEventHook: u.func('intptr __stdcall SetWinEventHook(uint32 min, uint32 max, intptr mod, MrTermWinEventProc *cb, uint32 pid, uint32 tid, uint32 flags)'),
UnhookWinEvent: u.func('bool __stdcall UnhookWinEvent(intptr hook)'),
GetDlgItem: u.func('intptr __stdcall GetDlgItem(intptr hwnd, int id)'),
PostMessageW: u.func('bool __stdcall PostMessageW(intptr hwnd, uint32 msg, uintptr w, intptr l)'),
GetAncestor: u.func('intptr __stdcall GetAncestor(intptr hwnd, uint32 flags)'),
GetWindowRect: u.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ MrTermRECT *rect)'),
SetLayeredWindowAttributes: u.func('bool __stdcall SetLayeredWindowAttributes(intptr hwnd, uint32 key, uint8 alpha, uint32 flags)'),
WinEventProc,
GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'), GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'),
GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'), GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'),
IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'), IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'),
@@ -34,58 +46,143 @@ function winApi() {
return winApi.api; return winApi.api;
} }
function findWindowsOfPid(pid, cls) { const buf = Buffer.alloc(512);
const api = winApi(); const className = (hwnd) => buf.toString('utf16le', 0, winApi().GetClassNameW(hwnd, buf, 256) * 2);
const out = []; function childClasses(hwnd) {
const buf = Buffer.alloc(512); const out = new Set();
api.EnumWindows((hwnd) => { winApi().EnumChildWindows(hwnd, (c) => { out.add(className(c)); return true; }, 0);
const p = [0];
api.GetWindowThreadProcessId(hwnd, p);
if (p[0] === pid && api.IsWindowVisible(hwnd)) {
const n = api.GetClassNameW(hwnd, buf, 256);
if (!cls || buf.toString('utf16le', 0, n * 2) === cls) out.push(hwnd);
}
return true;
}, 0);
return out; return out;
} }
const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8; const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8;
const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000; const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000;
const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000; const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000;
const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080; const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080, WS_EX_LAYERED = 0x00080000;
const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
const SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40; const SWP_NOSIZE = 0x1, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40;
const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5; const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5;
const EVENT_OBJECT_CREATE = 0x8000, EVENT_OBJECT_SHOW = 0x8002, WINEVENT_OUTOFCONTEXT = 0, GA_ROOT = 2, LWA_ALPHA = 2;
const BM_CLICK = 0xf5, ID_CERT_YES = 14004, ID_CERT_VIEW = 13443; // Zertifikatswarnung von mstsc: "Ja", "Zertifikat anzeigen"
class WinEmbed { class WinEmbed {
constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.cls = 'TscShellContainerClass'; this.visible = true; } constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.visible = true; }
start({ rdpFile }) { // args: mstsc-Kommandozeile. Mit /v:… statt .rdp-Datei zeigt mstsc keine Sicherheitswarnung "Unbekannter Herausgeber".
this.proc = spawn('mstsc.exe', rdpFile ? [rdpFile] : [], { stdio: 'ignore' }); start({ args }) {
this.proc = spawn('mstsc.exe', args, { stdio: 'ignore' });
this.hookWindows();
this.proc.on('exit', () => this.unhook());
return this.proc; return this.proc;
} }
// Wartet, bis mstsc sein Sitzungsfenster geöffnet hat (Anmelde-/Zertifikatsdialoge davor bleiben eigene Fenster) // Fängt die Fenster von mstsc ab, sobald sie entstehen (läuft über die Nachrichtenschleife des Electron-Hauptthreads):
async attach(bounds, isCancelled) { // Sitzungsfenster -> noch unsichtbar rahmenlos über den Tab legen, damit nie ein normales mstsc-Fenster aufblitzt
const t0 = Date.now(); // "Verbindung wird hergestellt" (Dialog mit Fortschrittsbalken) -> unsichtbar lassen, der Tab zeigt den Status selbst
while (Date.now() - t0 < 120000) { // andere Dialoge (Zertifikat, Fehler) -> brauchen eine Entscheidung, daher mittig über dem Tab einblenden
if (isCancelled() || this.proc.exitCode !== null) return false; hookWindows() {
const [hwnd] = findWindowsOfPid(this.proc.pid, this.cls); const api = winApi();
if (hwnd) { this.cb = koffi.register((_hook, event, hwnd, idObject, idChild) => {
const api = winApi(); try {
const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0; if (idObject !== 0 || idChild !== 0 || Number(api.GetAncestor(hwnd, GA_ROOT)) !== Number(hwnd)) return;
api.SetWindowLongPtrW(hwnd, GWL_STYLE, style); const cls = className(hwnd);
const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0); if (cls === 'TscShellContainerClass') {
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex); if (event === EVENT_OBJECT_CREATE || !this.hwnd) this.adopt(hwnd);
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent); // Besitzer = MrTerm if (event === EVENT_OBJECT_SHOW) this.onSessionShown();
this.hwnd = hwnd; } else if (cls === '#32770') {
this.setBounds(bounds); const isCert = () => api.GetDlgItem(hwnd, ID_CERT_YES) && api.GetDlgItem(hwnd, ID_CERT_VIEW);
return true; if (event === EVENT_OBJECT_CREATE) this.setAlpha(hwnd, 0);
} else if (childClasses(hwnd).has('msctls_progress32')) api.ShowWindow(hwnd, SW_HIDE);
await new Promise((r) => setTimeout(r, 250)); else if (this.acceptCert && isCert()) api.PostMessageW(api.GetDlgItem(hwnd, ID_CERT_YES), BM_CLICK, 0, 0);
} else { if (isCert()) this.certPrompted = true; this.presentDialog(hwnd); }
return false; }
} catch { /* Fenster kann bereits wieder zu sein */ }
}, koffi.pointer(api.WinEventProc));
this.hook = api.SetWinEventHook(EVENT_OBJECT_CREATE, EVENT_OBJECT_SHOW, 0, this.cb, this.proc.pid, 0, WINEVENT_OUTOFCONTEXT);
}
unhook() {
if (this.hook) { winApi().UnhookWinEvent(this.hook); this.hook = null; }
if (this.cb) { koffi.unregister(this.cb); this.cb = null; }
}
setAlpha(hwnd, alpha) {
const api = winApi();
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) | WS_EX_LAYERED) >>> 0);
api.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA);
}
presentDialog(hwnd) {
const api = winApi();
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
const r = {};
api.GetWindowRect(hwnd, r);
const b = this.bounds || { x: 0, y: 0, width: 0, height: 0 };
const pt = { x: b.x + Math.round((b.width - (r.right - r.left)) / 2), y: b.y + Math.round((b.height - (r.bottom - r.top)) / 2) };
api.ClientToScreen(this.parent, pt);
api.SetWindowPos(hwnd, 0, pt.x, pt.y, 0, 0, SWP_NOSIZE | SWP_NOZORDER);
this.setAlpha(hwnd, 255);
api.SetForegroundWindow(hwnd);
}
// Sitzungsfenster rahmenlos und ohne Taskleisten-Eintrag als "owned window" von MrTerm
adopt(hwnd) {
const api = winApi();
this.hwnd = hwnd;
const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0;
api.SetWindowLongPtrW(hwnd, GWL_STYLE, style);
const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0);
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
// Bis zur Anmeldung außerhalb des Bildschirms parken: mstsc zentriert seine Dialoge über diesem Fenster,
// so bleibt auch "Verbindung wird hergestellt" unsichtbar, bevor der Hook ihn ausblenden kann.
if (!this.connected) api.SetWindowPos(hwnd, 0, -32000, -32000, this.bounds?.width || 800, this.bounds?.height || 600, SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
// mstsc blendet das Sitzungsfenster erst nach erfolgreicher Anmeldung ein und setzt dabei Rahmen und Größe neu
onSessionShown() {
this.connected = true;
this.adopt(this.hwnd);
if (!this.visible) winApi().ShowWindow(this.hwnd, SW_HIDE);
this.setBounds();
this.detectFixedSize();
this.setBounds();
this.onConnected?.();
}
// Ohne dynamische Auflösung (Server zu alt, xrdp < 0.10 …) begrenzt mstsc das Fenster auf die Sitzungsgröße –
// erkennbar daran, dass es sich nicht vergrößern lässt. Dann hilft nur Neuverbinden mit der neuen Größe.
detectFixedSize() {
const api = winApi();
const r = {};
api.GetWindowRect(this.hwnd, r);
api.SetWindowPos(this.hwnd, 0, r.left, r.top, r.right - r.left + 32, r.bottom - r.top + 32, SWP_NOZORDER | SWP_NOACTIVATE);
const g = {};
api.GetWindowRect(this.hwnd, g);
this.fixedSize = g.right - g.left < r.right - r.left + 32 ? { width: r.right - r.left, height: r.bottom - r.top } : null;
}
// Zertifikat wurde für diese Sitzung bestätigt (Rückfrage gezeigt und danach verbunden) – gilt für Neuverbindungen beim Größenändern
get certAccepted() { return this.acceptCert || (this.certPrompted && this.connected); }
checkFixedSize() {
clearTimeout(this.resizeTimer);
const b = this.bounds;
if (!this.fixedSize || !this.visible || (Math.abs(b.width - this.fixedSize.width) < 8 && Math.abs(b.height - this.fixedSize.height) < 8)) return;
this.resizeTimer = setTimeout(() => this.onResizeNeeded?.(this.bounds), 800);
}
// Wartet, bis die Sitzung steht (Sitzungsfenster sichtbar)
attach(bounds, isCancelled) {
this.bounds = bounds;
return new Promise((resolve) => {
const t0 = Date.now();
const done = (ok) => { clearInterval(iv); this.onConnected = null; resolve(ok); };
this.onConnected = () => done(true);
const iv = setInterval(() => {
if (this.connected) done(true);
else if (isCancelled() || this.proc.exitCode !== null || Date.now() - t0 > 120000) done(false);
}, 250);
});
} }
// b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten // b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten
@@ -96,15 +193,17 @@ class WinEmbed {
const pt = { x: this.bounds.x, y: this.bounds.y }; const pt = { x: this.bounds.x, y: this.bounds.y };
api.ClientToScreen(this.parent, pt); api.ClientToScreen(this.parent, pt);
api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height), api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height),
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible ? SWP_SHOWWINDOW : 0)); SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible && this.connected ? SWP_SHOWWINDOW : 0));
if (this.connected) this.checkFixedSize();
} }
reposition() { this.setBounds(); } reposition() { this.setBounds(); }
show() { this.visible = true; if (this.hwnd) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } } show() { this.visible = true; if (this.hwnd && this.connected) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
hide() { this.visible = false; if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); } hide() { this.visible = false; clearTimeout(this.resizeTimer); if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); }
focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); } focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); }
// Aus MrTerm lösen und als normales Fenster weiterlaufen lassen // Aus MrTerm lösen und als normales Fenster weiterlaufen lassen
detach() { detach() {
if (!this.hwnd) return; if (!this.hwnd) return;
this.unhook();
const api = winApi(); const api = winApi();
api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0); api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0);
const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0); const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0);
@@ -115,7 +214,7 @@ class WinEmbed {
api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW); api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
this.hwnd = null; this.hwnd = null;
} }
kill() { try { this.proc?.kill(); } catch {} } kill() { clearTimeout(this.resizeTimer); this.unhook(); try { this.proc?.kill(); } catch {} }
} }
// ------------------------------------------------------------------ Linux (X11) // ------------------------------------------------------------------ Linux (X11)
+11 -1
View File
@@ -63,6 +63,16 @@ function writeRdpFile(host, size) {
return file; return file;
} }
// mstsc-Kommandozeile für eingebettete Sitzungen. Seit 2026 zeigt mstsc bei jeder unsignierten .rdp-Datei eine
// Sicherheitswarnung – mit /v:… startet es ohne. Die übrigen Optionen kommen dann aus Default.rdp (Zwischenablage an,
// Audio lokal, keine Laufwerke, dynamische Auflösung); weicht der Host davon ab, geht es nur über eine .rdp-Datei.
function mstscArgs(host, size) {
const port = Number(host.port) || 3389;
const needsFile = host.rdpDrives || host.rdpClipboard === false || host.rdpAudio === false || host.rdpResize === 'scale';
if (needsFile) return [writeRdpFile(host, size)];
return [`/v:${port === 3389 ? host.address : `${host.address}:${port}`}`, `/w:${size.width}`, `/h:${size.height}`];
}
// Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet. // Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet.
function withWinCredentials(host, run) { function withWinCredentials(host, run) {
const user = userOf(host); const user = userOf(host);
@@ -110,4 +120,4 @@ function launch(host, settings) {
return { client, process: p, getErr: () => err }; return { client, process: p, getErr: () => err };
} }
module.exports = { detect, launch, linuxClient, writeRdpFile, withWinCredentials, freerdpArgs }; module.exports = { detect, launch, linuxClient, writeRdpFile, mstscArgs, withWinCredentials, freerdpArgs };
+25
View File
@@ -0,0 +1,25 @@
// sudo für Firewall, Netzwerk und Docker: erst ohne Passwort (NOPASSWD), dann mit dem am Host gespeicherten Passwort,
// sonst den Nutzer fragen (bis zu 3 Versuche). Das Passwort gilt nur für die offene Sitzung.
// run(cmd, stdin) → Promise<{ code, out, err }>
// → Passwort ('' bei NOPASSWD) oder null, wenn sudo nicht möglich ist bzw. abgebrochen wurde
const i18n = require('../i18n');
async function acquireSudo(ssh, sessionId, host, run) {
if ((await run('sudo -n true')).code === 0) return '';
const works = async (pw) => (await run("sudo -S -p '' -v", `${pw}\n`)).code === 0;
if (host.password && await works(host.password)) return host.password;
const user = `${host.username || ''}@${host.address}`.replace(/^@/, '');
for (let i = 0; i < 3; i++) {
const pw = await ssh.askSecret(sessionId, {
title: i18n.t('sudo password'),
prompt: i ? i18n.t('Wrong password. sudo password for {user}', { user }) : i18n.t('sudo password for {user}', { user }),
echo: false,
host: host.label || host.address,
});
if (pw == null) return null;
if (await works(pw)) return pw;
}
return null;
}
module.exports = { acquireSudo };
+6
View File
@@ -20,6 +20,12 @@ contextBridge.exposeInMainWorld('api', {
min: () => ipcRenderer.send('win:min'), min: () => ipcRenderer.send('win:min'),
max: () => ipcRenderer.send('win:max'), max: () => ipcRenderer.send('win:max'),
close: () => ipcRenderer.send('win:close'), 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: { ssh: {
write: (id, d) => ipcRenderer.send('ssh:write', id, d), write: (id, d) => ipcRenderer.send('ssh:write', id, d),
+131 -9
View File
@@ -159,6 +159,12 @@ async function reload() {
S.activeForwards = new Set(await call('fw:active')); S.activeForwards = new Set(await call('fw:active'));
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({})); if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
render(); 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) // 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.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() }] : []), ...(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; } } }, { 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'), icon: 'close', run: () => closeTab(session.id) },
{ label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) }, { label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) },
]); ]);
session.tabEl = tab; session.tabEl = tab;
tabDrag(tab, session);
if (!WEB) api.win.claim(session.id);
$('#tabs').append(tab); $('#tabs').append(tab);
$('#sessions').append(session.el); $('#sessions').append(session.el);
activateTab(session.id); activateTab(session.id);
@@ -1578,17 +1588,101 @@ function closeTab(id) {
t.dispose(); t.dispose();
t.el.remove(); t.tabEl.remove(); t.el.remove(); t.tabEl.remove();
S.tabs.splice(i, 1); 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'); if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home');
} }
$('.tab.home').onclick = () => activateTab('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 // ============================================================ Terminal-Session
class TerminalSession { class TerminalSession {
constructor(host) { // attach: { id, title, text, alive } – bestehende SSH-Sitzung aus einem anderen Fenster übernehmen
constructor(host, attach) {
this.kind = 'ssh'; this.kind = 'ssh';
this.id = uid(); this.id = attach?.id || uid();
this.host = host; this.host = host;
this.title = host.label || host.address; this.title = attach?.title || host.label || host.address;
this.el = h(`<div class="session"> this.el = h(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div> <div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button> <button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
@@ -1607,7 +1701,21 @@ class TerminalSession {
this.bindUi(); this.bindUi();
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); }); this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this); addTab(this);
this.connect(); if (attach) this.adopt(attach); else this.connect();
}
adopt({ text, alive }) {
if (text) this.term.write(text.replace(/\r?\n/g, '\r\n'));
this.alive = !!alive; this.ended = !alive;
setTabState(this, alive ? 'on' : '');
requestAnimationFrame(() => { this.doFit(); if (this.alive) api.ssh.resize(this.id, this.term.cols, this.term.rows); });
}
// Gesamter Puffer als Text (beim Lösen in ein anderes Fenster; Farben gehen dabei verloren)
bufferText() {
const b = this.term.buffer.active, out = [];
for (let i = 0; i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');
while (out.length && !out[out.length - 1]) out.pop();
return out.join('\n');
} }
initTerm() { initTerm() {
@@ -1760,7 +1868,7 @@ class TerminalSession {
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
} }
} }
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); } dispose() { this.unsub(); this.ro.disconnect(); if (!this.keepAlive) api.ssh.close(this.id); this.term.dispose(); }
} }
// Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett // Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
function aiMarkdown(md) { function aiMarkdown(md) {
@@ -1887,7 +1995,8 @@ function initAi() {
AI.chat = aiChats().find((c) => c.id === e.target.value) || null; AI.chat = aiChats().find((c) => c.id === e.target.value) || null;
aiRender(); aiRender();
}; };
p.addEventListener('click', async (e) => { // onclick statt addEventListener: initAi() läuft bei jedem reload(), sonst doppelte Handler (z. B. zwei Löschdialoge)
p.onclick = async (e) => {
const a = e.target.closest('[data-ai]')?.dataset.ai; const a = e.target.closest('[data-ai]')?.dataset.ai;
if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); } if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); }
if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) { if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) {
@@ -1907,7 +2016,7 @@ function initAi() {
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter // Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); } if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); }
else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); } else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); }
}); };
if ($('#aiPanel').classList.contains('open')) aiRender(); if ($('#aiPanel').classList.contains('open')) aiRender();
if (AI.init) return; if (AI.init) return;
AI.init = true; AI.init = true;
@@ -1994,6 +2103,10 @@ class RdpSession {
this.overlay?.remove(); this.overlay = null; this.overlay?.remove(); this.overlay = null;
setTabState(this, 'on'); setTabState(this, 'on');
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id); if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
} else if (type === 'resizing') {
this.attached = false;
setTabState(this, 'wait');
this.showOverlay(T('Adjusting resolution …'));
} else if (type === 'exit') { } else if (type === 'exit') {
this.attached = false; this.exited = true; this.attached = false; this.exited = true;
setTabState(this, payload?.code ? 'err' : ''); setTabState(this, payload?.code ? 'err' : '');
@@ -2037,7 +2150,7 @@ class FilePane {
<input class="path" spellcheck="false"/> <input class="path" spellcheck="false"/>
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button> <button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button> <button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div> ${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `${side === 'remote' ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload files …')}">${ICONS.upload}</button>` : ''}<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div>
<div class="files"></div></div>`); <div class="files"></div></div>`);
this.el.addEventListener('click', (e) => { this.el.addEventListener('click', (e) => {
const p = e.target.closest('[data-p]')?.dataset.p; const p = e.target.closest('[data-p]')?.dataset.p;
@@ -2064,7 +2177,14 @@ class FilePane {
}); });
} }
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; } get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
pickUpload() { async pickUpload() {
if (!WEB) {
// Desktop: Dateiauswahl des Systems, Upload in den aktuellen Server-Ordner
const files = await call('sftp:pickUpload');
for (const f of files) await this.sftp.transfer('upload', f.path, await this.join(this.cwd, f.name), f.name);
if (files.length) this.go(this.cwd);
return;
}
const inp = h('<input type="file" multiple style="display:none"/>'); const inp = h('<input type="file" multiple style="display:none"/>');
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); }; inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
document.body.append(inp); document.body.append(inp);
@@ -3165,5 +3285,7 @@ api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch
api.on('toast', (m, t) => toast(m, t)); api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; }); api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
// Abgelöstes Fenster: nur Tabs, ohne Seitenleiste und Tresor-Tab; den übergebenen Tab übernehmen
if (DETACHED) document.body.classList.add('detached');
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo); $('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
+1 -1
View File
@@ -17,7 +17,7 @@
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span> <svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
</button> </button>
</div> </div>
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button> <button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button>
<div class="drag-fill"></div> <div class="drag-fill"></div>
<button id="aiBtn" class="icon-btn" data-ai-toggle title="Support assistant (Ollama)" data-i18n-title="Support assistant (Ollama)"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg></button> <button id="aiBtn" class="icon-btn" data-ai-toggle title="Support assistant (Ollama)" data-i18n-title="Support assistant (Ollama)"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg></button>
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button> <button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
+14 -7
View File
@@ -24,23 +24,30 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.logo { width: 24px; height: 24px; border-radius: 6px; } .logo { width: 24px; height: 24px; border-radius: 6px; }
.brand { gap: 8px !important; } .brand { gap: 8px !important; }
.brand-term { color: #5ef0c4; margin-left: -8px; } .brand-term { color: #5ef0c4; margin-left: -8px; }
#tabs { display: flex; gap: 4px; height: 100%; align-items: flex-end; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; } #tabs { display: flex; gap: 4px; height: 100%; align-items: center; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; }
#tabs::-webkit-scrollbar { height: 0; } #tabs::-webkit-scrollbar { height: 0; }
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 12px; border: none; background: transparent; border-radius: 8px 8px 0 0; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; } .tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px 0 12px; border: none; background: transparent; border-radius: 8px; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
.tab.home { min-width: 0; } .tab.home { min-width: 0; }
/* Tabs verschieben / lösen */
.tab.drop-before { box-shadow: inset 3px 0 0 var(--accent); }
.tab.drop-after { box-shadow: inset -3px 0 0 var(--accent); }
body.detached #sidebar, body.detached .tab.home, body.detached #homeView { display: none !important; }
body.detached .brand { width: auto; padding-right: 18px; }
.tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; } .tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
.tab:hover { background: var(--hover); color: var(--text); } .tab:hover { background: var(--hover); color: var(--text); }
.tab.active { background: var(--bg); color: var(--text); } .tab.active { background: var(--bg); color: var(--text); box-shadow: 0 0 0 1px var(--chrome-line); }
.tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; } .tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
.tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); } .tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.tab .dot.err { background: var(--red); } .tab .dot.err { background: var(--red); }
.tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; } .tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; }
.tab .x { opacity: 0; border: none; background: none; padding: 2px; border-radius: 4px; color: var(--muted); display: flex; } .tab .x { opacity: 0; border: none; background: none; padding: 3px; border-radius: 4px; color: var(--muted); display: flex; }
.tab:hover .x, .tab.active .x { opacity: 1; } .tab:hover .x, .tab.active .x { opacity: 1; }
.tab .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); } .tab .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); }
.tab .x svg { width: 12px; height: 12px; } .tab .x svg { width: 12px; height: 12px; }
@keyframes pulse { 50% { opacity: .35; } } @keyframes pulse { 50% { opacity: .35; } }
.icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; font-size: 18px; margin-left: 4px; flex: none; } .icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; margin-left: 4px; flex: none; display: flex; align-items: center; justify-content: center; padding: 0; }
/* Alle Titelleisten-Symbole gleich groß und mittig (vorher: „+“ als Text mit eigener Grundlinie) */
.icon-btn svg { width: 16px; height: 16px; }
.icon-btn:hover { background: var(--chrome-line); color: var(--text); } .icon-btn:hover { background: var(--chrome-line); color: var(--text); }
.drag-fill { flex: 1; min-width: 40px; height: 100%; } .drag-fill { flex: 1; min-width: 40px; height: 100%; }
.win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; } .win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; }
@@ -317,7 +324,7 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
#sidebar.nav-collapsed .nav-menu .nav span { display: none; } #sidebar.nav-collapsed .nav-menu .nav span { display: none; }
/* App-Sperre */ /* App-Sperre */
.lock-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; } .lock-btn svg { fill: none; stroke: currentColor; stroke-width: 2; }
.lock-screen { position: fixed; inset: 0; top: var(--titlebar); z-index: 200; background: var(--bg); display: flex; align-items: center; justify-content: center; animation: fade .15s; } .lock-screen { position: fixed; inset: 0; top: var(--titlebar); z-index: 200; background: var(--bg); display: flex; align-items: center; justify-content: center; animation: fade .15s; }
.lock-box { width: 340px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; } .lock-box { width: 340px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
.lock-box h2 { margin: 0 0 6px; font-size: 18px; } .lock-box h2 { margin: 0 0 6px; font-size: 18px; }
@@ -361,7 +368,7 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
/* Firewall */ /* Firewall */
.seg.sm button { padding: 4px 10px; font-size: 12px; } .seg.sm button { padding: 4px 10px; font-size: 12px; }
.session.docker .sbar .seg { margin: 0 6px 0 0; flex: none; } .session .sbar .seg { margin: 0 6px 0 0; flex: none; }
.docker-table .dot.none { background: transparent; border: 1px solid var(--faint); } .docker-table .dot.none { background: transparent; border: 1px solid var(--faint); }
.docker-table .badge, .tpl-it .badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--icon-bg); color: var(--muted); margin-left: 4px; vertical-align: middle; } .docker-table .badge, .tpl-it .badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--icon-bg); color: var(--muted); margin-left: 4px; vertical-align: middle; }
.stack-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; position: sticky; top: 0; background: var(--bg); z-index: 2; } .stack-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; position: sticky; top: 0; background: var(--bg); z-index: 2; }