Compare commits
14
Commits
ecaf579ccd
...
v0.22.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8d73dd60 | ||
|
|
d87feb63af | ||
|
|
ec5ca37356 | ||
|
|
5c974f31ce | ||
|
|
25115f8747 | ||
|
|
c3a4d06310 | ||
|
|
b4eedc77bb | ||
|
|
93123c5c60 | ||
|
|
3714205dab | ||
|
|
4818523b29 | ||
|
|
6f52873b69 | ||
|
|
51bd769d6e | ||
|
|
136e4b4e2c | ||
|
|
6b02dc078a |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.18.0",
|
||||
"version": "0.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.18.0",
|
||||
"version": "0.22.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"productName": "MrTerm",
|
||||
"version": "0.18.0",
|
||||
"version": "0.22.0",
|
||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||
"main": "src/main/main.js",
|
||||
"author": "MrBlake",
|
||||
@@ -53,6 +53,7 @@
|
||||
"directories": {
|
||||
"buildResources": "build"
|
||||
},
|
||||
"beforePack": "scripts/ensure-koffi.js",
|
||||
"asarUnpack": [
|
||||
"node_modules/koffi/**",
|
||||
"node_modules/@koromix/**"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// electron-builder beforePack: stellt sicher, dass die native koffi-Binary der Zielplattform in node_modules liegt.
|
||||
// npm installiert nur die Binary der Build-Plattform – beim Cross-Build (z. B. Windows-Paket auf Linux) fehlt sie sonst,
|
||||
// und RDP im Tab fällt mit "koffi nicht verfügbar" auf ein externes Fenster zurück.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { Arch } = require('electron-builder');
|
||||
|
||||
module.exports = async (context) => {
|
||||
const platform = context.electronPlatformName;
|
||||
const arch = Arch[context.arch];
|
||||
const pkg = `@koromix/koffi-${platform}-${arch}`;
|
||||
const root = path.join(__dirname, '..');
|
||||
if (fs.existsSync(path.join(root, 'node_modules', pkg))) return;
|
||||
const { version } = require(path.join(root, 'node_modules', 'koffi', 'package.json'));
|
||||
console.log(` • koffi-Binary für ${platform}-${arch} fehlt – installiere ${pkg}@${version}`);
|
||||
execSync(`npm install --no-save --force ${pkg}@${version}`, { cwd: root, stdio: 'inherit' });
|
||||
if (!fs.existsSync(path.join(root, 'node_modules', pkg))) throw new Error(`${pkg} konnte nicht installiert werden`);
|
||||
};
|
||||
+8
-1
@@ -151,6 +151,13 @@
|
||||
'Send Enter automatically': 'Enter automatisch senden',
|
||||
'Command is missing.': 'Befehl fehlt.',
|
||||
'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
|
||||
'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.',
|
||||
'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
|
||||
'RDP session ended.': 'RDP-Sitzung beendet.',
|
||||
'Adjusting resolution …': 'Auflösung wird angepasst …',
|
||||
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
|
||||
'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}',
|
||||
'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
|
||||
'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.',
|
||||
'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).',
|
||||
|
||||
+13
-6
@@ -2,6 +2,7 @@
|
||||
// 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.
|
||||
const i18n = require('../i18n');
|
||||
const { acquireSudo } = require('./sudo');
|
||||
|
||||
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
|
||||
@@ -37,12 +38,18 @@ class DockerManager {
|
||||
|
||||
async open(id, host, onClose) {
|
||||
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('error', () => {});
|
||||
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) {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) throw new Error(i18n.t('Docker session not found'));
|
||||
@@ -51,7 +58,7 @@ class DockerManager {
|
||||
|
||||
run(s, args) {
|
||||
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) {
|
||||
@@ -62,7 +69,7 @@ class DockerManager {
|
||||
s.runtime = r.out.trim();
|
||||
}
|
||||
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;
|
||||
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||
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)
|
||||
async sh(s, 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) {
|
||||
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.host.password}\n`);
|
||||
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.sudoPw}\n`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
@@ -158,7 +165,7 @@ class DockerManager {
|
||||
|
||||
runCompose(s, compose, args) {
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
|
||||
const i18n = require('../i18n');
|
||||
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 RX = {
|
||||
@@ -58,7 +59,7 @@ class FirewallManager {
|
||||
// Befehl mit root-Rechten ausführen
|
||||
run(s, 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) {
|
||||
const r = await this.run(s, cmd);
|
||||
@@ -75,8 +76,8 @@ class FirewallManager {
|
||||
const uid = (await execOn(conn, 'id -u')).out.trim();
|
||||
if (uid !== '0') {
|
||||
s.sudo = true;
|
||||
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
|
||||
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.'));
|
||||
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
|
||||
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\'');
|
||||
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
+93
-20
@@ -43,8 +43,19 @@ process.on('uncaughtException', (e) => {
|
||||
});
|
||||
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||
|
||||
// Abgelöste Tabs laufen in eigenen Fenstern. Ereignisse gehen an alle Fenster (die Oberfläche filtert nach Sitzungs-ID),
|
||||
// Passwortabfragen nur an das Fenster, das die Sitzung zeigt; Kopplung/Updates nur an das Hauptfenster.
|
||||
const tabWins = new Set();
|
||||
const sessionOwner = new Map(); // Sitzungs-ID -> BrowserWindow (nur für abgelöste Fenster)
|
||||
const MAIN_ONLY = new Set(['sync:pairPrompt', 'update:available']);
|
||||
const alive = (w) => w && !w.isDestroyed();
|
||||
function send(channel, ...args) {
|
||||
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args);
|
||||
if (channel === 'secret:request') {
|
||||
const w = sessionOwner.get(args[0]?.sessionId);
|
||||
if (alive(w)) return w.webContents.send(channel, ...args);
|
||||
}
|
||||
if (alive(win)) win.webContents.send(channel, ...args);
|
||||
if (!MAIN_ONLY.has(channel)) for (const w of tabWins) if (alive(w)) w.webContents.send(channel, ...args);
|
||||
}
|
||||
|
||||
async function confirmHostKey(target, fingerprint, known) {
|
||||
@@ -91,12 +102,13 @@ const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
|
||||
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
|
||||
store.onChange = () => sync.schedule();
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
function newWindow(opts = {}) {
|
||||
const w = new BrowserWindow({
|
||||
width: 1360,
|
||||
height: 860,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
...opts,
|
||||
backgroundColor: '#14161d',
|
||||
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
|
||||
frame: false,
|
||||
@@ -108,18 +120,57 @@ function createWindow() {
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
w.on('maximize', () => w.webContents.send('win:state', true));
|
||||
w.on('unmaximize', () => w.webContents.send('win:state', false));
|
||||
w.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||
return w;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = newWindow({ minWidth: 900, minHeight: 560 });
|
||||
Menu.setApplicationMenu(null);
|
||||
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
||||
win.on('maximize', () => send('win:state', true));
|
||||
// Hauptfenster zu = MrTerm beenden, auch abgelöste Tab-Fenster schließen
|
||||
win.on('closed', () => { for (const w of tabWins) if (alive(w)) w.close(); });
|
||||
// Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen
|
||||
const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } };
|
||||
win.on('move', followRdp);
|
||||
win.on('resize', followRdp);
|
||||
win.on('restore', followRdp);
|
||||
win.on('unmaximize', () => send('win:state', false));
|
||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||
}
|
||||
|
||||
// Tab in ein eigenes Fenster lösen: data = { kind, host, id, title, … } wird vom neuen Fenster per win:handoff abgeholt
|
||||
const handoffs = new Map();
|
||||
function openTabWindow(data, pos) {
|
||||
const token = crypto.randomUUID();
|
||||
handoffs.set(token, data);
|
||||
const w = newWindow({ width: 1000, height: 650, ...(pos ? { x: Math.round(pos.x - 120), y: Math.round(pos.y - 20) } : {}) });
|
||||
tabWins.add(w);
|
||||
w.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'), { query: { detached: token } });
|
||||
// Fenster zu: dort noch offene Sitzungen beenden
|
||||
w.on('closed', () => {
|
||||
tabWins.delete(w);
|
||||
for (const [id, o] of sessionOwner) {
|
||||
if (o !== w) continue;
|
||||
sessionOwner.delete(id);
|
||||
for (const m of [ssh, docker, firewall, network]) { try { m.close(id); } catch {} }
|
||||
}
|
||||
});
|
||||
}
|
||||
ipcMain.handle('win:detach', (_e, data, pos) => { openTabWindow(data, pos); return true; });
|
||||
ipcMain.handle('win:handoff', (_e, token) => { const d = handoffs.get(token); handoffs.delete(token); return d || null; });
|
||||
// Sitzung gehört (jetzt) dem aufrufenden Fenster; im Hauptfenster gilt keine Zuordnung
|
||||
ipcMain.on('win:claim', (e, id) => {
|
||||
const w = BrowserWindow.fromWebContents(e.sender);
|
||||
if (w === win) sessionOwner.delete(id); else sessionOwner.set(id, w);
|
||||
});
|
||||
// Tab ins Hauptfenster zurück
|
||||
ipcMain.handle('win:attach', (_e, data) => { if (alive(win)) { win.webContents.send('win:adopt', data); win.focus(); } return true; });
|
||||
// Ein anderes Fenster hat den Tab übernommen (Drag & Drop): beim bisherigen Fenster ohne Trennen entfernen
|
||||
ipcMain.on('win:released', (e, id) => {
|
||||
for (const w of [win, ...tabWins]) if (alive(w) && w.webContents !== e.sender) w.webContents.send('win:released', id);
|
||||
});
|
||||
|
||||
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
|
||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||
function handle(channel, fn) {
|
||||
@@ -133,9 +184,10 @@ function handle(channel, fn) {
|
||||
}
|
||||
|
||||
// ---------- Fenster ----------
|
||||
ipcMain.on('win:min', () => win.minimize());
|
||||
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
||||
ipcMain.on('win:close', () => win.close());
|
||||
const senderWin = (e) => BrowserWindow.fromWebContents(e.sender);
|
||||
ipcMain.on('win:min', (e) => senderWin(e)?.minimize());
|
||||
ipcMain.on('win:max', (e) => { const w = senderWin(e); if (w) w.isMaximized() ? w.unmaximize() : w.maximize(); });
|
||||
ipcMain.on('win:close', (e) => senderWin(e)?.close());
|
||||
|
||||
// ---------- App-Sperre (Passwort / FIDO2) ----------
|
||||
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||
@@ -408,6 +460,11 @@ handle('sftp:open', async (sessionId, hostRef) => {
|
||||
});
|
||||
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||
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) => {
|
||||
let last = 0;
|
||||
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
|
||||
@@ -473,30 +530,46 @@ handle('rdp:open', async (id, hostRef, bounds) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
const emb = createEmbed(win.getNativeWindowHandle());
|
||||
const sess = { emb, cancelled: false };
|
||||
const sess = { cancelled: false };
|
||||
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 };
|
||||
let proc;
|
||||
if (process.platform === 'win32') {
|
||||
const file = rdp.writeRdpFile(host, size);
|
||||
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ rdpFile: file }))));
|
||||
const args = rdp.mstscArgs(host, size);
|
||||
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ args }))));
|
||||
} else {
|
||||
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) => {
|
||||
if (!current()) return; // durch Neuverbindung ersetzt
|
||||
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);
|
||||
});
|
||||
const ok = await emb.attach(bounds, () => sess.cancelled);
|
||||
if (ok) {
|
||||
// Server ohne dynamische Auflösung: mit neuer Größe neu verbinden (die Sitzung auf dem Server bleibt erhalten)
|
||||
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();
|
||||
send('rdp:event', id, 'attached');
|
||||
}
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
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:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
|
||||
|
||||
+4
-3
@@ -4,6 +4,7 @@
|
||||
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
|
||||
const i18n = require('../i18n');
|
||||
const { execOn } = require('./docker');
|
||||
const { acquireSudo } = require('./sudo');
|
||||
const nc = require('./netconf');
|
||||
|
||||
const STATE = '/var/lib/mrterm-net';
|
||||
@@ -127,7 +128,7 @@ class NetworkConfigManager {
|
||||
root(s, script, conn = s.conn) {
|
||||
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
|
||||
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) {
|
||||
const r = await this.root(s, script);
|
||||
@@ -142,8 +143,8 @@ class NetworkConfigManager {
|
||||
this.watch(id, s);
|
||||
if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
|
||||
s.sudo = true;
|
||||
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
|
||||
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.'));
|
||||
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
|
||||
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);
|
||||
}
|
||||
|
||||
+142
-43
@@ -1,5 +1,5 @@
|
||||
// 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).
|
||||
// Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters.
|
||||
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
|
||||
// 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.
|
||||
// Mit "dynamic resolution" passt mstsc die Auflösung der Sitzung an, sobald sich die Fenstergröße ändert.
|
||||
function winApi() {
|
||||
if (winApi.api) return winApi.api;
|
||||
const u = koffi.load('user32.dll');
|
||||
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 RECT = koffi.struct('MrTermRECT', { left: 'int', top: 'int', right: 'int', bottom: 'int' });
|
||||
winApi.api = {
|
||||
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)'),
|
||||
GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'),
|
||||
IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'),
|
||||
@@ -34,58 +46,143 @@ function winApi() {
|
||||
return winApi.api;
|
||||
}
|
||||
|
||||
function findWindowsOfPid(pid, cls) {
|
||||
const api = winApi();
|
||||
const out = [];
|
||||
const buf = Buffer.alloc(512);
|
||||
api.EnumWindows((hwnd) => {
|
||||
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);
|
||||
const buf = Buffer.alloc(512);
|
||||
const className = (hwnd) => buf.toString('utf16le', 0, winApi().GetClassNameW(hwnd, buf, 256) * 2);
|
||||
function childClasses(hwnd) {
|
||||
const out = new Set();
|
||||
winApi().EnumChildWindows(hwnd, (c) => { out.add(className(c)); return true; }, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
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_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 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 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 {
|
||||
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 }) {
|
||||
this.proc = spawn('mstsc.exe', rdpFile ? [rdpFile] : [], { stdio: 'ignore' });
|
||||
// args: mstsc-Kommandozeile. Mit /v:… statt .rdp-Datei zeigt mstsc keine Sicherheitswarnung "Unbekannter Herausgeber".
|
||||
start({ args }) {
|
||||
this.proc = spawn('mstsc.exe', args, { stdio: 'ignore' });
|
||||
this.hookWindows();
|
||||
this.proc.on('exit', () => this.unhook());
|
||||
return this.proc;
|
||||
}
|
||||
|
||||
// Wartet, bis mstsc sein Sitzungsfenster geöffnet hat (Anmelde-/Zertifikatsdialoge davor bleiben eigene Fenster)
|
||||
async attach(bounds, isCancelled) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < 120000) {
|
||||
if (isCancelled() || this.proc.exitCode !== null) return false;
|
||||
const [hwnd] = findWindowsOfPid(this.proc.pid, this.cls);
|
||||
if (hwnd) {
|
||||
const api = winApi();
|
||||
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); // Besitzer = MrTerm
|
||||
this.hwnd = hwnd;
|
||||
this.setBounds(bounds);
|
||||
return true;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
return false;
|
||||
// Fängt die Fenster von mstsc ab, sobald sie entstehen (läuft über die Nachrichtenschleife des Electron-Hauptthreads):
|
||||
// Sitzungsfenster -> noch unsichtbar rahmenlos über den Tab legen, damit nie ein normales mstsc-Fenster aufblitzt
|
||||
// "Verbindung wird hergestellt" (Dialog mit Fortschrittsbalken) -> unsichtbar lassen, der Tab zeigt den Status selbst
|
||||
// andere Dialoge (Zertifikat, Fehler) -> brauchen eine Entscheidung, daher mittig über dem Tab einblenden
|
||||
hookWindows() {
|
||||
const api = winApi();
|
||||
this.cb = koffi.register((_hook, event, hwnd, idObject, idChild) => {
|
||||
try {
|
||||
if (idObject !== 0 || idChild !== 0 || Number(api.GetAncestor(hwnd, GA_ROOT)) !== Number(hwnd)) return;
|
||||
const cls = className(hwnd);
|
||||
if (cls === 'TscShellContainerClass') {
|
||||
if (event === EVENT_OBJECT_CREATE || !this.hwnd) this.adopt(hwnd);
|
||||
if (event === EVENT_OBJECT_SHOW) this.onSessionShown();
|
||||
} else if (cls === '#32770') {
|
||||
const isCert = () => api.GetDlgItem(hwnd, ID_CERT_YES) && api.GetDlgItem(hwnd, ID_CERT_VIEW);
|
||||
if (event === EVENT_OBJECT_CREATE) this.setAlpha(hwnd, 0);
|
||||
else if (childClasses(hwnd).has('msctls_progress32')) api.ShowWindow(hwnd, SW_HIDE);
|
||||
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); }
|
||||
}
|
||||
} 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
|
||||
@@ -96,15 +193,17 @@ class WinEmbed {
|
||||
const pt = { x: this.bounds.x, y: this.bounds.y };
|
||||
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),
|
||||
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(); }
|
||||
show() { this.visible = true; if (this.hwnd) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
|
||||
hide() { this.visible = false; if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); }
|
||||
show() { this.visible = true; if (this.hwnd && this.connected) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
|
||||
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); }
|
||||
// Aus MrTerm lösen und als normales Fenster weiterlaufen lassen
|
||||
detach() {
|
||||
if (!this.hwnd) return;
|
||||
this.unhook();
|
||||
const api = winApi();
|
||||
api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 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);
|
||||
this.hwnd = null;
|
||||
}
|
||||
kill() { try { this.proc?.kill(); } catch {} }
|
||||
kill() { clearTimeout(this.resizeTimer); this.unhook(); try { this.proc?.kill(); } catch {} }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Linux (X11)
|
||||
|
||||
+11
-1
@@ -63,6 +63,16 @@ function writeRdpFile(host, size) {
|
||||
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.
|
||||
function withWinCredentials(host, run) {
|
||||
const user = userOf(host);
|
||||
@@ -110,4 +120,4 @@ function launch(host, settings) {
|
||||
return { client, process: p, getErr: () => err };
|
||||
}
|
||||
|
||||
module.exports = { detect, launch, linuxClient, writeRdpFile, withWinCredentials, freerdpArgs };
|
||||
module.exports = { detect, launch, linuxClient, writeRdpFile, mstscArgs, withWinCredentials, freerdpArgs };
|
||||
|
||||
@@ -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 };
|
||||
@@ -20,6 +20,12 @@ contextBridge.exposeInMainWorld('api', {
|
||||
min: () => ipcRenderer.send('win:min'),
|
||||
max: () => ipcRenderer.send('win:max'),
|
||||
close: () => ipcRenderer.send('win:close'),
|
||||
// Tabs lösen / zurückholen
|
||||
detach: (data, pos) => ipcRenderer.invoke('win:detach', data, pos),
|
||||
attach: (data) => ipcRenderer.invoke('win:attach', data),
|
||||
handoff: (token) => ipcRenderer.invoke('win:handoff', token),
|
||||
claim: (id) => ipcRenderer.send('win:claim', id),
|
||||
released: (id) => ipcRenderer.send('win:released', id),
|
||||
},
|
||||
ssh: {
|
||||
write: (id, d) => ipcRenderer.send('ssh:write', id, d),
|
||||
|
||||
+131
-9
@@ -159,6 +159,12 @@ async function reload() {
|
||||
S.activeForwards = new Set(await call('fw:active'));
|
||||
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||||
render();
|
||||
// Abgelöstes Fenster: übergebenen Tab erst nach dem Entsperren übernehmen (vorher fehlen Tresor und Einstellungen)
|
||||
if (DETACHED && !S.handoffDone) {
|
||||
S.handoffDone = true;
|
||||
const d = await api.win.handoff(DETACHED);
|
||||
if (d) adoptTab(d); else api.win.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title)
|
||||
@@ -1547,10 +1553,14 @@ function addTab(session) {
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []),
|
||||
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
|
||||
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
|
||||
...(DETACHABLE.has(session.kind) ? ['-', { label: T('Open in new window'), icon: 'dup', run: () => detachTab(session) },
|
||||
...(DETACHED ? [{ label: T('Back to main window'), icon: 'home', run: () => attachTab(session) }] : [])] : []),
|
||||
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||||
{ label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) },
|
||||
]);
|
||||
session.tabEl = tab;
|
||||
tabDrag(tab, session);
|
||||
if (!WEB) api.win.claim(session.id);
|
||||
$('#tabs').append(tab);
|
||||
$('#sessions').append(session.el);
|
||||
activateTab(session.id);
|
||||
@@ -1578,17 +1588,101 @@ function closeTab(id) {
|
||||
t.dispose();
|
||||
t.el.remove(); t.tabEl.remove();
|
||||
S.tabs.splice(i, 1);
|
||||
if (DETACHED && !S.tabs.length) return api.win.close();
|
||||
if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home');
|
||||
}
|
||||
$('.tab.home').onclick = () => activateTab('home');
|
||||
|
||||
// ============================================================ Tabs verschieben, lösen und zurückholen (Desktop)
|
||||
// Ziehen in der Tableiste = Reihenfolge ändern; aus dem Fenster ziehen = eigenes Fenster;
|
||||
// in die Tableiste eines anderen MrTerm-Fensters ziehen = dorthin verschieben. Terminals behalten dabei ihre SSH-Verbindung.
|
||||
const DETACHED = !WEB && new URLSearchParams(location.search).get('detached');
|
||||
const DETACHABLE = new Set(WEB ? [] : ['ssh', 'sftp', 'docker', 'firewall', 'network']);
|
||||
const TAB_MIME = 'application/x-mrterm-tab';
|
||||
|
||||
function tabHandoff(t) {
|
||||
const d = { kind: t.kind, host: t.host, id: t.id, title: t.title };
|
||||
if (t.kind === 'ssh') Object.assign(d, { text: t.bufferText(), alive: t.alive });
|
||||
return d;
|
||||
}
|
||||
// Tab hier entfernen, ohne die SSH-Verbindung zu trennen (andere Tabarten verbinden im neuen Fenster neu)
|
||||
function releaseTab(t) { t.keepAlive = true; closeTab(t.id); }
|
||||
function adoptTab(d) {
|
||||
if (d.kind === 'ssh') return new TerminalSession(d.host, d);
|
||||
return ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[d.kind])?.(d.host);
|
||||
}
|
||||
async function detachTab(t, pos) { await api.win.detach(tabHandoff(t), pos); releaseTab(t); }
|
||||
async function attachTab(t) { await api.win.attach(tabHandoff(t)); releaseTab(t); }
|
||||
|
||||
function tabDrag(tab, session) {
|
||||
tab.draggable = true;
|
||||
tab.addEventListener('dragstart', (e) => {
|
||||
S.dragTab = session;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
if (DETACHABLE.has(session.kind)) e.dataTransfer.setData(TAB_MIME, JSON.stringify(tabHandoff(session)));
|
||||
else e.dataTransfer.setData('text/plain', session.title);
|
||||
});
|
||||
tab.addEventListener('dragend', (e) => {
|
||||
const t = S.dragTab; S.dragTab = null;
|
||||
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
|
||||
if (!t || !DETACHABLE.has(t.kind) || e.dataTransfer.dropEffect !== 'none') return;
|
||||
// Außerhalb des Fensters losgelassen → eigenes Fenster an dieser Stelle
|
||||
const out = e.screenX < window.screenX || e.screenX > window.screenX + window.outerWidth || e.screenY < window.screenY || e.screenY > window.screenY + window.outerHeight;
|
||||
if (out) detachTab(t, { x: e.screenX, y: e.screenY });
|
||||
});
|
||||
}
|
||||
function tabDropTarget(e) {
|
||||
const tabs = $$('#tabs .tab:not(.home)');
|
||||
const over = tabs.find((x) => { const r = x.getBoundingClientRect(); return e.clientX >= r.left && e.clientX <= r.right; });
|
||||
if (!over) return null;
|
||||
const r = over.getBoundingClientRect();
|
||||
return { el: over, after: e.clientX > r.left + r.width / 2 };
|
||||
}
|
||||
const tabsBar = $('#tabs');
|
||||
tabsBar.addEventListener('dragover', (e) => {
|
||||
const local = !!S.dragTab, foreign = !local && [...e.dataTransfer.types].includes(TAB_MIME);
|
||||
if (!local && !foreign) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
|
||||
const t = tabDropTarget(e);
|
||||
if (t && t.el !== S.dragTab?.tabEl) t.el.classList.add(t.after ? 'drop-after' : 'drop-before');
|
||||
});
|
||||
tabsBar.addEventListener('dragleave', (e) => { if (!tabsBar.contains(e.relatedTarget)) $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); });
|
||||
tabsBar.addEventListener('drop', (e) => {
|
||||
const target = tabDropTarget(e);
|
||||
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
|
||||
let moved = S.dragTab;
|
||||
if (!moved) {
|
||||
// Tab aus einem anderen Fenster
|
||||
const raw = e.dataTransfer.getData(TAB_MIME);
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
const d = JSON.parse(raw);
|
||||
api.win.released(d.id);
|
||||
moved = adoptTab(d);
|
||||
if (!moved) return;
|
||||
} else e.preventDefault();
|
||||
if (target && target.el !== moved.tabEl) target.el.insertAdjacentElement(target.after ? 'afterend' : 'beforebegin', moved.tabEl);
|
||||
else if (!target) tabsBar.append(moved.tabEl);
|
||||
// Reihenfolge für Strg+Tab / Strg+1..9 übernehmen
|
||||
const order = $$('#tabs .tab[data-tab]').map((x) => x.dataset.tab);
|
||||
S.tabs.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
||||
activateTab(moved.id);
|
||||
});
|
||||
if (!WEB) {
|
||||
api.on('win:adopt', (d) => { adoptTab(d); });
|
||||
api.on('win:released', (id) => { const t = S.tabs.find((x) => x.id === id); if (t) releaseTab(t); });
|
||||
}
|
||||
|
||||
// ============================================================ Terminal-Session
|
||||
class TerminalSession {
|
||||
constructor(host) {
|
||||
// attach: { id, title, text, alive } – bestehende SSH-Sitzung aus einem anderen Fenster übernehmen
|
||||
constructor(host, attach) {
|
||||
this.kind = 'ssh';
|
||||
this.id = uid();
|
||||
this.id = attach?.id || uid();
|
||||
this.host = host;
|
||||
this.title = host.label || host.address;
|
||||
this.title = attach?.title || host.label || host.address;
|
||||
this.el = h(`<div class="session">
|
||||
<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>
|
||||
@@ -1607,7 +1701,21 @@ class TerminalSession {
|
||||
this.bindUi();
|
||||
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
|
||||
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() {
|
||||
@@ -1760,7 +1868,7 @@ class TerminalSession {
|
||||
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
|
||||
function aiMarkdown(md) {
|
||||
@@ -1887,7 +1995,8 @@ function initAi() {
|
||||
AI.chat = aiChats().find((c) => c.id === e.target.value) || null;
|
||||
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;
|
||||
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'))) {
|
||||
@@ -1907,7 +2016,7 @@ function initAi() {
|
||||
// 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(); }
|
||||
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 (AI.init) return;
|
||||
AI.init = true;
|
||||
@@ -1994,6 +2103,10 @@ class RdpSession {
|
||||
this.overlay?.remove(); this.overlay = null;
|
||||
setTabState(this, 'on');
|
||||
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') {
|
||||
this.attached = false; this.exited = true;
|
||||
setTabState(this, payload?.code ? 'err' : '');
|
||||
@@ -2037,7 +2150,7 @@ class FilePane {
|
||||
<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="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>`);
|
||||
this.el.addEventListener('click', (e) => {
|
||||
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; }
|
||||
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"/>');
|
||||
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
|
||||
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('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
|
||||
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
+14
-7
@@ -24,23 +24,30 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
|
||||
.logo { width: 24px; height: 24px; border-radius: 6px; }
|
||||
.brand { gap: 8px !important; }
|
||||
.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; }
|
||||
.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; }
|
||||
/* 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: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.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.tab .dot.err { background: var(--red); }
|
||||
.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 .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); }
|
||||
.tab .x svg { width: 12px; height: 12px; }
|
||||
@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); }
|
||||
.drag-fill { flex: 1; min-width: 40px; height: 100%; }
|
||||
.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; }
|
||||
|
||||
/* 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-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; }
|
||||
@@ -361,7 +368,7 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
|
||||
/* Firewall */
|
||||
.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 .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; }
|
||||
|
||||
Reference in New Issue
Block a user