// Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab). // Windows: mstsc.exe wird gestartet, sein Sitzungsfenster (TscShellContainerClass) rahmenlos über den Tab gelegt. // Linux: FreeRDP mit /parent-window:, 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'); let koffi = null; try { koffi = require('koffi'); } catch { /* ohne koffi nur externe Fenster */ } const i18n = require('../i18n'); // ------------------------------------------------------------------ Windows // 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)'), IsWindow: u.func('bool __stdcall IsWindow(intptr hwnd)'), GetWindowLongPtrW: u.func('intptr __stdcall GetWindowLongPtrW(intptr hwnd, int idx)'), SetWindowLongPtrW: u.func('intptr __stdcall SetWindowLongPtrW(intptr hwnd, int idx, intptr value)'), SetWindowPos: u.func('bool __stdcall SetWindowPos(intptr hwnd, intptr after, int x, int y, int cx, int cy, uint32 flags)'), ShowWindow: u.func('bool __stdcall ShowWindow(intptr hwnd, int cmd)'), SetForegroundWindow: u.func('bool __stdcall SetForegroundWindow(intptr hwnd)'), ClientToScreen: u.func('bool __stdcall ClientToScreen(intptr hwnd, _Inout_ MrTermPOINT *pt)'), POINT, }; return winApi.api; } 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, WS_EX_LAYERED = 0x00080000; const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; 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.visible = true; } // 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; } // 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 setBounds(b) { if (b) this.bounds = b; if (!this.hwnd || !this.bounds) return; const api = winApi(); 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 && this.connected ? SWP_SHOWWINDOW : 0)); if (this.connected) this.checkFixedSize(); } reposition() { this.setBounds(); } 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); api.SetWindowLongPtrW(this.hwnd, GWL_STYLE, style); const ex = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW) >>> 0); api.SetWindowLongPtrW(this.hwnd, GWL_EXSTYLE, ex); api.ShowWindow(this.hwnd, SW_SHOW); 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() { clearTimeout(this.resizeTimer); this.unhook(); try { this.proc?.kill(); } catch {} } } // ------------------------------------------------------------------ Linux (X11) function x11() { if (x11.api) return x11.api; const l = koffi.load('libX11.so.6'); x11.api = { XOpenDisplay: l.func('void *XOpenDisplay(const char *name)'), XQueryTree: l.func('int XQueryTree(void *d, unsigned long w, _Out_ unsigned long *root, _Out_ unsigned long *parent, _Out_ void **children, _Out_ unsigned int *n)'), XFree: l.func('int XFree(void *p)'), XMoveResizeWindow: l.func('int XMoveResizeWindow(void *d, unsigned long w, int x, int y, unsigned int wd, unsigned int ht)'), XMapWindow: l.func('int XMapWindow(void *d, unsigned long w)'), XUnmapWindow: l.func('int XUnmapWindow(void *d, unsigned long w)'), XRaiseWindow: l.func('int XRaiseWindow(void *d, unsigned long w)'), XSetInputFocus: l.func('int XSetInputFocus(void *d, unsigned long w, int revert, unsigned long time)'), XReparentWindow: l.func('int XReparentWindow(void *d, unsigned long w, unsigned long parent, int x, int y)'), XFlush: l.func('int XFlush(void *d)'), XSync: l.func('int XSync(void *d, int discard)'), }; x11.api.display = x11.api.XOpenDisplay(null); if (!x11.api.display) throw new Error(i18n.t('No connection to the X server (MrTerm is not running under X11/XWayland).')); return x11.api; } function x11Children(win) { const X = x11(); const root = [0], parent = [0], children = [null], n = [0]; if (!X.XQueryTree(X.display, win, root, parent, children, n) || !children[0]) return []; const list = koffi.decode(children[0], 'unsigned long', n[0]).map(Number); X.XFree(children[0]); return list; } class LinuxEmbed { constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); x11(); // früh scheitern, falls kein X11 } start({ client, args, password }) { this.before = new Set(x11Children(this.parent)); this.proc = spawn(client, [...args, `/parent-window:${this.parent}`], { stdio: [password ? 'pipe' : 'ignore', 'ignore', 'pipe'] }); if (password) { this.proc.stdin.write(password + '\n'); this.proc.stdin.end(); } this.err = ''; this.proc.stderr.on('data', (d) => { this.err = (this.err + d).slice(-4000); }); return this.proc; } async attach(bounds, isCancelled) { const t0 = Date.now(); while (Date.now() - t0 < 120000) { if (isCancelled() || this.proc.exitCode !== null) return false; const fresh = x11Children(this.parent).filter((w) => !this.before.has(w)); if (fresh.length) { this.xwin = fresh[fresh.length - 1]; this.setBounds(bounds); return true; } await new Promise((r) => setTimeout(r, 250)); } return false; } setBounds(b) { if (!this.xwin) return; this.bounds = b; const X = x11(); X.XMoveResizeWindow(X.display, this.xwin, b.x, b.y, Math.max(50, b.width), Math.max(50, b.height)); X.XRaiseWindow(X.display, this.xwin); X.XFlush(X.display); } show() { if (!this.xwin) return; const X = x11(); X.XMapWindow(X.display, this.xwin); if (this.bounds) this.setBounds(this.bounds); X.XFlush(X.display); } hide() { if (!this.xwin) return; const X = x11(); X.XUnmapWindow(X.display, this.xwin); X.XFlush(X.display); } focus() { if (!this.xwin) return; const X = x11(); X.XSetInputFocus(X.display, this.xwin, 2, 0); X.XFlush(X.display); } reposition() {} detach() { /* FreeRDP-Fenster lassen sich nicht sauber herauslösen – Sitzung stattdessen extern neu starten */ } kill() { try { this.proc?.kill(); } catch {} } } function embedSupported() { if (!koffi) return { ok: false, reason: i18n.t('koffi not available') }; if (process.platform === 'win32') return { ok: true }; if (process.platform === 'linux') { try { x11(); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; } } return { ok: false, reason: i18n.t('Platform not supported') }; } function createEmbed(parentHandle) { return process.platform === 'win32' ? new WinEmbed(parentHandle) : new LinuxEmbed(parentHandle); } module.exports = { createEmbed, embedSupported };