RDM-Import, App-Designs, Auto-Update über Gitea, Logo; SFTP-Namensspalte repariert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:04:19 +02:00
co-authored by Claude Opus 5.5
parent a5eccc7764
commit 972fe0d316
16 changed files with 650 additions and 46 deletions
+12
View File
@@ -10,6 +10,9 @@ SSH-, SFTP- und RDP-Client im Stil von Termius – Electron + xterm.js + ssh2. L
- **Keychain**: Ed25519/ECDSA/RSA generieren, importieren, Public Key kopieren - **Keychain**: Ed25519/ECDSA/RSA generieren, importieren, Public Key kopieren
- **Port-Forwarding**: lokal (-L), remote (-R), dynamisch/SOCKS5 (-D) - **Port-Forwarding**: lokal (-L), remote (-R), dynamisch/SOCKS5 (-D)
- **Snippets**, **Known Hosts** (TOFU, Warnung bei geändertem Schlüssel), **Verlauf**, `~/.ssh/config`-Import, Backup Export/Import - **Snippets**, **Known Hosts** (TOFU, Warnung bei geändertem Schlüssel), **Verlauf**, `~/.ssh/config`-Import, Backup Export/Import
- **Import aus Devolutions Remote Desktop Manager** (Einstellungen → Import): Ordnerstruktur, RDP- und SSH-Einträge aus `.rdm`/XML, JSON oder CSV
- **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) - **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+K`, `Strg+W`, `Strg+Tab`, `Strg+1..9`, `Strg+Shift+C/V/F`
@@ -25,3 +28,12 @@ npm start
- Arch/CachyOS (auf Linux): `npm run dist:linux` → AppImage + `.pacman`-Paket; installieren mit `sudo pacman -U dist/mrterm-*.pacman` - Arch/CachyOS (auf Linux): `npm run dist:linux` → AppImage + `.pacman`-Paket; installieren mit `sudo pacman -U dist/mrterm-*.pacman`
Abhängigkeiten unter Arch/CachyOS: `sudo pacman -S freerdp gnome-keyring` (oder `kwallet` unter KDE) für RDP bzw. Vault-Verschlüsselung. Abhängigkeiten unter Arch/CachyOS: `sudo pacman -S freerdp gnome-keyring` (oder `kwallet` unter KDE) für RDP bzw. Vault-Verschlüsselung.
## Release veröffentlichen (für Auto-Update)
1. Version in `package.json` erhöhen
2. Auf jeder Plattform bauen (`npm run dist:win` bzw. `npm run dist:linux`)
3. `GITEA_TOKEN=<token> npm run release -- --notes "Was ist neu"` – legt das Release `v<version>` an und lädt die Pakete hoch
Installierte Clients prüfen beim Start (und über Einstellungen → Updates) und installieren das passende Paket. Für ein privates Repo in den Einstellungen ein Lese-Token hinterlegen.
Icons neu erzeugen nach Logo-Änderung: `npx electron scripts/make-icons.js`
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+18 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.1.0", "version": "0.2.0",
"description": "Moderner SSH-, SFTP- und RDP-Client", "description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js", "main": "src/main/main.js",
"author": "MrBlake", "author": "MrBlake",
@@ -9,13 +9,15 @@
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"dist:win": "electron-builder --win", "dist:win": "electron-builder --win",
"dist:linux": "electron-builder --linux" "dist:linux": "electron-builder --linux",
"release": "node scripts/release.js"
}, },
"build": { "build": {
"appId": "de.mrterm.app", "appId": "de.mrterm.app",
"productName": "MrTerm", "productName": "MrTerm",
"files": [ "files": [
"src/**/*", "src/**/*",
"assets/icon.png",
"node_modules/**/*", "node_modules/**/*",
"package.json" "package.json"
], ],
@@ -35,7 +37,20 @@
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-Setup-${version}.${ext}"
},
"portable": {
"artifactName": "${productName}-${version}-portable.${ext}"
},
"appImage": {
"artifactName": "${productName}-${version}.${ext}"
},
"pacman": {
"artifactName": "${productName}-${version}.${ext}"
},
"directories": {
"buildResources": "build"
} }
}, },
"dependencies": { "dependencies": {
+14
View File
@@ -0,0 +1,14 @@
// Erzeugt App-Icons aus assets/logo-full.png: npx electron scripts/make-icons.js
const { app, nativeImage } = require('electron');
const fs = require('fs');
app.whenReady().then(() => {
const src = nativeImage.createFromPath('assets/logo-full.png');
const { width } = src.getSize();
const k = width / 1254;
// Ausschnitt: Kopf mit Hut, ohne Schriftzug
const head = src.crop({ x: Math.round(282 * k), y: Math.round(172 * k), width: Math.round(706 * k), height: Math.round(706 * k) });
fs.writeFileSync('build/icon.png', head.resize({ width: 512, height: 512, quality: 'best' }).toPNG());
fs.writeFileSync('src/renderer/logo.png', head.resize({ width: 96, height: 96, quality: 'best' }).toPNG());
fs.writeFileSync('assets/icon.png', head.resize({ width: 256, height: 256, quality: 'best' }).toPNG());
app.quit();
});
+41
View File
@@ -0,0 +1,41 @@
// Lädt die gebauten Pakete aus dist/ als Release in das Gitea-Repository hoch.
// Nutzung: GITEA_TOKEN=xxx node scripts/release.js [--notes "Text"] [--prerelease]
// Mehrere Plattformen: auf Windows und Linux jeweils bauen und ausführen – Assets werden zum selben Release (Tag v<version>) hinzugefügt.
const fs = require('fs');
const path = require('path');
const REPO = process.env.MRTERM_REPO || 'https://git.mrblake.cc/MrBlake/MrTerm';
const token = process.env.GITEA_TOKEN;
if (!token) { console.error('GITEA_TOKEN fehlt'); process.exit(1); }
const { version } = require('../package.json');
const tag = 'v' + version;
const u = new URL(REPO);
const [owner, repo] = u.pathname.split('/').filter(Boolean);
const api = `${u.origin}/api/v1/repos/${owner}/${repo}`;
const headers = { Authorization: `token ${token}` };
const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : undefined; };
(async () => {
let res = await fetch(`${api}/releases/tags/${tag}`, { headers });
let rel;
if (res.ok) rel = await res.json();
else {
res = await fetch(`${api}/releases`, {
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: tag, name: `MrTerm ${version}`, body: arg('--notes') || '', prerelease: process.argv.includes('--prerelease') }),
});
if (!res.ok) throw new Error(`Release anlegen fehlgeschlagen: ${res.status} ${await res.text()}`);
rel = await res.json();
console.log('Release angelegt:', rel.html_url);
}
const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman)$/.test(f));
if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.');
for (const f of files) {
const old = (rel.assets || []).find((a) => a.name === f);
if (old) await fetch(`${api}/releases/${rel.id}/assets/${old.id}`, { method: 'DELETE', headers });
const form = new FormData();
form.append('attachment', new Blob([fs.readFileSync(path.join('dist', f))]), f);
const r = await fetch(`${api}/releases/${rel.id}/assets?name=${encodeURIComponent(f)}`, { method: 'POST', headers, body: form });
console.log(r.ok ? '✓' : '✗', f, r.ok ? '' : await r.text());
}
})().catch((e) => { console.error(e.message); process.exit(1); });
+46
View File
@@ -7,6 +7,7 @@ const { utils: sshUtils } = require('ssh2');
const { Store } = require('./store'); const { Store } = require('./store');
const { SshManager } = require('./ssh'); const { SshManager } = require('./ssh');
const rdp = require('./rdp'); const rdp = require('./rdp');
const { Updater } = require('./updater');
let win; let win;
const store = new Store(); const store = new Store();
@@ -42,6 +43,7 @@ function askSecret(sessionId, req) {
} }
const ssh = new SshManager(store, confirmHostKey, askSecret); const ssh = new SshManager(store, confirmHostKey, askSecret);
const updater = new Updater(store, send);
function createWindow() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
@@ -50,6 +52,7 @@ function createWindow() {
minWidth: 900, minWidth: 900,
minHeight: 560, minHeight: 560,
backgroundColor: '#14161d', backgroundColor: '#14161d',
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
frame: false, frame: false,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden', titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
webPreferences: { webPreferences: {
@@ -241,6 +244,46 @@ handle('rdp:launch', (hostId) => {
return r.client; return r.client;
}); });
// ---------- 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'] });
if (r.canceled) return null;
const buf = fs.readFileSync(r.filePaths[0]);
// UTF-16-Exporte (BOM) erkennen
const text = buf[0] === 0xff && buf[1] === 0xfe ? buf.toString('utf16le') : buf.toString('utf8');
return { name: path.basename(r.filePaths[0]), content: text.replace(/^/, '') };
});
// entries: [{ folder: ['A','B'], host?: {...} }] – Ordner werden per Pfad angelegt bzw. wiederverwendet
handle('vault:bulkImport', (entries) => {
const groups = store.get().groups;
const ensure = (parts) => {
let parent = null;
for (const label of parts) {
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
parent = g.id;
}
return parent;
};
let hosts = 0, skipped = 0;
for (const e of entries) {
const groupId = ensure(e.folder || []);
if (!e.host) continue;
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
if (dup) { skipped++; continue; }
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
hosts++;
}
store.save();
return { hosts, skipped, groups: groups.length };
});
// ---------- Updates ----------
handle('update:check', () => updater.check());
handle('update:download', () => updater.download());
handle('update:install', () => updater.install());
handle('app:version', () => app.getVersion());
handle('clipboard:write', (t) => clipboard.writeText(t)); handle('clipboard:write', (t) => clipboard.writeText(t));
handle('clipboard:read', () => clipboard.readText()); handle('clipboard:read', () => clipboard.readText());
handle('shell:open', (url) => shell.openExternal(url)); handle('shell:open', (url) => shell.openExternal(url));
@@ -248,6 +291,9 @@ handle('shell:open', (url) => shell.openExternal(url));
app.whenReady().then(() => { app.whenReady().then(() => {
store.load(); store.load();
createWindow(); createWindow();
if (store.get().settings.updateAutoCheck && app.isPackaged) {
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
}
}); });
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); }); app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
+6
View File
@@ -24,6 +24,12 @@ const DEFAULTS = {
copyOnSelect: true, copyOnSelect: true,
keepAlive: 30, keepAlive: 30,
rdpClientLinux: 'auto', rdpClientLinux: 'auto',
appTheme: 'midnight',
accent: '',
updateUrl: 'https://git.mrblake.cc/MrBlake/MrTerm',
updateToken: '',
updateAutoCheck: true,
updatePrerelease: false,
}, },
}; };
+141
View File
@@ -0,0 +1,141 @@
// Auto-Update über Gitea/Forgejo-Releases (z. B. https://git.mrblake.cc/MrBlake/MrTerm).
// Erwartete Release-Assets: *Setup*.exe (NSIS), *portable*.exe, *.AppImage, *.pacman
const { app } = require('electron');
const { spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const DEFAULT_REPO = 'https://git.mrblake.cc/MrBlake/MrTerm';
function parseRepo(url) {
const u = new URL((url || DEFAULT_REPO).replace(/\.git$/, '').replace(/\/+$/, ''));
const [owner, repo] = u.pathname.split('/').filter(Boolean);
if (!owner || !repo) throw new Error('Ungültige Repository-URL: ' + url);
return { base: u.origin, owner, repo };
}
function cmpVersion(a, b) {
const pa = String(a).replace(/^v/i, '').split(/[.-]/).map((x) => parseInt(x, 10) || 0);
const pb = String(b).replace(/^v/i, '').split(/[.-]/).map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
}
return 0;
}
// Welche Paketart läuft gerade?
function installKind() {
if (!app.isPackaged) return 'dev';
if (process.platform === 'win32') return process.env.PORTABLE_EXECUTABLE_FILE ? 'portable' : 'nsis';
if (process.env.APPIMAGE) return 'appimage';
return 'pacman';
}
function pickAsset(assets, kind) {
const by = (fn) => assets.find((a) => fn(a.name.toLowerCase()));
switch (kind) {
case 'nsis': return by((n) => n.endsWith('.exe') && n.includes('setup')) || by((n) => n.endsWith('.exe') && !n.includes('portable'));
case 'portable': return by((n) => n.endsWith('.exe') && n.includes('portable')) || by((n) => n.endsWith('.exe'));
case 'appimage': return by((n) => n.endsWith('.appimage'));
case 'pacman': return by((n) => n.endsWith('.pacman') || n.endsWith('.pkg.tar.zst')) || by((n) => n.endsWith('.appimage'));
default: return process.platform === 'win32' ? by((n) => n.endsWith('.exe')) : by((n) => n.endsWith('.appimage'));
}
}
class Updater {
constructor(store, send) {
this.store = store;
this.send = send;
this.latest = null;
this.file = null;
}
headers() {
const t = this.store.get().settings.updateToken;
return { Accept: 'application/json', ...(t ? { Authorization: `token ${t}` } : {}) };
}
async check() {
const { base, owner, repo } = parseRepo(this.store.get().settings.updateUrl);
const res = await fetch(`${base}/api/v1/repos/${owner}/${repo}/releases?limit=20`, { headers: this.headers() });
if (res.status === 404) throw new Error('Repository nicht gefunden (privat? Dann Zugriffstoken in den Einstellungen hinterlegen).');
if (!res.ok) throw new Error(`Update-Server antwortet mit ${res.status}`);
const allowPre = this.store.get().settings.updatePrerelease;
const rel = (await res.json()).find((r) => !r.draft && (allowPre || !r.prerelease));
const current = app.getVersion();
if (!rel) return { current, available: false };
const kind = installKind();
const asset = pickAsset(rel.assets || [], kind);
this.latest = { version: rel.tag_name.replace(/^v/i, ''), notes: rel.body || '', url: rel.html_url, asset, kind };
return {
current,
available: cmpVersion(rel.tag_name, current) > 0,
version: this.latest.version,
notes: this.latest.notes,
url: rel.html_url,
asset: asset ? { name: asset.name, size: asset.size } : null,
kind,
};
}
async download() {
if (!this.latest) await this.check();
const { asset } = this.latest;
if (!asset) throw new Error('Kein passendes Paket für dieses System im Release gefunden.');
const dir = path.join(os.tmpdir(), 'mrterm-update');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, asset.name);
const res = await fetch(asset.browser_download_url, { headers: { ...this.headers(), Accept: 'application/octet-stream' } });
if (!res.ok) throw new Error(`Download fehlgeschlagen (${res.status})`);
const total = Number(res.headers.get('content-length')) || asset.size || 0;
const out = fs.createWriteStream(file + '.part');
let done = 0, last = 0;
for await (const chunk of res.body) {
out.write(chunk);
done += chunk.length;
if (Date.now() - last > 150) { last = Date.now(); this.send('update:progress', done, total); }
}
await new Promise((r) => out.end(r));
fs.renameSync(file + '.part', file);
this.send('update:progress', total, total);
this.file = file;
return file;
}
async install() {
if (!this.file) await this.download();
const file = this.file;
const kind = this.latest.kind;
if (kind === 'nsis') {
// Stilles Update, danach App automatisch neu starten
spawn(file, ['/S', '--force-run'], { detached: true, stdio: 'ignore' }).unref();
setTimeout(() => app.quit(), 300);
} else if (kind === 'portable') {
const target = path.join(path.dirname(process.env.PORTABLE_EXECUTABLE_FILE), path.basename(file));
fs.copyFileSync(file, target);
spawn(target, [], { detached: true, stdio: 'ignore' }).unref();
setTimeout(() => app.quit(), 300);
} else if (kind === 'appimage') {
const target = process.env.APPIMAGE;
const tmp = target + '.new';
fs.copyFileSync(file, tmp);
fs.chmodSync(tmp, 0o755);
fs.renameSync(tmp, target);
app.relaunch({ execPath: target });
app.exit(0);
} else if (kind === 'pacman' && /\.(pacman|pkg\.tar\.zst)$/.test(file)) {
await new Promise((resolve, reject) => {
const p = spawn('pkexec', ['pacman', '-U', '--noconfirm', file], { stdio: 'ignore' });
p.on('error', reject);
p.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(`pacman beendet mit Code ${c}`))));
});
app.relaunch();
app.exit(0);
} else {
throw new Error('Automatische Installation hier nicht möglich. Datei liegt unter: ' + file);
}
}
}
module.exports = { Updater, DEFAULT_REPO };
+124 -15
View File
@@ -140,14 +140,15 @@ async function reload() {
const b = $('#vaultBadge'); const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>Vault verschlüsselt</span>` : `${ICONS.unlock}<span>Vault unverschlüsselt</span>`; b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>Vault verschlüsselt</span>` : `${ICONS.unlock}<span>Vault unverschlüsselt</span>`;
b.title = S.vault.encrypted ? 'Daten werden mit dem System-Schlüsselbund verschlüsselt.' : 'Kein System-Schlüsselbund verfügbar (z. B. gnome-keyring/kwallet installieren).'; b.title = S.vault.encrypted ? 'Daten werden mit dem System-Schlüsselbund verschlüsselt.' : 'Kein System-Schlüsselbund verfügbar (z. B. gnome-keyring/kwallet installieren).';
applyAppTheme();
S.activeForwards = new Set(await call('fw:active')); S.activeForwards = new Set(await call('fw:active'));
render(); render();
} }
// ============================================================ Navigation // ============================================================ Navigation
$$('#sidebar .nav').forEach((n) => n.addEventListener('click', () => { $$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => {
S.view = n.dataset.view; S.view = n.dataset.view;
$$('#sidebar .nav').forEach((x) => x.classList.toggle('active', x === n)); $$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x === n));
closeDrawer(); closeDrawer();
activateTab('home'); activateTab('home');
render(); render();
@@ -456,7 +457,7 @@ function viewKeys(page) {
for (const k of list) { for (const k of list) {
const type = (k.publicKey || '').split(' ')[0] || 'key'; const type = (k.publicKey || '').split(' ')[0] || 'key';
const used = S.vault.hosts.filter((x) => x.keyId === k.id).length; const used = S.vault.hosts.filter((x) => x.keyId === k.id).length;
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="Public Key kopieren">${ICONS.copy}</button><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`); const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="Public Key kopieren">${ICONS.copy}</button><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
card.onclick = (e) => { card.onclick = (e) => {
const a = e.target.closest('[data-a]')?.dataset.a; const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'copy') { call('clipboard:write', k.publicKey || ''); toast('Public Key kopiert', 'ok'); } if (a === 'copy') { call('clipboard:write', k.publicKey || ''); toast('Public Key kopiert', 'ok'); }
@@ -519,7 +520,7 @@ function viewSnippets(page) {
if (!list.length) return c.append(emptyState('code', 'Keine Snippets', 'Speichere häufig genutzte Befehle und sende sie mit einem Klick an ein Terminal.', 'Neues Snippet', () => editSnippet())); if (!list.length) return c.append(emptyState('code', 'Keine Snippets', 'Speichere häufig genutzte Befehle und sende sie mit einem Klick an ein Terminal.', 'Neues Snippet', () => editSnippet()));
const l = h('<div class="list"></div>'); const l = h('<div class="list"></div>');
for (const s of list) { for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="Im aktiven Terminal ausführen">${ICONS.play}</button><button data-a="copy" title="Kopieren">${ICONS.copy}</button><button data-a="del" title="Löschen">${ICONS.trash}</button></div></div>`); const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="Im aktiven Terminal ausführen">${ICONS.play}</button><button data-a="copy" title="Kopieren">${ICONS.copy}</button><button data-a="del" title="Löschen">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => { card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a; const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'run') runSnippet(s); if (a === 'run') runSnippet(s);
@@ -564,7 +565,7 @@ function viewForwards(page) {
const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}` const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}`
: f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}` : f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}`
: `Lokal ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`; : `Lokal ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`;
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? 'aktiv' : 'inaktiv'}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div> const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? 'aktiv' : 'inaktiv'}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + 'Stoppen' : ICONS.play + 'Starten'}</button><div class="actions"><button data-a="del">${ICONS.trash}</button></div></div>`); <button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + 'Stoppen' : ICONS.play + 'Starten'}</button><div class="actions"><button data-a="del">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => { card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a; const a = e.target.closest('[data-a]')?.dataset.a;
@@ -608,7 +609,7 @@ function viewKnown(page) {
if (!entries.length) return c.append(emptyState('shield', 'Keine bekannten Hosts', 'Host-Schlüssel werden bei der ersten Verbindung hier gespeichert.')); if (!entries.length) return c.append(emptyState('shield', 'Keine bekannten Hosts', 'Host-Schlüssel werden bei der ersten Verbindung hier gespeichert.'));
const l = h('<div class="list"></div>'); const l = h('<div class="list"></div>');
for (const [id, v] of entries) { for (const [id, v] of entries) {
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="Entfernen">${ICONS.trash}</button></div></div>`); const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="Entfernen">${ICONS.trash}</button></div></div>`);
$('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); }; $('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); };
l.append(card); l.append(card);
} }
@@ -636,7 +637,25 @@ async function viewSettings(page) {
const c = h('<div class="content settings-grid"></div>'); page.append(c); const c = h('<div class="content settings-grid"></div>'); page.append(c);
const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); }; const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); };
// ---- App-Design
const designCard = h('<div class="settings-card"><h3>Design</h3><div class="theme-list design-list"></div><div class="field" style="margin:16px 0 0"><label>Akzentfarbe</label><div class="colors accent-colors"></div></div></div>');
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(`<div class="theme-prev design-prev ${st.appTheme === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div><div class="nm">${esc(t.name)}</div></div>`);
p.onclick = async () => { await save({ appTheme: id }); applyAppTheme(); render(); };
$('.design-list', designCard).append(p);
}
['', ...COLORS.slice(0, 7), '#21c7a8', '#cba6f7'].forEach((col) => {
const b = h(`<button type="button" title="${col || 'Standard des Designs'}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b);
});
const themeCard = h('<div class="settings-card"><h3>Terminal-Farbschema</h3><div class="theme-list"></div></div>'); const themeCard = h('<div class="settings-card"><h3>Terminal-Farbschema</h3><div class="theme-list"></div></div>');
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
const autoP = h(`<div class="theme-prev ${st.terminalTheme === 'auto' ? 'active' : ''}" style="background:${autoT.background};color:${autoT.foreground}"><div class="nm">Passend zum Design</div><span style="color:${autoT.green}">user@host</span>:<span style="color:${autoT.blue}">~</span>$ ls<br><span style="color:${autoT.blue}">folgt dem Design</span></div>`);
autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); };
$('.theme-list', themeCard).append(autoP);
for (const [id, t] of Object.entries(TERM_THEMES)) { for (const [id, t] of Object.entries(TERM_THEMES)) {
const p = h(`<div class="theme-prev ${st.terminalTheme === id ? 'active' : ''}" style="background:${t.background};color:${t.foreground}"><div class="nm">${esc(t.name)}</div><span style="color:${t.green}">user@host</span>:<span style="color:${t.blue}">~</span>$ ls<br><span style="color:${t.blue}">docs</span> <span style="color:${t.green}">run.sh</span> <span style="color:${t.red}">err</span></div>`); const p = h(`<div class="theme-prev ${st.terminalTheme === id ? 'active' : ''}" style="background:${t.background};color:${t.foreground}"><div class="nm">${esc(t.name)}</div><span style="color:${t.green}">user@host</span>:<span style="color:${t.blue}">~</span>$ ls<br><span style="color:${t.blue}">docs</span> <span style="color:${t.green}">run.sh</span> <span style="color:${t.red}">err</span></div>`);
p.onclick = async () => { await save({ terminalTheme: id }); render(); }; p.onclick = async () => { await save({ terminalTheme: id }); render(); };
@@ -675,6 +694,31 @@ async function viewSettings(page) {
]; ];
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); }); btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
// ---- Import aus anderen Programmen
const importCard = h(`<div class="settings-card"><h3>Import</h3><p style="color:var(--muted);margin-top:0">Ordner und Hosts (RDP &amp; SSH) aus <b>Devolutions Remote Desktop Manager</b> übernehmen. In RDM: <i>Datei → Exportieren → Einträge exportieren</i> als <code>.rdm</code> (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.</p></div>`);
const rdmBtn = h('<button class="btn primary">Aus Remote Desktop Manager importieren…</button>');
rdmBtn.onclick = importRdm;
importCard.append(rdmBtn);
// ---- Updates
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">Installierte Version: …</p></div>`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = `Installierte Version: ${v}`; });
const updFields = h('<div></div>');
updFields.append(
field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: 'Gitea/Forgejo-Repository, dessen Releases die Installationspakete enthalten.' }),
field('Zugriffstoken (nur für private Repos)', 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }),
check('Beim Start automatisch nach Updates suchen', 'updateAutoCheck', st.updateAutoCheck),
check('Vorabversionen (Pre-Releases) einbeziehen', 'updatePrerelease', st.updatePrerelease),
);
updFields.addEventListener('change', () => save(formValues(updFields)));
const chk = h(`<button class="btn">${ICONS.refresh}Jetzt nach Updates suchen</button>`);
chk.onclick = async () => {
chk.disabled = true;
try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r); else toast(`MrTerm ist aktuell (${r.current}).`, 'ok'); } catch {}
chk.disabled = false;
};
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)"> 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>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>W</kbd></span><span>Tab schließen</span>
@@ -683,9 +727,73 @@ async function viewSettings(page) {
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>Kopieren / Einfügen im Terminal</span> <span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>Kopieren / Einfügen im Terminal</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>Im Terminal suchen</span> <span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>Im Terminal suchen</span>
<span><kbd>Strg</kbd>+<kbd>+/−/0</kbd></span><span>Schriftgröße</span></div></div>`); <span><kbd>Strg</kbd>+<kbd>+/−/0</kbd></span><span>Schriftgröße</span></div></div>`);
c.append(themeCard, termCard, rdpCard, dataCard, keysCard); c.append(designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
} }
// ============================================================ Design
function termTheme() {
const id = settings().terminalTheme;
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
return TERM_THEMES[id] || TERM_THEMES.mrterm;
}
function applyAppTheme() {
const st = settings();
const root = document.documentElement;
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
S.tabs.forEach((t) => t.applySettings?.());
}
// ============================================================ RDM-Import
async function importRdm() {
const f = await call('import:pickFile', 'Remote Desktop Manager Export', ['rdm', 'xml', 'json', 'csv']);
if (!f) return;
let entries;
try { entries = parseRdmExport(f.name, f.content); } catch (e) { return toast('Import fehlgeschlagen: ' + e.message, 'error'); }
const hosts = entries.filter((e) => e.host);
if (!hosts.length) return toast('Keine RDP- oder SSH-Einträge in der Datei gefunden.', 'error');
const folders = new Set(entries.map((e) => e.folder.join(' / ')).filter(Boolean));
const rdpN = hosts.filter((e) => e.host.protocol === 'rdp').length;
const preview = hosts.slice(0, 8).map((e) => `<div class="card" style="padding:8px 10px;cursor:default">${avatar(e.host)}<div class="meta"><div class="title">${esc(e.host.label)}</div><div class="sub">${esc(e.folder.join(' / ') || 'Stammebene')} · ${esc(hostSub(e.host))}</div></div></div>`).join('');
const ok = await modal({
title: 'Import aus Remote Desktop Manager',
text: `${f.name}: ${hosts.length} Hosts (${rdpN} RDP, ${hosts.length - rdpN} SSH) in ${folders.size} Ordnern gefunden.`,
body: `<div class="list" style="max-height:300px;overflow:auto">${preview}</div>${hosts.length > 8 ? `<p style="margin:10px 0 0">… und ${hosts.length - 8} weitere</p>` : ''}`,
buttons: [{ label: 'Abbrechen', value: false, cls: 'ghost' }, { label: 'Importieren', value: true, cls: 'primary' }],
});
if (!ok) return;
const r = await call('vault:bulkImport', entries);
toast(`${r.hosts} Hosts importiert${r.skipped ? `, ${r.skipped} bereits vorhanden` : ''}`, 'ok');
await reload();
$('[data-view=hosts]').click();
}
// ============================================================ Updates
let updateInfo = null;
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
const r = await modal({
title: `MrTerm ${info.version} ist verfügbar`,
text: `Installiert: ${info.current}${info.asset ? ` · Paket: ${info.asset.name} (${fmtSize(info.asset.size)})` : ''}`,
body: `${info.notes ? `<div class="mono" style="white-space:pre-wrap;max-height:240px;overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:6px">${esc(info.notes)}</div>` : ''}
${info.kind === 'dev' ? '<p>Entwicklungsmodus: Bitte per <code>git pull</code> aktualisieren.</p>' : !info.asset ? '<p>Im Release ist kein passendes Paket für dieses System.</p>' : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">Lade herunter …</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
buttons: [{ label: 'Später', value: false, cls: 'ghost' }, { label: 'Release-Seite', value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: 'Jetzt installieren', value: true, cls: 'primary' }] : [])],
});
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast('Update wird heruntergeladen …');
try { await call('update:download'); toast('Update wird installiert, MrTerm startet neu …', 'ok'); await call('update:install'); } catch {}
}
api.on('update:available', (info) => showUpdate(info));
api.on('update:progress', (done, total) => {
const b = $('#updateBadge span');
if (b) b.textContent = total ? `Update ${Math.round((done / total) * 100)}%` : 'Update …';
});
// ============================================================ Tabs // ============================================================ Tabs
function addTab(session) { function addTab(session) {
S.tabs.push(session); S.tabs.push(session);
@@ -758,7 +866,7 @@ class TerminalSession {
const st = settings(); const st = settings();
this.term = new Terminal({ this.term = new Terminal({
fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink,
scrollback: st.scrollback, theme: TERM_THEMES[st.terminalTheme] || TERM_THEMES.mrterm, allowProposedApi: true, macOptionIsMeta: true, scrollback: st.scrollback, theme: termTheme(), allowProposedApi: true, macOptionIsMeta: true,
}); });
this.fit = new FitAddon.FitAddon(); this.fit = new FitAddon.FitAddon();
this.search = new SearchAddon.SearchAddon(); this.search = new SearchAddon.SearchAddon();
@@ -836,7 +944,7 @@ class TerminalSession {
onShow() { this.doFit(); this.term.focus(); } onShow() { this.doFit(); this.term.focus(); }
applySettings() { applySettings() {
const st = settings(); const st = settings();
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: TERM_THEMES[st.terminalTheme] || TERM_THEMES.mrterm }); Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
this.doFit(); this.doFit();
} }
@@ -956,7 +1064,7 @@ class FilePane {
const files = await this.list(dir); const files = await this.list(dir);
this.cwd = dir; this.files = files; this.sel.clear(); this.cwd = dir; this.files = files; this.sel.clear();
$('.path', this.el).value = dir; $('.path', this.el).value = dir;
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 3).toUpperCase(); const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 2).toUpperCase() + '\';
this.draw(); this.draw();
} catch (e) { toast(e.message, 'error'); } } catch (e) { toast(e.message, 'error'); }
} }
@@ -969,8 +1077,8 @@ class FilePane {
const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : ''); const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : '');
const rows = this.view = this.sorted(); const rows = this.view = this.sorted();
box.innerHTML = `<table><thead><tr><th data-s="name">Name${arrow('name')}</th><th data-s="size" style="text-align:right">Größe${arrow('size')}</th><th data-s="mtime">Geändert${arrow('mtime')}</th></tr></thead><tbody> box.innerHTML = `<table><thead><tr><th data-s="name">Name${arrow('name')}</th><th data-s="size" style="text-align:right">Größe${arrow('size')}</th><th data-s="mtime">Geändert${arrow('mtime')}</th></tr></thead><tbody>
<tr data-up="1"><td class="name"><span class="ico-dir">${ICONS.folder}</span><span>..</span></td><td></td><td></td></tr> <tr data-up="1"><td class="name"><div><span class="ico-dir">${ICONS.folder}</span><span>..</span></div></td><td></td><td></td></tr>
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`; ${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><div><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></div></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
$$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); })); $$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); }));
$('tr[data-up]', box).ondblclick = () => this.up(); $('tr[data-up]', box).ondblclick = () => this.up();
$$('tr[data-i]', box).forEach((tr) => { $$('tr[data-i]', box).forEach((tr) => {
@@ -1113,11 +1221,11 @@ function openPalette() {
hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999))); hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999)));
hosts.slice(0, 12).forEach((x) => { hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) }); items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:#2a2f45;color:#6ea8ff">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
}); });
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) })); S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[['Neuer Host', () => { activateTab('home'); editHost({}); }], ['Schlüssel generieren', generateKey], ['Einstellungen', () => $('[data-view=settings]').click()]] [['Neuer Host', () => { activateTab('home'); editHost({}); }], ['Schlüssel generieren', generateKey], ['Einstellungen', () => $('[data-view=settings]').click()]]
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: 'Aktionen', icon: `<div class="avatar" style="background:#2a2f45;color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run })); .filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: 'Aktionen', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run }));
idx = Math.min(idx, Math.max(0, items.length - 1)); idx = Math.min(idx, Math.max(0, items.length - 1));
let last = ''; let last = '';
res.innerHTML = ''; res.innerHTML = '';
@@ -1182,3 +1290,4 @@ api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; }); api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
reload(); reload();
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
+3 -1
View File
@@ -10,7 +10,7 @@
<body> <body>
<div id="app"> <div id="app">
<header id="titlebar"> <header id="titlebar">
<div class="brand"><span class="logo">&gt;_</span>MrTerm</div> <div class="brand"><img class="logo" src="logo.png" alt="" />Mr<span class="brand-term">Term</span></div>
<div id="tabs"> <div id="tabs">
<button class="tab home active" data-tab="home" title="Vault"> <button class="tab home active" data-tab="home" title="Vault">
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span> <svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
@@ -35,6 +35,7 @@
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button> <button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>Verlauf</span></button> <button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>Verlauf</span></button>
<div class="spacer"></div> <div class="spacer"></div>
<button id="updateBadge" class="nav update-badge" style="display:none"><svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg><span>Update</span></button>
<div id="vaultBadge" class="vault-badge"></div> <div id="vaultBadge" class="vault-badge"></div>
<button class="nav" data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Einstellungen</span></button> <button class="nav" data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Einstellungen</span></button>
</nav> </nav>
@@ -56,6 +57,7 @@
<script src="../../node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script> <script src="../../node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script>
<script src="../../node_modules/@xterm/addon-search/lib/addon-search.js"></script> <script src="../../node_modules/@xterm/addon-search/lib/addon-search.js"></script>
<script src="themes.js"></script> <script src="themes.js"></script>
<script src="rdm-import.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+142
View File
@@ -0,0 +1,142 @@
// Parser für Exporte aus Devolutions Remote Desktop Manager.
// Unterstützt: .rdm / .xml (Export "Remote Desktop Manager-Datei"), .json und .csv.
// Liefert [{ folder: ['A','B'], host?: {...} }] für vault:bulkImport.
'use strict';
(function () {
// Numerische ConnectionType-Werte aus RDM-JSON (die gängigsten)
const NUMERIC_TYPES = { 1: 'RDPConfigured', 25: 'Group', 77: 'SSHShell', 24: 'Putty', 108: 'SSHShell' };
function classify(type) {
const t = String(NUMERIC_TYPES[type] || type || '').toLowerCase();
if (!t) return null;
if (t === 'group' || t === 'folder' || t === 'repository') return 'folder';
if (t.startsWith('rdp') || t === 'remotedesktop' || t === 'microsoftremotedesktop') return 'rdp';
if (t.includes('ssh') || t === 'putty' || t === 'terminal' || t === 'sshshell') return 'ssh';
return null; // VNC, Web, Credentials, Dokumente … werden übersprungen
}
const splitPath = (p) => String(p || '').split(/[\\/]/).map((s) => s.trim()).filter(Boolean);
function splitHostPort(url, defPort) {
let address = String(url || '').trim().replace(/^(rdp|ssh|https?):\/\//i, '').replace(/\/.*$/, '');
let port;
const m = address.match(/^\[(.+)\](?::(\d+))?$/) || address.match(/^([^:]+):(\d+)$/);
if (m) { address = m[1]; port = m[2] ? Number(m[2]) : undefined; }
return { address, port: port && port !== defPort ? port : undefined };
}
function makeEntry(r) {
const kind = classify(r.type);
if (!kind) return null;
let folder = splitPath(r.group);
if (kind === 'folder') {
// RDM speichert bei Ordnern meist den vollständigen Pfad inkl. eigenem Namen im Feld "Group"
if (r.name && folder[folder.length - 1] !== r.name) folder.push(r.name);
return folder.length ? { folder } : null;
}
const defPort = kind === 'rdp' ? 3389 : 22;
const hp = splitHostPort(r.host || r.url, defPort);
if (!hp.address) return null;
const port = Number(r.port) && Number(r.port) !== defPort ? Number(r.port) : hp.port;
const host = {
protocol: kind,
label: r.name || hp.address,
address: hp.address,
port,
username: r.username || undefined,
domain: kind === 'rdp' ? r.domain || undefined : undefined,
tags: ['rdm'],
};
if (r.password) host.password = r.password;
if (r.description) host.notes = r.description;
return { folder, host };
}
// ---------- XML ----------
function child(el, path) {
let cur = el;
for (const name of path.split('/')) {
cur = [...(cur?.children || [])].find((c) => c.localName.toLowerCase() === name.toLowerCase());
if (!cur) return '';
}
return cur.textContent.trim();
}
const firstOf = (el, paths) => { for (const p of paths) { const v = child(el, p); if (v) return v; } return ''; };
function parseXml(text) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('Datei ist kein gültiges XML.');
const conns = [...doc.getElementsByTagName('Connection')].filter((c) => child(c, 'ConnectionType'));
return conns.map((c) => makeEntry({
type: child(c, 'ConnectionType'),
name: child(c, 'Name'),
group: firstOf(c, ['Group', 'GroupName', 'Folder']),
url: firstOf(c, ['Url', 'Host', 'HostName']),
host: firstOf(c, ['Terminal/Host', 'Putty/Host', 'SSHTunnel/Host', 'RDP/Host']),
port: firstOf(c, ['Terminal/HostPort', 'Putty/Port', 'RDP/Port', 'Port', 'HostPort']),
username: firstOf(c, ['RDP/UserName', 'Terminal/Username', 'Terminal/UserName', 'Putty/UserName', 'Putty/Username', 'UserName', 'Username']),
domain: firstOf(c, ['RDP/Domain', 'Domain']),
description: child(c, 'Description'),
})).filter(Boolean);
}
// ---------- JSON ----------
function parseJson(text) {
const data = JSON.parse(text);
const list = Array.isArray(data) ? data : data.Connections || data.connections || data.Entries || [];
const g = (o, ...keys) => { for (const k of keys) { const v = k.split('.').reduce((a, p) => a?.[p], o); if (v != null && v !== '') return v; } return ''; };
return list.map((c) => makeEntry({
type: g(c, 'ConnectionType', 'Type'),
name: g(c, 'Name'),
group: g(c, 'Group', 'GroupName', 'Folder'),
url: g(c, 'Url', 'Host'),
host: g(c, 'Terminal.Host', 'Putty.Host', 'RDP.Host'),
port: g(c, 'Terminal.HostPort', 'Putty.Port', 'RDP.Port', 'Port'),
username: g(c, 'RDP.UserName', 'Terminal.Username', 'Putty.UserName', 'UserName', 'Username'),
domain: g(c, 'RDP.Domain', 'Domain'),
description: g(c, 'Description'),
})).filter(Boolean);
}
// ---------- CSV ----------
function csvRows(text) {
const delim = (text.split('\n')[0].match(/;/g) || []).length > (text.split('\n')[0].match(/,/g) || []).length ? ';' : ',';
const rows = []; let row = [], cur = '', q = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') { cur += '"'; i++; }
else if (ch === '"') q = false;
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === delim) { row.push(cur); cur = ''; }
else if (ch === '\n' || ch === '\r') { if (ch === '\r' && text[i + 1] === '\n') i++; row.push(cur); rows.push(row); row = []; cur = ''; }
else cur += ch;
}
if (cur || row.length) { row.push(cur); rows.push(row); }
return rows.filter((r) => r.some((c) => c.trim()));
}
function parseCsv(text) {
const [head, ...rows] = csvRows(text);
const idx = (...names) => head.findIndex((h) => names.includes(h.trim().toLowerCase().replace(/[\s_]/g, '')));
const col = {
type: idx('connectiontype', 'type', 'protocol'), name: idx('name', 'displayname'), group: idx('group', 'folder', 'groupname'),
host: idx('host', 'hostname', 'url', 'computername', 'address'), port: idx('port', 'hostport'),
username: idx('username', 'user', 'login'), domain: idx('domain'), password: idx('password'), description: idx('description'),
};
if (col.host < 0 && col.type < 0) throw new Error('CSV-Spalten nicht erkannt (erwartet u. a. Name, Host, ConnectionType, Group).');
return rows.map((r) => {
const v = (k) => (col[k] >= 0 ? (r[col[k]] || '').trim() : '');
return makeEntry({ type: v('type') || 'RDPConfigured', name: v('name'), group: v('group'), url: v('host'), port: v('port'), username: v('username'), domain: v('domain'), password: v('password'), description: v('description') });
}).filter(Boolean);
}
window.parseRdmExport = function (name, content) {
const t = content.trimStart();
if (/\.json$/i.test(name) || t.startsWith('{') || t.startsWith('[')) return parseJson(content);
if (/\.csv$/i.test(name)) return parseCsv(content);
if (t.startsWith('<')) return parseXml(content);
return parseCsv(content);
};
})();
+86 -27
View File
@@ -13,12 +13,59 @@
--green: #3ecf8e; --green: #3ecf8e;
--red: #ff5f6d; --red: #ff5f6d;
--orange: #ffb454; --orange: #ffb454;
--accent-3: #b36bff;
--chrome: #0f1117;
--chrome-line: #1f2330;
--hover: #1a1d27;
--nav-active: #1f2233;
--icon-bg: #2a2f45;
--folder: #6ea8ff;
--row-line: #1c1f29;
--overlay: #13151ce6;
--tint: 255 255 255;
--radius: 10px; --radius: 10px;
--titlebar: 42px; --titlebar: 42px;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif; font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
font-size: 13px; font-size: 13px;
color: var(--text); color: var(--text);
} }
/* ---------- App-Designs ---------- */
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
[data-theme='navy'] {
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
}
[data-theme='nord'] {
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
}
[data-theme='dracula'] {
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
}
[data-theme='mocha'] {
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
}
[data-theme='forest'] {
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
}
[data-theme='light'] {
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
--tint: 0 0 0;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
[data-theme='light'] .card { border-color: var(--border); }
[data-theme='light'] .avatar .proto { background: var(--panel); }
[data-theme='light'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; } html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
button { font: inherit; color: inherit; cursor: pointer; } button { font: inherit; color: inherit; cursor: pointer; }
@@ -31,15 +78,17 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
#app { display: flex; flex-direction: column; height: 100vh; } #app { display: flex; flex-direction: column; height: 100vh; }
/* ---------- Titlebar & Tabs ---------- */ /* ---------- Titlebar & Tabs ---------- */
#titlebar { height: var(--titlebar); display: flex; align-items: center; background: #0f1117; -webkit-app-region: drag; border-bottom: 1px solid #1f2330; flex: none; } #titlebar { height: var(--titlebar); display: flex; align-items: center; background: var(--chrome); -webkit-app-region: drag; border-bottom: 1px solid var(--chrome-line); flex: none; }
.brand { width: 212px; padding-left: 16px; font-weight: 700; letter-spacing: .3px; display: flex; align-items: center; gap: 10px; flex: none; } .brand { width: 212px; padding-left: 16px; font-weight: 700; letter-spacing: .3px; display: flex; align-items: center; gap: 10px; flex: none; }
.logo { background: linear-gradient(135deg, var(--accent), #b36bff); color: white; font-family: monospace; font-size: 11px; padding: 3px 6px; border-radius: 6px; } .logo { width: 24px; height: 24px; border-radius: 6px; }
.brand { gap: 8px !important; }
.brand-term { color: #5ef0c4; margin-left: -8px; }
#tabs { display: flex; gap: 4px; height: 100%; align-items: flex-end; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; } #tabs { display: flex; gap: 4px; height: 100%; align-items: flex-end; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; }
#tabs::-webkit-scrollbar { height: 0; } #tabs::-webkit-scrollbar { height: 0; }
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 12px; border: none; background: transparent; border-radius: 8px 8px 0 0; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; } .tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 12px; border: none; background: transparent; border-radius: 8px 8px 0 0; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
.tab.home { min-width: 0; } .tab.home { min-width: 0; }
.tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; } .tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
.tab:hover { background: #1a1d27; color: var(--text); } .tab:hover { background: var(--hover); color: var(--text); }
.tab.active { background: var(--bg); color: var(--text); } .tab.active { background: var(--bg); color: var(--text); }
.tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; } .tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
.tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); } .tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
@@ -47,25 +96,25 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; } .tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; }
.tab .x { opacity: 0; border: none; background: none; padding: 2px; border-radius: 4px; color: var(--muted); display: flex; } .tab .x { opacity: 0; border: none; background: none; padding: 2px; border-radius: 4px; color: var(--muted); display: flex; }
.tab:hover .x, .tab.active .x { opacity: 1; } .tab:hover .x, .tab.active .x { opacity: 1; }
.tab .x:hover { background: #ffffff18; color: var(--text); } .tab .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); }
.tab .x svg { width: 12px; height: 12px; } .tab .x svg { width: 12px; height: 12px; }
@keyframes pulse { 50% { opacity: .35; } } @keyframes pulse { 50% { opacity: .35; } }
.icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; font-size: 18px; margin-left: 4px; flex: none; } .icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; font-size: 18px; margin-left: 4px; flex: none; }
.icon-btn:hover { background: #1f2330; color: var(--text); } .icon-btn:hover { background: var(--chrome-line); color: var(--text); }
.drag-fill { flex: 1; min-width: 40px; height: 100%; } .drag-fill { flex: 1; min-width: 40px; height: 100%; }
.win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; } .win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; }
.win-ctrls button { width: 46px; border: none; background: transparent; color: var(--muted); display: flex; align-items: center; justify-content: center; } .win-ctrls button { width: 46px; border: none; background: transparent; color: var(--muted); display: flex; align-items: center; justify-content: center; }
.win-ctrls button svg { width: 12px; height: 12px; stroke-width: 1.2; } .win-ctrls button svg { width: 12px; height: 12px; stroke-width: 1.2; }
.win-ctrls button:hover { background: #ffffff12; color: var(--text); } .win-ctrls button:hover { background: rgb(var(--tint) / 0.071); color: var(--text); }
.win-ctrls button.close:hover { background: #e81123; color: white; } .win-ctrls button.close:hover { background: #e81123; color: white; }
/* ---------- Layout ---------- */ /* ---------- Layout ---------- */
#body { flex: 1; display: flex; min-height: 0; position: relative; } #body { flex: 1; display: flex; min-height: 0; position: relative; }
#sidebar { width: 212px; background: #0f1117; padding: 10px 10px 12px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid #1f2330; flex: none; } #sidebar { width: 212px; background: var(--chrome); padding: 10px 10px 12px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--chrome-line); flex: none; }
#sidebar .spacer { flex: 1; } #sidebar .spacer { flex: 1; }
.nav { display: flex; align-items: center; gap: 12px; border: none; background: none; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-align: left; font-weight: 500; } .nav { display: flex; align-items: center; gap: 12px; border: none; background: none; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-align: left; font-weight: 500; }
.nav:hover { background: #1a1d27; color: var(--text); } .nav:hover { background: var(--hover); color: var(--text); }
.nav.active { background: #1f2233; color: var(--text); } .nav.active { background: var(--nav-active); color: var(--text); }
.nav.active svg { color: var(--accent-2); } .nav.active svg { color: var(--accent-2); }
.vault-badge { font-size: 11px; color: var(--faint); padding: 8px 12px; display: flex; gap: 6px; align-items: center; } .vault-badge { font-size: 11px; color: var(--faint); padding: 8px 12px; display: flex; gap: 6px; align-items: center; }
.vault-badge svg { width: 13px; height: 13px; } .vault-badge svg { width: 13px; height: 13px; }
@@ -87,7 +136,7 @@ body.in-session #sidebar { display: none; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; } .btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.primary:hover { background: var(--accent-2); } .btn.primary:hover { background: var(--accent-2); }
.btn.danger { color: var(--red); } .btn.danger { color: var(--red); }
.btn.danger:hover { background: #ff5f6d1a; } .btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); } .btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.ghost:hover { background: var(--card); color: var(--text); } .btn.ghost:hover { background: var(--card); color: var(--text); }
.btn.sm { height: 28px; padding: 0 10px; font-size: 12px; } .btn.sm { height: 28px; padding: 0 10px; font-size: 12px; }
@@ -97,7 +146,7 @@ body.in-session #sidebar { display: none; }
.section-title .count { background: var(--card); padding: 1px 7px; border-radius: 10px; font-size: 11px; } .section-title .count { background: var(--card); padding: 1px 7px; border-radius: 10px; font-size: 11px; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; } .chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip { border: 1px solid var(--border); background: transparent; color: var(--muted); padding: 4px 10px; border-radius: 20px; font-size: 12px; } .chip { border: 1px solid var(--border); background: transparent; color: var(--muted); padding: 4px 10px; border-radius: 20px; font-size: 12px; }
.chip.active, .chip:hover { background: #6e7bff22; border-color: var(--accent); color: var(--text); } .chip.active, .chip:hover { background: color-mix(in srgb, var(--accent) 13%, transparent); border-color: var(--accent); color: var(--text); }
/* ---------- Cards ---------- */ /* ---------- Cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
@@ -110,14 +159,14 @@ body.in-session #sidebar { display: none; }
.card .actions { display: flex; gap: 2px; opacity: 0; transition: opacity .12s; } .card .actions { display: flex; gap: 2px; opacity: 0; transition: opacity .12s; }
.card:hover .actions { opacity: 1; } .card:hover .actions { opacity: 1; }
.card .actions button { border: none; background: none; color: var(--muted); padding: 6px; border-radius: 6px; display: flex; } .card .actions button { border: none; background: none; color: var(--muted); padding: 6px; border-radius: 6px; display: flex; }
.card .actions button:hover { background: #ffffff14; color: var(--text); } .card .actions button:hover { background: rgb(var(--tint) / 0.078); color: var(--text); }
.card .actions svg { width: 16px; height: 16px; } .card .actions svg { width: 16px; height: 16px; }
.avatar { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: white; flex: none; position: relative; } .avatar { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: white; flex: none; position: relative; }
.avatar svg { width: 20px; height: 20px; } .avatar svg { width: 20px; height: 20px; }
.avatar .proto { position: absolute; right: -4px; bottom: -4px; font-size: 8px; background: #0f1117; color: var(--muted); padding: 1px 3px; border-radius: 4px; border: 1px solid var(--border); font-weight: 700; } .avatar .proto { position: absolute; right: -4px; bottom: -4px; font-size: 8px; background: var(--chrome); color: var(--muted); padding: 1px 3px; border-radius: 4px; border: 1px solid var(--border); font-weight: 700; }
.group-card .avatar { background: #2a2f45; color: var(--accent-2); } .group-card .avatar { background: var(--icon-bg); color: var(--accent-2); }
.tags { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; } .tags { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
.tag { font-size: 10px; background: #ffffff0f; color: var(--muted); padding: 1px 6px; border-radius: 4px; } .tag { font-size: 10px; background: rgb(var(--tint) / 0.059); color: var(--muted); padding: 1px 6px; border-radius: 4px; }
.empty { text-align: center; color: var(--muted); padding: 70px 20px; } .empty { text-align: center; color: var(--muted); padding: 70px 20px; }
.empty .big { width: 64px; height: 64px; margin: 0 auto 16px; border-radius: 18px; background: var(--card); display: flex; align-items: center; justify-content: center; color: var(--accent-2); } .empty .big { width: 64px; height: 64px; margin: 0 auto 16px; border-radius: 18px; background: var(--card); display: flex; align-items: center; justify-content: center; color: var(--accent-2); }
.empty .big svg { width: 30px; height: 30px; } .empty .big svg { width: 30px; height: 30px; }
@@ -128,8 +177,8 @@ body.in-session #sidebar { display: none; }
.list { display: flex; flex-direction: column; gap: 6px; } .list { display: flex; flex-direction: column; gap: 6px; }
.list .card { padding: 11px 14px; } .list .card { padding: 11px 14px; }
.mono { font-family: 'Cascadia Code', 'JetBrains Mono', Consolas, monospace; font-size: 12px; } .mono { font-family: 'Cascadia Code', 'JetBrains Mono', Consolas, monospace; font-size: 12px; }
.status-pill { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #ffffff10; color: var(--muted); } .status-pill { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: rgb(var(--tint) / 0.063); color: var(--muted); }
.status-pill.on { background: #3ecf8e22; color: var(--green); } .status-pill.on { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
/* ---------- Drawer (Editor) ---------- */ /* ---------- Drawer (Editor) ---------- */
.drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 400px; background: var(--bg-2); border-left: 1px solid var(--border); transform: translateX(100%); transition: transform .18s ease; z-index: 20; display: flex; flex-direction: column; box-shadow: -20px 0 40px #0006; } .drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 400px; background: var(--bg-2); border-left: 1px solid var(--border); transform: translateX(100%); transition: transform .18s ease; z-index: 20; display: flex; flex-direction: column; box-shadow: -20px 0 40px #0006; }
@@ -167,7 +216,7 @@ body.in-session #sidebar { display: none; }
.session .term-wrap { flex: 1; position: relative; min-height: 0; display: flex; } .session .term-wrap { flex: 1; position: relative; min-height: 0; display: flex; }
.session .term { flex: 1; padding: 8px 0 4px 10px; min-width: 0; } .session .term { flex: 1; padding: 8px 0 4px 10px; min-width: 0; }
.session .term .xterm { height: 100%; } .session .term .xterm { height: 100%; }
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: #13151ce6; z-index: 3; } .session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: var(--overlay); z-index: 3; }
.session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; } .session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; }
.session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; } .session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; }
.session .overlay .msg.err { color: var(--red); } .session .overlay .msg.err { color: var(--red); }
@@ -191,17 +240,18 @@ body.in-session #sidebar { display: none; }
.pane .phead input { flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; min-width: 60px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; } .pane .phead input { flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; min-width: 60px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; }
.pane .phead select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px; } .pane .phead select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px; }
.pane .files { flex: 1; overflow: auto; } .pane .files { flex: 1; overflow: auto; }
.pane.dragover .files { outline: 2px dashed var(--accent); outline-offset: -6px; background: #6e7bff0d; } .pane.dragover .files { outline: 2px dashed var(--accent); outline-offset: -6px; background: color-mix(in srgb, var(--accent) 5%, transparent); }
.pane table { width: 100%; border-collapse: collapse; } .pane table { width: 100%; border-collapse: collapse; }
.pane th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 7px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); cursor: pointer; } .pane th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 7px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); cursor: pointer; }
.pane td { padding: 6px 10px; white-space: nowrap; border-bottom: 1px solid #1c1f29; } .pane td { padding: 6px 10px; white-space: nowrap; border-bottom: 1px solid var(--row-line); }
.pane td.name { display: flex; align-items: center; gap: 8px; max-width: 0; width: 100%; } .pane td.name { max-width: 0; width: 100%; }
.pane td.name span { overflow: hidden; text-overflow: ellipsis; } .pane td.name > div { display: flex; align-items: center; gap: 8px; min-width: 0; }
.pane td.name span:last-child { overflow: hidden; text-overflow: ellipsis; }
.pane td.num { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } .pane td.num { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.pane td.date { color: var(--muted); } .pane td.date { color: var(--muted); }
.pane tr:hover td { background: #ffffff06; } .pane tr:hover td { background: rgb(var(--tint) / 0.024); }
.pane tr.sel td { background: #6e7bff26; } .pane tr.sel td { background: color-mix(in srgb, var(--accent) 15%, transparent); }
.pane .ico-dir { color: #6ea8ff; } .pane .ico-dir { color: var(--folder); }
.pane .ico-file { color: var(--faint); } .pane .ico-file { color: var(--faint); }
.pane-empty { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 14px; color: var(--muted); } .pane-empty { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 14px; color: var(--muted); }
.host-picker { width: min(520px, 90%); max-height: 60%; overflow: auto; display: flex; flex-direction: column; gap: 6px; } .host-picker { width: min(520px, 90%); max-height: 60%; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
@@ -228,12 +278,12 @@ body.in-session #sidebar { display: none; }
.palette .res { max-height: 50vh; overflow: auto; padding: 8px; } .palette .res { max-height: 50vh; overflow: auto; padding: 8px; }
.palette .item { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border-radius: 8px; cursor: pointer; } .palette .item { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.palette .item .avatar { width: 32px; height: 32px; font-size: 13px; border-radius: 8px; } .palette .item .avatar { width: 32px; height: 32px; font-size: 13px; border-radius: 8px; }
.palette .item.active { background: #6e7bff26; } .palette .item.active { background: color-mix(in srgb, var(--accent) 15%, transparent); }
.palette .item .k { margin-left: auto; color: var(--faint); font-size: 11px; } .palette .item .k { margin-left: auto; color: var(--faint); font-size: 11px; }
.palette .grp { font-size: 11px; color: var(--faint); padding: 8px 10px 4px; text-transform: uppercase; letter-spacing: .5px; } .palette .grp { font-size: 11px; color: var(--faint); padding: 8px 10px 4px; text-transform: uppercase; letter-spacing: .5px; }
.ctx { position: fixed; z-index: 200; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 5px; min-width: 190px; box-shadow: 0 14px 40px #000a; } .ctx { position: fixed; z-index: 200; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 5px; min-width: 190px; box-shadow: 0 14px 40px #000a; }
.ctx button { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: none; padding: 7px 10px; border-radius: 6px; text-align: left; } .ctx button { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: none; padding: 7px 10px; border-radius: 6px; text-align: left; }
.ctx button:hover { background: #6e7bff33; } .ctx button:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.ctx button.danger { color: var(--red); } .ctx button.danger { color: var(--red); }
.ctx hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; } .ctx hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
.ctx svg { width: 15px; height: 15px; color: var(--muted); } .ctx svg { width: 15px; height: 15px; color: var(--muted); }
@@ -250,3 +300,12 @@ body.in-session #sidebar { display: none; }
.theme-prev.active { border-color: var(--accent); } .theme-prev.active { border-color: var(--accent); }
.theme-prev .nm { font-family: 'Segoe UI', sans-serif; font-weight: 600; margin-bottom: 4px; } .theme-prev .nm { font-family: 'Segoe UI', sans-serif; font-weight: 600; margin-bottom: 4px; }
kbd { background: var(--card); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; font-size: 11px; font-family: inherit; } kbd { background: var(--card); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; font-size: 11px; font-family: inherit; }
.update-badge { color: var(--accent-2) !important; background: color-mix(in srgb, var(--accent) 14%, transparent) !important; }
.design-prev { padding: 8px; }
.design-prev .dp { display: flex; gap: 6px; height: 62px; margin-bottom: 8px; }
.design-prev .dp i { width: 22%; border-radius: 4px; }
.design-prev .dp > div { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.design-prev .dp b { flex: 1; border-radius: 4px; position: relative; }
.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; }
+17
View File
@@ -22,3 +22,20 @@ window.TERM_THEMES = {
black: '#2b2f3a', red: '#d63a4a', green: '#1f9d63', yellow: '#b68600', blue: '#3d63dd', magenta: '#9d45c9', cyan: '#138a9a', white: '#c9ccd6', black: '#2b2f3a', red: '#d63a4a', green: '#1f9d63', yellow: '#b68600', blue: '#3d63dd', magenta: '#9d45c9', cyan: '#138a9a', white: '#c9ccd6',
brightBlack: '#6b7080', brightRed: '#e8505f', brightGreen: '#27b474', brightYellow: '#cc9a00', brightBlue: '#5a7cf0', brightMagenta: '#b25bdd', brightCyan: '#1ca3b5', brightWhite: '#ffffff' }, brightBlack: '#6b7080', brightRed: '#e8505f', brightGreen: '#27b474', brightYellow: '#cc9a00', brightBlue: '#5a7cf0', brightMagenta: '#b25bdd', brightCyan: '#1ca3b5', brightWhite: '#ffffff' },
}; };
TERM_THEMES.mocha = { name: 'Catppuccin Mocha', background: '#1e1e2e', foreground: '#cdd6f4', cursor: '#f5e0dc', selectionBackground: '#585b7066',
black: '#45475a', red: '#f38ba8', green: '#a6e3a1', yellow: '#f9e2af', blue: '#89b4fa', magenta: '#f5c2e7', cyan: '#94e2d5', white: '#bac2de',
brightBlack: '#585b70', brightRed: '#f38ba8', brightGreen: '#a6e3a1', brightYellow: '#f9e2af', brightBlue: '#89b4fa', brightMagenta: '#f5c2e7', brightCyan: '#94e2d5', brightWhite: '#a6adc8' };
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
window.APP_THEMES = {
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
navy: { name: 'Navy', bg: '#141729', side: '#0f1122', card: '#212742', accent: '#21c7a8', text: '#e8eaf6', term: 'navy' },
nord: { name: 'Nord', bg: '#2e3440', side: '#272c36', card: '#3b4252', accent: '#88c0d0', text: '#eceff4', term: 'nord' },
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
light: { name: 'Hell', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' },
};