diff --git a/README.md b/README.md index 2cb824e..35e52ed 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ SSH-, SFTP- und RDP-Client im Stil von Termius – Electron + xterm.js + ssh2. L - **Hosts**: Gruppen (verschachtelt), Tags, Farben, Suche, Quick Connect (`user@host:port`, `rdp://host`) - **SSH-Terminal** in Tabs: Passwort, Schlüssel, SSH-Agent (Windows OpenSSH / `SSH_AUTH_SOCK`), Keyboard-Interactive/2FA, Jump-Hosts (ProxyJump-Ketten), Startbefehl, Env-Variablen, Suche, Zoom, 7 Farbschemata - **SFTP**: Dual-Pane (lokal ↔ remote), Drag & Drop, Ordner rekursiv, Umbenennen, Löschen, chmod, Fortschrittsanzeige +- **RDP als Tab** direkt in MrTerm (Windows: mstsc eingebettet, Linux: xfreerdp3 via /parent-window unter X11/XWayland), alternativ im eigenen Fenster - **RDP**: Windows → `mstsc` (Anmeldedaten temporär via `cmdkey`), Linux → FreeRDP (`xfreerdp3`/`sdl-freerdp3`/`wlfreerdp3`, Passwort über stdin) - **Keychain**: Ed25519/ECDSA/RSA generieren, importieren, Public Key kopieren - **Port-Forwarding**: lokal (-L), remote (-R), dynamisch/SOCKS5 (-D) @@ -14,7 +15,7 @@ SSH-, SFTP- und RDP-Client im Stil von Termius – Electron + xterm.js + ssh2. L - **7 App-Designs** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Hell) + frei wählbare Akzentfarbe; Terminal-Schema optional „passend zum Design“ - **Auto-Update** aus den Gitea-Releases von https://git.mrblake.cc/MrBlake/MrTerm (Setup-EXE, Portable, AppImage, pacman) - **Vault verschlüsselt** über das OS (Windows DPAPI, Linux libsecret/KWallet) -- Befehlspalette `Strg+K`, `Strg+W`, `Strg+Tab`, `Strg+1..9`, `Strg+Shift+C/V/F` +- Befehlspalette `Strg+Shift+K`, Tab schließen `Strg+Shift+W` (Strg+W/K/T bleiben im Terminal für nano & Co.), `Strg+Tab`, `Strg+1..9`, `Strg+Shift+C/V/F` ## Entwicklung ```bash diff --git a/package-lock.json b/package-lock.json index 17695e2..9fd0abe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "mrterm", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mrterm", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", + "koffi": "^3.3.1", "ssh2": "^1.17.0" }, "devDependencies": { @@ -337,6 +338,326 @@ "node": ">=18.0.0" } }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.1.tgz", + "integrity": "sha512-S3yMzUGca5UJRioY7wdBjeO+LuW9mQhrksL+Wd5ri1X7kWMB2IGLzrT1mn1xqx02Vicgvuy5GtkhHuxG5mkzYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.1.tgz", + "integrity": "sha512-QaKSyVi37j19CfN2tR3q8lQMosZ+mQIOQXcNQmWXfYDUs9x2CSCxzvMTeQyFyGdAqB58dBluEiy8V5ZMvARSHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.1.tgz", + "integrity": "sha512-xVLQMZzACn6TQJsGp/mBF71mfRyuhmxTBGcQLfJM59YArNN/9+1JV8FFCElmgspffijASZsLwbSEdPwZMNeDhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.1.tgz", + "integrity": "sha512-+VGqqnBQvR+AbAbRyRDsxK0D6wwAivUURWsyVX8NhR5agf+0+sSAdNMsSy+9DyO7CmioTUVwV+Do+12uWnFrmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.1.tgz", + "integrity": "sha512-oPkfxEiLiZs1jV6qqw3DzZy3aigzOb9mGbRtK6ENNgoiZ2QdAw55cXUTOMNiAwgbkBwt7SsorFdUnYUuP5b0Gg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.1.tgz", + "integrity": "sha512-uLb93keIlk6TkECtb17z2d5nehEIJ2labIXF0ava3s+9w7VE7QZUw3O2IQVq4t95sIm5rMoE1Y+cFa0aN6444w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.1.tgz", + "integrity": "sha512-+Iuh+m5Ln0fjubgw/6bzEkliBcFBIFWWL7IqzmktPy0Q0Bq+XCGtUf+5/KTlodYlTOLv/SE/YTbVkk3c02+E6w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.1.tgz", + "integrity": "sha512-rE6QypE/h4nDHPlzzVPok5W2gL5CopvBYI44lJXO2SauxVAeV86bbf+ucmF/toYENdjF0DBdY7GbloyixwQpPQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.1.tgz", + "integrity": "sha512-Itv2N+TvBFTSdEZdokM4ydjbBwr6GNKd51+UXoh/5HVXALBRnMQek2QhBnG7z6H9+/p2SB4rqBnvhuLQpv4B7g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.1.tgz", + "integrity": "sha512-pb1NJ/Ih4MAPB1e2koQOKxGtLuPWKMT3QXxBrPptkkkDz/XpV6+bAA4ys7amXxpA0ybM2u/+VM0gGzdJRThkRg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.1.tgz", + "integrity": "sha512-RdS57JuF39tLXS/yYq4KlFnU7CMrn1+x/4v171bji+1aPYOXPxzYDAJVjapv/6tnrESrMTnfWIja3jfzieFmpQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ppc64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.1.tgz", + "integrity": "sha512-nmOTpQQS5pWG73X01y7K70JQaL+5T/v04i0G4BtXDAxQq3WHmyeJCgmdHhz5W0JVtjHicitSZoothTspAopF9A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.1.tgz", + "integrity": "sha512-mk0Av5QmK0BPD3OHytOThVhEe4VCTrYv3+em6h75E1rNYiCfixzeh3d5+s5x2vCaby8zzxvNd39sO89Bp1juwg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.1.tgz", + "integrity": "sha512-uU5cJNe145TvITqrUWvBmZO4WLNObdZ2Gnm8eNk/OdrhRFG9LB44RUv07aQwPbdo1438j+Cr+tFNnpjTBadZFQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-arm64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.1.tgz", + "integrity": "sha512-owXmXHI+5kkDyVjqGPfPgIHIzH1swsGY+YNbQ4ozkO23UKzJElAtmQPcmA1pQwiDCeyojHEelgd9+BV58uOIkA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.1.tgz", + "integrity": "sha512-fvIKWycDdc9XL3ATQT0uvt/j82EB3/JWzJU4yqUCJbMotTP8mvo/c1D1Jus4mO+fNGEYM33YuFDkJcQkHJd9Dg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.1.tgz", + "integrity": "sha512-UYk2xNAK5wEyskLtrgTRt+2lwfBOvgaK8UJDz4B2qm57VvRrqb7ye1hzKFZrRVa8NWRPUk6W/zsqZyFHu4BoLA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.1.tgz", + "integrity": "sha512-SZsmAWUKxBHdUR6Z5IJTslOOy0VEFHxoDHMZkn3k0P5TuP/aOZfQYoEa34UwnLI8t2Fmgyhmympth+39tR/XUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.1.tgz", + "integrity": "sha512-pARisOvxCPkk6Wg1HkwRCnrkJIVS31kDweWLo+bQay/Dr1915s7skVydWpTKS30EcfOh4Jay7B4c0dgamTLBHg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.1.tgz", + "integrity": "sha512-LYoO19cTIA7xjl+3s4VeGagCAO38VNXZ969DNszb1wTwJUjrHLrAbHFkk8fYkXineM3HJkFwElW0VLmZqXcfRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -2409,6 +2730,38 @@ "json-buffer": "3.0.1" } }, + "node_modules/koffi": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.1.tgz", + "integrity": "sha512-FZYfhBfYQr/cmHhZpaQ9rhbKpkjHhxaytn2A0mbPhiD5yV8QzF3SiQwR1w/34rCJ1fw+6qBQSj/ZoXRdC+Xc2Q==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.3.1", + "@koromix/koffi-android-x64": "3.3.1", + "@koromix/koffi-darwin-arm64": "3.3.1", + "@koromix/koffi-darwin-x64": "3.3.1", + "@koromix/koffi-freebsd-arm64": "3.3.1", + "@koromix/koffi-freebsd-ia32": "3.3.1", + "@koromix/koffi-freebsd-x64": "3.3.1", + "@koromix/koffi-linux-arm": "3.3.1", + "@koromix/koffi-linux-arm64": "3.3.1", + "@koromix/koffi-linux-ia32": "3.3.1", + "@koromix/koffi-linux-loong64": "3.3.1", + "@koromix/koffi-linux-ppc64": "3.3.1", + "@koromix/koffi-linux-riscv64": "3.3.1", + "@koromix/koffi-linux-x64": "3.3.1", + "@koromix/koffi-openbsd-arm64": "3.3.1", + "@koromix/koffi-openbsd-ia32": "3.3.1", + "@koromix/koffi-openbsd-x64": "3.3.1", + "@koromix/koffi-win32-arm64": "3.3.1", + "@koromix/koffi-win32-ia32": "3.3.1", + "@koromix/koffi-win32-x64": "3.3.1" + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", diff --git a/package.json b/package.json index 8d6399d..b0b67fb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mrterm", "productName": "MrTerm", - "version": "0.2.0", + "version": "0.3.0", "description": "Moderner SSH-, SFTP- und RDP-Client", "main": "src/main/main.js", "author": "MrBlake", @@ -51,13 +51,18 @@ }, "directories": { "buildResources": "build" - } + }, + "asarUnpack": [ + "node_modules/koffi/**", + "node_modules/@koromix/**" + ] }, "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", + "koffi": "^3.3.1", "ssh2": "^1.17.0" }, "devDependencies": { @@ -67,6 +72,7 @@ "allowScripts": { "electron@44.4.5": true, "electron-winstaller@5.4.0": true, - "ssh2@1.17.0": true + "ssh2@1.17.0": true, + "koffi@3.3.1": true } } diff --git a/src/main/main.js b/src/main/main.js index 7bbce8a..39dd7a5 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -8,9 +8,16 @@ const { Store } = require('./store'); const { SshManager } = require('./ssh'); const rdp = require('./rdp'); const { Updater } = require('./updater'); +const { createEmbed, embedSupported } = require('./rdp-embed'); let win; const store = new Store(); + +// Unter Linux muss MrTerm für eingebettete RDP-Tabs als X11-Client laufen (unter Wayland via XWayland). +// Die Wahl wird unverschlüsselt in launch.json gespeichert, weil sie vor dem Start des Fensters gebraucht wird. +const launchFile = path.join(app.getPath('userData'), 'launch.json'); +function readLaunch() { try { return JSON.parse(fs.readFileSync(launchFile, 'utf8')); } catch { return {}; } } +if (process.platform === 'linux' && readLaunch().x11 !== false) app.commandLine.appendSwitch('ozone-platform', 'x11'); const pendingSecrets = new Map(); function send(channel, ...args) { @@ -85,7 +92,10 @@ ipcMain.on('win:close', () => win.close()); handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform })); handle('vault:upsert', (col, item) => store.upsert(col, item)); handle('vault:remove', (col, id) => store.remove(col, id)); -handle('vault:settings', (s) => store.setSettings(s)); +handle('vault:settings', (s) => { + store.setSettings(s); + if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false })); +}); handle('vault:forgetHost', (id) => { delete store.get().knownHosts[id]; store.save(); }); handle('vault:export', async () => { const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); @@ -244,6 +254,60 @@ handle('rdp:launch', (hostId) => { return r.client; }); +// Eingebettete RDP-Sitzungen (Anzeige im Tab) +const rdpSessions = new Map(); +handle('rdp:embedSupported', () => { + const s = embedSupported(); + if (s.ok && process.platform === 'linux' && !rdp.linuxClient(null, true)) return { ok: false, reason: 'xfreerdp3 fehlt (sudo pacman -S freerdp)' }; + return s; +}); +handle('rdp:open', async (id, hostRef, bounds) => { + const host = hostWithOverrides(hostRef); + if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); + const emb = createEmbed(win.getNativeWindowHandle()); + const sess = { emb, cancelled: false }; + rdpSessions.set(id, sess); + 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 })))); + } 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 })); + proc.on('exit', (code) => { + 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 }); + rdpSessions.delete(id); + }); + const ok = await emb.attach(bounds, () => sess.cancelled); + if (ok) { + 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 {} } }); +ipcMain.on('rdp:focus', (_e, id) => { try { rdpSessions.get(id)?.emb.focus(); } catch {} }); +ipcMain.on('rdp:close', (_e, id) => { + const s = rdpSessions.get(id); + if (!s) return; + s.cancelled = true; + rdpSessions.delete(id); + s.emb.kill(); +}); +handle('rdp:detach', (id) => { + const s = rdpSessions.get(id); + if (!s) return false; + if (process.platform !== 'win32') return false; + s.emb.detach(); + rdpSessions.delete(id); // läuft als eigenständiges Fenster weiter + return true; +}); + // ---------- Import aus anderen Programmen (z. B. Devolutions RDM) ---------- handle('import:pickFile', async (title, extensions) => { const r = await dialog.showOpenDialog(win, { title, filters: [{ name: title, extensions }, { name: 'Alle Dateien', extensions: ['*'] }], properties: ['openFile'] }); diff --git a/src/main/rdp-embed.js b/src/main/rdp-embed.js new file mode 100644 index 0000000..641143b --- /dev/null +++ b/src/main/rdp-embed.js @@ -0,0 +1,194 @@ +// 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. +// 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 */ } + +// ------------------------------------------------------------------ Windows +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)'); + winApi.api = { + EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'), + 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)'), + SetParent: u.func('intptr __stdcall SetParent(intptr child, intptr parent)'), + 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)'), + SetFocus: u.func('intptr __stdcall SetFocus(intptr hwnd)'), + }; + 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); + return out; +} + +const GWL_STYLE = -16, GWL_EXSTYLE = -20; +const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000; +const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000, WS_EX_APPWINDOW = 0x00040000; +const SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40; + +class WinEmbed { + constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.cls = 'TscShellContainerClass'; } + + start({ rdpFile }) { + this.proc = spawn('mstsc.exe', rdpFile ? [rdpFile] : [], { stdio: 'ignore' }); + 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(); + let style = Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0; + style = ((style & ~(WS_POPUP | WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX)) | WS_CHILD) >>> 0; + api.SetWindowLongPtrW(hwnd, GWL_STYLE, style); + const ex = (Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW; + api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex >>> 0); + api.SetParent(hwnd, this.parent); + this.hwnd = hwnd; + this.setBounds(bounds); + return true; + } + await new Promise((r) => setTimeout(r, 250)); + } + return false; + } + + setBounds(b) { + if (!this.hwnd) return; + this.bounds = b; + winApi().SetWindowPos(this.hwnd, 0, b.x, b.y, Math.max(50, b.width), Math.max(50, b.height), SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW); + } + show() { if (this.hwnd) { winApi().ShowWindow(this.hwnd, 5); if (this.bounds) this.setBounds(this.bounds); } } + hide() { if (this.hwnd) winApi().ShowWindow(this.hwnd, 0); } + focus() { if (this.hwnd) { winApi().SetFocus(this.hwnd); } } + // Aus MrTerm lösen und als normales Fenster weiterlaufen lassen + detach() { + if (!this.hwnd) return; + const api = winApi(); + api.SetParent(this.hwnd, 0); + const style = ((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_CHILD) | WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; + api.SetWindowLongPtrW(this.hwnd, GWL_STYLE, style >>> 0); + 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 {} } +} + +// ------------------------------------------------------------------ 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('Keine Verbindung zum X-Server (MrTerm läuft nicht unter 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); } + 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: 'koffi nicht verfügbar' }; + 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: 'Plattform nicht unterstützt' }; +} + +function createEmbed(parentHandle) { + return process.platform === 'win32' ? new WinEmbed(parentHandle) : new LinuxEmbed(parentHandle); +} + +module.exports = { createEmbed, embedSupported }; diff --git a/src/main/rdp.js b/src/main/rdp.js index b0bf22e..d61b688 100644 --- a/src/main/rdp.js +++ b/src/main/rdp.js @@ -1,4 +1,5 @@ -// RDP: Windows nutzt das eingebaute mstsc.exe, Linux (Arch/CachyOS) FreeRDP (xfreerdp3 / xfreerdp / wlfreerdp). +// RDP: Windows nutzt das eingebaute mstsc.exe, Linux (Arch/CachyOS) FreeRDP (xfreerdp3 / xfreerdp / sdl-freerdp3 / wlfreerdp3). +// Verbindungen laufen entweder eingebettet im MrTerm-Tab (siehe rdp-embed.js) oder im eigenen Fenster. const { spawn, execFile, execFileSync } = require('child_process'); const fs = require('fs'); const os = require('os'); @@ -11,7 +12,9 @@ function which(bin) { } catch { return false; } } -function linuxClient(pref) { +// Eingebettet geht nur der X11-Client (unterstützt /parent-window) +function linuxClient(pref, embedded) { + if (embedded) return ['xfreerdp3', 'xfreerdp'].find(which); const candidates = pref && pref !== 'auto' ? [pref] : process.env.WAYLAND_DISPLAY @@ -21,53 +24,62 @@ function linuxClient(pref) { } function detect(settings) { - if (process.platform === 'win32') return { available: true, client: 'mstsc' }; + if (process.platform === 'win32') return { available: true, client: 'mstsc', embedClient: 'mstsc' }; const c = linuxClient(settings.rdpClientLinux); - return c ? { available: true, client: c } : { available: false, hint: 'sudo pacman -S freerdp' }; + const e = linuxClient(null, true); + return c ? { available: true, client: c, embedClient: e || null } : { available: false, hint: 'sudo pacman -S freerdp' }; } -function launch(host, settings) { +const userOf = (host) => (host.domain ? `${host.domain}\\${host.username || ''}` : host.username || ''); + +// ---------- Windows: .rdp-Datei + temporäre Anmeldedaten +function writeRdpFile(host, size) { const port = Number(host.port) || 3389; - const user = host.domain ? `${host.domain}\\${host.username || ''}` : host.username || ''; + const target = port === 3389 ? host.address : `${host.address}:${port}`; + const user = userOf(host); + const w = size?.width || host.rdpWidth; + const hgt = size?.height || host.rdpHeight; + const lines = [ + `full address:s:${target}`, + user && `username:s:${user}`, + `screen mode id:i:${!size && host.rdpFullscreen ? 2 : 1}`, + w && `desktopwidth:i:${w}`, + hgt && `desktopheight:i:${hgt}`, + 'dynamic resolution:i:1', + // Im Tab keine Skalierung, damit die Auflösung beim Größenändern mitwächst + `smart sizing:i:${size ? 0 : 1}`, + 'displayconnectionbar:i:0', + `redirectclipboard:i:${host.rdpClipboard === false ? 0 : 1}`, + `redirectdrives:i:${host.rdpDrives ? 1 : 0}`, + `audiomode:i:${host.rdpAudio === false ? 2 : 0}`, + 'prompt for credentials:i:0', + 'authentication level:i:2', + 'autoreconnection enabled:i:1', + ].filter(Boolean); + const file = path.join(os.tmpdir(), `mrterm-${host.id || 'quick'}-${Date.now()}.rdp`); + fs.writeFileSync(file, lines.join('\r\n'), 'utf16le'); + setTimeout(() => fs.rm(file, () => {}), 60000); + return file; +} - if (process.platform === 'win32') { - const target = port === 3389 ? host.address : `${host.address}:${port}`; - const lines = [ - `full address:s:${target}`, - user && `username:s:${user}`, - `screen mode id:i:${host.rdpFullscreen ? 2 : 1}`, - host.rdpWidth && `desktopwidth:i:${host.rdpWidth}`, - host.rdpHeight && `desktopheight:i:${host.rdpHeight}`, - 'dynamic resolution:i:1', - 'smart sizing:i:1', - `redirectclipboard:i:${host.rdpClipboard === false ? 0 : 1}`, - `redirectdrives:i:${host.rdpDrives ? 1 : 0}`, - `audiomode:i:${host.rdpAudio === false ? 2 : 0}`, - 'prompt for credentials:i:0', - 'authentication level:i:2', - ].filter(Boolean); - const file = path.join(os.tmpdir(), `mrterm-${host.id}.rdp`); - fs.writeFileSync(file, lines.join('\r\n'), 'utf16le'); - const run = () => { - const p = spawn('mstsc.exe', [file], { detached: true, stdio: 'ignore' }); - p.unref(); - }; - if (host.password && user) { - // Anmeldedaten temporär im Windows-Anmeldeinformationsspeicher ablegen, danach wieder entfernen. - execFile('cmdkey', [`/generic:TERMSRV/${host.address}`, `/user:${user}`, `/pass:${host.password}`], () => { - run(); - setTimeout(() => execFile('cmdkey', [`/delete:TERMSRV/${host.address}`], () => {}), 20000); - }); - } else run(); - return { client: 'mstsc' }; - } +// Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet. +function withWinCredentials(host, run) { + const user = userOf(host); + if (!(host.password && user)) return run(); + execFile('cmdkey', [`/generic:TERMSRV/${host.address}`, `/user:${user}`, `/pass:${host.password}`], () => { + run(); + setTimeout(() => execFile('cmdkey', [`/delete:TERMSRV/${host.address}`], () => {}), 20000); + }); +} - const client = linuxClient(settings.rdpClientLinux); - if (!client) throw new Error('Kein FreeRDP gefunden. Installieren mit: sudo pacman -S freerdp'); +// ---------- Linux: FreeRDP-Argumente +function freerdpArgs(host, size) { + const port = Number(host.port) || 3389; const args = [`/v:${host.address}:${port}`, '/cert:tofu', '+auto-reconnect']; if (host.username) args.push(`/u:${host.username}`); if (host.domain) args.push(`/d:${host.domain}`); - if (host.rdpFullscreen) args.push('/f'); + if (size) args.push(`/size:${size.width}x${size.height}`, '/dynamic-resolution'); + else if (host.rdpFullscreen) args.push('/f'); else if (host.rdpWidth && host.rdpHeight) args.push(`/size:${host.rdpWidth}x${host.rdpHeight}`); else args.push('/dynamic-resolution'); if (host.rdpClipboard !== false) args.push('+clipboard'); @@ -76,12 +88,24 @@ function launch(host, settings) { args.push(`/title:${host.label || host.address}`); // Passwort über stdin statt Kommandozeile (nicht in `ps` sichtbar) if (host.password) args.push('/from-stdin'); - const p = spawn(client, args, { detached: true, stdio: [host.password ? 'pipe' : 'ignore', 'ignore', 'pipe'] }); + return args; +} + +// Externes Fenster +function launch(host, settings) { + if (process.platform === 'win32') { + const file = writeRdpFile(host); + withWinCredentials(host, () => spawn('mstsc.exe', [file], { detached: true, stdio: 'ignore' }).unref()); + return { client: 'mstsc' }; + } + const client = linuxClient(settings.rdpClientLinux); + if (!client) throw new Error('Kein FreeRDP gefunden. Installieren mit: sudo pacman -S freerdp'); + const p = spawn(client, freerdpArgs(host), { detached: true, stdio: [host.password ? 'pipe' : 'ignore', 'ignore', 'pipe'] }); if (host.password) { p.stdin.write(host.password + '\n'); p.stdin.end(); } let err = ''; - p.stderr.on('data', (d) => { err += d; }); + p.stderr.on('data', (d) => { err = (err + d).slice(-4000); }); p.unref(); return { client, process: p, getErr: () => err }; } -module.exports = { detect, launch }; +module.exports = { detect, launch, linuxClient, writeRdpFile, withWinCredentials, freerdpArgs }; diff --git a/src/main/store.js b/src/main/store.js index 1de6af6..9b550bb 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -24,6 +24,7 @@ const DEFAULTS = { copyOnSelect: true, keepAlive: 30, rdpClientLinux: 'auto', + rdpEmbed: true, appTheme: 'midnight', accent: '', updateUrl: 'https://git.mrblake.cc/MrBlake/MrTerm', diff --git a/src/preload.js b/src/preload.js index c18fa43..596007b 100644 --- a/src/preload.js +++ b/src/preload.js @@ -26,5 +26,12 @@ contextBridge.exposeInMainWorld('api', { resize: (id, c, r) => ipcRenderer.send('ssh:resize', id, c, r), close: (id) => ipcRenderer.send('ssh:close', id), }, + rdp: { + bounds: (id, b) => ipcRenderer.send('rdp:bounds', id, b), + show: (id) => ipcRenderer.send('rdp:show', id), + hide: (id) => ipcRenderer.send('rdp:hide', id), + focus: (id) => ipcRenderer.send('rdp:focus', id), + close: (id) => ipcRenderer.send('rdp:close', id), + }, replySecret: (reqId, v) => ipcRenderer.send('secret:reply', reqId, v), }); diff --git a/src/renderer/app.js b/src/renderer/app.js index a6039ad..ea94f6f 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -267,7 +267,8 @@ function hostCard(hst) { function hostMenu(hst) { return [ - { label: hst.protocol === 'rdp' ? 'RDP-Verbindung starten' : 'Verbinden', icon: 'play', run: () => connectHost(hst) }, + { label: hst.protocol === 'rdp' ? 'Im Tab verbinden' : 'Verbinden', icon: 'play', run: () => connectHost(hst) }, + ...(hst.protocol === 'rdp' ? [{ label: 'In eigenem Fenster öffnen', icon: 'screen', run: () => launchRdp(hst) }] : []), ...(hst.protocol !== 'rdp' ? [{ label: 'SFTP öffnen', icon: 'folder', run: () => openSftp(hst) }] : []), '-', { label: 'Bearbeiten', icon: 'edit', run: () => editHost(hst) }, @@ -279,10 +280,19 @@ function hostMenu(hst) { } function connectHost(hst) { - if (hst.protocol === 'rdp') return launchRdp(hst); + if (hst.protocol === 'rdp') return openRdp(hst); openTerminal(hst); } +async function openRdp(hst) { + if (settings().rdpEmbed !== false) { + const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message })); + if (s.ok) return new RdpSession(hst); + toast('RDP im Tab nicht möglich (' + s.reason + ') – öffne eigenes Fenster.'); + } + launchRdp(hst); +} + async function launchRdp(hst) { try { const client = await call('rdp:launch', hst.id || hst); @@ -676,8 +686,15 @@ async function viewSettings(page) { }); const rdpCard = h('

RDP

Prüfe …
'); + const emb = check('RDP-Verbindungen als Tab in MrTerm anzeigen', 'rdpEmbed', st.rdpEmbed !== false); + emb.onchange = async () => { + await save({ rdpEmbed: $('input', emb).checked }); + if (api.platform === 'linux') toast('Wird nach einem Neustart von MrTerm wirksam (X11/XWayland-Modus).'); + }; + rdpCard.append(emb); + if (api.platform === 'linux') rdpCard.append(h('
Nutzt xfreerdp3 mit /parent-window. MrTerm läuft dafür unter Wayland über XWayland.
')); if (api.platform !== 'win32') { - const f = field('FreeRDP-Client', 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', 'Automatisch'], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] }); + const f = field('FreeRDP-Client (eigenes Fenster)', 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', 'Automatisch'], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] }); f.onchange = () => save({ rdpClientLinux: $('select', f).value }).then(render); rdpCard.append(f); } @@ -720,8 +737,8 @@ async function viewSettings(page) { updCard.append(updFields, chk); const keysCard = h(`

Tastenkürzel

- Strg+K / Strg+TQuick Connect / Befehlspalette - Strg+WTab schließen + Strg+Shift+K / TQuick Connect / Befehlspalette (außerhalb des Terminals auch Strg+K) + Strg+Shift+WTab schließen Strg+TabNächster Tab Strg+1..9Zu Tab wechseln Strg+Shift+C/VKopieren / Einfügen im Terminal @@ -797,7 +814,7 @@ api.on('update:progress', (done, total) => { // ============================================================ Tabs function addTab(session) { S.tabs.push(session); - const tab = h(`
${esc(session.title)}
`); + const tab = h(`
${esc(session.title)}
`); tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); }; tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); }; tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ @@ -822,6 +839,7 @@ function activateTab(id) { S.tabs.forEach((t) => { t.el.classList.toggle('active', t.id === id); if (t.id === id) requestAnimationFrame(() => t.onShow?.()); + else t.onHide?.(); }); $$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } @@ -882,7 +900,9 @@ class TerminalSession { if (e.ctrlKey && e.shiftKey && e.code === 'KeyC') { if (this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); return false; } if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') { this.paste(); return false; } if (e.ctrlKey && e.shiftKey && e.code === 'KeyF') { this.toggleFind(true); return false; } - if (e.ctrlKey && !e.shiftKey && ['KeyK', 'KeyT', 'KeyW', 'Tab'].includes(e.code)) return false; + // App-Kürzel im Terminal nur mit Strg+Shift, damit Strg+W/K/T (nano, bash, …) im Terminal ankommen + if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW'].includes(e.code)) return false; + if (e.ctrlKey && e.code === 'Tab') return false; if (e.ctrlKey && /^Digit[1-9]$/.test(e.code)) return false; if (e.ctrlKey && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) { this.zoom(e.key); return false; } return true; @@ -1001,6 +1021,103 @@ class TerminalSession { } function openTerminal(host) { return new TerminalSession(host); } +// ============================================================ RDP-Session (eingebettet im Tab) +// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML. +// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist. +S.covered = false; +function updateCover() { + const covered = !!document.querySelector('.modal-bg, .ctx'); + if (covered === S.covered) return; + S.covered = covered; + const t = S.tabs.find((x) => x.id === S.active && x.kind === 'rdp'); + if (t) covered ? t.onHide() : t.onShow(); +} +new MutationObserver(updateCover).observe(document.body, { childList: true, subtree: true }); + +class RdpSession { + constructor(host) { + this.kind = 'rdp'; + this.id = uid(); + this.host = host; + this.title = host.label || host.address; + this.attached = false; + this.el = h(`
+
${avatar(host, 22)}${esc(hostSub(host))}
+ + ${api.platform === 'win32' ? `` : ''} +
+
RDP-Sitzung ausgeblendet, solange ein Dialog geöffnet ist.
`); + this.area = $('.rdp-area', this.el); + this.el.addEventListener('click', (e) => { + const a = e.target.closest('[data-a]')?.dataset.a; + if (a === 'reconnect') this.reconnect(); + if (a === 'detach') this.detach(); + }); + let raf = 0; + this.ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => this.pushBounds()); }); + this.ro.observe(this.area); + this.unsub = api.on('rdp:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); }); + addTab(this); + this.connect(); + } + + bounds() { + const r = this.area.getBoundingClientRect(); + const d = window.devicePixelRatio || 1; + return { x: Math.round(r.left * d), y: Math.round(r.top * d), width: Math.round(r.width * d), height: Math.round(r.height * d) }; + } + pushBounds() { if (this.attached && this.area.offsetWidth) api.rdp.bounds(this.id, this.bounds()); } + isVisible() { return S.active === this.id && !S.covered; } + + showOverlay(msg, err) { + this.overlay?.remove(); + this.overlay = h(`
${err ? '' : '
'}
${esc(msg)}
+
${err ? `` : ''}
`); + this.overlay.onclick = (e) => { + const o = e.target.closest('[data-o]')?.dataset.o; + if (o === 'retry') this.reconnect(); + if (o === 'cancel') closeTab(this.id); + if (o === 'window') { launchRdp(this.host); closeTab(this.id); } + }; + this.area.append(this.overlay); + } + + async connect() { + setTabState(this, 'wait'); + this.showOverlay(`Verbinde mit ${this.host.address} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.`); + await new Promise((r) => requestAnimationFrame(r)); + try { + const ok = await api.call('rdp:open', this.id, this.host.id || this.host, this.bounds()); + if (!ok && !this.attached && !this.disposed && !this.exited) this.showOverlay('Das RDP-Fenster konnte nicht eingebettet werden.', true); + } catch (e) { + setTabState(this, 'err'); + this.showOverlay(e.message, true); + } + } + + onEvent(type, payload) { + if (type === 'attached') { + this.attached = true; this.exited = false; + 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 === 'exit') { + this.attached = false; this.exited = true; + setTabState(this, payload?.code ? 'err' : ''); + this.showOverlay(`RDP-Sitzung beendet${payload?.code ? ` (Code ${payload.code})` : ''}.${payload?.message ? ' ' + payload.message : ''}`, true); + } + } + + reconnect() { api.rdp.close(this.id); this.attached = false; this.exited = false; this.connect(); } + async detach() { + if (!this.attached) return; + if (await api.call('rdp:detach', this.id)) { this.attached = false; closeTab(this.id); toast('Sitzung läuft als eigenes Fenster weiter', 'ok'); } + } + onShow() { if (this.attached && this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } } + onHide() { if (this.attached) api.rdp.hide(this.id); } + dispose() { this.disposed = true; this.unsub(); this.ro.disconnect(); api.rdp.close(this.id); } +} + // ============================================================ SFTP function viewSftpPicker(page) { page.append(toolbar('Host für SFTP wählen …')); @@ -1261,8 +1378,9 @@ if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none'; document.addEventListener('keydown', (e) => { if (!e.ctrlKey) return; - if (!e.shiftKey && (e.code === 'KeyK' || e.code === 'KeyT')) { e.preventDefault(); openPalette(); } - else if (!e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); } + const inTerm = !!e.target.closest?.('.xterm'); + if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); } + else if (e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); } else if (e.code === 'Tab') { e.preventDefault(); const ids = ['home', ...S.tabs.map((t) => t.id)]; diff --git a/src/renderer/index.html b/src/renderer/index.html index c135d8a..40d7bee 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -16,7 +16,7 @@ Vault
- +
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index c5666d5..a219147 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -309,3 +309,5 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi .design-prev .dp s { position: absolute; left: 6px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; border-radius: 3px; } .design-prev .nm { margin: 0; } [data-theme='light'] .brand-term { color: #14a37a; } +.rdp-area { background: #000; } +.rdp-area .rdp-hint { margin: auto; color: var(--faint); font-size: 12px; }