RDP-Verbindungen als Tabs (mstsc/FreeRDP eingebettet); App-Kürzel im Terminal auf Strg+Shift, damit Strg+W/K/T bei nano & Co. ankommen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:20:04 +02:00
co-authored by Claude Opus 5.5
parent b1c6141264
commit aa31e753c2
11 changed files with 830 additions and 60 deletions
+2 -1
View File
@@ -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
+355 -2
View File
@@ -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",
+9 -3
View File
@@ -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
}
}
+65 -1
View File
@@ -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'] });
+194
View File
@@ -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:<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');
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 };
+67 -43
View File
@@ -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 };
+1
View File
@@ -24,6 +24,7 @@ const DEFAULTS = {
copyOnSelect: true,
keepAlive: 30,
rdpClientLinux: 'auto',
rdpEmbed: true,
appTheme: 'midnight',
accent: '',
updateUrl: 'https://git.mrblake.cc/MrBlake/MrTerm',
+7
View File
@@ -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),
});
+127 -9
View File
@@ -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('<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">Prüfe …</div></div>');
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('<div class="hint" style="color:var(--faint);margin:-4px 0 12px 26px;font-size:11px">Nutzt xfreerdp3 mit /parent-window. MrTerm läuft dafür unter Wayland über XWayland.</div>'));
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(`<div class="settings-card"><h3>Tastenkürzel</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 18px;color:var(--muted)">
<span><kbd>Strg</kbd>+<kbd>K</kbd> / <kbd>Strg</kbd>+<kbd>T</kbd></span><span>Quick Connect / Befehlspalette</span>
<span><kbd>Strg</kbd>+<kbd>W</kbd></span><span>Tab schließen</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>K</kbd> / <kbd>T</kbd></span><span>Quick Connect / Befehlspalette (außerhalb des Terminals auch <kbd>Strg</kbd>+<kbd>K</kbd>)</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span><span>Tab schließen</span>
<span><kbd>Strg</kbd>+<kbd>Tab</kbd></span><span>Nächster Tab</span>
<span><kbd>Strg</kbd>+<kbd>1..9</kbd></span><span>Zu Tab wechseln</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>Kopieren / Einfügen im Terminal</span>
@@ -797,7 +814,7 @@ api.on('update:progress', (done, total) => {
// ============================================================ Tabs
function addTab(session) {
S.tabs.push(session);
const tab = h(`<div class="tab" data-tab="${session.id}"><span class="dot wait"></span><span>${esc(session.title)}</span><button class="x" title="Schließen (Strg+W)">${ICONS.close}</button></div>`);
const tab = h(`<div class="tab" data-tab="${session.id}"><span class="dot wait"></span><span>${esc(session.title)}</span><button class="x" title="Schließen (Strg+Shift+W)">${ICONS.close}</button></div>`);
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(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="reconnect" title="Neu verbinden">${ICONS.refresh}Neu verbinden</button>
${api.platform === 'win32' ? `<button class="btn ghost sm" data-a="detach" title="Sitzung als eigenes Fenster weiterführen">${ICONS.screen}Als Fenster lösen</button>` : ''}
</div>
<div class="term-wrap rdp-area"><div class="rdp-hint">RDP-Sitzung ausgeblendet, solange ein Dialog geöffnet ist.</div></div></div>`);
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(`<div class="overlay">${err ? '' : '<div class="spinner"></div>'}<div class="msg ${err ? 'err' : ''}">${esc(msg)}</div>
<div class="row">${err ? `<button class="btn" data-o="window">${ICONS.screen}Im eigenen Fenster</button><button class="btn primary" data-o="retry">${ICONS.refresh}Erneut verbinden</button>` : '<button class="btn ghost" data-o="cancel">Abbrechen</button>'}</div></div>`);
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)];
+1 -1
View File
@@ -16,7 +16,7 @@
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
</button>
</div>
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Strg+K)">+</button>
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Strg+Shift+K)">+</button>
<div class="drag-fill"></div>
<div class="win-ctrls">
<button id="winMin" title="Minimieren"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
+2
View File
@@ -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; }