MrTerm: SSH/SFTP/RDP-Client im Termius-Stil (Electron)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 14:43:32 +02:00
co-authored by Claude Opus 5.5
commit a5eccc7764
13 changed files with 6133 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
const { app, safeStorage } = require('electron');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DEFAULTS = {
version: 1,
groups: [],
hosts: [],
keys: [],
snippets: [],
forwards: [],
knownHosts: {},
history: [],
settings: {
terminalTheme: 'mrterm',
fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace',
fontSize: 14,
cursorStyle: 'block',
cursorBlink: true,
scrollback: 10000,
copyOnSelect: true,
keepAlive: 30,
rdpClientLinux: 'auto',
},
};
class Store {
constructor() {
this.dir = app.getPath('userData');
this.file = path.join(this.dir, 'vault.dat');
this.data = structuredClone(DEFAULTS);
this.encrypted = false;
}
canEncrypt() {
try {
if (!safeStorage.isEncryptionAvailable()) return false;
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend?.() === 'basic_text') return false;
return true;
} catch { return false; }
}
load() {
fs.mkdirSync(this.dir, { recursive: true });
if (!fs.existsSync(this.file)) { this.save(); return this.data; }
const raw = fs.readFileSync(this.file);
let json;
if (raw.slice(0, 4).toString() === 'ENC1') {
json = safeStorage.decryptString(raw.slice(4));
this.encrypted = true;
} else {
json = raw.toString('utf8');
}
const parsed = JSON.parse(json);
this.data = { ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } };
return this.data;
}
save() {
const json = JSON.stringify(this.data, null, 2);
const tmp = this.file + '.tmp';
if (this.canEncrypt()) {
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)]));
this.encrypted = true;
} else {
fs.writeFileSync(tmp, json, { mode: 0o600 });
this.encrypted = false;
}
fs.renameSync(tmp, this.file);
}
get() { return this.data; }
// Generisches Upsert für Collections (hosts, groups, keys, snippets, forwards)
upsert(collection, item) {
const list = this.data[collection];
if (!Array.isArray(list)) throw new Error('Unbekannte Collection: ' + collection);
if (!item.id) item.id = crypto.randomUUID();
const i = list.findIndex((x) => x.id === item.id);
if (i >= 0) list[i] = { ...list[i], ...item, updatedAt: Date.now() };
else list.push({ ...item, createdAt: Date.now(), updatedAt: Date.now() });
this.save();
return item;
}
remove(collection, id) {
this.data[collection] = this.data[collection].filter((x) => x.id !== id);
if (collection === 'groups') this.data.hosts.forEach((h) => { if (h.groupId === id) h.groupId = null; });
this.save();
}
setSettings(s) { this.data.settings = { ...this.data.settings, ...s }; this.save(); }
addHistory(entry) {
this.data.history = [entry, ...this.data.history.filter((h) => h.hostId !== entry.hostId)].slice(0, 30);
this.save();
}
resolveHost(id) { return this.data.hosts.find((h) => h.id === id); }
resolveKey(id) { return this.data.keys.find((k) => k.id === id); }
}
module.exports = { Store };