diff --git a/README.md b/README.md index 7eb6bf4..b935fba 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,97 @@ # MrTerm -SSH-, SFTP- und RDP-Client im Stil von Termius – Electron + xterm.js + ssh2. Läuft auf Windows und Arch/CachyOS. +A modern SSH, SFTP and RDP client for Windows and Arch Linux / CachyOS. All your servers live in one place and open in tabs: terminals, file transfers and remote desktops. -## Funktionen -- **Hosts**: Gruppen (verschachtelt), Tags, Farben, Suche, Quick Connect (`user@host:port`, `rdp://host`) -- **SSH-Terminal** in Tabs: Passwort, Schlüssel, SSH-Agent (Windows OpenSSH / `SSH_AUTH_SOCK`), Keyboard-Interactive/2FA, Jump-Hosts (ProxyJump-Ketten), Startbefehl, Env-Variablen, Suche, Zoom, 7 Farbschemata -- **SFTP**: Dual-Pane (lokal ↔ remote), Drag & Drop, Ordner rekursiv, Umbenennen, Löschen, chmod, Fortschrittsanzeige -- **RDP als Tab** direkt in MrTerm (Windows: mstsc eingebettet, Linux: xfreerdp3 via /parent-window unter X11/XWayland), alternativ im eigenen Fenster -- **RDP**: Windows → `mstsc` (Anmeldedaten temporär via `cmdkey`), Linux → FreeRDP (`xfreerdp3`/`sdl-freerdp3`/`wlfreerdp3`, Passwort über stdin) -- **Keychain**: Ed25519/ECDSA/RSA generieren, importieren, Public Key kopieren -- **Port-Forwarding**: lokal (-L), remote (-R), dynamisch/SOCKS5 (-D) -- **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) -- Befehlspalette `Strg+Shift+K`, Tab schließen `Strg+Shift+W` (Strg+W/K/T bleiben im Terminal für nano & Co.), `Strg+Tab`, `Strg+1..9`, `Strg+Shift+C/V/F` +MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**. + +## Download & installation + +Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases). + +**Windows** +- `MrTerm-Setup-.exe`: installer (recommended) +- `MrTerm--portable.exe`: portable, runs without installation + +**Arch Linux / CachyOS** +- `MrTerm-.pacman`: install with + ```sh + sudo pacman -U MrTerm-.pacman + ``` +- `MrTerm-.AppImage`: make it executable (`chmod +x`) and run it + +Optional packages on Linux: +```sh +sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on KDE +``` +`freerdp` is needed for RDP connections. `gnome-keyring` or `kwallet` lets MrTerm encrypt your saved passwords and keys. + +## Features + +- **Hosts**: nested groups, tags, colors, search and Quick Connect (`user@host:port`, `rdp://host`) +- **SSH terminal in tabs**: password, key, SSH agent, keyboard-interactive/2FA, jump hosts (ProxyJump chains), startup command, environment variables, search, zoom and several color schemes +- **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display +- **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer +- **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key +- **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D) +- **Snippets**: save frequently used commands and send them to a terminal with one click +- **Known hosts**: MrTerm warns you if a server's host key changes +- **History** of recent connections +- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV) +- **Backup** export and import +- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color +- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet) +- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you + +## Getting started + +1. Click **New host**, enter the address, username and password or key, and click **Save**. +2. Double-click the host to connect. SSH hosts open a terminal, RDP hosts open a remote desktop tab. +3. Right-click a host for more options, such as opening SFTP, duplicating it or copying its address. + +For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `rdp://host`). + +## Keyboard shortcuts + +| Shortcut | Action | +|---|---| +| `Ctrl+Shift+K` / `Ctrl+Shift+T` | Quick Connect / command palette (outside the terminal also `Ctrl+K`) | +| `Ctrl+Shift+W` | Close tab | +| `Ctrl+Tab` | Next tab | +| `Ctrl+1..9` | Switch to tab | +| `Ctrl+Shift+C` / `Ctrl+Shift+V` | Copy / paste in the terminal | +| `Ctrl+Shift+F` | Search in the terminal | +| `Ctrl` + `+` / `-` / `0` | Font size | + +`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual. + +## Updates + +MrTerm looks for updates on startup. You can also check manually under **Settings → Updates**. When a new version is available, click **Install now** and MrTerm will download the right package for your system and restart. + +## Troubleshooting + +- **"Vault not encrypted"** (Linux): install `gnome-keyring` or `kwallet` and restart MrTerm. +- **RDP doesn't work** (Linux): install FreeRDP with `sudo pacman -S freerdp`. Under **Settings → RDP** you can see which client MrTerm uses and choose another one. +- **RDP tab stays empty**: turn off *Show RDP connections as tabs* under **Settings → RDP** to use a separate window instead. + +--- + +## For developers -## Entwicklung ```bash npm install npm start ``` -> In VS Code-Terminals ggf. vorher `ELECTRON_RUN_AS_NODE` entfernen. +In VS Code terminals, unset `ELECTRON_RUN_AS_NODE` first. -## Pakete bauen -- Windows (auf Windows): `npm run dist:win` → Installer + Portable in `dist/` -- Arch/CachyOS (auf Linux): `npm run dist:linux` → AppImage + `.pacman`-Paket; installieren mit `sudo pacman -U dist/mrterm-*.pacman` +**Building packages** +- Windows: `npm run dist:win` (on Linux, this needs `wine`) +- Arch/CachyOS: `npm run dist:linux` -Abhängigkeiten unter Arch/CachyOS: `sudo pacman -S freerdp gnome-keyring` (oder `kwallet` unter KDE) für RDP bzw. Vault-Verschlüsselung. +If the build fails with `EACCES: permission denied`, some files in `node_modules` belong to root. Run `sudo chown -R $USER: node_modules` and don't run npm with `sudo`. -**Build bricht mit `EACCES: permission denied, unlink 'build/config.gypi'` ab** (z. B. bei `cpu-features`): Dateien in `node_modules` gehören root, meist nach einem `npm install` oder Build mit `sudo`. Besitz zurückholen und erneut bauen: +**Publishing a release**: `./scripts/release-all.sh` asks for the version and release notes, commits and pushes the version, builds Windows + Arch/CachyOS and uploads everything to Gitea. It reads the token from `GITEA_TOKEN` or from `.gitea-token`, which is not committed. -```sh -sudo chown -R $USER: node_modules -npm run dist:linux -``` +**Translations** live in [`src/i18n.js`](src/i18n.js). The English text in the code is the key. To add a language, add a dictionary and list it in `LANGUAGES`. -npm und die Builds danach nicht mehr mit `sudo` ausführen – nur `pacman -U` zum Installieren braucht root. - -## 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= npm run release -- --notes "Was ist neu"` – legt das Release `v` an und lädt die Pakete hoch - -Oder alles in einem Schritt unter Linux (Windows-Build per `wine`): `./scripts/release-all.sh` – fragt nach Version und Notizen, committet/pusht die Version, baut Windows + Arch/CachyOS und lädt hoch. Token aus `GITEA_TOKEN` oder `.gitea-token` (nicht eingecheckt). - -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` +To regenerate the icons after changing the logo, run `npx electron scripts/make-icons.js`. diff --git a/package-lock.json b/package-lock.json index 9fd0abe..1fb1bba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mrterm", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mrterm", - "version": "0.2.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index b4e8ae1..a28ee3f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mrterm", "productName": "MrTerm", - "version": "0.3.0", + "version": "0.4.0", "description": "Moderner SSH-, SFTP- und RDP-Client", "main": "src/main/main.js", "author": "MrBlake", diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..f65516c --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,365 @@ +// Übersetzungen für Renderer (window.I18N) und Main-Prozess (require). +// Quellsprache ist Englisch: der Text im Code ist der Schlüssel, weitere Sprachen liefern ein Wörterbuch. +// Neue Sprache: Wörterbuch unten ergänzen und in LANGUAGES eintragen. +(function (root) { + 'use strict'; + + const de = { + // Allgemein + 'Cancel': 'Abbrechen', + 'Save': 'Speichern', + 'OK': 'OK', + 'Delete': 'Löschen', + 'Delete?': 'Löschen?', + 'Edit': 'Bearbeiten', + 'Open': 'Öffnen', + 'Close': 'Schließen', + 'Close (Ctrl+Shift+W)': 'Schließen (Strg+Shift+W)', + 'Close others': 'Andere schließen', + 'Rename': 'Umbenennen', + 'Duplicate': 'Duplizieren', + 'Copy': 'Kopieren', + 'Copied': 'Kopiert', + 'Connect': 'Verbinden', + 'Reconnect': 'Neu verbinden', + 'Reconnecting': 'Neu verbinden', + 'Remove': 'Entfernen', + 'Import': 'Importieren', + 'Generate': 'Generieren', + 'Automatic': 'Automatisch', + 'Color': 'Farbe', + 'Name': 'Name', + 'Type': 'Typ', + 'Title': 'Titel', + 'Label': 'Bezeichnung', + 'Settings': 'Einstellungen', + 'Menu': 'Menü', + 'History': 'Verlauf', + 'Minimize': 'Minimieren', + 'Maximize': 'Maximieren', + 'Quick Connect (Ctrl+Shift+K)': 'Quick Connect (Strg+Shift+K)', + 'Collapse menu': 'Menü einklappen', + 'Expand menu': 'Menü ausklappen', + 'Vault encrypted': 'Vault verschlüsselt', + 'Vault not encrypted': 'Vault unverschlüsselt', + 'Data is encrypted with the system keyring.': 'Daten werden mit dem System-Schlüsselbund verschlüsselt.', + 'No system keyring available (e.g. install gnome-keyring/kwallet).': 'Kein System-Schlüsselbund verfügbar (z. B. gnome-keyring/kwallet installieren).', + 'just now': 'gerade eben', + '{n} min ago': 'vor {n} Min.', + '{n} h ago': 'vor {n} Std.', + '{n} days ago': 'vor {n} Tagen', + + // Verzeichnisbaum / Hosts + 'Connections': 'Verbindungen', + 'Collapse all': 'Alle einklappen', + 'No hosts yet': 'Noch keine Hosts', + 'Add host': 'Host hinzufügen', + 'Create subfolder': 'Unterordner anlegen', + 'Delete group?': 'Gruppe löschen?', + '“{name}” will be deleted. Hosts are kept.': '„{name}“ wird gelöscht. Hosts bleiben erhalten.', + 'Search hosts or enter user@host …': 'Hosts suchen oder user@host eingeben …', + 'Group': 'Gruppe', + 'Groups': 'Gruppen', + 'New host': 'Neuer Host', + 'All hosts': 'Alle Hosts', + 'Connect to {q}?': 'Mit {q} verbinden?', + 'Quick Connect without saving the host.': 'Quick Connect ohne den Host zu speichern.', + 'Add your first SSH or RDP host or import ~/.ssh/config.': 'Lege deinen ersten SSH- oder RDP-Host an oder importiere ~/.ssh/config.', + 'Import ~/.ssh/config': '~/.ssh/config importieren', + 'No results': 'Keine Treffer', + 'Adjust the search or filters.': 'Passe die Suche oder Filter an.', + 'Connect in tab': 'Im Tab verbinden', + 'Open in separate window': 'In eigenem Fenster öffnen', + 'Open SFTP': 'SFTP öffnen', + ' (copy)': ' (Kopie)', + 'Copy address': 'Adresse kopieren', + 'Delete host?': 'Host löschen?', + '“{name}” will be permanently removed.': '„{name}“ wird dauerhaft entfernt.', + 'RDP in a tab is not possible ({reason}) – opening a separate window.': 'RDP im Tab nicht möglich ({reason}) – öffne eigenes Fenster.', + 'RDP session to {name} started ({client})': 'RDP-Sitzung zu {name} gestartet ({client})', + 'Invalid address. Format: user@host:port': 'Ungültige Adresse. Format: user@host:port', + '{n} host(s) imported': '{n} Host(s) importiert', + + // Host-Editor + '— No group —': '— Keine Gruppe —', + '— No key —': '— Kein Schlüssel —', + '— Direct —': '— Direkt —', + 'SSH key': 'SSH-Schlüssel', + 'Use SSH agent': 'SSH-Agent verwenden', + 'Advanced': 'Erweitert', + 'Jump host (ProxyJump)': 'Jump-Host (ProxyJump)', + 'Startup command': 'Startbefehl', + 'e.g. tmux attach || tmux': 'z. B. tmux attach || tmux', + 'Environment variables': 'Umgebungsvariablen', + 'One per line, KEY=VALUE (server must allow AcceptEnv).': 'Eine pro Zeile, KEY=VALUE (Server muss AcceptEnv erlauben).', + 'Domain': 'Domäne', + 'optional': 'optional', + 'Width': 'Breite', + 'Height': 'Höhe', + 'On resize': 'Bei Größenänderung', + 'Adjust resolution dynamically': 'Auflösung dynamisch anpassen', + 'Scale image (fixed resolution)': 'Bild skalieren (feste Auflösung)', + 'Fullscreen': 'Vollbild', + 'Share clipboard': 'Zwischenablage teilen', + 'Play audio': 'Audio wiedergeben', + 'Share local drives/home': 'Lokale Laufwerke/Home freigeben', + 'Edit host': 'Host bearbeiten', + 'Address': 'Adresse', + 'IP or hostname': 'IP oder Hostname', + 'My server': 'Mein Server', + 'Authentication': 'Anmeldung', + 'Username': 'Benutzername', + 'Password': 'Passwort', + 'stored encrypted': 'wird verschlüsselt gespeichert', + 'Appearance': 'Darstellung', + 'Please enter an address.': 'Bitte eine Adresse angeben.', + 'Edit group': 'Gruppe bearbeiten', + 'New group': 'Neue Gruppe', + 'Production': 'Produktion', + 'Parent group': 'Übergeordnete Gruppe', + 'Name is missing.': 'Name fehlt.', + + // Keychain + 'Search keys …': 'Schlüssel suchen …', + 'No keys': 'Keine Schlüssel', + 'Generate a new Ed25519 key or import an existing one.': 'Generiere einen neuen Ed25519-Schlüssel oder importiere einen bestehenden.', + 'Generate key': 'Schlüssel generieren', + 'Keys': 'Schlüssel', + 'Copy public key': 'Public Key kopieren', + 'Public key copied': 'Public Key kopiert', + 'Delete key?': 'Schlüssel löschen?', + '“{name}” will be removed from the vault.': '„{name}“ wird aus dem Vault entfernt.', + 'Generate SSH key': 'SSH-Schlüssel generieren', + 'recommended': 'empfohlen', + 'Key generated – public key copied to clipboard': 'Schlüssel erzeugt – Public Key in Zwischenablage kopiert', + 'Edit key': 'Schlüssel bearbeiten', + 'Import key': 'Schlüssel importieren', + 'Derived from the private key automatically if left empty.': 'Wird bei Bedarf automatisch aus dem Private Key berechnet.', + 'Private key is missing.': 'Private Key fehlt.', + 'File…': 'Datei…', + + // Snippets + 'Search snippets …': 'Snippets suchen …', + 'New snippet': 'Neues Snippet', + 'No snippets': 'Keine Snippets', + 'Save frequently used commands and send them to a terminal with one click.': 'Speichere häufig genutzte Befehle und sende sie mit einem Klick an ein Terminal.', + 'Run in active terminal': 'Im aktiven Terminal ausführen', + 'Delete snippet?': 'Snippet löschen?', + 'Edit snippet': 'Snippet bearbeiten', + 'System update': 'Systemupdate', + 'Command(s)': 'Befehl(e)', + 'Send Enter automatically': 'Enter automatisch senden', + 'Command is missing.': 'Befehl fehlt.', + 'No open terminal': 'Kein offenes Terminal', + 'No snippets yet.': 'Noch keine Snippets.', + + // Port Forwarding + 'Search rules …': 'Regeln suchen …', + 'New rule': 'Neue Regel', + 'No port forwards': 'Keine Port-Weiterleitungen', + 'Local (-L), remote (-R) or dynamic as SOCKS5 proxy (-D).': 'Lokal (-L), Remote (-R) oder dynamisch als SOCKS5-Proxy (-D).', + 'Local': 'Lokal', + 'active': 'aktiv', + 'inactive': 'inaktiv', + 'Stop': 'Stoppen', + 'Start': 'Starten', + 'Forward active': 'Weiterleitung aktiv', + 'Delete rule?': 'Regel löschen?', + 'Add an SSH host first.': 'Lege zuerst einen SSH-Host an.', + 'Edit rule': 'Regel bearbeiten', + 'New forward': 'Neue Weiterleitung', + 'Local (-L)': 'Lokal (-L)', + 'Dynamic / SOCKS5 (-D)': 'Dynamisch / SOCKS5 (-D)', + 'Via host': 'Über Host', + 'Bind address': 'Bind-Adresse', + 'Bind port': 'Bind-Port', + 'Target host': 'Ziel-Host', + 'Target port': 'Ziel-Port', + 'Bind port is missing.': 'Bind-Port fehlt.', + 'Target port is missing.': 'Ziel-Port fehlt.', + + // Known Hosts & Verlauf + 'Search known hosts …': 'Known Hosts suchen …', + 'No known hosts': 'Keine bekannten Hosts', + 'Host keys are stored here on first connection.': 'Host-Schlüssel werden bei der ersten Verbindung hier gespeichert.', + 'Search history …': 'Verlauf durchsuchen …', + 'No connections yet': 'Noch keine Verbindungen', + 'Recently used hosts appear here.': 'Zuletzt genutzte Hosts erscheinen hier.', + + // Einstellungen + 'Language': 'Sprache', + 'System default': 'Systemsprache', + 'Accent color': 'Akzentfarbe', + 'Theme default': 'Standard des Designs', + 'Light': 'Hell', + 'Terminal color scheme': 'Terminal-Farbschema', + 'Match theme': 'Passend zum Design', + 'follows the theme': 'folgt dem Design', + 'Font': 'Schriftart', + 'Size': 'Größe', + 'Bar': 'Balken', + 'Underline': 'Unterstrich', + 'Scrollback lines': 'Scrollback-Zeilen', + 'Blinking cursor': 'Cursor blinkt', + 'Copy on select': 'Auswahl automatisch kopieren', + 'SSH keep-alive (seconds, 0 = off)': 'SSH Keep-Alive (Sekunden, 0 = aus)', + 'Checking …': 'Prüfe …', + 'Show RDP connections as tabs in MrTerm': 'RDP-Verbindungen als Tab in MrTerm anzeigen', + 'Takes effect after restarting MrTerm (X11/XWayland mode).': 'Wird nach einem Neustart von MrTerm wirksam (X11/XWayland-Modus).', + 'Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.': 'Nutzt xfreerdp3 mit /parent-window. MrTerm läuft dafür unter Wayland über XWayland.', + 'FreeRDP client (separate window)': 'FreeRDP-Client (eigenes Fenster)', + 'Client in use: {client}': 'Verwendeter Client: {client}', + 'No RDP client found. Install: {hint}': 'Kein RDP-Client gefunden. Installation: {hint}', + 'Data': 'Daten', + 'The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).': 'Der Vault ist mit dem Schlüsselbund des Betriebssystems verschlüsselt (Windows DPAPI / libsecret bzw. KWallet).', + 'The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.': 'Der Vault ist nicht verschlüsselt, da kein Schlüsselbund verfügbar ist. Unter Arch/CachyOS: gnome-keyring oder kwallet installieren.', + 'Export backup': 'Backup exportieren', + 'Exported to {path}': 'Exportiert nach {path}', + 'Import backup': 'Backup importieren', + 'Import complete': 'Import abgeschlossen', + 'Import folders and hosts (RDP & SSH) from Devolutions Remote Desktop Manager. In RDM: File → Export → Export entries as .rdm (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.': 'Ordner und Hosts (RDP & SSH) aus Devolutions Remote Desktop Manager übernehmen. In RDM: Datei → Exportieren → Einträge exportieren als .rdm (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.', + 'Import from Remote Desktop Manager…': 'Aus Remote Desktop Manager importieren…', + 'Installed version: {v}': 'Installierte Version: {v}', + 'Gitea/Forgejo repository whose releases contain the installation packages.': 'Gitea/Forgejo-Repository, dessen Releases die Installationspakete enthalten.', + 'Access token (private repos only)': 'Zugriffstoken (nur für private Repos)', + 'Check for updates on startup': 'Beim Start automatisch nach Updates suchen', + 'Include pre-releases': 'Vorabversionen (Pre-Releases) einbeziehen', + 'Check for updates now': 'Jetzt nach Updates suchen', + 'The repository has version {repo}, but there is no release with installation packages for it yet. Installed: {current}.': 'Im Repository liegt Version {repo}, aber dafür gibt es noch kein Release mit Installationspaketen. Installiert: {current}.', + 'The repository has no releases yet.': 'Im Repository gibt es noch keine Releases.', + 'MrTerm is up to date ({v}).': 'MrTerm ist aktuell ({v}).', + 'Keyboard shortcuts': 'Tastenkürzel', + 'Ctrl': 'Strg', + 'Quick Connect / command palette (outside the terminal also {k})': 'Quick Connect / Befehlspalette (außerhalb des Terminals auch {k})', + 'Close tab': 'Tab schließen', + 'Next tab': 'Nächster Tab', + 'Switch to tab': 'Zu Tab wechseln', + 'Copy / paste in terminal': 'Kopieren / Einfügen im Terminal', + 'Search in terminal': 'Im Terminal suchen', + 'Font size': 'Schriftgröße', + + // RDM-Import + 'Import failed: {msg}': 'Import fehlgeschlagen: {msg}', + 'No RDP or SSH entries found in the file.': 'Keine RDP- oder SSH-Einträge in der Datei gefunden.', + 'Root level': 'Stammebene', + 'Import from Remote Desktop Manager': 'Import aus Remote Desktop Manager', + '{file}: found {n} hosts ({rdp} RDP, {ssh} SSH) in {folders} folders.': '{file}: {n} Hosts ({rdp} RDP, {ssh} SSH) in {folders} Ordnern gefunden.', + '… and {n} more': '… und {n} weitere', + '{n} hosts imported': '{n} Hosts importiert', + ', {n} already existed': ', {n} bereits vorhanden', + 'File is not valid XML.': 'Datei ist kein gültiges XML.', + 'CSV columns not recognized (expected e.g. Name, Host, ConnectionType, Group).': 'CSV-Spalten nicht erkannt (erwartet u. a. Name, Host, ConnectionType, Group).', + + // Updates + 'MrTerm {v} is available': 'MrTerm {v} ist verfügbar', + 'Installed: {v}': 'Installiert: {v}', + 'Package: {name} ({size})': 'Paket: {name} ({size})', + 'Development mode: please update via git pull.': 'Entwicklungsmodus: Bitte per git pull aktualisieren.', + 'The release contains no package for this system.': 'Im Release ist kein passendes Paket für dieses System.', + 'Downloading …': 'Lade herunter …', + 'Later': 'Später', + 'Release page': 'Release-Seite', + 'Install now': 'Jetzt installieren', + 'Downloading update …': 'Update wird heruntergeladen …', + 'Installing update, MrTerm will restart …': 'Update wird installiert, MrTerm startet neu …', + + // Tabs & Sitzungen + 'Rename tab': 'Tab umbenennen', + 'SFTP for this host': 'SFTP für diesen Host', + 'Search (Ctrl+Shift+F)': 'Suchen (Strg+Shift+F)', + 'Search …': 'Suchen …', + 'Connecting to {host} …': 'Verbinde mit {host} …', + 'Connection closed. Press [Enter] to reconnect.': 'Verbindung beendet. [Enter] zum erneuten Verbinden.', + 'Continue session in a separate window': 'Sitzung als eigenes Fenster weiterführen', + 'Detach as window': 'Als Fenster lösen', + 'RDP session hidden while a dialog is open.': 'RDP-Sitzung ausgeblendet, solange ein Dialog geöffnet ist.', + 'In separate window': 'Im eigenen Fenster', + 'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.', + 'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.', + 'RDP session ended.': 'RDP-Sitzung beendet.', + 'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).', + 'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter', + + // SFTP + 'Choose a host for SFTP …': 'Host für SFTP wählen …', + 'No SSH hosts': 'Keine SSH-Hosts', + 'SFTP requires an SSH host.': 'SFTP benötigt einen SSH-Host.', + 'Open host in SFTP browser': 'Host für SFTP-Browser öffnen', + 'Parent folder': 'Übergeordneter Ordner', + 'Refresh': 'Aktualisieren', + 'New folder': 'Neuer Ordner', + 'Upload →': 'Hochladen →', + '← Download': '← Herunterladen', + 'Upload': 'Hochladen', + 'Download': 'Herunterladen', + 'Modified': 'Geändert', + 'Copy path': 'Pfad kopieren', + 'Permissions (chmod)…': 'Rechte (chmod)…', + 'Move to trash': 'In Papierkorb', + 'New name': 'Neuer Name', + 'Change permissions': 'Rechte ändern', + 'Octal (e.g. 644)': 'Oktal (z. B. 644)', + 'Move {n} item(s) to the trash: {list}': '{n} Element(e) in den Papierkorb verschieben: {list}', + 'Permanently delete {n} item(s) from the server: {list}': '{n} Element(e) endgültig vom Server löschen: {list}', + 'Nothing selected': 'Nichts ausgewählt', + 'Try again': 'Erneut versuchen', + 'done': 'fertig', + 'Error': 'Fehler', + + // Befehlspalette & Anmeldung + 'Search hosts or enter user@host:port or rdp://host …': 'Host suchen, user@host:port oder rdp://host eingeben …', + 'Actions': 'Aktionen', + 'Log in': 'Anmelden', + + // Main-Prozess + 'Host key has changed!': 'Host-Schlüssel hat sich geändert!', + 'Unknown host': 'Unbekannter Host', + 'WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.': 'WARNUNG: Der Host-Schlüssel von {target} hat sich geändert. Das kann auf einen Man-in-the-Middle-Angriff hindeuten.', + 'The authenticity of {target} cannot be verified.': 'Die Echtheit von {target} kann nicht bestätigt werden.', + 'Previously stored:': 'Bisher gespeichert:', + 'Connect anyway & replace': 'Trotzdem verbinden & ersetzen', + 'Trust & connect': 'Vertrauen & verbinden', + '~/.ssh/config not found': '~/.ssh/config nicht gefunden', + 'Host not found': 'Host nicht gefunden', + 'Rule not found': 'Regel nicht gefunden', + 'RDP exited (code {code}): {err}': 'RDP beendet (Code {code}): {err}', + 'RDP launch failed: {msg}': 'RDP-Start fehlgeschlagen: {msg}', + 'xfreerdp3 missing (sudo pacman -S freerdp)': 'xfreerdp3 fehlt (sudo pacman -S freerdp)', + 'All files': 'Alle Dateien', + 'No FreeRDP found. Install with: sudo pacman -S freerdp': 'Kein FreeRDP gefunden. Installieren mit: sudo pacman -S freerdp', + 'No connection to the X server (MrTerm is not running under X11/XWayland).': 'Keine Verbindung zum X-Server (MrTerm läuft nicht unter X11/XWayland).', + 'koffi not available': 'koffi nicht verfügbar', + 'Platform not supported': 'Plattform nicht unterstützt', + 'Connecting to {name} ({addr}) …': 'Verbinde mit {name} ({addr}) …', + 'Password for {user}': 'Passwort für {user}', + 'SFTP session not found': 'SFTP-Sitzung nicht gefunden', + 'Invalid repository URL: {url}': 'Ungültige Repository-URL: {url}', + 'Repository not found (private? Then add an access token in the settings).': 'Repository nicht gefunden (privat? Dann Zugriffstoken in den Einstellungen hinterlegen).', + 'Update server responded with {status}': 'Update-Server antwortet mit {status}', + 'No matching package for this system found in the release.': 'Kein passendes Paket für dieses System im Release gefunden.', + 'Download failed ({status})': 'Download fehlgeschlagen ({status})', + 'pacman exited with code {code}': 'pacman beendet mit Code {code}', + 'Automatic installation is not possible here. The file is located at: {file}': 'Automatische Installation hier nicht möglich. Datei liegt unter: {file}', + }; + + const DICTS = { de }; + const LANGUAGES = [['en', 'English'], ['de', 'Deutsch']]; + let lang = 'en'; + + // pref: 'auto' oder Sprachcode; system: z. B. navigator.language / app.getLocale() + function setLanguage(pref, system) { + const code = String(pref && pref !== 'auto' ? pref : system || 'en').slice(0, 2).toLowerCase(); + lang = DICTS[code] ? code : 'en'; + return lang; + } + + function t(s, vars) { + let r = (DICTS[lang] && DICTS[lang][s]) || s; + if (vars) r = r.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)); + return r; + } + + const api = { t, setLanguage, LANGUAGES, get lang() { return lang; } }; + if (typeof module === 'object' && module.exports) module.exports = api; + else root.I18N = api; +})(this); diff --git a/src/main/main.js b/src/main/main.js index 42186bc..aa2664e 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -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); diff --git a/src/main/rdp-embed.js b/src/main/rdp-embed.js index ceb4707..6436df9 100644 --- a/src/main/rdp-embed.js +++ b/src/main/rdp-embed.js @@ -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) { diff --git a/src/main/rdp.js b/src/main/rdp.js index 6e4f255..8f2d010 100644 --- a/src/main/rdp.js +++ b/src/main/rdp.js @@ -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 = ''; diff --git a/src/main/ssh.js b/src/main/ssh.js index 5afad30..8fa4842 100644 --- a/src/main/ssh.js +++ b/src/main/ssh.js @@ -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); diff --git a/src/main/store.js b/src/main/store.js index 9b550bb..da7c5ad 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -31,6 +31,7 @@ const DEFAULTS = { updateToken: '', updateAutoCheck: true, updatePrerelease: false, + language: 'auto', }, }; diff --git a/src/main/updater.js b/src/main/updater.js index 6d4deed..2fec910 100644 --- a/src/main/updater.js +++ b/src/main/updater.js @@ -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 })); } } } diff --git a/src/renderer/app.js b/src/renderer/app.js index da97b85..c054279 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -7,6 +7,7 @@ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const uid = () => crypto.randomUUID(); function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; } +const T = I18N.t; const ICONS = { search: '', @@ -57,13 +58,13 @@ function fmtSize(n) { while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; } -const fmtDate = (t) => (t ? new Date(t).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''); +const fmtDate = (t) => (t ? new Date(t).toLocaleString(I18N.lang, { dateStyle: 'short', timeStyle: 'short' }) : ''); function relTime(t) { const s = (Date.now() - t) / 1000; - if (s < 60) return 'gerade eben'; - if (s < 3600) return `vor ${Math.floor(s / 60)} Min.`; - if (s < 86400) return `vor ${Math.floor(s / 3600)} Std.`; - return `vor ${Math.floor(s / 86400)} Tagen`; + if (s < 60) return T('just now'); + if (s < 3600) return T('{n} min ago', { n: Math.floor(s / 60) }); + if (s < 86400) return T('{n} h ago', { n: Math.floor(s / 3600) }); + return T('{n} days ago', { n: Math.floor(s / 86400) }); } function toast(msg, type = '') { @@ -97,11 +98,11 @@ function modal({ title, text = '', body = '', buttons = [] }) { ($('input,select,textarea', bg) || foot.lastElementChild).focus(); }); } -const confirmBox = (title, text, label = 'Löschen') => - modal({ title, text, buttons: [{ label: 'Abbrechen', value: false, cls: 'ghost' }, { label, value: true, cls: label === 'Löschen' ? 'danger' : 'primary' }] }); +const confirmBox = (title, text, label = T('Delete')) => + modal({ title, text, buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label, value: true, cls: label === T('Delete') ? 'danger' : 'primary' }] }); async function promptBox(title, label, value = '', type = 'text') { const r = await modal({ title, body: `
`, - buttons: [{ label: 'Abbrechen', value: null, cls: 'ghost' }, { label: 'OK', value: 'form', cls: 'primary' }] }); + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('OK'), value: 'form', cls: 'primary' }] }); return r ? r.v : null; } @@ -137,14 +138,26 @@ const settings = () => S.vault.settings; async function reload() { S.vault = await call('vault:get'); + I18N.setLanguage(settings().language, navigator.language); + applyStatic(); const b = $('#vaultBadge'); - b.innerHTML = S.vault.encrypted ? `${ICONS.lock}Vault verschlüsselt` : `${ICONS.unlock}Vault unverschlüsselt`; - 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.innerHTML = S.vault.encrypted ? `${ICONS.lock}${T('Vault encrypted')}` : `${ICONS.unlock}${T('Vault not encrypted')}`; + b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).'); applyAppTheme(); S.activeForwards = new Set(await call('fw:active')); render(); } +// Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title) +function applyStatic() { + document.documentElement.lang = I18N.lang; + $$('[data-i18n]').forEach((el) => { el.textContent = T(el.dataset.i18n); }); + $$('[data-i18n-title]').forEach((el) => { el.title = T(el.dataset.i18nTitle); }); + // Tooltips für die eingeklappte Symbolansicht + $$('#navMenu .nav').forEach((n) => { n.title = n.querySelector('span')?.textContent || ''; }); + $('#navToggle').title = $('#sidebar').classList.contains('nav-collapsed') ? T('Expand menu') : T('Collapse menu'); +} + // ============================================================ Navigation $$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => { S.view = n.dataset.view; @@ -158,11 +171,9 @@ $$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => (() => { const KEY = 'mrterm.nav.collapsed'; const sb = $('#sidebar'); - // Tooltips für die Symbolansicht - $$('#navMenu .nav').forEach((n) => { n.title = n.querySelector('span')?.textContent || ''; }); const apply = (collapsed) => { sb.classList.toggle('nav-collapsed', collapsed); - $('#navToggle').title = collapsed ? 'Menü ausklappen' : 'Menü einklappen'; + $('#navToggle').title = collapsed ? T('Expand menu') : T('Collapse menu'); }; let collapsed = false; try { collapsed = localStorage.getItem(KEY) === '1'; } catch {} @@ -214,11 +225,11 @@ function renderTree() { const byLabel = (x, y) => (x.label || x.address || '').localeCompare(y.label || y.address || '', undefined, { numeric: true }); const countIn = (gid) => hosts.filter((x) => x.groupId === gid).length + groups.filter((g) => g.parentId === gid).reduce((n, g) => n + countIn(g.id), 0); - const head = h(`
Verbindungen
`); + const head = h(`
${T('Connections')}
`); $('button', head).onclick = () => { treeOpen.clear(); saveTreeOpen(); renderTree(); }; dropTarget(head, null); // auf die Überschrift ziehen = Stammebene box.append(head); - if (!groups.length && !hosts.length) { box.append(h('
Noch keine Hosts
')); return; } + if (!groups.length && !hosts.length) { box.append(h(`
${T('No hosts yet')}
`)); return; } const pad = (depth) => `padding-left:${4 + depth * 14}px`; const walk = (parentId, depth) => { @@ -231,12 +242,12 @@ function renderTree() { $('.chev', row).onclick = (e) => { e.stopPropagation(); toggle(); }; row.onclick = () => { if (!open) { treeOpen.add(g.id); saveTreeOpen(); } goGroup(g.id); }; row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ - { label: 'Öffnen', icon: 'folder', run: () => goGroup(g.id) }, - { label: 'Host hinzufügen', icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } }, - { label: 'Unterordner anlegen', icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } }, - { label: 'Bearbeiten', icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } }, + { label: T('Open'), icon: 'folder', run: () => goGroup(g.id) }, + { label: T('Add host'), icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } }, + { label: T('Create subfolder'), icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } }, + { label: T('Edit'), icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } }, '-', - { label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Gruppe löschen?', `„${g.label}“ wird gelöscht. Hosts bleiben erhalten.`)) { await call('vault:remove', 'groups', g.id); reload(); } } }, + { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } }, ]); dropTarget(row, g.id); box.append(row); @@ -288,9 +299,9 @@ function hostSub(hst) { } function viewHosts(page) { - page.append(toolbar('Hosts suchen oder user@host eingeben …', [ - { label: 'Gruppe', icon: 'newdir', run: () => editGroup({ parentId: S.group }) }, - { label: 'Neuer Host', icon: 'plus', cls: 'primary', run: () => editHost({ groupId: S.group }) }, + page.append(toolbar(T('Search hosts or enter user@host …'), [ + { label: T('Group'), icon: 'newdir', run: () => editGroup({ parentId: S.group }) }, + { label: T('New host'), icon: 'plus', cls: 'primary', run: () => editHost({ groupId: S.group }) }, ])); const c = h('
'); page.append(c); @@ -301,7 +312,7 @@ function viewHosts(page) { const trail = []; let g = groups.find((x) => x.id === S.group); while (g) { trail.unshift(g); g = groups.find((x) => x.id === g.parentId); } const bc = h(''); - const root = h('Alle Hosts'); root.onclick = () => { S.group = null; render(); }; bc.append(root); + const root = h(`${esc(T('All hosts'))}`); root.onclick = () => { S.group = null; render(); }; bc.append(root); trail.forEach((t) => { bc.append(' / '); const a = h(`${esc(t.label)}`); a.onclick = () => { S.group = t.id; render(); }; bc.append(a); }); c.append(bc); } @@ -315,16 +326,16 @@ function viewHosts(page) { const subGroups = searching ? groups.filter((g) => matches(g.label)) : groups.filter((g) => (g.parentId || null) === S.group); if (subGroups.length && !S.tag) { - c.append(h(`
Gruppen ${subGroups.length}
`)); + c.append(h(`
${T('Groups')} ${subGroups.length}
`)); const grid = h('
'); for (const g of subGroups) { const count = hosts.filter((x) => x.groupId === g.id).length; - const card = h(`
${ICONS.folder}
${esc(g.label)}
${count} Host${count === 1 ? '' : 's'}
`); + const card = h(`
${ICONS.folder}
${esc(g.label)}
${count} Host${count === 1 ? '' : 's'}
`); card.onclick = (e) => { if (e.target.closest('[data-a=edit]')) return editGroup(g); S.group = g.id; S.search = ''; render(); }; card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ - { label: 'Bearbeiten', icon: 'edit', run: () => editGroup(g) }, - { label: 'Host hinzufügen', icon: 'plus', run: () => editHost({ groupId: g.id }) }, - '-', { label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Gruppe löschen?', `„${g.label}“ wird gelöscht. Hosts bleiben erhalten.`)) { await call('vault:remove', 'groups', g.id); reload(); } } }, + { label: T('Edit'), icon: 'edit', run: () => editGroup(g) }, + { label: T('Add host'), icon: 'plus', run: () => editHost({ groupId: g.id }) }, + '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } }, ]); grid.append(card); } @@ -344,20 +355,20 @@ function viewHosts(page) { } else if (!subGroups.length) { const q = S.search.trim(); if (q && /^[^\s@]+@[^\s]+$|^[\w.-]+(:\d+)?$/.test(q)) { - c.append(emptyState('term', `Mit ${q} verbinden?`, 'Quick Connect ohne den Host zu speichern.', 'Verbinden', () => quickConnect(q))); + c.append(emptyState('term', T('Connect to {q}?', { q }), T('Quick Connect without saving the host.'), T('Connect'), () => quickConnect(q))); } else if (!hosts.length) { - const e = emptyState('screen', 'Noch keine Hosts', 'Lege deinen ersten SSH- oder RDP-Host an oder importiere ~/.ssh/config.', 'Neuer Host', () => editHost({})); - const imp = h(''); + const e = emptyState('screen', T('No hosts yet'), T('Add your first SSH or RDP host or import ~/.ssh/config.'), T('New host'), () => editHost({})); + const imp = h(``); imp.onclick = importSshConfig; e.append(imp); c.append(e); - } else c.append(emptyState('search', 'Keine Treffer', 'Passe die Suche oder Filter an.')); + } else c.append(emptyState('search', T('No results'), T('Adjust the search or filters.'))); } } function hostCard(hst) { const tags = (hst.tags || []).length ? `
${hst.tags.map((t) => `${esc(t)}`).join('')}
` : ''; const card = h(`
${avatar(hst)}
${esc(hst.label || hst.address)}
${esc(hostSub(hst))}
${tags}
-
${hst.protocol !== 'rdp' ? `` : ''}
`); +
${hst.protocol !== 'rdp' ? `` : ''}
`); card.addEventListener('click', (e) => { const a = e.target.closest('[data-a]')?.dataset.a; if (a === 'edit') return editHost(hst); @@ -370,15 +381,15 @@ function hostCard(hst) { function hostMenu(hst) { return [ - { label: hst.protocol === 'rdp' ? 'Im Tab verbinden' : 'Verbinden', icon: 'play', run: () => connectHost(hst) }, - ...(hst.protocol === 'rdp' ? [{ label: 'In eigenem Fenster öffnen', icon: 'screen', run: () => launchRdp(hst) }] : []), - ...(hst.protocol !== 'rdp' ? [{ label: 'SFTP öffnen', icon: 'folder', run: () => openSftp(hst) }] : []), + { label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, + ...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []), + ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }] : []), '-', - { label: 'Bearbeiten', icon: 'edit', run: () => editHost(hst) }, - { label: 'Duplizieren', icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + ' (Kopie)' }); reload(); } }, - { label: 'Adresse kopieren', icon: 'copy', run: () => call('clipboard:write', `${hst.username ? hst.username + '@' : ''}${hst.address}`) }, + { label: T('Edit'), icon: 'edit', run: () => editHost(hst) }, + { label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } }, + { label: T('Copy address'), icon: 'copy', run: () => call('clipboard:write', `${hst.username ? hst.username + '@' : ''}${hst.address}`) }, '-', - { label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Host löschen?', `„${hst.label || hst.address}“ wird dauerhaft entfernt.`)) { await call('vault:remove', 'hosts', hst.id); reload(); } } }, + { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); reload(); } } }, ]; } @@ -391,7 +402,7 @@ async function openRdp(hst) { if (settings().rdpEmbed !== false) { const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message })); if (s.ok) return new RdpSession(hst); - toast('RDP im Tab nicht möglich (' + s.reason + ') – öffne eigenes Fenster.'); + toast(T('RDP in a tab is not possible ({reason}) – opening a separate window.', { reason: s.reason })); } launchRdp(hst); } @@ -399,7 +410,7 @@ async function openRdp(hst) { async function launchRdp(hst) { try { const client = await call('rdp:launch', hst.id || hst); - toast(`RDP-Sitzung zu ${hst.label || hst.address} gestartet (${client})`, 'ok'); + toast(T('RDP session to {name} started ({client})', { name: hst.label || hst.address, client }), 'ok'); } catch {} } @@ -410,12 +421,12 @@ function parseQuick(q) { } function quickConnect(q) { const hst = parseQuick(q); - if (!hst) return toast('Ungültige Adresse. Format: user@host:port', 'error'); + if (!hst) return toast(T('Invalid address. Format: user@host:port'), 'error'); connectHost(hst); } async function importSshConfig() { - try { const n = await call('vault:importSshConfig'); toast(`${n} Host(s) importiert`, 'ok'); reload(); } catch {} + try { const n = await call('vault:importSshConfig'); toast(T('{n} host(s) imported', { n }), 'ok'); reload(); } catch {} } // ============================================================ Drawer / Editoren @@ -427,8 +438,8 @@ function openDrawer(title, fields, onSave, extraFooter) { $('button', hd).onclick = closeDrawer; const ft = h('
'); if (extraFooter) ft.prepend(extraFooter); - const cancel = h(''); cancel.onclick = closeDrawer; - const save = h(''); + const cancel = h(``); cancel.onclick = closeDrawer; + const save = h(``); save.onclick = async () => { try { if ((await onSave()) !== false) closeDrawer(); } catch (e) { if (e.message) toast(e.message, 'error'); } }; ft.append(cancel, save); form.append(...[].concat(fields)); @@ -461,10 +472,10 @@ function formValues(root) { return o; } function colorPicker(current) { - const el = h(`
`); + const el = h(`
`); const wrap = $('.colors', el); ['', ...COLORS].forEach((c) => { - const b = h(``); + const b = h(``); b.onclick = () => { $('input', wrap).value = c; $$('button', wrap).forEach((x) => x.classList.toggle('active', x === b)); }; wrap.append(b); }); @@ -472,7 +483,7 @@ function colorPicker(current) { } function groupOptions() { - const out = [['', '— Keine Gruppe —']]; + const out = [['', T('— No group —')]]; const walk = (parent, depth) => S.vault.groups.filter((g) => (g.parentId || null) === parent).forEach((g) => { out.push([g.id, `${' '.repeat(depth)}${g.label}`]); walk(g.id, depth + 1); }); walk(null, 0); return out; @@ -494,41 +505,41 @@ function editHost(hst = {}) { }; $$('button', seg).forEach((b) => (b.onclick = () => setProto(b.dataset.p))); - const keys = [['', '— Kein Schlüssel —'], ...S.vault.keys.map((k) => [k.id, k.label])]; - const jumps = [['', '— Direkt —'], ...S.vault.hosts.filter((x) => x.id !== hst.id && x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address])]; + const keys = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])]; + const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((x) => x.id !== hst.id && x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address])]; sshBox.append( - field('SSH-Schlüssel', 'keyId', hst.keyId, { type: 'select', options: keys }), - check('SSH-Agent verwenden', 'useAgent', hst.useAgent !== false), - heading('Erweitert'), - field('Jump-Host (ProxyJump)', 'jumpHostId', hst.jumpHostId, { type: 'select', options: jumps }), - field('Startbefehl', 'startupCommand', hst.startupCommand, { placeholder: 'z. B. tmux attach || tmux' }), - field('Umgebungsvariablen', 'env', hst.env, { type: 'textarea', placeholder: 'LANG=de_DE.UTF-8', hint: 'Eine pro Zeile, KEY=VALUE (Server muss AcceptEnv erlauben).' }), + field(T('SSH key'), 'keyId', hst.keyId, { type: 'select', options: keys }), + check(T('Use SSH agent'), 'useAgent', hst.useAgent !== false), + heading(T('Advanced')), + field(T('Jump host (ProxyJump)'), 'jumpHostId', hst.jumpHostId, { type: 'select', options: jumps }), + field(T('Startup command'), 'startupCommand', hst.startupCommand, { placeholder: T('e.g. tmux attach || tmux') }), + field(T('Environment variables'), 'env', hst.env, { type: 'textarea', placeholder: 'LANG=de_DE.UTF-8', hint: T('One per line, KEY=VALUE (server must allow AcceptEnv).') }), ); rdpBox.append( - field('Domäne', 'domain', hst.domain, { placeholder: 'optional' }), - row(field('Breite', 'rdpWidth', hst.rdpWidth, { type: 'number', placeholder: 'auto' }), field('Höhe', 'rdpHeight', hst.rdpHeight, { type: 'number', placeholder: 'auto' })), - field('Bei Größenänderung', 'rdpResize', hst.rdpResize || 'dynamic', { type: 'select', options: [['dynamic', 'Auflösung dynamisch anpassen'], ['scale', 'Bild skalieren (feste Auflösung)']] }), - check('Vollbild', 'rdpFullscreen', hst.rdpFullscreen), - check('Zwischenablage teilen', 'rdpClipboard', hst.rdpClipboard !== false), - check('Audio wiedergeben', 'rdpAudio', hst.rdpAudio !== false), - check('Lokale Laufwerke/Home freigeben', 'rdpDrives', hst.rdpDrives), + field(T('Domain'), 'domain', hst.domain, { placeholder: 'optional' }), + row(field(T('Width'), 'rdpWidth', hst.rdpWidth, { type: 'number', placeholder: 'auto' }), field(T('Height'), 'rdpHeight', hst.rdpHeight, { type: 'number', placeholder: 'auto' })), + field(T('On resize'), 'rdpResize', hst.rdpResize || 'dynamic', { type: 'select', options: [['dynamic', T('Adjust resolution dynamically')], ['scale', T('Scale image (fixed resolution)')]] }), + check(T('Fullscreen'), 'rdpFullscreen', hst.rdpFullscreen), + check(T('Share clipboard'), 'rdpClipboard', hst.rdpClipboard !== false), + check(T('Play audio'), 'rdpAudio', hst.rdpAudio !== false), + check(T('Share local drives/home'), 'rdpDrives', hst.rdpDrives), ); - const form = openDrawer(isNew ? 'Neuer Host' : 'Host bearbeiten', [ + const form = openDrawer(isNew ? T('New host') : T('Edit host'), [ seg, - row(field('Adresse', 'address', hst.address, { placeholder: 'IP oder Hostname' }), portField), - field('Bezeichnung', 'label', hst.label, { placeholder: 'Mein Server' }), - field('Gruppe', 'groupId', hst.groupId, { type: 'select', options: groupOptions() }), + row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField), + field(T('Label'), 'label', hst.label, { placeholder: T('My server') }), + field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }), field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }), - heading('Anmeldung'), - field('Benutzername', 'username', hst.username, { placeholder: 'root' }), - field('Passwort', 'password', hst.password, { type: 'password', placeholder: 'wird verschlüsselt gespeichert' }), + heading(T('Authentication')), + field(T('Username'), 'username', hst.username, { placeholder: 'root' }), + field(T('Password'), 'password', hst.password, { type: 'password', placeholder: T('stored encrypted') }), sshBox, rdpBox, - heading('Darstellung'), + heading(T('Appearance')), colorPicker(hst.color), ], async () => { const v = formValues(form); - if (!v.address.trim()) throw new Error('Bitte eine Adresse angeben.'); + if (!v.address.trim()) throw new Error(T('Please enter an address.')); const item = { ...hst, ...v, protocol: proto, address: v.address.trim(), @@ -538,20 +549,20 @@ function editHost(hst = {}) { }; await call('vault:upsert', 'hosts', item); reload(); - }, isNew ? null : (() => { const b = h(``); b.title = 'Löschen'; b.onclick = async () => { if (await confirmBox('Host löschen?', `„${hst.label || hst.address}“ wird dauerhaft entfernt.`)) { await call('vault:remove', 'hosts', hst.id); closeDrawer(); reload(); } }; return b; })()); + }, isNew ? null : (() => { const b = h(``); b.title = T('Delete'); b.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); closeDrawer(); reload(); } }; return b; })()); setProto(proto); return form; } function editGroup(g = {}) { const parents = groupOptions().filter(([id]) => id !== g.id); - const form = openDrawer(g.id ? 'Gruppe bearbeiten' : 'Neue Gruppe', [ - field('Name', 'label', g.label, { placeholder: 'Produktion' }), - field('Übergeordnete Gruppe', 'parentId', g.parentId, { type: 'select', options: parents }), + const form = openDrawer(g.id ? T('Edit group') : T('New group'), [ + field('Name', 'label', g.label, { placeholder: T('Production') }), + field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: parents }), colorPicker(g.color), ], async () => { const v = formValues(form); - if (!v.label.trim()) throw new Error('Name fehlt.'); + if (!v.label.trim()) throw new Error(T('Name is missing.')); await call('vault:upsert', 'groups', { ...g, ...v, parentId: v.parentId || null }); reload(); }); @@ -559,28 +570,28 @@ function editGroup(g = {}) { // ============================================================ Keychain function viewKeys(page) { - page.append(toolbar('Schlüssel suchen …', [ - { label: 'Importieren', icon: 'download', run: () => editKey({}, true) }, - { label: 'Generieren', icon: 'plus', cls: 'primary', run: generateKey }, + page.append(toolbar(T('Search keys …'), [ + { label: T('Import'), icon: 'download', run: () => editKey({}, true) }, + { label: T('Generate'), icon: 'plus', cls: 'primary', run: generateKey }, ])); const c = h('
'); page.append(c); const list = S.vault.keys.filter((k) => matches(k.label, k.publicKey)); - if (!list.length) return c.append(emptyState('key', 'Keine Schlüssel', 'Generiere einen neuen Ed25519-Schlüssel oder importiere einen bestehenden.', 'Schlüssel generieren', generateKey)); - c.append(h(`
Schlüssel ${list.length}
`)); + if (!list.length) return c.append(emptyState('key', T('No keys'), T('Generate a new Ed25519 key or import an existing one.'), T('Generate key'), generateKey)); + c.append(h(`
${T('Keys')} ${list.length}
`)); const grid = h('
'); for (const k of list) { const type = (k.publicKey || '').split(' ')[0] || 'key'; const used = S.vault.hosts.filter((x) => x.keyId === k.id).length; - const card = h(`
${ICONS.key}
${esc(k.label)}
${esc(type)} · ${used} Host${used === 1 ? '' : 's'}
`); + const card = h(`
${ICONS.key}
${esc(k.label)}
${esc(type)} · ${used} Host${used === 1 ? '' : 's'}
`); card.onclick = (e) => { 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(T('Public key copied'), 'ok'); } else editKey(k); }; card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ - { label: 'Public Key kopieren', icon: 'copy', run: () => { call('clipboard:write', k.publicKey || ''); toast('Public Key kopiert', 'ok'); } }, - { label: 'Bearbeiten', icon: 'edit', run: () => editKey(k) }, - '-', { label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Schlüssel löschen?', `„${k.label}“ wird aus dem Vault entfernt.`)) { await call('vault:remove', 'keys', k.id); reload(); } } }, + { label: T('Copy public key'), icon: 'copy', run: () => { call('clipboard:write', k.publicKey || ''); toast(T('Public key copied'), 'ok'); } }, + { label: T('Edit'), icon: 'edit', run: () => editKey(k) }, + '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete key?'), T('“{name}” will be removed from the vault.', { name: k.label }))) { await call('vault:remove', 'keys', k.id); reload(); } } }, ]); grid.append(card); } @@ -589,33 +600,33 @@ function viewKeys(page) { async function generateKey() { const r = await modal({ - title: 'SSH-Schlüssel generieren', + title: T('Generate SSH key'), body: `
-
+
`, - buttons: [{ label: 'Abbrechen', value: null, cls: 'ghost' }, { label: 'Generieren', value: 'form', cls: 'primary' }], + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Generate'), value: 'form', cls: 'primary' }], }); if (!r) return; const k = await call('key:generate', { type: r.type, passphrase: r.passphrase, comment: r.label }); await call('vault:upsert', 'keys', { label: r.label, privateKey: k.privateKey, publicKey: k.publicKey, passphrase: r.passphrase || undefined }); await call('clipboard:write', k.publicKey); - toast('Schlüssel erzeugt – Public Key in Zwischenablage kopiert', 'ok'); + toast(T('Key generated – public key copied to clipboard'), 'ok'); reload(); } function editKey(k = {}, pick = false) { - const form = openDrawer(k.id ? 'Schlüssel bearbeiten' : 'Schlüssel importieren', [ + const form = openDrawer(k.id ? T('Edit key') : T('Import key'), [ field('Name', 'label', k.label, { placeholder: 'id_ed25519' }), field('Private Key', 'privateKey', k.privateKey, { type: 'textarea', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----' }), field('Passphrase', 'passphrase', k.passphrase, { type: 'password' }), - field('Public Key', 'publicKey', k.publicKey, { type: 'textarea', hint: 'Wird bei Bedarf automatisch aus dem Private Key berechnet.' }), + field('Public Key', 'publicKey', k.publicKey, { type: 'textarea', hint: T('Derived from the private key automatically if left empty.') }), ], async () => { const v = formValues(form); - if (!v.privateKey.trim()) throw new Error('Private Key fehlt.'); + if (!v.privateKey.trim()) throw new Error(T('Private key is missing.')); const parsed = await call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase }); await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey }); reload(); - }, (() => { const b = h(``); b.onclick = loadFile; return b; })()); + }, (() => { const b = h(``); b.onclick = loadFile; return b; })()); async function loadFile() { const f = await call('key:pickFile'); if (!f) return; @@ -628,18 +639,18 @@ function editKey(k = {}, pick = false) { // ============================================================ Snippets function viewSnippets(page) { - page.append(toolbar('Snippets suchen …', [{ label: 'Neues Snippet', icon: 'plus', cls: 'primary', run: () => editSnippet() }])); + page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }])); const c = h('
'); page.append(c); const list = S.vault.snippets.filter((s) => matches(s.label, s.command)); - 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', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet())); const l = h('
'); for (const s of list) { - const card = h(`
${ICONS.code}
${esc(s.label)}
${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}
`); + const card = h(`
${ICONS.code}
${esc(s.label)}
${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}
`); card.onclick = async (e) => { const a = e.target.closest('[data-a]')?.dataset.a; if (a === 'run') runSnippet(s); - else if (a === 'copy') { call('clipboard:write', s.command); toast('Kopiert', 'ok'); } - else if (a === 'del') { if (await confirmBox('Snippet löschen?', s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } } + else if (a === 'copy') { call('clipboard:write', s.command); toast(T('Copied'), 'ok'); } + else if (a === 'del') { if (await confirmBox(T('Delete snippet?'), s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } } else editSnippet(s); }; l.append(card); @@ -647,20 +658,20 @@ function viewSnippets(page) { c.append(l); } function editSnippet(s = {}) { - const form = openDrawer(s.id ? 'Snippet bearbeiten' : 'Neues Snippet', [ - field('Name', 'label', s.label, { placeholder: 'Systemupdate' }), - field('Befehl(e)', 'command', s.command, { type: 'textarea', placeholder: 'sudo pacman -Syu' }), - check('Enter automatisch senden', 'autoRun', s.autoRun !== false), + const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [ + field('Name', 'label', s.label, { placeholder: T('System update') }), + field(T('Command(s)'), 'command', s.command, { type: 'textarea', placeholder: 'sudo pacman -Syu' }), + check(T('Send Enter automatically'), 'autoRun', s.autoRun !== false), ], async () => { const v = formValues(form); - if (!v.command.trim()) throw new Error('Befehl fehlt.'); + if (!v.command.trim()) throw new Error(T('Command is missing.')); await call('vault:upsert', 'snippets', { ...s, ...v, label: v.label || v.command.split('\n')[0].slice(0, 40) }); reload(); }); } function runSnippet(s, session) { const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh'); - if (!t) return toast('Kein offenes Terminal', 'error'); + if (!t) return toast(T('No open terminal'), 'error'); t.sendText(s.command.replace(/\r?\n/g, '\r') + (s.autoRun !== false ? '\r' : '')); activateTab(t.id); t.term.focus(); @@ -668,29 +679,29 @@ function runSnippet(s, session) { // ============================================================ Port Forwarding function viewForwards(page) { - page.append(toolbar('Regeln suchen …', [{ label: 'Neue Regel', icon: 'plus', cls: 'primary', run: () => editForward() }])); + page.append(toolbar(T('Search rules …'), [{ label: T('New rule'), icon: 'plus', cls: 'primary', run: () => editForward() }])); const c = h('
'); page.append(c); const list = S.vault.forwards.filter((f) => matches(f.label, f.destHost)); - if (!list.length) return c.append(emptyState('fwd', 'Keine Port-Weiterleitungen', 'Lokal (-L), Remote (-R) oder dynamisch als SOCKS5-Proxy (-D).', 'Neue Regel', () => editForward())); + if (!list.length) return c.append(emptyState('fwd', T('No port forwards'), T('Local (-L), remote (-R) or dynamic as SOCKS5 proxy (-D).'), T('New rule'), () => editForward())); const l = h('
'); for (const f of list) { const hst = S.vault.hosts.find((x) => x.id === f.hostId); const on = S.activeForwards.has(f.id); 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}` - : `Lokal ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`; - const card = h(`
${ICONS.fwd}
${esc(f.label || desc)} ${on ? 'aktiv' : 'inaktiv'}
${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}
-
`); + : `${T('Local')} ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`; + const card = h(`
${ICONS.fwd}
${esc(f.label || desc)} ${on ? T('active') : T('inactive')}
${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}
+
`); card.onclick = async (e) => { const a = e.target.closest('[data-a]')?.dataset.a; if (a === 'toggle') { const b = e.target.closest('button'); b.disabled = true; try { if (on) await call('fw:stop', f.id); - else { await call('fw:start', f.id); toast('Weiterleitung aktiv', 'ok'); } + else { await call('fw:start', f.id); toast(T('Forward active'), 'ok'); } } catch {} reload(); - } else if (a === 'del') { if (await confirmBox('Regel löschen?', f.label || desc)) { await call('fw:stop', f.id); await call('vault:remove', 'forwards', f.id); reload(); } } + } else if (a === 'del') { if (await confirmBox(T('Delete rule?'), f.label || desc)) { await call('fw:stop', f.id); await call('vault:remove', 'forwards', f.id); reload(); } } else editForward(f); }; l.append(card); @@ -699,17 +710,17 @@ function viewForwards(page) { } function editForward(f = {}) { const hosts = S.vault.hosts.filter((x) => x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address]); - if (!hosts.length) return toast('Lege zuerst einen SSH-Host an.', 'error'); - const form = openDrawer(f.id ? 'Regel bearbeiten' : 'Neue Weiterleitung', [ - field('Bezeichnung', 'label', f.label, { placeholder: 'Postgres Prod' }), - field('Typ', 'type', f.type || 'local', { type: 'select', options: [['local', 'Lokal (-L)'], ['remote', 'Remote (-R)'], ['dynamic', 'Dynamisch / SOCKS5 (-D)']] }), - field('Über Host', 'hostId', f.hostId, { type: 'select', options: hosts }), - row(field('Bind-Adresse', 'bindAddress', f.bindAddress, { placeholder: '127.0.0.1' }), field('Bind-Port', 'bindPort', f.bindPort, { type: 'number', placeholder: '8080', cls: 'small' })), - row(field('Ziel-Host', 'destHost', f.destHost, { placeholder: 'localhost' }), field('Ziel-Port', 'destPort', f.destPort, { type: 'number', placeholder: '5432', cls: 'small' })), + if (!hosts.length) return toast(T('Add an SSH host first.'), 'error'); + const form = openDrawer(f.id ? T('Edit rule') : T('New forward'), [ + field(T('Label'), 'label', f.label, { placeholder: 'Postgres Prod' }), + field(T('Type'), 'type', f.type || 'local', { type: 'select', options: [['local', T('Local (-L)')], ['remote', 'Remote (-R)'], ['dynamic', T('Dynamic / SOCKS5 (-D)')]] }), + field(T('Via host'), 'hostId', f.hostId, { type: 'select', options: hosts }), + row(field(T('Bind address'), 'bindAddress', f.bindAddress, { placeholder: '127.0.0.1' }), field(T('Bind port'), 'bindPort', f.bindPort, { type: 'number', placeholder: '8080', cls: 'small' })), + row(field(T('Target host'), 'destHost', f.destHost, { placeholder: 'localhost' }), field(T('Target port'), 'destPort', f.destPort, { type: 'number', placeholder: '5432', cls: 'small' })), ], async () => { const v = formValues(form); - if (!v.bindPort) throw new Error('Bind-Port fehlt.'); - if (v.type !== 'dynamic' && !v.destPort) throw new Error('Ziel-Port fehlt.'); + if (!v.bindPort) throw new Error(T('Bind port is missing.')); + if (v.type !== 'dynamic' && !v.destPort) throw new Error(T('Target port is missing.')); await call('vault:upsert', 'forwards', { ...f, ...v }); reload(); }); @@ -717,26 +728,26 @@ function editForward(f = {}) { // ============================================================ Known Hosts & Verlauf function viewKnown(page) { - page.append(toolbar('Known Hosts suchen …')); + page.append(toolbar(T('Search known hosts …'))); const c = h('
'); page.append(c); const entries = Object.entries(S.vault.knownHosts).filter(([id, v]) => matches(id, v.fingerprint)); - 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', T('No known hosts'), T('Host keys are stored here on first connection.'))); const l = h('
'); for (const [id, v] of entries) { - const card = h(`
${ICONS.shield}
${esc(id)}
${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}
`); + const card = h(`
${ICONS.shield}
${esc(id)}
${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}
`); $('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); }; l.append(card); } c.append(l); } function viewHistory(page) { - page.append(toolbar('Verlauf durchsuchen …')); + page.append(toolbar(T('Search history …'))); const c = h('
'); page.append(c); const items = S.vault.history.map((x) => ({ ...x, host: S.vault.hosts.find((y) => y.id === x.hostId) })).filter((x) => x.host && matches(x.host.label, x.host.address)); - if (!items.length) return c.append(emptyState('clock', 'Noch keine Verbindungen', 'Zuletzt genutzte Hosts erscheinen hier.')); + if (!items.length) return c.append(emptyState('clock', T('No connections yet'), T('Recently used hosts appear here.'))); const l = h('
'); for (const it of items) { - const card = h(`
${avatar(it.host)}
${esc(it.host.label || it.host.address)}
${esc(hostSub(it.host))} · ${relTime(it.at)}
`); + const card = h(`
${avatar(it.host)}
${esc(it.host.label || it.host.address)}
${esc(hostSub(it.host))} · ${relTime(it.at)}
`); card.onclick = () => connectHost(it.host); card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(it.host)); l.append(card); @@ -747,27 +758,33 @@ function viewHistory(page) { // ============================================================ Einstellungen async function viewSettings(page) { const st = settings(); - page.append(h('

Einstellungen

')); + page.append(h(`

${T('Settings')}

`)); const c = h('
'); page.append(c); const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); }; + // ---- Sprache + const langCard = h(`

${T('Language')}

`); + const langField = field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System default')], ...I18N.LANGUAGES] }); + langField.onchange = async () => { await save({ language: $('select', langField).value }); I18N.setLanguage(settings().language, navigator.language); reload(); }; + langCard.append(langField); + // ---- App-Design - const designCard = h('

Design

'); + const designCard = h(`

Design

`); for (const [id, t] of Object.entries(APP_THEMES)) { const p = h(`
-
${esc(t.name)}
`); +
${esc(T(t.name))}
`); 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(``); + const b = h(``); b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); }; $('.accent-colors', designCard).append(b); }); - const themeCard = h('

Terminal-Farbschema

'); + const themeCard = h(`

${T('Terminal color scheme')}

`); const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term]; - const autoP = h(`
Passend zum Design
user@host:~$ ls
folgt dem Design
`); + const autoP = h(`
${T('Match theme')}
user@host:~$ ls
${T('follows the theme')}
`); autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); }; $('.theme-list', themeCard).append(autoP); for (const [id, t] of Object.entries(TERM_THEMES)) { @@ -778,80 +795,81 @@ async function viewSettings(page) { const termCard = h('

Terminal

'); termCard.append( - row(field('Schriftart', 'fontFamily', st.fontFamily), field('Größe', 'fontSize', st.fontSize, { type: 'number', cls: 'small' })), - row(field('Cursor', 'cursorStyle', st.cursorStyle, { type: 'select', options: [['block', 'Block'], ['bar', 'Balken'], ['underline', 'Unterstrich']] }), field('Scrollback-Zeilen', 'scrollback', st.scrollback, { type: 'number' })), - check('Cursor blinkt', 'cursorBlink', st.cursorBlink), - check('Auswahl automatisch kopieren', 'copyOnSelect', st.copyOnSelect), - field('SSH Keep-Alive (Sekunden, 0 = aus)', 'keepAlive', st.keepAlive, { type: 'number' }), + row(field(T('Font'), 'fontFamily', st.fontFamily), field(T('Size'), 'fontSize', st.fontSize, { type: 'number', cls: 'small' })), + row(field('Cursor', 'cursorStyle', st.cursorStyle, { type: 'select', options: [['block', 'Block'], ['bar', T('Bar')], ['underline', T('Underline')]] }), field(T('Scrollback lines'), 'scrollback', st.scrollback, { type: 'number' })), + check(T('Blinking cursor'), 'cursorBlink', st.cursorBlink), + check(T('Copy on select'), 'copyOnSelect', st.copyOnSelect), + field(T('SSH keep-alive (seconds, 0 = off)'), 'keepAlive', st.keepAlive, { type: 'number' }), ); termCard.addEventListener('change', () => { const v = formValues(termCard); save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 }); }); - const rdpCard = h('

RDP

Prüfe …
'); - const emb = check('RDP-Verbindungen als Tab in MrTerm anzeigen', 'rdpEmbed', st.rdpEmbed !== false); + const rdpCard = h(`

RDP

${T('Checking …')}
`); + const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false); emb.onchange = async () => { await save({ rdpEmbed: $('input', emb).checked }); - if (api.platform === 'linux') toast('Wird nach einem Neustart von MrTerm wirksam (X11/XWayland-Modus).'); + if (api.platform === 'linux') toast(T('Takes effect after restarting MrTerm (X11/XWayland mode).')); }; rdpCard.append(emb); - if (api.platform === 'linux') rdpCard.append(h('
Nutzt xfreerdp3 mit /parent-window. MrTerm läuft dafür unter Wayland über XWayland.
')); + if (api.platform === 'linux') rdpCard.append(h(`
${T('Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.')}
`)); if (api.platform !== 'win32') { - const f = field('FreeRDP-Client (eigenes Fenster)', 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', 'Automatisch'], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] }); + const f = field(T('FreeRDP client (separate window)'), 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', T('Automatic')], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] }); f.onchange = () => save({ rdpClientLinux: $('select', f).value }).then(render); rdpCard.append(f); } api.call('rdp:detect').then((d) => { - $('.rdp-info', rdpCard).textContent = d.available ? `Verwendeter Client: ${d.client}` : `Kein RDP-Client gefunden. Installation: ${d.hint}`; + $('.rdp-info', rdpCard).textContent = d.available ? T('Client in use: {client}', { client: d.client }) : T('No RDP client found. Install: {hint}', { hint: d.hint }); if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)'; }).catch(() => {}); - const dataCard = h(`

Daten

${S.vault.encrypted ? 'Der Vault ist mit dem Schlüsselbund des Betriebssystems verschlüsselt (Windows DPAPI / libsecret bzw. KWallet).' : 'Der Vault ist nicht verschlüsselt, da kein Schlüsselbund verfügbar ist. Unter Arch/CachyOS: gnome-keyring oder kwallet installieren.'}

`); + const dataCard = h(`

${T('Data')}

${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.')}

`); const btns = [ - ['~/.ssh/config importieren', importSshConfig], - ['Backup exportieren', async () => { const p = await call('vault:export'); if (p) toast('Exportiert nach ' + p, 'ok'); }], - ['Backup importieren', async () => { if (await call('vault:import')) { toast('Import abgeschlossen', 'ok'); reload(); } }], + [T('Import ~/.ssh/config'), importSshConfig], + [T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }], + [T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }], ]; btns.forEach(([l, fn]) => { const b = h(``); b.onclick = fn; $('.row', dataCard).append(b); }); // ---- Import aus anderen Programmen - const importCard = h(`

Import

Ordner und Hosts (RDP & SSH) aus Devolutions Remote Desktop Manager übernehmen. In RDM: Datei → Exportieren → Einträge exportieren als .rdm (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.

`); - const rdmBtn = h(''); + const importCard = h(`

${T('Import')}

${T('Import folders and hosts (RDP & SSH) from Devolutions Remote Desktop Manager. In RDM: File → Export → Export entries as .rdm (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.')}

`); + const rdmBtn = h(``); rdmBtn.onclick = importRdm; importCard.append(rdmBtn); // ---- Updates - const updCard = h(`

Updates

Installierte Version: …

`); - api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = `Installierte Version: ${v}`; }); + const updCard = h(`

Updates

${T('Installed version: {v}', { v: '…' })}

`); + api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); }); const updFields = h('
'); 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), + field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: T('Gitea/Forgejo repository whose releases contain the installation packages.') }), + field(T('Access token (private repos only)'), 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }), + check(T('Check for updates on startup'), 'updateAutoCheck', st.updateAutoCheck), + check(T('Include pre-releases'), 'updatePrerelease', st.updatePrerelease), ); updFields.addEventListener('change', () => save(formValues(updFields))); - const chk = h(``); + const chk = h(``); chk.onclick = async () => { chk.disabled = true; try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r); - else if (r.repoVersion) toast(`Im Repository liegt Version ${r.repoVersion}, aber dafür gibt es noch kein Release mit Installationspaketen. Installiert: ${r.current}.`, 'error'); - else if (r.noRelease) toast('Im Repository gibt es noch keine Releases.', 'error'); - else toast(`MrTerm ist aktuell (${r.current}).`, 'ok'); } catch {} + else if (r.repoVersion) toast(T('The repository has version {repo}, but there is no release with installation packages for it yet. Installed: {current}.', { repo: r.repoVersion, current: r.current }), 'error'); + else if (r.noRelease) toast(T('The repository has no releases yet.'), 'error'); + else toast(T('MrTerm is up to date ({v}).', { v: r.current }), 'ok'); } catch {} chk.disabled = false; }; updCard.append(updFields, chk); - const keysCard = h(`

Tastenkürzel

- Strg+Shift+K / TQuick Connect / Befehlspalette (außerhalb des Terminals auch Strg+K) - Strg+Shift+WTab schließen - Strg+TabNächster Tab - Strg+1..9Zu Tab wechseln - Strg+Shift+C/VKopieren / Einfügen im Terminal - Strg+Shift+FIm Terminal suchen - Strg++/−/0Schriftgröße
`); - c.append(designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard); + const C = `${T('Ctrl')}`; + const keysCard = h(`

${T('Keyboard shortcuts')}

+ ${C}+Shift+K / T${T('Quick Connect / command palette (outside the terminal also {k})', { k: `${C}+K` })} + ${C}+Shift+W${T('Close tab')} + ${C}+Tab${T('Next tab')} + ${C}+1..9${T('Switch to tab')} + ${C}+Shift+C/V${T('Copy / paste in terminal')} + ${C}+Shift+F${T('Search in terminal')} + ${C}++/−/0${T('Font size')}
`); + c.append(langCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard); } // ============================================================ Design @@ -873,21 +891,21 @@ 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'); } + try { entries = parseRdmExport(f.name, f.content); } catch (e) { return toast(T('Import failed: {msg}', { msg: 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'); + if (!hosts.length) return toast(T('No RDP or SSH entries found in the file.'), '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) => `
${avatar(e.host)}
${esc(e.host.label)}
${esc(e.folder.join(' / ') || 'Stammebene')} · ${esc(hostSub(e.host))}
`).join(''); + const preview = hosts.slice(0, 8).map((e) => `
${avatar(e.host)}
${esc(e.host.label)}
${esc(e.folder.join(' / ') || T('Root level'))} · ${esc(hostSub(e.host))}
`).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: `
${preview}
${hosts.length > 8 ? `

… und ${hosts.length - 8} weitere

` : ''}`, - buttons: [{ label: 'Abbrechen', value: false, cls: 'ghost' }, { label: 'Importieren', value: true, cls: 'primary' }], + title: T('Import from Remote Desktop Manager'), + text: T('{file}: found {n} hosts ({rdp} RDP, {ssh} SSH) in {folders} folders.', { file: f.name, n: hosts.length, rdp: rdpN, ssh: hosts.length - rdpN, folders: folders.size }), + body: `
${preview}
${hosts.length > 8 ? `

${T('… and {n} more', { n: hosts.length - 8 })}

` : ''}`, + buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: T('Import'), 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'); + toast(T('{n} hosts imported', { n: r.hosts }) + (r.skipped ? T(', {n} already existed', { n: r.skipped }) : ''), 'ok'); await reload(); $('[data-view=hosts]').click(); } @@ -900,17 +918,17 @@ async function showUpdate(info) { 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)})` : ''}`, + title: T('MrTerm {v} is available', { v: info.version }), + text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''), body: `${info.notes ? `
${esc(info.notes)}
` : ''} - ${info.kind === 'dev' ? '

Entwicklungsmodus: Bitte per git pull aktualisieren.

' : !info.asset ? '

Im Release ist kein passendes Paket für dieses System.

' : ''} - `, - 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' }] : [])], + ${info.kind === 'dev' ? `

${T('Development mode: please update via git pull.')}

` : !info.asset ? `

${T('The release contains no package for this system.')}

` : ''} + `, + buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), 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 {} + toast(T('Downloading update …')); + try { await call('update:download'); toast(T('Installing update, MrTerm will restart …'), 'ok'); await call('update:install'); } catch {} } api.on('update:available', (info) => showUpdate(info)); api.on('update:progress', (done, total) => { @@ -921,15 +939,15 @@ api.on('update:progress', (done, total) => { // ============================================================ Tabs function addTab(session) { S.tabs.push(session); - const tab = h(`
${esc(session.title)}
`); + const tab = h(`
${esc(session.title)}
`); tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); }; tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); }; tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ - ...(session.host ? [{ label: 'Duplizieren', icon: 'dup', run: () => (session.kind === 'sftp' ? openSftp(session.host) : openTerminal(session.host)) }] : []), - ...(session.kind === 'ssh' ? [{ label: 'Neu verbinden', icon: 'refresh', run: () => session.reconnect() }] : []), - { label: 'Umbenennen', icon: 'edit', run: async () => { const n = await promptBox('Tab umbenennen', 'Titel', session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, - '-', { label: 'Schließen', icon: 'close', run: () => closeTab(session.id) }, - { label: 'Andere schließen', run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) }, + ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => (session.kind === 'sftp' ? openSftp(session.host) : openTerminal(session.host)) }] : []), + ...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []), + { label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, + '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) }, + { label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) }, ]); session.tabEl = tab; $('#tabs').append(tab); @@ -971,12 +989,12 @@ class TerminalSession { this.title = host.label || host.address; this.el = h(`
${avatar(host, 22).replace('SSH', '')}${esc(hostSub(host))}
- + - +
-
+
Snippets
`); this.overlay = null; @@ -1017,7 +1035,7 @@ class TerminalSession { }); $('.term', this.el).addEventListener('contextmenu', (e) => { e.preventDefault(); - if (this.term.hasSelection()) { api.call('clipboard:write', this.term.getSelection()); this.term.clearSelection(); toast('Kopiert'); } + if (this.term.hasSelection()) { api.call('clipboard:write', this.term.getSelection()); this.term.clearSelection(); toast(T('Copied')); } else this.paste(); }); this.ro = new ResizeObserver(() => { if (this.el.classList.contains('active')) this.doFit(); }); @@ -1046,7 +1064,7 @@ class TerminalSession { renderSnips() { const box = $('.snip-panel .items', this.el); box.innerHTML = ''; - if (!S.vault.snippets.length) box.append(h('
Noch keine Snippets.
')); + if (!S.vault.snippets.length) box.append(h(`
${T('No snippets yet.')}
`)); S.vault.snippets.forEach((s) => { const it = h(`
${esc(s.label)}
${esc(s.command)}
`); it.onclick = () => runSnippet(s, this); @@ -1079,8 +1097,8 @@ class TerminalSession { showOverlay(msg, err) { if (!this.overlay) { this.overlay = h('
'); $('.term-wrap', this.el).append(this.overlay); } this.overlay.innerHTML = err - ? `
${esc(msg)}
` - : `
${esc(msg)}
`; + ? `
${esc(msg)}
` + : `
${esc(msg)}
`; this.overlay.onclick = (e) => { const o = e.target.closest('[data-o]')?.dataset.o; if (o === 'retry') this.reconnect(); @@ -1093,7 +1111,7 @@ class TerminalSession { async connect() { this.ended = false; setTabState(this, 'wait'); - this.showOverlay(`Verbinde mit ${this.host.address} …`); + this.showOverlay(T('Connecting to {host} …', { host: this.host.address })); this.doFit(); try { await api.call('ssh:open', this.id, this.host.id || this.host, { cols: this.term.cols, rows: this.term.rows }); @@ -1111,7 +1129,7 @@ class TerminalSession { reconnect() { api.ssh.close(this.id); this.alive = false; - this.term.write('\r\n\x1b[90m— Neu verbinden —\x1b[0m\r\n'); + this.term.write(`\r\n\x1b[90m— ${T('Reconnecting')} —\x1b[0m\r\n`); this.connect(); } onEvent(type, payload) { @@ -1122,7 +1140,7 @@ class TerminalSession { if (!this.alive) return; this.alive = false; this.ended = true; setTabState(this, ''); - this.term.write('\r\n\x1b[90mVerbindung beendet. [Enter] zum erneuten Verbinden.\x1b[0m\r\n'); + this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); } } dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); } @@ -1151,10 +1169,10 @@ class RdpSession { this.attached = false; this.el = h(`
${avatar(host, 22)}${esc(hostSub(host))}
- - ${api.platform === 'win32' ? `` : ''} + + ${api.platform === 'win32' ? `` : ''}
-
RDP-Sitzung ausgeblendet, solange ein Dialog geöffnet ist.
`); +
${T('RDP session hidden while a dialog is open.')}
`); this.area = $('.rdp-area', this.el); this.el.addEventListener('click', (e) => { const a = e.target.closest('[data-a]')?.dataset.a; @@ -1180,7 +1198,7 @@ class RdpSession { showOverlay(msg, err) { this.overlay?.remove(); this.overlay = h(`
${err ? '' : '
'}
${esc(msg)}
-
${err ? `` : ''}
`); +
${err ? `` : ``}
`); this.overlay.onclick = (e) => { const o = e.target.closest('[data-o]')?.dataset.o; if (o === 'retry') this.reconnect(); @@ -1192,11 +1210,11 @@ class RdpSession { async connect() { setTabState(this, 'wait'); - this.showOverlay(`Verbinde mit ${this.host.address} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.`); + this.showOverlay(T('Connecting to {host} … Login or certificate prompts may appear in a separate window.', { host: this.host.address })); await new Promise((r) => requestAnimationFrame(r)); try { const ok = await api.call('rdp:open', this.id, this.host.id || this.host, this.bounds()); - if (!ok && !this.attached && !this.disposed && !this.exited) this.showOverlay('Das RDP-Fenster konnte nicht eingebettet werden.', true); + if (!ok && !this.attached && !this.disposed && !this.exited) this.showOverlay(T('The RDP window could not be embedded.'), true); } catch (e) { setTabState(this, 'err'); this.showOverlay(e.message, true); @@ -1212,14 +1230,14 @@ class RdpSession { } else if (type === 'exit') { this.attached = false; this.exited = true; setTabState(this, payload?.code ? 'err' : ''); - this.showOverlay(`RDP-Sitzung beendet${payload?.code ? ` (Code ${payload.code})` : ''}.${payload?.message ? ' ' + payload.message : ''}`, true); + this.showOverlay((payload?.code ? T('RDP session ended (code {code}).', { code: payload.code }) : T('RDP session ended.')) + (payload?.message ? ' ' + payload.message : ''), true); } } reconnect() { api.rdp.close(this.id); this.attached = false; this.exited = false; this.connect(); } async detach() { if (!this.attached) return; - if (await api.call('rdp:detach', this.id)) { this.attached = false; closeTab(this.id); toast('Sitzung läuft als eigenes Fenster weiter', 'ok'); } + if (await api.call('rdp:detach', this.id)) { this.attached = false; closeTab(this.id); toast(T('Session continues in a separate window'), 'ok'); } } onShow() { if (this.attached && this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } } onHide() { if (this.attached) api.rdp.hide(this.id); } @@ -1228,11 +1246,11 @@ class RdpSession { // ============================================================ SFTP function viewSftpPicker(page) { - page.append(toolbar('Host für SFTP wählen …')); + page.append(toolbar(T('Choose a host for SFTP …'))); const c = h('
'); page.append(c); const list = S.vault.hosts.filter((x) => x.protocol !== 'rdp' && matches(x.label, x.address, x.username)); - if (!list.length) return c.append(emptyState('folder', 'Keine SSH-Hosts', 'SFTP benötigt einen SSH-Host.', 'Neuer Host', () => editHost({}))); - c.append(h('
Host für SFTP-Browser öffnen
')); + if (!list.length) return c.append(emptyState('folder', T('No SSH hosts'), T('SFTP requires an SSH host.'), T('New host'), () => editHost({}))); + c.append(h(`
${T('Open host in SFTP browser')}
`)); const grid = h('
'); list.forEach((hst) => { const card = h(`
${avatar(hst)}
${esc(hst.label || hst.address)}
${esc(hostSub(hst))}
`); @@ -1245,14 +1263,14 @@ function viewSftpPicker(page) { class FilePane { constructor(sftp, side) { this.sftp = sftp; this.side = side; this.cwd = ''; this.files = []; this.sel = new Set(); this.sort = { k: 'name', dir: 1 }; - this.el = h(`
${side === 'local' ? ICONS.screen + 'Lokal' : ICONS.folder + esc(sftp.host.label || sftp.host.address)} + this.el = h(`
${side === 'local' ? ICONS.screen + T('Local') : ICONS.folder + esc(sftp.host.label || sftp.host.address)} ${side === 'local' && api.platform === 'win32' ? '' : ''} - + - - -
+ + +
`); this.el.addEventListener('click', (e) => { const p = e.target.closest('[data-p]')?.dataset.p; @@ -1264,7 +1282,7 @@ class FilePane { }); $('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); }); const files = $('.files', this.el); - files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: 'Neuer Ordner', icon: 'newdir', run: () => this.mkdir() }, { label: 'Aktualisieren', icon: 'refresh', run: () => this.go(this.cwd) }]); } }); + files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } }); // Drag & Drop zwischen den Panes this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } }); this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); }); @@ -1301,7 +1319,7 @@ class FilePane { const box = $('.files', this.el); const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : ''); const rows = this.view = this.sorted(); - box.innerHTML = ` + box.innerHTML = `
Name${arrow('name')}Größe${arrow('size')}Geändert${arrow('mtime')}
${rows.map((f, i) => ``).join('')}
Name${arrow('name')}${T('Size')}${arrow('size')}${T('Modified')}${arrow('mtime')}
${ICONS.folder}..
${f.isDir ? ICONS.folder : ICONS.file}${esc(f.name)}
${f.isDir ? '' : fmtSize(f.size)}${fmtDate(f.mtime)}
`; $$('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(); })); @@ -1325,33 +1343,33 @@ class FilePane { e.preventDefault(); if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); this.draw(); } ctxMenu(e.clientX, e.clientY, [ - { label: this.side === 'local' ? 'Hochladen' : 'Herunterladen', icon: this.side === 'local' ? 'upload' : 'download', run: () => this.transferSelected() }, - ...(f.isDir ? [{ label: 'Öffnen', icon: 'folder', run: async () => this.go(await this.join(this.cwd, f.name)) }] : []), - { label: 'Umbenennen', icon: 'edit', run: () => this.rename(f) }, - { label: 'Pfad kopieren', icon: 'copy', run: async () => api.call('clipboard:write', await this.join(this.cwd, f.name)) }, - ...(this.side === 'remote' && !f.isDir ? [{ label: 'Rechte (chmod)…', icon: 'lock', run: () => this.chmod(f) }] : []), - '-', { label: this.side === 'local' ? 'In Papierkorb' : 'Löschen', icon: 'trash', danger: true, run: () => this.remove() }, + { label: this.side === 'local' ? T('Upload') : T('Download'), icon: this.side === 'local' ? 'upload' : 'download', run: () => this.transferSelected() }, + ...(f.isDir ? [{ label: T('Open'), icon: 'folder', run: async () => this.go(await this.join(this.cwd, f.name)) }] : []), + { label: T('Rename'), icon: 'edit', run: () => this.rename(f) }, + { label: T('Copy path'), icon: 'copy', run: async () => api.call('clipboard:write', await this.join(this.cwd, f.name)) }, + ...(this.side === 'remote' && !f.isDir ? [{ label: T('Permissions (chmod)…'), icon: 'lock', run: () => this.chmod(f) }] : []), + '-', { label: this.side === 'local' ? T('Move to trash') : T('Delete'), icon: 'trash', danger: true, run: () => this.remove() }, ]); }; }); } async mkdir() { - const n = await promptBox('Neuer Ordner', 'Name'); if (!n) return; + const n = await promptBox(T('New folder'), T('Name')); if (!n) return; const p = await this.join(this.cwd, n); try { this.side === 'local' ? await api.call('local:op', 'mkdir', p) : await api.call('sftp:op', this.sftp.id, 'mkdir', p); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); } } async rename(f) { - const n = await promptBox('Umbenennen', 'Neuer Name', f.name); if (!n || n === f.name) return; + const n = await promptBox(T('Rename'), T('New name'), f.name); if (!n || n === f.name) return; const a = await this.join(this.cwd, f.name), b = await this.join(this.cwd, n); try { this.side === 'local' ? await api.call('local:op', 'rename', a, b) : await api.call('sftp:op', this.sftp.id, 'rename', a, b); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); } } async chmod(f) { - const m = await promptBox('Rechte ändern', 'Oktal (z. B. 644)', (f.mode & 0o777).toString(8)); if (!m) return; + const m = await promptBox(T('Change permissions'), T('Octal (e.g. 644)'), (f.mode & 0o777).toString(8)); if (!m) return; try { await api.call('sftp:op', this.sftp.id, 'chmod', await this.join(this.cwd, f.name), parseInt(m, 8)); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); } } async remove() { const names = [...this.sel]; if (!names.length) return; - if (!(await confirmBox('Löschen?', `${names.length} Element(e) ${this.side === 'local' ? 'in den Papierkorb verschieben' : 'endgültig vom Server löschen'}: ${names.slice(0, 5).join(', ')}${names.length > 5 ? ' …' : ''}`))) return; + if (!(await confirmBox(T('Delete?'), T(this.side === 'local' ? 'Move {n} item(s) to the trash: {list}' : 'Permanently delete {n} item(s) from the server: {list}', { n: names.length, list: names.slice(0, 5).join(', ') + (names.length > 5 ? ' …' : '') })))) return; for (const n of names) { const f = this.files.find((x) => x.name === n); const p = await this.join(this.cwd, n); @@ -1364,7 +1382,7 @@ class FilePane { } async transferSelected() { const names = [...this.sel]; - if (!names.length) return toast('Nichts ausgewählt'); + if (!names.length) return toast(T('Nothing selected')); const target = this.other; for (const n of names) { const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n); @@ -1383,7 +1401,7 @@ class SftpSession { this.local = new FilePane(this, 'local'); this.remote = new FilePane(this, 'remote'); const wrap = $('.sftp', this.el); - wrap.append(this.local.el, h(`
Verbinde mit ${esc(host.address)} …
`)); + wrap.append(this.local.el, h(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`)); this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total)); this.xfers = new Map(); addTab(this); @@ -1400,7 +1418,7 @@ class SftpSession { setTabState(this, 'err'); const pe = $('.pane-empty', this.el); pe.innerHTML = `
${esc(e.message)}
`; - const b = h(``); + const b = h(``); b.onclick = () => { closeTab(this.id); openSftp(this.host); }; pe.append(b); } @@ -1412,10 +1430,10 @@ class SftpSession { this.xfers.set(tid, row); try { await api.call('sftp:transfer', this.id, dir, localPath, remotePath, tid); - row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = 'fertig'; + row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done'); setTimeout(() => row.remove(), 5000); } catch (e) { - row.classList.add('fail'); $('.pct', row).textContent = 'Fehler'; row.title = e.message; toast(`${name}: ${e.message}`, 'error'); + row.classList.add('fail'); $('.pct', row).textContent = T('Error'); row.title = e.message; toast(`${name}: ${e.message}`, 'error'); setTimeout(() => row.remove(), 10000); } } @@ -1432,7 +1450,7 @@ function openSftp(host) { return new SftpSession(host); } // ============================================================ Command Palette / Quick Connect function openPalette() { if ($('.palette')) return; - const bg = h(``); + const bg = h(``); const inp = $('input', bg), res = $('.res', bg); let items = [], idx = 0; const close = () => bg.remove(); @@ -1449,8 +1467,8 @@ function openPalette() { if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.folder}
`, 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: `
${ICONS.code}
`, label: s.label, sub: s.command, run: () => runSnippet(s) })); - [['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: `
${ICONS.plus}
`, label: l, run })); + [[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]] + .filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: T('Actions'), icon: `
${ICONS.plus}
`, label: l, run })); idx = Math.min(idx, Math.max(0, items.length - 1)); let last = ''; res.innerHTML = ''; @@ -1504,10 +1522,10 @@ document.addEventListener('keydown', (e) => { api.on('secret:request', async (req) => { const r = await modal({ - title: req.title || 'Anmeldung', + title: req.title || T('Authentication'), text: req.host, - body: `
`, - buttons: [{ label: 'Abbrechen', value: null, cls: 'ghost' }, { label: 'Anmelden', value: 'form', cls: 'primary' }], + body: `
`, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Log in'), value: 'form', cls: 'primary' }], }); api.replySecret(req.reqId, r ? r.v : null); }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 463038f..7e6b3a5 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1,5 +1,5 @@ - + @@ -16,18 +16,18 @@ Vault
- +
- - - + + +
@@ -60,6 +60,7 @@ + diff --git a/src/renderer/rdm-import.js b/src/renderer/rdm-import.js index d873116..3ab5d03 100644 --- a/src/renderer/rdm-import.js +++ b/src/renderer/rdm-import.js @@ -66,7 +66,7 @@ function parseXml(text) { const doc = new DOMParser().parseFromString(text, 'application/xml'); - if (doc.querySelector('parsererror')) throw new Error('Datei ist kein gültiges XML.'); + if (doc.querySelector('parsererror')) throw new Error(I18N.t('File is not valid XML.')); const conns = [...doc.getElementsByTagName('Connection')].filter((c) => child(c, 'ConnectionType')); return conns.map((c) => makeEntry({ type: child(c, 'ConnectionType'), @@ -125,7 +125,7 @@ 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).'); + if (col.host < 0 && col.type < 0) throw new Error(I18N.t('CSV columns not recognized (expected e.g. 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') }); diff --git a/src/renderer/themes.js b/src/renderer/themes.js index e3bde29..14aee52 100644 --- a/src/renderer/themes.js +++ b/src/renderer/themes.js @@ -37,5 +37,5 @@ window.APP_THEMES = { 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' }, + light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' }, };