Compare commits

...
9 Commits
Author SHA1 Message Date
MrBlake 25115f8747 Version 0.21.0 2026-10-14 21:36:00 +02:00
MrBlakeandClaude Opus 5.5 c3a4d06310 sudo for firewall, network and Docker: try passwordless sudo first, then the saved host password, otherwise ask for the sudo password (up to 3 attempts, kept only for the open tab) instead of failing with "root privileges required"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:35:59 +02:00
MrBlake b4eedc77bb Version 0.20.0 2026-10-14 21:29:50 +02:00
MrBlakeandClaude Opus 5.5 93123c5c60 SFTP: upload button on the server pane (desktop: system file dialog with multi-select, uploads into the current folder); fix the backend switch (UFW/iptables) sitting too high in the firewall tab bar (.seg bottom margin was only reset for Docker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:29:49 +02:00
MrBlake 3714205dab Version 0.19.1 2026-10-13 21:53:46 +02:00
MrBlake 4818523b29 Version 0.18.1 2026-10-13 21:50:56 +02:00
MrBlakeandClaude Opus 5.5 6f52873b69 Support assistant: fix the delete-chat confirmation opening twice (panel click handler was added again on every reload)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:50:55 +02:00
MrBlakeandClaude Opus 5.5 51bd769d6e Version 0.19.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:34 +02:00
MrBlakeandClaude Opus 5.5 136e4b4e2c RDP tabs on Windows: no visible mstsc windows and resolution follows the tab size. mstsc is started with /v: instead of an unsigned .rdp file (avoids the mandatory "unknown publisher" security warning; a file is still used when the host needs drive redirection, clipboard/audio off or scaling); a WinEvent hook adopts the session window while it is still hidden (borderless, parked off-screen until logged in), keeps the "connecting" progress dialog invisible and shows real prompts (certificate, errors) centered over the tab; servers without dynamic resolution (mstsc clamps the window to the session size) are detected and reconnected with the new size after resizing, re-confirming a certificate the user already accepted for this session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:30 +02:00
12 changed files with 255 additions and 74 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mrterm",
"version": "0.18.1",
"version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mrterm",
"version": "0.18.1",
"version": "0.21.0",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mrterm",
"productName": "MrTerm",
"version": "0.18.1",
"version": "0.21.0",
"description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js",
"author": "MrBlake",
+6 -1
View File
@@ -151,6 +151,11 @@
'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}',
// Support-Assistent
'Delete chat': 'Chat löschen',
'Delete chat?': 'Chat löschen?',
@@ -306,6 +311,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 +583,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
View File
@@ -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) {
+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.
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);
+30 -9
View File
@@ -408,6 +408,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 +478,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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+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 };
+16 -4
View File
@@ -1887,7 +1887,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 +1908,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 +1995,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 +2042,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 +2069,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);
+1 -1
View File
@@ -361,7 +361,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; }