Compare commits

..
6 Commits
12 changed files with 253 additions and 39 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.20.0", "version": "0.22.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.20.0", "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.20.0", "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",
+6 -1
View File
@@ -152,6 +152,12 @@
'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 …', '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?',
@@ -579,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);
+63 -11
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) =>
+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);
} }
+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),
+115 -5
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) {
@@ -3177,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>
+13 -6
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; }