Mehrsprachigkeit (Englisch/Deutsch, Systemsprache oder Auswahl in den Einstellungen), README auf Englisch für Nutzer, Version 0.4.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 18:18:03 +02:00
co-authored by Claude Opus 5.5
parent b578a6c7ec
commit 2890844c81
14 changed files with 761 additions and 322 deletions
+16 -12
View File
@@ -9,6 +9,8 @@ const { SshManager } = require('./ssh');
const rdp = require('./rdp');
const { Updater } = require('./updater');
const { createEmbed, embedSupported } = require('./rdp-embed');
const i18n = require('../i18n');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win;
const store = new Store();
@@ -34,12 +36,12 @@ async function confirmHostKey(target, fingerprint, known) {
const changed = !!known;
const r = await dialog.showMessageBox(win, {
type: changed ? 'warning' : 'question',
title: changed ? 'Host-Schlüssel hat sich geändert!' : 'Unbekannter Host',
title: changed ? i18n.t('Host key has changed!') : i18n.t('Unknown host'),
message: changed
? `WARNUNG: Der Host-Schlüssel von ${target.host}:${target.port} hat sich geändert. Das kann auf einen Man-in-the-Middle-Angriff hindeuten.`
: `Die Echtheit von ${target.host}:${target.port} kann nicht bestätigt werden.`,
detail: `Fingerprint:\n${fingerprint}${changed ? `\n\nBisher gespeichert:\n${known.fingerprint}` : ''}`,
buttons: [changed ? 'Trotzdem verbinden & ersetzen' : 'Vertrauen & verbinden', 'Abbrechen'],
? i18n.t('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: `${target.host}:${target.port}` })
: i18n.t('The authenticity of {target} cannot be verified.', { target: `${target.host}:${target.port}` }),
detail: `Fingerprint:\n${fingerprint}${changed ? `\n\n${i18n.t('Previously stored:')}\n${known.fingerprint}` : ''}`,
buttons: [changed ? i18n.t('Connect anyway & replace') : i18n.t('Trust & connect'), i18n.t('Cancel')],
defaultId: changed ? 1 : 0,
cancelId: 1,
});
@@ -104,6 +106,7 @@ 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);
if ('language' in s) applyLanguage();
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(); });
@@ -125,7 +128,7 @@ handle('vault:import', async () => {
// Import aus ~/.ssh/config
handle('vault:importSshConfig', () => {
const file = path.join(os.homedir(), '.ssh', 'config');
if (!fs.existsSync(file)) throw new Error('~/.ssh/config nicht gefunden');
if (!fs.existsSync(file)) throw new Error(i18n.t('~/.ssh/config not found'));
const entries = [];
let cur = null;
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
@@ -188,7 +191,7 @@ handle('key:pickFile', async () => {
function hostWithOverrides(hostOrId) {
if (typeof hostOrId === 'string') {
const h = store.resolveHost(hostOrId);
if (!h) throw new Error('Host nicht gefunden');
if (!h) throw new Error(i18n.t('Host not found'));
return h;
}
return hostOrId; // Quick-Connect: temporärer Host
@@ -245,7 +248,7 @@ handle('local:op', (op, a, b) => {
// ---------- Port-Forwarding ----------
handle('fw:start', (id) => {
const fw = store.get().forwards.find((f) => f.id === id);
if (!fw) throw new Error('Regel nicht gefunden');
if (!fw) throw new Error(i18n.t('Rule not found'));
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
});
handle('fw:stop', (id) => ssh.stopForward(id));
@@ -258,8 +261,8 @@ handle('rdp:launch', (hostId) => {
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
const r = rdp.launch(host, store.get().settings);
if (r.process) {
r.process.on('exit', (code) => { if (code && code !== 0 && code !== 12) send('toast', `RDP beendet (Code ${code}): ${r.getErr().split('\n').filter(Boolean).slice(-1)[0] || ''}`, 'error'); });
r.process.on('error', (e) => send('toast', 'RDP-Start fehlgeschlagen: ' + e.message, 'error'));
r.process.on('exit', (code) => { if (code && code !== 0 && code !== 12) send('toast', i18n.t('RDP exited (code {code}): {err}', { code, err: r.getErr().split('\n').filter(Boolean).slice(-1)[0] || '' }), 'error'); });
r.process.on('error', (e) => send('toast', i18n.t('RDP launch failed: {msg}', { msg: e.message }), 'error'));
}
return r.client;
});
@@ -268,7 +271,7 @@ handle('rdp:launch', (hostId) => {
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)' };
if (s.ok && process.platform === 'linux' && !rdp.linuxClient(null, true)) return { ok: false, reason: i18n.t('xfreerdp3 missing (sudo pacman -S freerdp)') };
return s;
});
handle('rdp:open', async (id, hostRef, bounds) => {
@@ -320,7 +323,7 @@ handle('rdp:detach', (id) => {
// ---------- 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'] });
const r = await dialog.showOpenDialog(win, { title, filters: [{ name: title, extensions }, { name: i18n.t('All files'), extensions: ['*'] }], properties: ['openFile'] });
if (r.canceled) return null;
const buf = fs.readFileSync(r.filePaths[0]);
// UTF-16-Exporte (BOM) erkennen
@@ -364,6 +367,7 @@ handle('shell:open', (url) => shell.openExternal(url));
app.whenReady().then(() => {
store.load();
applyLanguage();
createWindow();
if (store.get().settings.updateAutoCheck && app.isPackaged) {
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
+4 -3
View File
@@ -6,6 +6,7 @@ const { spawn } = require('child_process');
let koffi = null;
try { koffi = require('koffi'); } catch { /* ohne koffi nur externe Fenster */ }
const i18n = require('../i18n');
// ------------------------------------------------------------------ Windows
// Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes
@@ -135,7 +136,7 @@ function x11() {
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).');
if (!x11.api.display) throw new Error(i18n.t('No connection to the X server (MrTerm is not running under X11/XWayland).'));
return x11.api;
}
@@ -195,12 +196,12 @@ class LinuxEmbed {
}
function embedSupported() {
if (!koffi) return { ok: false, reason: 'koffi nicht verfügbar' };
if (!koffi) return { ok: false, reason: i18n.t('koffi not available') };
if (process.platform === 'win32') return { ok: true };
if (process.platform === 'linux') {
try { x11(); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; }
}
return { ok: false, reason: 'Plattform nicht unterstützt' };
return { ok: false, reason: i18n.t('Platform not supported') };
}
function createEmbed(parentHandle) {
+2 -1
View File
@@ -4,6 +4,7 @@ const { spawn, execFile, execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const i18n = require('../i18n');
function which(bin) {
try {
@@ -100,7 +101,7 @@ function launch(host, settings) {
return { client: 'mstsc' };
}
const client = linuxClient(settings.rdpClientLinux);
if (!client) throw new Error('Kein FreeRDP gefunden. Installieren mit: sudo pacman -S freerdp');
if (!client) throw new Error(i18n.t('No FreeRDP found. Install with: 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 = '';
+6 -5
View File
@@ -5,6 +5,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const i18n = require('../i18n');
function defaultAgent() {
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
@@ -77,7 +78,7 @@ class SshManager {
let sock;
try {
for (const hop of [...chain, host]) {
onStatus(`Verbinde mit ${hop.label || hop.address} (${hop.address}:${hop.port || 22}) …`);
onStatus(i18n.t('Connecting to {name} ({addr}) …', { name: hop.label || hop.address, addr: `${hop.address}:${hop.port || 22}` }));
const conn = await this.openClient(hop, sessionId, sock);
opened.push(conn);
if (hop !== host) {
@@ -104,7 +105,7 @@ class SshManager {
const answers = [];
for (const p of prompts) {
if (!p.echo && host.password && !askedPassword) { answers.push(host.password); askedPassword = true; continue; }
const a = await this.askSecret(sessionId, { title: name || 'Anmeldung', prompt: p.prompt, echo: p.echo, host: host.label || host.address });
const a = await this.askSecret(sessionId, { title: name || i18n.t('Authentication'), prompt: p.prompt, echo: p.echo, host: host.label || host.address });
if (a == null) return finish([]);
answers.push(a);
}
@@ -114,7 +115,7 @@ class SshManager {
conn.once('error', async (err) => {
// Kein Passwort hinterlegt und alle Methoden schlugen fehl -> nach Passwort fragen und neu versuchen
if (err.level === 'client-authentication' && !host.password && !host._retried) {
const pw = await this.askSecret(sessionId, { title: 'Passwort', prompt: `Passwort für ${cfg.username}@${host.address}`, echo: false, host: host.label || host.address });
const pw = await this.askSecret(sessionId, { title: i18n.t('Password'), prompt: i18n.t('Password for {user}', { user: `${cfg.username}@${host.address}` }), echo: false, host: host.label || host.address });
if (pw != null) {
this.openClient({ ...host, password: pw, _retried: true }, sessionId, sock).then(resolve, reject);
return;
@@ -178,7 +179,7 @@ class SshManager {
sftpOf(id) {
const s = this.sessions.get(id);
if (!s?.sftp) throw new Error('SFTP-Sitzung nicht gefunden');
if (!s?.sftp) throw new Error(i18n.t('SFTP session not found'));
return s.sftp;
}
@@ -250,7 +251,7 @@ class SshManager {
// ---------- Port-Forwarding ----------
async startForward(fw, onEvent) {
const host = this.store.resolveHost(fw.hostId);
if (!host) throw new Error('Host nicht gefunden');
if (!host) throw new Error(i18n.t('Host not found'));
const { conn, jumps } = await this.connect(host, 'fw-' + fw.id);
const entry = { conn, jumps };
this.forwards.set(fw.id, entry);
+1
View File
@@ -31,6 +31,7 @@ const DEFAULTS = {
updateToken: '',
updateAutoCheck: true,
updatePrerelease: false,
language: 'auto',
},
};
+8 -7
View File
@@ -5,13 +5,14 @@ const { spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const i18n = require('../i18n');
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);
if (!owner || !repo) throw new Error(i18n.t('Invalid repository URL: {url}', { url }));
return { base: u.origin, owner, repo };
}
@@ -59,8 +60,8 @@ class Updater {
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}`);
if (res.status === 404) throw new Error(i18n.t('Repository not found (private? Then add an access token in the settings).'));
if (!res.ok) throw new Error(i18n.t('Update server responded with {status}', { status: 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();
@@ -90,12 +91,12 @@ class Updater {
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.');
if (!asset) throw new Error(i18n.t('No matching package for this system found in the release.'));
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})`);
if (!res.ok) throw new Error(i18n.t('Download failed ({status})', { status: res.status }));
const total = Number(res.headers.get('content-length')) || asset.size || 0;
const out = fs.createWriteStream(file + '.part');
let done = 0, last = 0;
@@ -136,12 +137,12 @@ class Updater {
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}`))));
p.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(i18n.t('pacman exited with code {code}', { code: c })))));
});
app.relaunch();
app.exit(0);
} else {
throw new Error('Automatische Installation hier nicht möglich. Datei liegt unter: ' + file);
throw new Error(i18n.t('Automatic installation is not possible here. The file is located at: {file}', { file }));
}
}
}