Compare commits

...
20 Commits
Author SHA1 Message Date
MrBlake 6c6e25df36 Version 0.13.1 2026-09-26 15:03:55 +02:00
MrBlakeandClaude Opus 5.5 32bf502ffa Web: fix SSH, SFTP and all other sessions failing over plain http:// (crypto.randomUUID only exists in secure contexts), add a clipboard fallback without HTTPS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:03:55 +02:00
MrBlake a2a0893b8c Version 0.13.0 2026-09-26 14:52:26 +02:00
MrBlakeandClaude Opus 5.5 45b0be8557 Add MrTerm Web: self-hosted browser version (Docker Compose) with user accounts, per-user vaults encrypted by the login password, the desktop UI over WebSocket, SFTP upload/download over HTTP; share backend channels between Android and web (src/core/backend.js)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:52:26 +02:00
MrBlake c143053319 Version 0.12.0 2026-09-26 14:31:51 +02:00
MrBlakeandClaude Opus 5.5 58709e09f2 Themes: share app theme colors between desktop and Android (app-themes.css), add theme picker and accent color to the Android app, and add Windows XP, Windows 11, macOS (Liquid Glass) and Cyberpunk 2077 themes with matching terminal schemes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:31:51 +02:00
MrBlake 27091ab5d1 Version 0.11.4 2026-09-26 14:15:09 +02:00
MrBlakeandClaude Opus 5.5 6a6971e1cb Android: stop adding keyboard padding on top of the system's own resize, which left an empty block above the keyboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:15:09 +02:00
MrBlake edea4ce0d2 Version 0.11.3 2026-09-26 00:12:21 +02:00
MrBlakeandClaude Opus 5.5 b431428260 Android: handle status bar, navigation bar and keyboard insets natively and consume them, so the keyboard no longer shrinks the view twice and content no longer sits under the status bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:12:21 +02:00
MrBlake aeb6c590db Version 0.11.2 2026-09-26 00:07:24 +02:00
MrBlakeandClaude Opus 5.5 428e74b2d8 Android: load @capacitor/core in the web view (registerPlugin was missing, so the app stopped on the loading screen) and show script errors on the start screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:07:24 +02:00
MrBlake b94f18d2e2 Version 0.11.1 2026-09-26 00:03:26 +02:00
MrBlakeandClaude Opus 5.5 7cff93419d Android: fix app hanging on the loading screen (Node start only returns when Node exits, wait for whenReady instead), build the native bridge with 16 KB page alignment, show an error instead of an endless spinner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:03:26 +02:00
MrBlake 1980d3044b Version 0.11.0 2026-09-25 23:56:47 +02:00
MrBlakeandClaude Opus 5.5 4542aff75f Add Android app: phone UI for hosts, SSH terminal with extra key row, keys, snippets, LAN sync and app lock (password + fingerprint), reusing the desktop Node modules via capacitor-nodejs; APKs per ABI in releases for Obtainium
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:56:39 +02:00
MrBlake 430c18b84e Version 0.10.3 2026-09-25 23:32:05 +02:00
MrBlakeandClaude Opus 5.5 98c7763c47 README: add screenshots of hosts, terminal, host settings, Docker, firewall, network, VPN and settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:32:05 +02:00
MrBlake e4396db3c8 Version 0.10.2 2026-09-25 23:22:21 +02:00
MrBlakeandClaude Opus 5.5 98d8cfa993 Network: confirm a change only after it has been fully applied, keep the session when the old connection times out, and retry reloading so changes show up without reopening the tab
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:22:16 +02:00
96 changed files with 6512 additions and 109 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
mobile
build
docs
.git
.gitea-token
web-data
+7
View File
@@ -2,3 +2,10 @@ node_modules/
dist/
package.json
.gitea-token
!mobile/package.json
# Android-App: Build-Ausgaben, Plugin-Tarball (lädt build.js), Signaturschlüssel
mobile/vendor/
mobile/www/
mobile/android/keystore.properties
mobile/android/*.jks
web-data/
+29
View File
@@ -0,0 +1,29 @@
# MrTerm Web – die MrTerm-Oberfläche im Browser, selbst gehostet.
# docker compose up -d → http://<server>:8080 (beim ersten Aufruf Admin-Konto anlegen)
# ---- Build: Server samt ssh2/ws in eine Datei bündeln
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY src ./src
RUN npx esbuild src/web/server.js --bundle --platform=node --target=node22 --minify --legal-comments=none \
--external:electron --external:cpu-features --external:bufferutil --external:utf-8-validate \
--outfile=dist/server.js
# ---- Laufzeit: nur Node, gebündelter Server und die Dateien der Oberfläche
FROM node:22-alpine
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data MRTERM_ROOT=/app
WORKDIR /app
COPY package.json ./
COPY --from=build /src/dist/server.js dist/server.js
COPY src/i18n.js src/i18n.js
COPY src/renderer src/renderer
COPY src/web/public src/web/public
COPY --from=build /src/node_modules/@xterm node_modules/@xterm
RUN mkdir -p /data && chown node:node /data
USER node
VOLUME /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
CMD ["node", "dist/server.js"]
+63 -1
View File
@@ -2,8 +2,12 @@
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.
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D)
MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**.
![MrTerm host overview with groups](docs/screenshots/hosts.png)
## Download & installation
Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases).
@@ -25,6 +29,23 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
```
`freerdp` is needed for RDP connections. `gnome-keyring` or `kwallet` lets MrTerm encrypt your saved passwords and keys.
**Android**
- Install with [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D): tap the badge above on your phone, or add `https://git.mrblake.cc/MrBlake/MrTerm` in Obtainium and choose **Forgejo (Codeberg)** as the source. Obtainium then keeps MrTerm up to date.
- Or download `MrTerm-<version>.apk` from the releases page and install it directly.
## Android app
The Android app is made for phones and focuses on what you need on the go:
- **Hosts** with groups, search and Quick Connect (`user@host:port`)
- **SSH terminal** in full screen with an extra key row (Esc, Tab, Ctrl, Alt, arrow keys, `|`, `~` …), pinch-to-zoom and several open sessions
- **Keys**: generate or import SSH keys and copy the public key
- **Snippets** you can send to the terminal with one tap
- **LAN sync** with MrTerm on your computer: pair once, and your hosts, groups and snippets appear on the phone. You choose which passwords and keys are shared
- **App lock** with a password and fingerprint unlock. The vault is encrypted with a key kept in the Android Keystore
RDP, SFTP, port forwarding and VPN are only available in the desktop app.
## Features
- **Hosts**: nested groups, tags, colors, search and Quick Connect (`user@host:port`, `rdp://host`)
@@ -42,12 +63,44 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
- **History** of recent connections
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
- **Backup** export and import
- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker)
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
- **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)
- **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
## Screenshots
| | |
|---|---|
| ![SSH terminal](docs/screenshots/terminal.png) | ![Editing a host](docs/screenshots/edit-host.png) |
| **SSH terminal** with tabs and quick access to SFTP, Docker, Firewall and Network | **Host settings**: authentication, keys, jump hosts and VPN |
| ![Docker containers](docs/screenshots/docker.png) | ![UFW firewall rules](docs/screenshots/firewall.png) |
| **Docker & Podman** containers with CPU and memory usage | **Firewall** rules for UFW and iptables |
| ![Network configuration](docs/screenshots/network.png) | ![VPN configurations](docs/screenshots/vpn.png) |
| **Network**: interfaces, bonds (LACP), bridges, gateway and DNS | **VPN**: WireGuard and OpenVPN, connected automatically per host |
| ![Settings](docs/screenshots/settings.png) | |
| **Settings**: language, app lock, LAN sync and themes | |
## Web version (Docker)
MrTerm also runs as a self-hosted web app, similar to Termix: you use the familiar MrTerm interface in the browser, from any computer.
```bash
curl -O https://git.mrblake.cc/MrBlake/MrTerm/raw/branch/main/docker-compose.yml
docker compose up -d
```
Then open `http://<your-server>:8080` and create the administrator account. As an administrator you can add more users under **Settings → Account**.
- **Included:** hosts and groups, SSH terminal tabs, SFTP file manager (upload by button or drag & drop, download to your computer), keys, snippets, known hosts, Docker, firewall and network management, themes.
- **Not included:** RDP, VPN, port forwarding, LAN sync and the app lock. These need direct access to your computer.
- **Security:** every user has their own vault, encrypted with a key that only their login password can unlock. The server stores no readable passwords or keys. If a user forgets their password, their vault cannot be recovered.
- **HTTPS:** put MrTerm behind a reverse proxy with HTTPS (Traefik, Caddy, nginx …) before you use it over the internet, and set `TRUST_PROXY=1` in `docker-compose.yml`. The proxy must forward WebSockets.
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
- **SSH connections** start from the server, so the server must be able to reach your hosts.
## Getting started
1. Click **New host**, enter the address, username and password or key, and click **Save**.
@@ -181,7 +234,16 @@ In VS Code terminals, unset `ELECTRON_RUN_AS_NODE` first.
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`.
**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.
**Android app** (`mobile/`): Capacitor with [capacitor-nodejs](https://github.com/hampoelz/Capacitor-NodeJS). The phone runs the same Node modules as the desktop app (`src/main/ssh.js`, `store.js`, `sync.js` …), bundled with esbuild; the phone UI lives in `mobile/web/`.
```bash
cd mobile
node build.js --setup # once: downloads the Node.js plugin (checksum-verified) and installs dependencies
node build.js --apk # builds signed APKs into dist/ (needs JDK 21 + Android SDK, see JAVA_HOME / ANDROID_HOME)
../node_modules/.bin/electron dev/electron-dev.js # try the phone UI on the desktop (after node build.js)
```
APKs are signed with `mobile/android/keystore.properties` and the keystore it points to. Both are not committed. Keep a backup, because Android only installs updates signed with the same key.
**Publishing a release**: `./scripts/release-all.sh` asks for the version and release notes, commits and pushes the version, builds Windows + Arch/CachyOS + Android and uploads everything to Gitea. It reads the token from `GITEA_TOKEN` or from `.gitea-token`, which is not committed.
**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`.
+22
View File
@@ -0,0 +1,22 @@
# MrTerm Web
# docker compose up -d
# Danach http://<server>:8080 öffnen und das Admin-Konto anlegen.
# Hinter einem Reverse-Proxy mit HTTPS (empfohlen): TRUST_PROXY=1 setzen und den Port nur lokal freigeben.
services:
mrterm:
build:
# Baut direkt aus dem Repository – kein Klonen nötig. Alternativ "context: ." in einem Klon.
context: https://git.mrblake.cc/MrBlake/MrTerm.git
image: mrterm-web
container_name: mrterm
restart: unless-stopped
ports:
- "8080:8080"
environment:
- TZ=Europe/Berlin
# - TRUST_PROXY=1
volumes:
- mrterm-data:/data
volumes:
mrterm-data:
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+82
View File
@@ -0,0 +1,82 @@
apply plugin: 'com.android.application'
android {
namespace = "de.mrterm.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "de.mrterm.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Version kommt aus der package.json des Desktop-Projekts (build.js setzt -PmrtermVersion)
def v = (project.findProperty('mrtermVersion') ?: '0.0.1').toString()
def p = v.tokenize('.-')
versionCode (p[0].toInteger() * 10000 + p[1].toInteger() * 100 + p[2].toInteger())
versionName v
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Signatur: keystore.properties (nicht im Repo) mit storeFile, storePassword, keyAlias, keyPassword
def ksFile = rootProject.file('keystore.properties')
signingConfigs {
release {
if (ksFile.exists()) {
def ks = new Properties()
ksFile.withInputStream { ks.load(it) }
storeFile rootProject.file(ks['storeFile'])
storePassword ks['storePassword']
keyAlias ks['keyAlias']
keyPassword ks['keyPassword']
}
}
}
// Eine APK je Prozessorarchitektur (libnode ist groß); Obtainium wählt die passende automatisch
splits {
abi {
enable true
reset()
include 'arm64-v8a', 'armeabi-v7a'
universalApk false
}
}
buildTypes {
release {
if (ksFile.exists()) signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation "androidx.biometric:biometric:1.1.0"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+19
View File
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-nodejs')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
</manifest>
@@ -0,0 +1,45 @@
package de.mrterm.app;
import android.os.Bundle;
import android.view.View;
import android.webkit.WebView;
import androidx.activity.OnBackPressedCallback;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(MrTermNative.class);
super.onCreate(savedInstanceState);
WebView web = getBridge().getWebView();
// Zurück-Taste an die Oberfläche geben (schließt Dialoge, verlässt das Terminal …)
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
web.evaluateJavascript("window.mrtermBack && window.mrtermBack()", null);
}
});
// Edge-to-Edge: Ränder für Status- und Navigationsleiste setzt diese Ansicht. Den Platz für die Tastatur
// schafft Android/WebView selbst – hier nur melden, ob sie offen ist (sonst würde doppelt verkleinert).
View parent = (View) web.getParent();
parent.setBackgroundColor(0xFF14161D);
final boolean[] imeOpen = { false };
ViewCompat.setOnApplyWindowInsetsListener(parent, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
boolean open = insets.isVisible(WindowInsetsCompat.Type.ime());
v.setPadding(bars.left, bars.top, bars.right, open ? 0 : bars.bottom);
if (open != imeOpen[0]) {
imeOpen[0] = open;
web.evaluateJavascript("window.mrtermIme && window.mrtermIme(" + open + ")", null);
}
return insets;
});
}
}
@@ -0,0 +1,261 @@
package de.mrterm.app;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.provider.Settings;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyPermanentlyInvalidatedException;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricPrompt;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentActivity;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.util.Locale;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
/**
* Plattformfunktionen für MrTerm:
* - Tresorschlüssel: zufällige 32 Byte, verpackt mit einem nicht exportierbaren AES-Schlüssel im Android Keystore
* - Fingerabdruck: Geheimnis, das nur nach erfolgreicher biometrischer Bestätigung entschlüsselt werden kann
* - Multicast-Lock (sonst verwirft WLAN die UDP-Broadcasts der LAN-Synchronisation), Zwischenablage, Gerätename
*/
@CapacitorPlugin(name = "MrTermNative")
public class MrTermNative extends Plugin {
private static final String KS = "AndroidKeyStore";
private static final String VAULT_ALIAS = "mrterm_vault";
private static final String BIO_ALIAS = "mrterm_bio";
private WifiManager.MulticastLock multicastLock;
private SharedPreferences prefs() {
return getContext().getSharedPreferences("mrterm", Context.MODE_PRIVATE);
}
private static String b64(byte[] b) { return Base64.encodeToString(b, Base64.NO_WRAP); }
private static byte[] unb64(String s) { return Base64.decode(s, Base64.NO_WRAP); }
private SecretKey keystoreKey(String alias, boolean userAuth) throws Exception {
KeyStore ks = KeyStore.getInstance(KS);
ks.load(null);
if (ks.containsAlias(alias)) return (SecretKey) ks.getKey(alias, null);
KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KS);
KeyGenParameterSpec.Builder spec = new KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256);
if (userAuth) {
spec.setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true);
if (Build.VERSION.SDK_INT >= 30) spec.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG);
}
kg.init(spec.build());
return kg.generateKey();
}
private static void deleteKey(String alias) {
try { KeyStore ks = KeyStore.getInstance(KS); ks.load(null); ks.deleteEntry(alias); } catch (Exception ignored) {}
}
// ---------- Tresorschlüssel ----------
@PluginMethod
public void vaultKey(PluginCall call) {
try {
SecretKey k = keystoreKey(VAULT_ALIAS, false);
String stored = prefs().getString("vaultKey", null);
byte[] plain;
if (stored == null) {
plain = new byte[32];
new SecureRandom().nextBytes(plain);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, k);
prefs().edit().putString("vaultKey", b64(c.getIV()) + ":" + b64(c.doFinal(plain))).apply();
} else {
String[] p = stored.split(":");
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, unb64(p[0])));
plain = c.doFinal(unb64(p[1]));
}
JSObject r = new JSObject();
r.put("key", b64(plain));
call.resolve(r);
} catch (Exception e) {
call.reject("Keystore: " + e.getMessage());
}
}
// ---------- Fingerabdruck ----------
@PluginMethod
public void bioAvailable(PluginCall call) {
int s = BiometricManager.from(getContext()).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG);
JSObject r = new JSObject();
r.put("available", s == BiometricManager.BIOMETRIC_SUCCESS);
r.put("enrolled", prefs().getString("bio", null) != null);
call.resolve(r);
}
@PluginMethod
public void bioEnroll(PluginCall call) {
try {
deleteKey(BIO_ALIAS);
SecretKey k = keystoreKey(BIO_ALIAS, true);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, k);
prompt(call, c, call.getString("title", "MrTerm"), call.getString("cancel", "Cancel"), (cipher) -> {
byte[] secret = new byte[32];
new SecureRandom().nextBytes(secret);
byte[] ct = cipher.doFinal(secret);
prefs().edit().putString("bio", b64(cipher.getIV()) + ":" + b64(ct)).apply();
JSObject r = new JSObject();
r.put("secret", b64(secret));
call.resolve(r);
});
} catch (Exception e) {
call.reject(e.getMessage());
}
}
@PluginMethod
public void bioUnlock(PluginCall call) {
String stored = prefs().getString("bio", null);
if (stored == null) { call.reject("not-enrolled"); return; }
try {
String[] p = stored.split(":");
SecretKey k = keystoreKey(BIO_ALIAS, true);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, unb64(p[0])));
prompt(call, c, call.getString("title", "MrTerm"), call.getString("cancel", "Cancel"), (cipher) -> {
JSObject r = new JSObject();
r.put("secret", b64(cipher.doFinal(unb64(p[1]))));
call.resolve(r);
});
} catch (KeyPermanentlyInvalidatedException e) {
// Neuer Fingerabdruck registriert → Schlüssel ungültig, Nutzer muss neu einrichten
prefs().edit().remove("bio").apply();
deleteKey(BIO_ALIAS);
call.reject("invalidated");
} catch (Exception e) {
call.reject(e.getMessage());
}
}
@PluginMethod
public void bioDisable(PluginCall call) {
prefs().edit().remove("bio").apply();
deleteKey(BIO_ALIAS);
call.resolve();
}
private interface CipherUse { void run(Cipher c) throws Exception; }
private void prompt(PluginCall call, Cipher cipher, String title, String cancel, CipherUse use) {
FragmentActivity act = getActivity();
act.runOnUiThread(() -> {
BiometricPrompt bp = new BiometricPrompt(act, ContextCompat.getMainExecutor(act), new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
try { use.run(result.getCryptoObject().getCipher()); }
catch (Exception e) { call.reject(e.getMessage()); }
}
@Override
public void onAuthenticationError(int code, CharSequence msg) {
call.reject(code == BiometricPrompt.ERROR_USER_CANCELED || code == BiometricPrompt.ERROR_NEGATIVE_BUTTON ? "cancelled" : msg.toString());
}
});
BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setNegativeButtonText(cancel)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build();
bp.authenticate(info, new BiometricPrompt.CryptoObject(cipher));
});
}
// ---------- Sonstiges ----------
@PluginMethod
public void info(PluginCall call) {
String name = null;
try { name = Settings.Global.getString(getContext().getContentResolver(), "device_name"); } catch (Exception ignored) {}
if (name == null || name.isEmpty()) name = Build.MANUFACTURER + " " + Build.MODEL;
JSObject r = new JSObject();
r.put("device", name);
r.put("locale", Locale.getDefault().toLanguageTag());
try { r.put("version", getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0).versionName); }
catch (Exception e) { r.put("version", "0.0.0"); }
call.resolve(r);
}
@PluginMethod
public void multicast(PluginCall call) {
boolean on = Boolean.TRUE.equals(call.getBoolean("on", true));
WifiManager wm = (WifiManager) getContext().getApplicationContext().getSystemService(Context.WIFI_SERVICE);
if (on && multicastLock == null && wm != null) {
multicastLock = wm.createMulticastLock("mrterm-sync");
multicastLock.setReferenceCounted(false);
multicastLock.acquire();
} else if (!on && multicastLock != null) {
multicastLock.release();
multicastLock = null;
}
call.resolve();
}
// Design: Hintergrund hinter Status-/Navigationsleiste und dunkle bzw. helle Symbole
@PluginMethod
public void bars(PluginCall call) {
final boolean light = Boolean.TRUE.equals(call.getBoolean("light", false));
final String color = call.getString("color", "#14161d");
getActivity().runOnUiThread(() -> {
try {
android.view.View parent = (android.view.View) getBridge().getWebView().getParent();
parent.setBackgroundColor(android.graphics.Color.parseColor(color));
androidx.core.view.WindowInsetsControllerCompat c = androidx.core.view.WindowCompat.getInsetsController(getActivity().getWindow(), parent);
c.setAppearanceLightStatusBars(light);
c.setAppearanceLightNavigationBars(light);
} catch (Exception ignored) {}
});
call.resolve();
}
@PluginMethod
public void background(PluginCall call) {
getActivity().runOnUiThread(() -> getActivity().moveTaskToBack(true));
call.resolve();
}
@PluginMethod
public void copy(PluginCall call) {
ClipboardManager cm = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
cm.setPrimaryClip(ClipData.newPlainText("MrTerm", call.getString("text", "")));
call.resolve();
}
@PluginMethod
public void paste(PluginCall call) {
ClipboardManager cm = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
String text = "";
if (cm.hasPrimaryClip() && cm.getPrimaryClip().getItemCount() > 0) {
CharSequence t = cm.getPrimaryClip().getItemAt(0).coerceToText(getContext());
if (t != null) text = t.toString();
}
JSObject r = new JSObject();
r.put("text", text);
call.resolve(r);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#111728</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">MrTerm</string>
<string name="title_activity_main">MrTerm</string>
<string name="package_name">de.mrterm.app</string>
<string name="custom_url_scheme">de.mrterm.app</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
+6
View File
@@ -0,0 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-nodejs'
project(':capacitor-nodejs').projectDir = new File('../node_modules/capacitor-nodejs/android')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+105
View File
@@ -0,0 +1,105 @@
// Baut die Android-App.
// node build.js --setup Node.js-Plugin herunterladen (Prüfsumme) und Abhängigkeiten installieren
// node build.js Weboberfläche + Node-Backend nach www/ bauen und ins Android-Projekt synchronisieren
// node build.js --apk zusätzlich signierte Release-APK nach ../dist/MrTerm-<version>.apk bauen
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const WWW = path.join(__dirname, 'www');
const version = require(path.join(ROOT, 'package.json')).version;
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: 'inherit', cwd: __dirname, ...opts });
const PLUGIN = {
url: 'https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.10/capacitor-nodejs.tgz',
file: path.join(__dirname, 'vendor', 'capacitor-nodejs-1.0.0-beta.10.tgz'),
sha256: 'cc47cba4190d5e0ea8e2b33f4e9bd47e159570c01571c51214467e4c21e87bd3',
};
async function setup() {
if (!fs.existsSync(PLUGIN.file)) {
console.log('Lade capacitor-nodejs …');
const res = await fetch(PLUGIN.url);
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
fs.mkdirSync(path.dirname(PLUGIN.file), { recursive: true });
fs.writeFileSync(PLUGIN.file, Buffer.from(await res.arrayBuffer()));
}
const sum = crypto.createHash('sha256').update(fs.readFileSync(PLUGIN.file)).digest('hex');
if (sum !== PLUGIN.sha256) { fs.rmSync(PLUGIN.file); throw new Error(`Prüfsumme von capacitor-nodejs stimmt nicht (${sum})`); }
run('npm', ['install']);
}
function copy(from, to) {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.cpSync(from, to, { recursive: true });
}
// capacitor-nodejs baut seine Brückenbibliothek mit 4-KB-Seitenausrichtung; neuere Geräte (z. B. Pixel mit
// 16-KB-Speicherseiten) laden sie dann nicht. Linker-Option einmalig ergänzen.
function patchPlugin() {
const f = path.join(__dirname, 'node_modules', 'capacitor-nodejs', 'android', 'CMakeLists.txt');
const s = fs.readFileSync(f, 'utf8');
if (!s.includes('max-page-size=16384')) fs.writeFileSync(f, `${s}\ntarget_link_options(native-lib PRIVATE "-Wl,-z,max-page-size=16384")\n`);
}
function buildWeb() {
patchPlugin();
fs.rmSync(WWW, { recursive: true, force: true });
copy(path.join(__dirname, 'web'), WWW);
const nm = path.join(ROOT, 'node_modules', '@xterm');
copy(path.join(nm, 'xterm', 'lib', 'xterm.js'), path.join(WWW, 'lib', 'xterm.js'));
copy(path.join(nm, 'xterm', 'css', 'xterm.css'), path.join(WWW, 'lib', 'xterm.css'));
copy(path.join(nm, 'addon-fit', 'lib', 'addon-fit.js'), path.join(WWW, 'lib', 'addon-fit.js'));
copy(path.join(__dirname, 'node_modules', '@capacitor', 'core', 'dist', 'capacitor.js'), path.join(WWW, 'lib', 'capacitor.js'));
copy(path.join(ROOT, 'src', 'i18n.js'), path.join(WWW, 'lib', 'i18n.js'));
copy(path.join(ROOT, 'src', 'renderer', 'themes.js'), path.join(WWW, 'lib', 'themes.js'));
copy(path.join(ROOT, 'src', 'renderer', 'app-themes.css'), path.join(WWW, 'lib', 'app-themes.css'));
copy(path.join(ROOT, 'src', 'renderer', 'logo.png'), path.join(WWW, 'logo.png'));
// Node-Backend inkl. ssh2 in eine Datei bündeln (nodejs-mobile = Node 18)
require('esbuild').buildSync({
entryPoints: [path.join(__dirname, 'nodejs', 'server.js')],
outfile: path.join(WWW, 'nodejs', 'index.js'),
bundle: true, platform: 'node', target: 'node18', minify: true, legalComments: 'none',
external: ['bridge', 'cpu-features'],
alias: { electron: path.join(__dirname, 'nodejs', 'electron-shim.js') },
logLevel: 'warning',
});
fs.writeFileSync(path.join(WWW, 'nodejs', 'package.json'), JSON.stringify({ name: 'mrterm-backend', main: 'index.js' }));
run('npx', ['cap', 'sync', 'android']);
}
function toolchain() {
const base = path.join(os.homedir(), '.local', 'share', 'android-toolchain');
const env = { ...process.env };
env.JAVA_HOME ||= path.join(base, 'jdk');
env.ANDROID_HOME ||= path.join(base, 'sdk');
if (!fs.existsSync(env.JAVA_HOME) || !fs.existsSync(env.ANDROID_HOME)) throw new Error('JDK/Android-SDK nicht gefunden: JAVA_HOME und ANDROID_HOME setzen');
fs.writeFileSync(path.join(__dirname, 'android', 'local.properties'), `sdk.dir=${env.ANDROID_HOME}\n`);
return env;
}
function buildApk() {
if (!fs.existsSync(path.join(__dirname, 'android', 'keystore.properties'))) {
throw new Error('android/keystore.properties fehlt (Signaturschlüssel). Ohne ihn können Updates nicht installiert werden.');
}
run('./gradlew', ['--no-daemon', '-q', 'assembleRelease', `-PmrtermVersion=${version}`], { cwd: path.join(__dirname, 'android'), env: toolchain() });
const dir = path.join(__dirname, 'android', 'app', 'build', 'outputs', 'apk', 'release');
for (const abi of ['arm64-v8a', 'armeabi-v7a']) {
const out = path.join(ROOT, 'dist', `MrTerm-${version}-${abi}.apk`);
copy(path.join(dir, `app-${abi}-release.apk`), out);
console.log(`APK: ${out}`);
}
}
(async () => {
const args = process.argv.slice(2);
if (args.includes('--setup')) await setup();
else {
buildWeb();
if (args.includes('--apk')) buildApk();
}
})().catch((e) => { console.error(e.message || e); process.exit(1); });
+9
View File
@@ -0,0 +1,9 @@
{
"appId": "de.mrterm.app",
"appName": "MrTerm",
"webDir": "www",
"android": { "backgroundColor": "#14161d" },
"plugins": {
"CapacitorNodeJS": { "nodeDir": "nodejs", "startMode": "manual" }
}
}
+63
View File
@@ -0,0 +1,63 @@
// Entwicklung ohne Handy: startet die gebaute Oberfläche (www/) in einem Fenster in Handygröße
// und das Node-Backend im Electron-Hauptprozess, verbunden über eine nachgebaute Capacitor-Bridge.
// cd mobile && node build.js && ../node_modules/.bin/electron dev/electron-dev.js [--user-data-dir=…]
const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
const path = require('path');
const Module = require('module');
const EventEmitter = require('events');
const crypto = require('crypto');
const WWW = path.join(__dirname, '..', 'www');
const toWeb = new EventEmitter();
const fromWeb = new EventEmitter();
let win;
// require('bridge') im Backend auf diese Nachbildung umleiten
const bridge = {
channel: { send: (name, ...args) => toWeb.emit('msg', name, args), addListener: (name, fn) => fromWeb.on(name, fn) },
getDataPath: () => path.join(app.getPath('userData'), 'mobile-data'),
};
const resolve = Module._resolveFilename;
Module._resolveFilename = function (req, ...rest) { return req === 'bridge' ? 'bridge' : resolve.call(this, req, ...rest); };
require.cache.bridge = { id: 'bridge', filename: 'bridge', loaded: true, exports: bridge };
toWeb.on('msg', (name, args) => { if (win && !win.isDestroyed()) win.webContents.send('dev:node', name, args); });
ipcMain.on('dev:send', (_e, name, args) => fromWeb.emit(name, ...args));
ipcMain.handle('dev:start', (_e, env) => {
Object.assign(process.env, env, { DATADIR: bridge.getDataPath() });
require('fs').mkdirSync(process.env.DATADIR, { recursive: true });
setTimeout(() => { started = true; require(path.join(WWW, 'nodejs', 'index.js')); }, 500);
});
let started;
ipcMain.handle('dev:ready', async () => { while (!started) await new Promise((r) => setTimeout(r, 100)); });
ipcMain.handle('dev:native', (_e, method, arg) => {
if (method === 'vaultKey') return { key: crypto.createHash('sha256').update(app.getPath('userData')).digest('base64') };
if (method === 'info') return { device: 'Dev Phone', locale: process.env.MRTERM_DEV_LOCALE || 'en-US', version: require('../../package.json').version };
if (method === 'bioAvailable') return { available: true, enrolled: false };
if (method === 'bioEnroll' || method === 'bioUnlock') return { secret: Buffer.alloc(32, 1).toString('base64') };
if (method === 'copy') return clipboard.writeText(arg.text);
if (method === 'paste') return { text: clipboard.readText() };
return {};
});
app.whenReady().then(() => {
win = new BrowserWindow({
width: 412, height: 870, backgroundColor: '#14161d',
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
});
win.loadFile(path.join(WWW, 'index.html'));
if (process.env.MRTERM_SHOT) {
const steps = JSON.parse(require('fs').readFileSync(process.env.MRTERM_SHOT, 'utf8'));
win.webContents.on('console-message', (e) => console.log('[r]', e.message));
win.webContents.once('did-finish-load', async () => {
await new Promise((r) => setTimeout(r, 2500));
for (const s of steps) {
if (s.js) await win.webContents.executeJavaScript(`(async()=>{${s.js}})();0`).catch((e) => console.log('js', e.message));
await new Promise((r) => setTimeout(r, s.wait || 900));
if (s.shot) require('fs').writeFileSync(path.join(process.env.MRTERM_SHOT_OUT, s.shot), (await win.webContents.capturePage()).toPNG());
}
app.exit(0);
});
}
});
app.on('window-all-closed', () => app.exit(0));
+16
View File
@@ -0,0 +1,16 @@
const { contextBridge, ipcRenderer } = require('electron');
const listeners = new Map();
ipcRenderer.on('dev:node', (_e, name, args) => listeners.get(name)?.forEach((fn) => fn({ args })));
const native = (m) => (arg) => ipcRenderer.invoke('dev:native', m, arg);
contextBridge.exposeInMainWorld('DevBridge', {
NodeJS: {
// Wie auf Android: start() kehrt erst zurück, wenn Node beendet wird
start: (o) => { ipcRenderer.invoke('dev:start', o.env); return new Promise(() => {}); },
whenReady: () => ipcRenderer.invoke('dev:ready'),
send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
},
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background', 'bars'].map((m) => [m, native(m)])),
});
+30
View File
@@ -0,0 +1,30 @@
// Ersatz für die von store.js genutzten Electron-APIs unter Android.
// safeStorage verschlüsselt mit einem 256-Bit-Schlüssel, den die App im Android Keystore verpackt aufbewahrt
// und beim Start als MRTERM_VAULT_KEY (base64) an den Node-Prozess übergibt.
const crypto = require('crypto');
const dataDir = () => process.env.DATADIR || require('bridge').getDataPath();
const key = () => {
const k = Buffer.from(process.env.MRTERM_VAULT_KEY || '', 'base64');
return k.length === 32 ? k : null;
};
const safeStorage = {
isEncryptionAvailable: () => !!key(),
encryptString(plain) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key(), iv);
const ct = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]);
},
decryptString(buf) {
const d = crypto.createDecipheriv('aes-256-gcm', key(), buf.subarray(0, 12));
d.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
},
};
module.exports = {
app: { getPath: () => dataDir(), getVersion: () => process.env.MRTERM_VERSION || '0.0.0', getLocale: () => process.env.MRTERM_LOCALE || 'en' },
safeStorage,
};
+48
View File
@@ -0,0 +1,48 @@
// Node-Backend der Android-App. Die Kanäle kommen aus src/core/backend.js (gemeinsam mit der Web-Version);
// hier nur, was Android betrifft: Bridge-Anbindung, Entsperren per Fingerabdruck, Pause/Fortsetzen.
// Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente];
// Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente].
const { channel } = require('bridge');
const crypto = require('crypto');
const { Store } = require('../../src/main/store');
const { createBackend } = require('../../src/core/backend');
const i18n = require('../../src/i18n');
const send = (ch, ...args) => channel.send('evt', ch, args);
process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.message || String(e), 'error'); });
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
const store = new Store();
const applyLanguage = (lang = store.get().settings.language) => i18n.setLanguage(lang, process.env.MRTERM_LOCALE || 'en');
const be = createBackend({ store, send, platform: 'android', version: process.env.MRTERM_VERSION || '0.0.0', withSync: true, onLanguage: applyLanguage });
channel.addListener('call', async (reqId, ch, args = []) => {
try { channel.send('reply', reqId, true, await be.call(ch, args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
});
channel.addListener('send', (ch, args = []) => be.notify(ch, args));
// ---------- Fingerabdruck (Geheimnis aus dem Android Keystore, nur zusätzlich zum Passwort) ----------
const bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32));
be.handle('lock:unlockBio', (secret) => {
if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.'));
try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your password.')); }
applyLanguage();
be.sync.start().catch(() => {});
return true;
});
be.handle('lock:setBio', (secret) => {
be.requireUnlocked();
if (!store.lock?.password) throw new Error(i18n.t('Set a password first.'));
store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null;
store.save();
return be.lockStatus();
});
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
be.handle('app:pause', () => be.sync.stop());
be.handle('app:resume', () => be.sync.start().catch(() => {}));
store.load();
applyLanguage();
be.sync.start().catch(() => {});
channel.send('ready');
+1731
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "mrterm-mobile",
"private": true,
"version": "0.0.0",
"description": "MrTerm for Android",
"scripts": {
"build": "node build.js",
"apk": "node build.js --apk"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"esbuild": "^0.28.2"
},
"allowScripts": {
"esbuild@0.28.2": true
},
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/core": "^8.5.2",
"capacitor-nodejs": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz"
}
}
+1078
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>MrTerm</title>
<link rel="stylesheet" href="lib/xterm.css" />
<link rel="stylesheet" href="lib/app-themes.css" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="boot"><img src="logo.png" alt="" /><div class="spinner"></div></div>
<!-- Hauptansicht: Inhalt + untere Navigation -->
<div id="main" hidden>
<main id="view"></main>
<nav id="tabs">
<button data-view="hosts"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg><span data-t>Hosts</span></button>
<button data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span data-t>Keys</span></button>
<button data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span data-t>Snippets</span></button>
<button data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z"/></svg><span data-t>Settings</span></button>
</nav>
</div>
<!-- Terminal im Vollbild -->
<div id="termScreen" hidden>
<header class="term-head">
<button class="icon" id="termBack" aria-label="Back"><svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg></button>
<button class="term-title" id="termTitle"><span class="dot"></span><span class="name"></span><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
<button class="icon" id="termKbd" aria-label="Keyboard"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg></button>
<button class="icon" id="termMenu" aria-label="Menu"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</header>
<div id="terms"></div>
<div id="keybar"></div>
</div>
<div id="lockScreen" hidden></div>
<div id="layer"></div>
<div id="toasts"></div>
<script>
// Fehler beim Laden sichtbar machen statt endlos den Ladekreis zu zeigen
window.addEventListener('error', (e) => {
const b = document.getElementById('boot');
if (b && !b.hidden) b.innerHTML = '<div style="color:#ff5f6d;padding:24px;text-align:center;font:14px system-ui">' + String(e.message || e.error).replace(/</g, '&lt;') + '</div>';
});
</script>
<script src="lib/capacitor.js"></script>
<script src="lib/i18n.js"></script>
<script src="lib/themes.js"></script>
<script src="lib/xterm.js"></script>
<script src="lib/addon-fit.js"></script>
<script src="app.js"></script>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
:root {
/* Farben kommen aus lib/app-themes.css (gemeinsam mit dem Desktop, Auswahl über data-theme) */
--surface: var(--card);
--surface2: var(--card-hover);
--line: var(--border);
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
--r: calc(var(--r) + 4px);
/* Status-/Navigationsleiste rechnet MainActivity als Rand um den WebView heraus */
--top: 0px;
--bottom: 0px;
color-scheme: dark;
}
:root.light { color-scheme: light; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden; }
body { font: 15px/1.4 var(--font); font-family: var(--font), system-ui, Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
[hidden] { display: none !important; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
input, select, textarea { font: inherit; color: var(--text); -webkit-user-select: text; user-select: text; }
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.mono { font-family: ui-monospace, 'JetBrains Mono', 'DejaVu Sans Mono', monospace; }
/* ---------- Start ---------- */
#boot { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 28px; }
#boot img { width: 84px; height: 84px; }
.spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--surface2); border-top-color: var(--accent); animation: spin .8s linear infinite; }
.spinner.sm { width: 16px; height: 16px; border-width: 2px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Grundlayout ---------- */
#main { position: fixed; inset: 0; display: flex; flex-direction: column; }
#view { flex: 1; overflow-y: auto; padding: calc(var(--top) + 8px) 16px 96px; -webkit-overflow-scrolling: touch; }
#tabs { display: flex; background: var(--surface); border-top: 1px solid var(--line); padding-bottom: var(--bottom); }
#tabs button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 9px 0 8px; color: var(--faint); font-size: 11.5px; font-weight: 600; }
#tabs button.active { color: var(--accent); }
.top { display: flex; align-items: center; gap: 10px; min-height: 52px; }
.top h1 { font-size: 26px; margin: 0; flex: 1; letter-spacing: -.3px; }
.top h1 .brand { color: var(--green); }
.icon { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: var(--muted); }
.icon:active { background: var(--surface2); }
.search { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 0 12px; margin: 6px 0 14px; }
.search svg { color: var(--faint); width: 18px; height: 18px; }
.search input { flex: 1; background: none; border: 0; outline: 0; padding: 12px 0; }
.section { margin: 18px 4px 8px; font-size: 12px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
.section svg { width: 16px; height: 16px; }
.list { background: var(--surface); border-radius: var(--r); overflow: hidden; }
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 62px; position: relative; }
.item + .item::before { content: ''; position: absolute; top: 0; left: 64px; right: 0; border-top: 1px solid var(--line); }
.item:active { background: var(--surface2); }
.item .meta { flex: 1; min-width: 0; }
.item .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .more { color: var(--faint); width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; margin-right: -6px; }
.item.disabled { opacity: .55; }
.avatar { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; font-weight: 800; color: #fff; flex: none; font-size: 16px; }
.avatar svg { width: 20px; height: 20px; }
.avatar.soft { background: var(--accent-soft); color: var(--accent); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
.dot.on { background: var(--green); }
.dot.err { background: var(--red); }
.sessions { display: flex; gap: 8px; overflow-x: auto; margin: 0 -16px 4px; padding: 2px 16px 8px; scrollbar-width: none; }
.sessions::-webkit-scrollbar { display: none; }
.chip { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; padding: 8px 14px; white-space: nowrap; font-weight: 600; font-size: 14px; }
.empty { text-align: center; padding: 60px 24px; color: var(--muted); }
.empty .avatar { margin: 0 auto 16px; width: 60px; height: 60px; border-radius: 18px; }
.empty .avatar svg { width: 28px; height: 28px; }
.empty h3 { color: var(--text); margin: 0 0 6px; }
.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: calc(var(--radius) + 8px); background: var(--accent); color: var(--on-accent); display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
.fab svg { width: 26px; height: 26px; }
/* ---------- Karten / Einstellungen ---------- */
.card { background: var(--surface); border-radius: var(--r); padding: 14px 16px; margin-bottom: 14px; }
.card h3 { margin: 0 0 4px; font-size: 16px; }
.card p, .hint { color: var(--muted); font-size: 13.5px; margin: 4px 0 10px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
.row + .row { border-top: 1px solid var(--line); }
.row .grow { flex: 1; min-width: 0; }
.row .sub { color: var(--muted); font-size: 13px; }
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); font-weight: 600; }
.btn svg { width: 18px; height: 18px; }
.btn.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.btn.danger { color: var(--red); }
.btn.block { width: 100%; }
.btn:disabled { opacity: .5; }
.btn:active { filter: brightness(1.15); }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted); margin: 0 2px 6px; }
.field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px 13px; outline: 0; min-height: 48px; }
.field textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, monospace; font-size: 13px; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
.field .hint { margin: 6px 2px 0; font-size: 12.5px; }
.pw { position: relative; }
.pw input { padding-right: 48px; }
.pw button { position: absolute; right: 4px; top: 4px; width: 40px; height: 40px; display: grid; place-items: center; color: var(--faint); }
.two { display: grid; grid-template-columns: 1fr 96px; gap: 10px; }
.switch { position: relative; width: 50px; height: 30px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
.switch input:checked + span { background: var(--accent); }
.switch input:checked + span::after { transform: translateX(20px); background: var(--on-accent); }
.warn-box { border: 1px solid #ffb34755; background: #ffb34712; border-radius: 12px; padding: 10px 12px; color: var(--orange); font-size: 13px; margin: 8px 0; }
/* ---------- Ebenen: Seiten, Sheets, Dialoge ---------- */
#layer > * { position: fixed; inset: 0; z-index: 20; }
.page { background: var(--bg); display: flex; flex-direction: column; animation: slidein .18s ease-out; }
@keyframes slidein { from { transform: translateX(30px); opacity: 0; } }
.page-head { display: flex; align-items: center; gap: 6px; padding: calc(var(--top) + 6px) 8px 6px; border-bottom: 1px solid var(--line); background: var(--surface); }
.page-head h2 { flex: 1; font-size: 18px; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.page-head .btn { min-height: 38px; padding: 0 14px; }
.page-body { flex: 1; overflow-y: auto; padding: 16px 16px calc(24px + var(--bottom)); }
.scrim { background: #0009; display: flex; align-items: flex-end; animation: fade .15s; }
.scrim.center { align-items: center; justify-content: center; padding: 20px; }
@keyframes fade { from { opacity: 0; } }
.sheet { width: 100%; background: var(--surface); border-radius: 20px 20px 0 0; padding: 8px 8px calc(10px + var(--bottom)); max-height: 85vh; overflow-y: auto; animation: up .18s ease-out; }
@keyframes up { from { transform: translateY(40px); } }
.sheet .grab { width: 38px; height: 4px; background: var(--line); border-radius: 4px; margin: 4px auto 8px; }
.sheet .sheet-title { padding: 6px 12px 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
.sheet button.opt { display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 14px; border-radius: 12px; text-align: left; font-weight: 500; }
.sheet button.opt:active { background: var(--surface2); }
.sheet button.opt.danger { color: var(--red); }
.sheet button.opt svg { color: var(--muted); }
.sheet button.opt.danger svg { color: var(--red); }
.dialog { width: 100%; max-width: 420px; background: var(--surface); border-radius: 20px; padding: 20px; max-height: 88vh; overflow-y: auto; animation: pop .15s ease-out; }
@keyframes pop { from { transform: scale(.96); opacity: 0; } }
.dialog h3 { margin: 0 0 8px; font-size: 18px; }
.dialog p { color: var(--muted); margin: 0 0 14px; }
.dialog .btns { justify-content: flex-end; margin-top: 16px; }
.fp { font-size: 12.5px; background: var(--bg); border-radius: 10px; padding: 10px; word-break: break-all; color: var(--text); -webkit-user-select: text; user-select: text; }
.pair-code { font: 700 38px ui-monospace, monospace; letter-spacing: 6px; text-align: center; padding: 16px; background: var(--bg); border-radius: 14px; color: var(--accent); }
.checks label { display: flex; align-items: center; gap: 12px; padding: 10px 2px; }
.checks input { width: 20px; height: 20px; accent-color: var(--accent); }
.checks h4 { margin: 12px 0 2px; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
#toasts { position: fixed; left: 16px; right: 16px; bottom: calc(84px + var(--bottom)); z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 12px; padding: 12px 14px; box-shadow: 0 8px 24px #0008; animation: up .2s; font-size: 14px; }
.toast.ok { border-color: var(--green); }
.toast.error { border-color: var(--red); }
/* ---------- Sperrbildschirm ---------- */
#lockScreen { position: fixed; inset: 0; z-index: 40; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 14px; }
#lockScreen img { width: 72px; height: 72px; margin-bottom: 6px; }
#lockScreen h2 { margin: 0; }
#lockScreen p { margin: 0 0 10px; color: var(--muted); text-align: center; }
#lockScreen form { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 10px; }
#lockScreen .field { margin: 0; }
.bio-btn { width: 64px; height: 64px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; margin-top: 10px; }
.bio-btn svg { width: 32px; height: 32px; }
/* ---------- Terminal ---------- */
#termScreen { position: fixed; left: 0; right: 0; top: 0; height: 100%; display: flex; flex-direction: column; background: var(--term-bg, #13151c); z-index: 10; }
.term-head { display: flex; align-items: center; gap: 2px; padding: calc(var(--top) + 2px) 4px 2px; background: var(--surface); border-bottom: 1px solid var(--line); }
.term-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 8px; font-weight: 700; }
.term-title .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.term-title svg { width: 16px; height: 16px; color: var(--faint); }
.term-title .count { background: var(--surface2); color: var(--muted); border-radius: 8px; padding: 0 7px; font-size: 12px; }
#terms { flex: 1; position: relative; overflow: hidden; }
.term-wrap { position: absolute; inset: 0; padding: 4px 2px 0 6px; }
.term-wrap .xterm { height: 100%; }
.term-wrap .xterm-viewport { background-color: transparent !important; }
.term-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: var(--term-bg, #13151c); color: var(--muted); padding: 24px; text-align: center; z-index: 5; }
#keybar { display: flex; gap: 6px; overflow-x: auto; padding: 6px 6px calc(6px + var(--bottom)); background: var(--surface); border-top: 1px solid var(--line); scrollbar-width: none; }
#keybar::-webkit-scrollbar { display: none; }
#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
#keybar button svg { width: 18px; height: 18px; }
#keybar button.on { background: var(--accent); color: var(--on-accent); }
#keybar button.lock { box-shadow: inset 0 0 0 2px var(--on-accent); }
body.kbd-open #keybar { padding-bottom: 6px; }
body.kbd-open #tabs, body.kbd-open .fab { display: none; }
/* Design-Extras */
[data-theme='xp'] .page-head, [data-theme='xp'] .term-head { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; border-color: #0a3f9f; }
[data-theme='xp'] .page-head .icon, [data-theme='xp'] .term-head .icon, [data-theme='xp'] .term-title svg { color: #fff; }
[data-theme='xp'] #tabs { background: linear-gradient(#3168d5, #1d4fbf); border-color: #0a3f9f; }
[data-theme='xp'] #tabs button { color: #c7d8ff; }
[data-theme='xp'] #tabs button.active { color: #fff; }
[data-theme='xp'] .btn.primary, [data-theme='xp'] .fab { background: linear-gradient(#3d86e8, #2159b8); }
[data-theme='cyberpunk'] .btn.primary, [data-theme='cyberpunk'] .fab { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 12px) 100%, 0 100%); }
[data-theme='cyberpunk'] .btn.primary { text-transform: uppercase; letter-spacing: .6px; }
[data-theme='cyberpunk'] .top h1 { text-transform: uppercase; letter-spacing: 1px; text-shadow: 2px 0 #ff003c88, -2px 0 #00f0ff88; }
[data-theme='cyberpunk'] .list, [data-theme='cyberpunk'] .card { border-left: 2px solid var(--accent); }
[data-theme='cyberpunk'] #tabs button.active { color: var(--accent); text-shadow: 0 0 8px #fcee0a88; }
.theme-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 6px 0 14px; }
.theme-prev { border-radius: 12px; padding: 8px; border: 2px solid var(--line); font-size: 12px; font-weight: 600; text-align: left; }
.theme-prev.active { border-color: var(--accent); }
.theme-prev .dp { display: flex; gap: 4px; height: 40px; margin-bottom: 6px; }
.theme-prev .dp i { width: 12px; border-radius: 3px; }
.theme-prev .dp div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.theme-prev .dp b { flex: 1; border-radius: 3px; position: relative; }
.theme-prev .dp s { position: absolute; left: 4px; top: 4px; width: 14px; height: 6px; border-radius: 2px; }
.colors { display: flex; flex-wrap: wrap; gap: 10px; }
.colors button { width: 34px; height: 34px; border-radius: 50%; border: 3px solid transparent; }
.colors button.active { border-color: var(--text); }
/* macOS Liquid Glass */
[data-theme='macos'] #main, [data-theme='macos'] #view { background: transparent; }
[data-theme='macos'] :is(.list, .card, .search, #tabs, .page-head, .term-head, .sheet, .dialog, .toast, .chip, .btn, .fab, .field input, .field select, .field textarea, #keybar) {
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
}
[data-theme='macos'] :is(.list, .card, .search, .sheet, .dialog, .toast, .chip) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
[data-theme='macos'] .page, [data-theme='macos'] #lockScreen { background: var(--wallpaper); }
[data-theme='macos'] .page-body { background: transparent; }
[data-theme='macos'] #tabs, [data-theme='macos'] .page-head { background: rgba(255, 255, 255, .42); border-color: rgba(255, 255, 255, .7); }
[data-theme='macos'] .btn { border-radius: 999px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9); }
[data-theme='macos'] .btn.primary, [data-theme='macos'] .fab { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 6px 18px rgba(0, 122, 255, .3); }
[data-theme='macos'] .fab { border-radius: 50%; }
[data-theme='macos'] .scrim { background: rgba(30, 40, 70, .18); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
[data-theme='macos'] .sheet, [data-theme='macos'] .dialog { background: rgba(255, 255, 255, .62); }
+511 -3
View File
@@ -1,12 +1,12 @@
{
"name": "mrterm",
"version": "0.10.1",
"version": "0.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mrterm",
"version": "0.10.1",
"version": "0.13.1",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
@@ -18,7 +18,9 @@
},
"devDependencies": {
"electron": "^44.4.5",
"electron-builder": "^26.15.3"
"electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"ws": "^8.21.3"
}
},
"node_modules/@electron-internal/extract-zip": {
@@ -325,6 +327,448 @@
"node": ">=14.14"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2071,6 +2515,48 @@
"license": "MIT",
"optional": true
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3992,6 +4478,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+6 -3
View File
@@ -1,7 +1,7 @@
{
"name": "mrterm",
"productName": "MrTerm",
"version": "0.10.1",
"version": "0.13.1",
"description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js",
"author": "MrBlake",
@@ -68,12 +68,15 @@
},
"devDependencies": {
"electron": "^44.4.5",
"electron-builder": "^26.15.3"
"electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"ws": "^8.21.3"
},
"allowScripts": {
"electron@44.4.5": true,
"electron-winstaller@5.4.0": true,
"ssh2@1.17.0": true,
"koffi@3.3.1": true
"koffi@3.3.1": true,
"esbuild@0.28.2": true
}
}
+8 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Fragt nach der Version, baut Windows (Setup + Portable, per wine) und Arch/CachyOS (pacman + AppImage)
# und lädt alles als Release v<version> nach Gitea hoch.
# Fragt nach der Version, baut Windows (Setup + Portable, per wine), Arch/CachyOS (pacman + AppImage)
# und Android (APK, signiert mit mobile/android/keystore.properties) und lädt alles als Release v<version> nach Gitea hoch.
# Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt).
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -31,5 +31,11 @@ git -c http.extraHeader="Authorization: Basic ${auth}" push origin HEAD
rm -rf dist
npm run dist:linux
npm run dist:win
if [[ -f mobile/android/keystore.properties ]]; then
[[ -d mobile/node_modules/capacitor-nodejs ]] || (cd mobile && node build.js --setup)
(cd mobile && node build.js --apk)
else
echo "mobile/android/keystore.properties fehlt – Android-APK wird übersprungen"
fi
npm run release -- --notes "$notes" "${prerelease[@]}"
+1 -1
View File
@@ -28,7 +28,7 @@ const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.a
rel = await res.json();
console.log('Release angelegt:', rel.html_url);
}
const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman)$/.test(f));
const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman|apk)$/.test(f));
if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.');
for (const f of files) {
const old = (rel.assets || []).find((a) => a.name === f);
+223
View File
@@ -0,0 +1,223 @@
// Gemeinsame Backend-Kanäle für die Android-App und die Web-Version (Docker).
// Der Electron-Hauptprozess (src/main/main.js) registriert seine Kanäle selbst, nutzt aber dieselben Module.
//
// createBackend({ store, send, platform, version, withSync, onLanguage })
// store Store-Instanz (bereits geladen)
// send (kanal, ...args) → Ereignis an die Oberfläche
// → { call(kanal, args), notify(kanal, args), close(), ssh, sync, handle }
const crypto = require('crypto');
const { utils: sshUtils } = require('ssh2');
const { SshManager } = require('../main/ssh');
const { DockerManager } = require('../main/docker');
const { FirewallManager } = require('../main/firewall');
const { NetworkConfigManager } = require('../main/network');
const i18n = require('../i18n');
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
const pending = new Map();
const ask = (ch, req, timeout = 0) => new Promise((resolve) => {
const reqId = crypto.randomUUID();
pending.set(reqId, resolve);
send(ch, { reqId, ...req });
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
});
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
const confirmHostKey = async (target, fingerprint, known) =>
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
const ssh = new SshManager(store, confirmHostKey, askSecret);
const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
let sync = null;
if (withSync) {
const { SyncService } = require('../main/sync');
sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
store.onChange = () => sync.schedule();
}
const handlers = new Map();
const handle = (ch, fn) => handlers.set(ch, fn);
const OPEN_WHILE_LOCKED = new Set(['app:version']);
async function call(ch, args = []) {
const fn = handlers.get(ch);
if (!fn) throw new Error(`Unknown channel ${ch}`);
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
return fn(...args);
}
const listeners = {
'ssh:write': (id, d) => ssh.write(id, d),
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
'ssh:close': (id) => ssh.close(id),
'ask:reply': answer,
// Namen der Desktop-Vorlage (preload.js)
'secret:reply': answer,
'sync:pairReply': answer,
};
const notify = (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } };
// ---------- App-Sperre (Passwort) ----------
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
const lockStatus = () => {
const { language, appTheme, accent } = store.get().settings;
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, fido: [], meta: { language, appTheme, accent } };
};
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
const afterUnlock = () => { onLanguage(store.get().settings.language); sync?.start().catch(() => {}); };
handle('lock:status', lockStatus);
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
handle('lock:unlockPassword', async (pw) => {
const p = store.lock?.password;
if (!p) throw new Error(i18n.t('No password set.'));
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
afterUnlock();
return true;
});
handle('lock:setPassword', async (pw) => {
requireUnlocked();
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
const salt = crypto.randomBytes(16), N = 2 ** 15;
const kek = await kdf(pw, salt, N);
store.lock = store.lock || { password: null, fido: [] };
store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
store.save();
return lockStatus();
});
handle('lock:removePassword', () => {
requireUnlocked();
if (store.lock) { store.lock.password = null; store.lock.bio = null; }
store.dropLockIfEmpty();
store.save();
return lockStatus();
});
// ---------- Tresor ----------
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform }));
handle('vault:upsert', (col, item) => store.upsert(col, item));
handle('vault:remove', (col, id) => {
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
return store.remove(col, id);
});
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:exportData', () => publicData());
handle('vault:importData', (data) => {
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
for (const item of data?.[col] || []) store.upsert(col, item);
return true;
});
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
handle('vault:bulkImport', (entries) => {
const groups = store.get().groups;
const ensure = (parts) => {
let parent = null;
for (const label of parts) {
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
parent = g.id;
}
return parent;
};
let hosts = 0, skipped = 0;
for (const e of entries) {
const groupId = ensure(e.folder || []);
if (!e.host) continue;
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
if (dup) { skipped++; continue; }
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
hosts++;
}
store.save();
return { hosts, skipped, groups: groups.length };
});
handle('app:version', () => version);
// ---------- Schlüssel ----------
handle('key:generate', ({ type, bits, comment, passphrase }) => {
const opts = { comment: comment || `mrterm@${platform}` };
if (type === 'rsa') opts.bits = Number(bits) || 4096;
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
return { privateKey: k.private, publicKey: k.public };
});
handle('key:parse', ({ privateKey, passphrase }) => {
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
if (k instanceof Error) throw k;
const key = Array.isArray(k) ? k[0] : k;
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
});
// ---------- SSH-Terminal & SFTP ----------
function hostWithOverrides(ref) {
if (typeof ref === 'string') {
const h = store.resolveHost(ref);
if (!h) throw new Error(i18n.t('Host not found'));
return h;
}
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
return ref;
}
handle('ssh:open', async (sessionId, ref, size) => {
const host = hostWithOverrides(ref);
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
return true;
});
handle('sftp:open', (id, ref) => ssh.openSftp(id, hostWithOverrides(ref)));
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
handle('sftp:close', (id) => ssh.close(id));
// ---------- Docker / Firewall / Netzwerk ----------
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id)));
handle('docker:list', (id) => docker.list(id));
handle('docker:stats', (id) => docker.stats(id));
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
handle('docker:close', (id) => docker.close(id));
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
handle('firewall:list', (id, backend) => firewall.list(id, backend));
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
handle('firewall:close', (id) => firewall.close(id));
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id)));
handle('network:read', (id) => network.read(id));
handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
handle('network:remove', (id, model) => network.removeInterface(id, model));
handle('network:hostname', (id, name) => network.setHostname(id, name));
handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
handle('network:readFile', (id, path) => network.readFile(id, path));
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
handle('network:close', (id) => network.close(id));
// ---------- Synchronisation ----------
if (sync) {
handle('sync:status', () => sync.status());
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
handle('sync:pair', (id) => sync.pair(id));
handle('sync:unpair', (id) => sync.unpair(id));
handle('sync:now', () => sync.syncAll());
handle('sync:share', (sel) => sync.setShare(sel));
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
handle('sync:probe', (address) => sync.probe(address));
}
function close() {
ssh.closeAll?.();
sync?.stop();
for (const r of pending.values()) r(null);
pending.clear();
}
return { call, notify, close, handle, ssh, sync, store, kdf, requireUnlocked, lockStatus };
}
module.exports = { createBackend };
+85
View File
@@ -597,6 +597,91 @@
'Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.': 'Soll dieses Geheimnis mit deinen gekoppelten Geräten ({names}) synchronisiert werden? Du kannst das später unter Einstellungen → Synchronisation ändern.',
'Don\'t share': 'Nicht teilen',
'Share': 'Teilen',
// Android-App
'Lock': 'Sperren',
'Add your first server or pair this phone with MrTerm on your computer to take over your hosts.': 'Lege deinen ersten Server an oder kopple dieses Handy mit MrTerm auf deinem Computer, um deine Hosts zu übernehmen.',
'No matching hosts. Press Enter to connect to “{q}”.': 'Keine passenden Hosts. Drücke Enter, um dich mit „{q}“ zu verbinden.',
'RDP is not available on Android': 'RDP ist unter Android nicht verfügbar',
'Please enter a name.': 'Bitte einen Namen eingeben.',
'Sessions': 'Sitzungen',
'New connection': 'Neue Verbindung',
'Paste': 'Einfügen',
'Copy selection': 'Auswahl kopieren',
'Nothing selected. Long-press the terminal to select text.': 'Nichts ausgewählt. Halte das Terminal gedrückt, um Text auszuwählen.',
'Larger font': 'Schrift größer',
'Smaller font': 'Schrift kleiner',
'Close session': 'Sitzung schließen',
'Generate a new SSH key or import an existing one.': 'Erzeuge einen neuen SSH-Schlüssel oder importiere einen vorhandenen.',
'Key created': 'Schlüssel erstellt',
'Add the public key to ~/.ssh/authorized_keys on your servers.': 'Trage den öffentlichen Schlüssel auf deinen Servern in ~/.ssh/authorized_keys ein.',
'Save frequently used commands and send them to a terminal with one tap.': 'Speichere häufig genutzte Befehle und sende sie mit einem Tippen an ein Terminal.',
'Run in terminal': 'Im Terminal ausführen',
'Command': 'Befehl',
'End the command with a line break to run it immediately.': 'Endet der Befehl mit einem Zeilenumbruch, wird er sofort ausgeführt.',
'Please enter a command.': 'Bitte einen Befehl eingeben.',
'General': 'Allgemein',
'System language': 'Systemsprache',
'Known hosts': 'Bekannte Hosts',
'{n} saved host keys': '{n} gespeicherte Hostschlüssel',
'Show': 'Anzeigen',
'Version {v}': 'Version {v}',
'Updates are installed with Obtainium.': 'Updates werden mit Obtainium installiert.',
'No known hosts yet.': 'Noch keine bekannten Hosts.',
'Remove host key?': 'Hostschlüssel entfernen?',
'Immediately': 'Sofort',
'After 1 minute': 'Nach 1 Minute',
'After 5 minutes': 'Nach 5 Minuten',
'Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.': 'Schütze MrTerm mit einem Passwort und optional deinem Fingerabdruck. Der Tresor wird dann zusätzlich verschlüsselt.',
'Remove app lock': 'App-Sperre entfernen',
'Remove app lock?': 'App-Sperre entfernen?',
'MrTerm will open without a password.': 'MrTerm öffnet sich dann ohne Passwort.',
'Unlock with fingerprint': 'Mit Fingerabdruck entsperren',
'Uses the fingerprint or face unlock of this phone.': 'Nutzt den Fingerabdruck- oder Gesichtsscanner dieses Handys.',
'Confirm fingerprint': 'Fingerabdruck bestätigen',
'Lock when leaving the app': 'Sperren beim Verlassen der App',
'Enter your password to unlock.': 'Gib dein Passwort ein, um zu entsperren.',
'Unlock MrTerm': 'MrTerm entsperren',
'Use password': 'Passwort verwenden',
'Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.': 'Deine Fingerabdrücke haben sich geändert. Entsperre mit deinem Passwort und richte das Entsperren per Fingerabdruck neu ein.',
'Take over hosts, groups, snippets and known hosts from MrTerm on your computer. The connection is end-to-end encrypted and stays in your local network.': 'Übernimm Hosts, Gruppen, Snippets und bekannte Hosts von MrTerm auf deinem Computer. Die Verbindung ist Ende-zu-Ende verschlüsselt und bleibt in deinem lokalen Netzwerk.',
'Both devices must be in the same Wi-Fi. If nothing is found, allow UDP port 47811 and TCP port 47812 in the firewall of your computer.': 'Beide Geräte müssen im selben WLAN sein. Wird nichts gefunden, gib in der Firewall deines Computers UDP-Port 47811 und TCP-Port 47812 frei.',
'Or enter an IP address': 'Oder IP-Adresse eingeben',
'Copy failed': 'Kopieren fehlgeschlagen',
'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.',
'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.',
'Set a password first.': 'Lege zuerst ein Passwort fest.',
// Web-Version
'RDP is not available in the web version.': 'RDP ist in der Web-Version nicht verfügbar.',
'Account': 'Konto',
'Signed in as {name}': 'Angemeldet als {name}',
'Administrator': 'Administrator',
'Current password': 'Aktuelles Passwort',
'Sign out': 'Abmelden',
'Users': 'Benutzer',
'User': 'Benutzer',
'Delete user?': 'Benutzer löschen?',
'“{name}” and their vault will be permanently deleted.': '„{name}“ und der zugehörige Tresor werden endgültig gelöscht.',
'Add user': 'Benutzer hinzufügen',
'Your vault is stored on the server, encrypted with a key that only your login password can unlock.': 'Dein Tresor liegt auf dem Server, verschlüsselt mit einem Schlüssel, den nur dein Login-Passwort entsperren kann.',
'Folders cannot be downloaded in the browser: {name}': 'Ordner können im Browser nicht heruntergeladen werden: {name}',
'Welcome! Create the administrator account for this MrTerm server.': 'Willkommen! Lege das Administrator-Konto für diesen MrTerm-Server an.',
'Sign in to your MrTerm server.': 'Melde dich an deinem MrTerm-Server an.',
'Create account': 'Konto anlegen',
'Sign in': 'Anmelden',
'Administrators only.': 'Nur für Administratoren.',
'Forbidden': 'Nicht erlaubt',
'Not signed in': 'Nicht angemeldet',
'Setup is already complete.': 'Die Einrichtung ist bereits abgeschlossen.',
'The current password is wrong.': 'Das aktuelle Passwort ist falsch.',
'The last administrator cannot be deleted.': 'Der letzte Administrator kann nicht gelöscht werden.',
'Too many failed attempts. Try again in 15 minutes.': 'Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.',
'User not found.': 'Benutzer nicht gefunden.',
'Wrong username or password.': 'Benutzername oder Passwort falsch.',
'You cannot delete yourself.': 'Du kannst dich nicht selbst löschen.',
'The password must be at least 8 characters long.': 'Das Passwort muss mindestens 8 Zeichen lang sein.',
'This username is already taken.': 'Dieser Benutzername ist bereits vergeben.',
'Usernames may contain letters, digits and . _ @ - (max. 64).': 'Benutzernamen dürfen Buchstaben, Ziffern und . _ @ - enthalten (max. 64).',
'Connection lost – reconnecting …': 'Verbindung verloren – verbinde neu …',
// Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host',
+13 -10
View File
@@ -149,8 +149,10 @@ class NetworkConfigManager {
}
watch(id, s) {
s.conn.on('close', () => { if (this.sessions.get(id) === s && !s.replacing) { this.close(id); s.onClose(); } });
s.conn.on('error', () => {});
const conn = s.conn;
// Nur die aktuelle Verbindung darf die Sitzung beenden (nach apply wird sie ersetzt)
conn.on('close', () => { if (this.sessions.get(id) === s && s.conn === conn) { this.close(id); s.onClose(); } });
conn.on('error', () => {});
}
async read(id) {
@@ -226,7 +228,7 @@ class NetworkConfigManager {
const be = BACKENDS[backendName || s.backend];
if (!be) throw new Error(i18n.t('Editing is not supported for this network configuration ({backend}).', { backend: s.backend }));
const script = `export PATH=$PATH:/usr/sbin:/sbin
mkdir -p ${STATE} || exit 1; rm -f ${STATE}/pending ${STATE}/rolled-back
mkdir -p ${STATE} || exit 1; rm -f ${STATE}/pending ${STATE}/applied ${STATE}/rolled-back
cd / && tar czf ${STATE}/backup.tgz $(for p in ${be.paths.join(' ')}; do [ -e "$p" ] && echo "$p"; done) || exit 1
# Schreiben in einer Subshell: bei einem Fehler sofort den alten Stand wiederherstellen
if ! ( set -e
@@ -234,7 +236,7 @@ ${writes.join('\n')}
) >${STATE}/write.log 2>&1; then ${be.restore}; cat ${STATE}/write.log >&2; exit 5; fi
if ! (${be.validate}) >${STATE}/validate.log 2>&1; then ${be.restore}; cat ${STATE}/validate.log >&2; exit 4; fi
touch ${STATE}/pending
nohup setsid sh -c 'export PATH=$PATH:/usr/sbin:/sbin; sleep 2; (${be.apply}) >${STATE}/apply.log 2>&1; sleep ${ROLLBACK_SECONDS}; if [ -f ${STATE}/pending ]; then ${be.restore}; (${be.apply}) >>${STATE}/apply.log 2>&1; rm -f ${STATE}/pending; date "+%Y-%m-%d %H:%M:%S" > ${STATE}/rolled-back; fi' >/dev/null 2>&1 &
nohup setsid sh -c 'export PATH=$PATH:/usr/sbin:/sbin; sleep 2; (${be.apply}) >${STATE}/apply.log 2>&1; touch ${STATE}/applied; sleep ${ROLLBACK_SECONDS}; if [ -f ${STATE}/pending ]; then ${be.restore}; (${be.apply}) >>${STATE}/apply.log 2>&1; rm -f ${STATE}/pending; date "+%Y-%m-%d %H:%M:%S" > ${STATE}/rolled-back; fi' >/dev/null 2>&1 &
echo started`;
const r = await this.root(s, script);
if (r.code === 4) throw new Error(i18n.t('The new configuration is invalid and was not applied: {err}', { err: lastLine(r.err) }));
@@ -245,13 +247,14 @@ echo started`;
while (Date.now() < deadline) {
try {
const { conn, jumps } = await this.ssh.connect(target, id);
const c = await this.root(s, `rm -f ${STATE}/pending`, conn);
if (c.code) { conn.end(); throw new Error(lastLine(c.err)); }
// Erst bestätigen, wenn das Anwenden fertig ist: sonst reißt z. B. ein Bond-Neustart die neue Verbindung wieder ab
const c = await this.root(s, `[ -f ${STATE}/applied ] || exit 7; rm -f ${STATE}/pending`, conn);
if (c.code) { conn.on('error', () => {}); conn.end(); jumps?.forEach((x) => { try { x.end(); } catch {} }); throw new Error(lastLine(c.err) || 'not applied yet'); }
// Neue Verbindung übernimmt die Sitzung
s.replacing = true;
try { s.conn.end(); } catch {}
s.jumps?.forEach((x) => { try { x.end(); } catch {} });
Object.assign(s, { conn, jumps, replacing: false });
const old = { conn: s.conn, jumps: s.jumps };
Object.assign(s, { conn, jumps });
// Alte Verbindung ist nach dem IP-Wechsel meist tot: Fehler (z. B. Keepalive-Timeout) still verwerfen
for (const c of [old.conn, ...(old.jumps || [])]) { c.on('error', () => {}); try { c.end(); } catch {} }
if (verifyAddress) s.host = target;
this.watch(id, s);
return { confirmed: true };
+1 -1
View File
@@ -35,7 +35,7 @@ class SshManager {
const cfg = {
host: host.address,
port: Number(host.port) || 22,
username: host.username || os.userInfo().username,
username: host.username || (() => { try { return os.userInfo().username; } catch { return 'root'; } })(),
readyTimeout: 20000,
keepaliveInterval: (this.store.get().settings.keepAlive || 0) * 1000,
tryKeyboard: true,
+10 -7
View File
@@ -1,6 +1,7 @@
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
const { app, safeStorage } = require('electron');
// Im Web-Server gibt es kein Electron: dort übergibt der Aufrufer Verzeichnis und Verschlüsselung (opts)
const electron = (() => { try { const e = require('electron'); return typeof e === 'object' ? e : {}; } catch { return {}; } })();
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
@@ -57,8 +58,10 @@ function unbox(key, b) {
}
class Store {
constructor() {
this.dir = app.getPath('userData');
// opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage
constructor(opts = {}) {
this.dir = opts.dir || electron.app.getPath('userData');
this.crypto = opts.crypto || electron.safeStorage;
this.file = path.join(this.dir, 'vault.dat');
this.data = structuredClone(DEFAULTS);
this.encrypted = false;
@@ -97,9 +100,9 @@ class Store {
canEncrypt() {
try {
if (!safeStorage.isEncryptionAvailable()) return false;
if (!this.crypto.isEncryptionAvailable()) return false;
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend?.() === 'basic_text') return false;
if (process.platform === 'linux' && this.crypto.getSelectedStorageBackend?.() === 'basic_text') return false;
return true;
} catch { return false; }
}
@@ -110,7 +113,7 @@ class Store {
const raw = fs.readFileSync(this.file);
let json;
if (raw.slice(0, 4).toString() === 'ENC1') {
json = safeStorage.decryptString(raw.slice(4));
json = this.crypto.decryptString(raw.slice(4));
this.encrypted = true;
} else {
json = raw.toString('utf8');
@@ -136,7 +139,7 @@ class Store {
}
const tmp = this.file + '.tmp';
if (this.canEncrypt()) {
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)]));
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), this.crypto.encryptString(json)]));
this.encrypted = true;
} else {
fs.writeFileSync(tmp, json, { mode: 0o600 });
+4 -2
View File
@@ -193,7 +193,7 @@ class SyncService {
d.sync ||= { enabled: false, peers: [], share: { keys: [], hosts: [], vpns: [] } };
const s = d.sync;
if (!s.deviceId) s.deviceId = crypto.randomUUID();
if (!s.deviceName) s.deviceName = os.hostname();
if (!s.deviceName) s.deviceName = process.env.MRTERM_DEVICE || os.hostname();
s.peers ||= []; s.share ||= { keys: [], hosts: [], vpns: [] };
return s;
}
@@ -241,7 +241,9 @@ class SyncService {
// ------------------------------------------------ Finden
broadcastAddrs() {
const out = new Set(['255.255.255.255']);
for (const list of Object.values(os.networkInterfaces())) {
let ifaces = {};
try { ifaces = os.networkInterfaces(); } catch {} // Android: je nach Version eingeschränkt
for (const list of Object.values(ifaces)) {
for (const a of list || []) {
if (a.family !== 'IPv4' || a.internal) continue;
const ip = a.address.split('.').map(Number), mask = a.netmask.split('.').map(Number);
+110
View File
@@ -0,0 +1,110 @@
/* App-Designs: gemeinsame Farbvariablen für Desktop (src/renderer) und Android-App (mobile/web) */
:root {
--bg: #13151c;
--bg-2: #181b24;
--panel: #1c1f2a;
--card: #212533;
--card-hover: #282d3d;
--border: #2b3040;
--text: #e6e8ef;
--muted: #8b91a5;
--faint: #5d6377;
--accent: #6e7bff;
--accent-2: #8b95ff;
--green: #3ecf8e;
--red: #ff5f6d;
--orange: #ffb454;
--accent-3: #b36bff;
--chrome: #0f1117;
--chrome-line: #1f2330;
--hover: #1a1d27;
--nav-active: #1f2233;
--icon-bg: #2a2f45;
--folder: #6ea8ff;
--row-line: #1c1f29;
--overlay: #13151ce6;
--tint: 255 255 255;
--radius: 10px;
--on-accent: #ffffff;
--font: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
}
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
[data-theme='navy'] {
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
}
[data-theme='nord'] {
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
}
[data-theme='dracula'] {
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
}
[data-theme='mocha'] {
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
}
[data-theme='forest'] {
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
}
[data-theme='light'] {
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
--tint: 0 0 0;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
/* Windows XP (Luna) */
[data-theme='xp'] {
--bg: #ece9d8; --bg-2: #e3dfcb; --panel: #ffffff; --card: #ffffff; --card-hover: #f1efe2; --border: #aca899;
--text: #000000; --muted: #4a4a4a; --faint: #7f7f7f; --accent: #316ac5; --accent-3: #3a9a3a; --green: #2b8a2b; --red: #c81e1e; --orange: #d87400;
--chrome: #d6dff7; --chrome-line: #7a96df; --hover: #e8eefc; --nav-active: #c1d2ee; --icon-bg: #dde7f8; --folder: #e0a92a; --row-line: #ece9d8; --overlay: #ece9d8e6;
--tint: 0 0 0; --radius: 3px; --font: Tahoma, Verdana, 'Segoe UI', sans-serif;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
/* Windows 11 (hell, Mica) */
[data-theme='win11'] {
--bg: #f3f3f3; --bg-2: #eeeeee; --panel: #fbfbfb; --card: #ffffff; --card-hover: #f6f6f6; --border: #e5e5e5;
--text: #1b1b1b; --muted: #5f5f5f; --faint: #8a8a8a; --accent: #0067c0; --accent-3: #8764b8; --green: #0f7b0f; --red: #c42b1c; --orange: #9d5d00;
--chrome: #ebebeb; --chrome-line: #e0e0e0; --hover: #eaeaea; --nav-active: #e0e0e0; --icon-bg: #e6eef8; --folder: #e8a33d; --row-line: #efefef; --overlay: #f3f3f3e6;
--tint: 0 0 0; --radius: 8px; --font: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
/* macOS 26 „Liquid Glass“: milchige, durchscheinende Flächen über einem farbigen Hintergrund */
[data-theme='macos'] {
--bg: rgba(255, 255, 255, .34); --bg-2: rgba(255, 255, 255, .28); --panel: rgba(255, 255, 255, .55); --card: rgba(255, 255, 255, .48);
--card-hover: rgba(255, 255, 255, .68); --border: rgba(255, 255, 255, .7);
--text: #1d1d1f; --muted: #55555c; --faint: #8a8a92; --accent: #007aff; --accent-3: #af52de; --green: #248a3d; --red: #e0352b; --orange: #c96b00;
--chrome: rgba(255, 255, 255, .3); --chrome-line: rgba(255, 255, 255, .55); --hover: rgba(255, 255, 255, .45); --nav-active: rgba(255, 255, 255, .72);
--icon-bg: rgba(0, 122, 255, .12); --folder: #1a8cff; --row-line: rgba(255, 255, 255, .5); --overlay: rgba(240, 242, 250, .55);
--tint: 0 0 0; --radius: 14px; --font: -apple-system, 'SF Pro Text', 'Helvetica Neue', 'Inter', system-ui, sans-serif;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
--glass-blur: blur(26px) saturate(185%);
--glass-edge: inset 0 1px 0 rgba(255, 255, 255, .95), inset 0 -1px 0 rgba(255, 255, 255, .3), 0 10px 30px rgba(30, 40, 80, .10);
}
/* Hintergrund, durch den das Glas schimmert */
[data-theme='macos'] {
--wallpaper:
radial-gradient(at 12% 18%, #9cc2ff 0, transparent 46%),
radial-gradient(at 88% 12%, #f7b6dd 0, transparent 42%),
radial-gradient(at 72% 88%, #98e2d2 0, transparent 46%),
radial-gradient(at 18% 92%, #ffd6a0 0, transparent 42%),
#eef1f8;
}
html[data-theme='macos'], html[data-theme='macos'] body { background: var(--wallpaper); background-attachment: fixed; }
/* Cyberpunk 2077 */
[data-theme='cyberpunk'] {
--bg: #0a0a0f; --bg-2: #0e0e15; --panel: #111119; --card: #15151f; --card-hover: #1e1c10; --border: #3d3a14;
--text: #eafcff; --muted: #8fb8c8; --faint: #5a7680; --accent: #fcee0a; --accent-3: #00f0ff; --green: #00ff9f; --red: #ff003c; --orange: #ff9e00;
--chrome: #07070b; --chrome-line: #2a2808; --hover: #17161b; --nav-active: #232008; --icon-bg: #26240b; --folder: #00f0ff; --row-line: #17161f; --overlay: #0a0a0fe6;
--radius: 2px; --on-accent: #0a0a0f; --font: 'Rajdhani', 'Orbitron', 'Bahnschrift', 'DIN Alternate', 'Segoe UI', system-ui, sans-serif;
--accent-2: #fff45c;
}
+143 -13
View File
@@ -5,9 +5,12 @@
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const uid = () => crypto.randomUUID();
// crypto.randomUUID gibt es nur in sicheren Kontexten (HTTPS/localhost) – die Web-Version läuft oft per http://<server>
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)));
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
const T = I18N.t;
// Web-Version (Docker, src/web): gleiche Oberfläche im Browser; ohne RDP, VPN, Port-Forwarding und lokale Dateien
const WEB = api.platform === 'web';
const ICONS = {
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
@@ -407,6 +410,7 @@ function connectHost(hst) {
}
async function openRdp(hst) {
if (WEB) return toast(T('RDP is not available in the web version.'), 'error');
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);
@@ -863,6 +867,60 @@ function viewHistory(page) {
}
// ============================================================ Einstellungen
// ---------- Web-Version: Konto und Benutzerverwaltung (HTTP-API des Web-Servers)
async function webApi(path, body) {
const r = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: body === undefined ? undefined : JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(T(j.error || r.statusText));
return j;
}
function webAccountCard() {
const card = h(`<div class="settings-card"><h3>${T('Account')}</h3><div class="acc"></div></div>`);
const draw = async () => {
const box = $('.acc', card);
let me;
try { me = await webApi('/api/me'); } catch (e) { box.textContent = e.message; return; }
box.innerHTML = `<p style="color:var(--muted);margin-top:0">${esc(T('Signed in as {name}', { name: me.name }))}${me.admin ? ` · ${esc(T('Administrator'))}` : ''}</p><div class="row" style="flex-wrap:wrap"></div>`;
const pw = h(`<button class="btn">${esc(T('Change password'))}</button>`);
pw.onclick = async () => {
const r = await modal({ title: T('Change password'), body: `<div class="field"><label>${esc(T('Current password'))}</label><input name="old" type="password"/></div><div class="field"><label>${esc(T('New password'))}</label><input name="a" type="password"/></div><div class="field"><label>${esc(T('Repeat password'))}</label><input name="b" type="password"/></div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
try { await webApi('/api/password', { old: r.old, password: r.a }); toast(T('Saved'), 'ok'); } catch (e) { toast(e.message, 'error'); }
};
const out = h(`<button class="btn">${esc(T('Sign out'))}</button>`);
out.onclick = async () => { await webApi('/api/logout', {}).catch(() => {}); location.href = '/'; };
$('.row', box).append(pw, out);
if (!me.admin) return;
const users = await webApi('/api/users').catch(() => ({ users: [] }));
const list = h(`<div style="margin-top:16px"><h4 style="margin:0 0 8px">${esc(T('Users'))}</h4></div>`);
for (const u of users.users) {
const row = h(`<div class="lock-row"><div class="grow"><b>${esc(u.name)}</b><div class="sub">${u.admin ? esc(T('Administrator')) : esc(T('User'))}</div></div></div>`);
if (u.id !== me.id) {
const del = h(`<button class="btn ghost" title="${esc(T('Delete'))}">${ICONS.trash}</button>`);
del.onclick = async () => {
if (!(await confirmBox(T('Delete user?'), T('“{name}” and their vault will be permanently deleted.', { name: u.name })))) return;
try { await webApi('/api/users/delete', { id: u.id }); draw(); } catch (e) { toast(e.message, 'error'); }
};
row.append(del);
}
list.append(row);
}
const add = h(`<button class="btn" style="margin-top:10px">${ICONS.plus}${esc(T('Add user'))}</button>`);
add.onclick = async () => {
const r = await modal({ title: T('Add user'), body: `<div class="field"><label>${esc(T('Username'))}</label><input name="name" autocomplete="off"/></div><div class="field"><label>${esc(T('Password'))}</label><input name="password" type="password" autocomplete="new-password"/></div><label class="check"><input type="checkbox" name="admin"/>${esc(T('Administrator'))}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add user'), value: 'form', cls: 'primary' }] });
if (!r) return;
try { await webApi('/api/users', r); toast(T('Saved'), 'ok'); draw(); } catch (e) { toast(e.message, 'error'); }
};
list.append(add);
box.append(list);
};
draw();
return card;
}
async function viewSettings(page) {
const st = settings();
page.append(h(`<div class="toolbar"><h2 style="margin:0;font-size:18px">${T('Settings')}</h2></div>`));
@@ -931,9 +989,9 @@ async function viewSettings(page) {
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
}).catch(() => {});
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${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 <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : 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 <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
const btns = [
[T('Import ~/.ssh/config'), importSshConfig],
...(WEB ? [] : [[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(); } }],
];
@@ -985,7 +1043,8 @@ async function viewSettings(page) {
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, importCard, dataCard, keysCard);
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
}
// ============================================================ App-Sperre
@@ -1643,7 +1702,7 @@ class FilePane {
<input class="path" spellcheck="false"/>
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button></div>
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div>
<div class="files"></div></div>`);
this.el.addEventListener('click', (e) => {
const p = e.target.closest('[data-p]')?.dataset.p;
@@ -1652,16 +1711,34 @@ class FilePane {
if (p === 'refresh') this.go(this.cwd);
if (p === 'mkdir') this.mkdir();
if (p === 'xfer') this.transferSelected();
if (p === 'upload') this.pickUpload();
});
$('.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: 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'); } });
// Web-Version: Dateien aus dem Betriebssystem in den Server-Bereich ziehen = hochladen
const osFiles = (e) => WEB && [...(e.dataTransfer?.types || [])].includes('Files');
this.el.addEventListener('dragover', (e) => { if ((S.drag && S.drag.pane !== this) || osFiles(e)) { e.preventDefault(); this.el.classList.add('dragover'); } });
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; });
this.el.addEventListener('drop', (e) => {
e.preventDefault(); this.el.classList.remove('dragover');
if (osFiles(e)) return this.uploadFiles([...e.dataTransfer.files]);
if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected();
S.drag = null;
});
}
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
pickUpload() {
const inp = h('<input type="file" multiple style="display:none"/>');
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
document.body.append(inp);
inp.click();
}
async uploadFiles(files) {
for (const f of files) await this.sftp.webTransfer('upload', f, `${this.cwd.replace(/\/$/, '')}/${f.name}`, f.name);
this.go(this.cwd);
}
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
@@ -1756,6 +1833,14 @@ class FilePane {
async transferSelected() {
const names = [...this.sel];
if (!names.length) return toast(T('Nothing selected'));
if (WEB) {
for (const n of names) {
const f = this.files.find((x) => x.name === n);
if (f?.isDir) { toast(T('Folders cannot be downloaded in the browser: {name}', { name: n })); continue; }
this.sftp.webTransfer('download', null, await this.join(this.cwd, n), n);
}
return;
}
const target = this.other;
for (const n of names) {
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
@@ -1771,17 +1856,18 @@ class SftpSession {
this.kind = 'sftp'; this.id = uid(); this.host = host;
this.title = `SFTP · ${host.label || host.address}`;
this.el = h(`<div class="session"><div class="sftp"></div><div class="transfers"></div></div>`);
this.local = new FilePane(this, 'local');
this.local = WEB ? null : new FilePane(this, 'local');
this.remote = new FilePane(this, 'remote');
const wrap = $('.sftp', this.el);
wrap.append(this.local.el, h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
if (this.local) wrap.append(this.local.el);
wrap.append(h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
this.xfers = new Map();
addTab(this);
this.open();
}
async open() {
this.local.init(await api.call('local:home'));
if (this.local) this.local.init(await api.call('local:home'));
try {
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
@@ -1810,6 +1896,32 @@ class SftpSession {
setTimeout(() => row.remove(), 10000);
}
}
// Web-Version: Upload per HTTP (mit Fortschritt), Download als Browser-Download
webTransfer(dir, file, remotePath, name) {
const q = `sid=${encodeURIComponent(this.id)}&path=${encodeURIComponent(remotePath)}`;
if (dir === 'download') {
const a = h(`<a href="/web/download?${q}" download="${esc(name)}" style="display:none"></a>`);
document.body.append(a); a.click(); a.remove();
return Promise.resolve();
}
const tid = uid();
const row = h(`<div class="transfer"><span>${ICONS.upload}</span><span class="nm">${esc(name)}</span><span class="pct">0%</span><div class="bar"><i></i></div></div>`);
$('.transfers', this.el).prepend(row);
this.xfers.set(tid, row);
return new Promise((resolve) => {
const x = new XMLHttpRequest();
x.open('POST', `/web/upload?${q}`);
x.setRequestHeader('X-MrTerm', '1');
x.upload.onprogress = (e) => this.progress(tid, e.loaded, e.total);
x.onload = () => {
if (x.status < 300) { row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done'); setTimeout(() => row.remove(), 5000); }
else { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); const m = (() => { try { return JSON.parse(x.responseText).error; } catch { return x.statusText; } })(); toast(`${name}: ${m}`, 'error'); setTimeout(() => row.remove(), 10000); }
resolve();
};
x.onerror = () => { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); resolve(); };
x.send(file);
});
}
progress(tid, done, total) {
const row = this.xfers.get(tid); if (!row) return;
const p = total ? Math.round((done / total) * 100) : 0;
@@ -2185,8 +2297,13 @@ class NetworkSession {
this.draw();
}
async refresh() {
try { this.apply(await api.call('network:read', this.id)); } catch (e) { toast(e.message, 'error'); }
async refresh(tries = 1) {
for (let i = 1; ; i++) {
try { return this.apply(await api.call('network:read', this.id)); } catch (e) {
if (i >= tries) return toast(e.message, 'error');
await new Promise((r) => setTimeout(r, 3000));
}
}
}
cfg(name) { return this.data.config.find((c) => c.name === name); }
@@ -2258,7 +2375,7 @@ class NetworkSession {
try {
const r = await fn();
ov.remove();
if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(); }
if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(4); }
else {
setTabState(this, 'err');
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--orange);max-width:560px;text-align:center">${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}</div></div>`;
@@ -2461,6 +2578,19 @@ document.addEventListener('keydown', (e) => {
}
});
// Web-Version: Hostschlüssel bestätigen (in Electron zeigt der Hauptprozess dafür einen Systemdialog)
api.on('hostkey:request', async (req) => {
const changed = !!req.previous;
const r = await modal({
title: changed ? T('Host key has changed!') : T('Unknown host'),
body: `<p style="margin-top:0">${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}</p>
<div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.fingerprint)}</div>
${changed ? `<p>${esc(T('Previously stored:'))}</p><div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.previous)}</div>` : ''}`,
buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }],
noEnter: changed,
});
api.replySecret(req.reqId, r === true);
});
api.on('secret:request', async (req) => {
const r = await modal({
title: req.title || T('Authentication'),
+1
View File
@@ -5,6 +5,7 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" />
<title>MrTerm</title>
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" />
<link rel="stylesheet" href="app-themes.css" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
+31 -65
View File
@@ -1,71 +1,12 @@
:root {
--bg: #13151c;
--bg-2: #181b24;
--panel: #1c1f2a;
--card: #212533;
--card-hover: #282d3d;
--border: #2b3040;
--text: #e6e8ef;
--muted: #8b91a5;
--faint: #5d6377;
--accent: #6e7bff;
--accent-2: #8b95ff;
--green: #3ecf8e;
--red: #ff5f6d;
--orange: #ffb454;
--accent-3: #b36bff;
--chrome: #0f1117;
--chrome-line: #1f2330;
--hover: #1a1d27;
--nav-active: #1f2233;
--icon-bg: #2a2f45;
--folder: #6ea8ff;
--row-line: #1c1f29;
--overlay: #13151ce6;
--tint: 255 255 255;
--radius: 10px;
--titlebar: 42px;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
font-family: var(--font);
font-size: 13px;
color: var(--text);
}
/* ---------- App-Designs ---------- */
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
[data-theme='navy'] {
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
}
[data-theme='nord'] {
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
}
[data-theme='dracula'] {
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
}
[data-theme='mocha'] {
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
}
[data-theme='forest'] {
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
}
[data-theme='light'] {
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
--tint: 0 0 0;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
[data-theme='light'] .card { border-color: var(--border); }
[data-theme='light'] .avatar .proto { background: var(--panel); }
[data-theme='light'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
[data-theme='light'] .card, [data-theme='xp'] .card, [data-theme='win11'] .card, [data-theme='macos'] .card { border-color: var(--border); }
[data-theme='light'] .avatar .proto, [data-theme='xp'] .avatar .proto, [data-theme='win11'] .avatar .proto, [data-theme='macos'] .avatar .proto { background: var(--panel); }
[data-theme='light'] ::-webkit-scrollbar-thumb, [data-theme='xp'] ::-webkit-scrollbar-thumb, [data-theme='win11'] ::-webkit-scrollbar-thumb, [data-theme='macos'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
button { font: inherit; color: inherit; cursor: pointer; }
@@ -154,7 +95,7 @@ body.in-session #sidebar { display: none; }
.search svg { color: var(--faint); }
.btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); font-weight: 600; white-space: nowrap; }
.btn:hover { background: var(--card-hover); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn.primary:hover { background: var(--accent-2); }
.btn.danger { color: var(--red); }
.btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
@@ -221,7 +162,7 @@ body.in-session #sidebar { display: none; }
.form h4 { margin: 20px 0 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent-2); }
.seg { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; margin-bottom: 14px; }
.seg button { flex: 1; border: none; background: none; padding: 7px; border-radius: 6px; color: var(--muted); font-weight: 600; }
.seg button.active { background: var(--accent); color: white; }
.seg button.active { background: var(--accent); color: var(--on-accent); }
.check { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; cursor: pointer; }
.check input { accent-color: var(--accent); width: 16px; height: 16px; }
.colors { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -443,3 +384,28 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
.fw-hint b { color: var(--text); }
.fw-hint p { margin: 4px 0 8px; color: var(--muted); }
.fw-hint pre, .fw-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 0 0 10px; color: var(--text); white-space: pre-wrap; user-select: text; }
/* Design-Extras: XP-Titelleiste, Cyberpunk-Neon */
[data-theme='xp'] #titlebar { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; }
[data-theme='xp'] #titlebar .brand, [data-theme='xp'] #titlebar .win-ctrls button, [data-theme='xp'] #titlebar .tab:not(.active) { color: #fff; }
[data-theme='xp'] #titlebar .tab.active { color: var(--text); }
[data-theme='xp'] .btn.primary { background: linear-gradient(#3d86e8, #2159b8); border-color: #1b4a9a; }
[data-theme='cyberpunk'] .btn.primary { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 10px) 100%, 0 100%); text-transform: uppercase; letter-spacing: .5px; }
[data-theme='cyberpunk'] #titlebar { border-bottom: 1px solid #fcee0a55; }
[data-theme='cyberpunk'] .card:hover { box-shadow: inset 2px 0 0 var(--accent-3); }
[data-theme='xp'] .brand-term, [data-theme='win11'] .brand-term, [data-theme='macos'] .brand-term { color: #14a37a; }
/* macOS Liquid Glass */
[data-theme='macos'] #main, [data-theme='macos'] .pane { background: transparent; }
[data-theme='macos'] :is(#titlebar, #sidebar, .toolbar, .card, .settings-card, .modal, .drawer, .ctx, .palette, .search, .toast, .net-card, .findbar, .pane th, .tab.active, .btn, .seg, .field input, .field select, .field textarea) {
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
}
[data-theme='macos'] :is(.card, .settings-card, .modal, .drawer, .ctx, .palette, .toast, .net-card, .search) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
[data-theme='macos'] #sidebar { border-right: 1px solid var(--chrome-line); box-shadow: inset -1px 0 0 rgba(255, 255, 255, .6); }
[data-theme='macos'] #titlebar { border-bottom: 1px solid var(--chrome-line); }
[data-theme='macos'] .btn { border-radius: 999px; border-color: rgba(255, 255, 255, .75); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
[data-theme='macos'] .btn.primary { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); border-color: rgba(255, 255, 255, .45); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 4px 14px rgba(0, 122, 255, .3); }
[data-theme='macos'] .modal-bg { -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
[data-theme='macos'] .nav.active { box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
/* Web-Version (Browser): Desktop-Funktionen ausblenden */
.web .win-ctrls, .web #lockBtn, .web .nav[data-view='vpns'], .web .nav[data-view='forwards'], .web #updateBadge { display: none !important; }
+19 -1
View File
@@ -29,6 +29,20 @@ TERM_THEMES.mocha = { name: 'Catppuccin Mocha', background: '#1e1e2e', foregroun
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
// Terminal-Schemata zu den System-Designs
TERM_THEMES.xp = { name: 'Windows XP (cmd.exe)', background: '#000000', foreground: '#c0c0c0', cursor: '#c0c0c0', cursorAccent: '#000000', selectionBackground: '#c0c0c066',
black: '#000000', red: '#800000', green: '#008000', yellow: '#808000', blue: '#000080', magenta: '#800080', cyan: '#008080', white: '#c0c0c0',
brightBlack: '#808080', brightRed: '#ff0000', brightGreen: '#00ff00', brightYellow: '#ffff00', brightBlue: '#0000ff', brightMagenta: '#ff00ff', brightCyan: '#00ffff', brightWhite: '#ffffff' };
TERM_THEMES.campbell = { name: 'Windows 11 (Campbell)', background: '#0c0c0c', foreground: '#cccccc', cursor: '#ffffff', cursorAccent: '#0c0c0c', selectionBackground: '#ffffff40',
black: '#0c0c0c', red: '#c50f1f', green: '#13a10e', yellow: '#c19c00', blue: '#0037da', magenta: '#881798', cyan: '#3a96dd', white: '#cccccc',
brightBlack: '#767676', brightRed: '#e74856', brightGreen: '#16c60c', brightYellow: '#f9f1a5', brightBlue: '#3b78ff', brightMagenta: '#b4009e', brightCyan: '#61d6d6', brightWhite: '#f2f2f2' };
TERM_THEMES.macos = { name: 'macOS Terminal', background: '#ffffff', foreground: '#1d1d1f', cursor: '#7f7f7f', cursorAccent: '#ffffff', selectionBackground: '#b4d7ff',
black: '#000000', red: '#c23621', green: '#25bc24', yellow: '#adad27', blue: '#492ee1', magenta: '#d338d3', cyan: '#33bbc8', white: '#cbcccd',
brightBlack: '#818383', brightRed: '#fc391f', brightGreen: '#31e722', brightYellow: '#eaec23', brightBlue: '#5833ff', brightMagenta: '#f935f8', brightCyan: '#14f0f0', brightWhite: '#e9ebeb' };
TERM_THEMES.cyberpunk = { name: 'Cyberpunk 2077', background: '#0a0a0f', foreground: '#eafcff', cursor: '#fcee0a', cursorAccent: '#0a0a0f', selectionBackground: '#00f0ff44',
black: '#15151f', red: '#ff003c', green: '#00ff9f', yellow: '#fcee0a', blue: '#00b8ff', magenta: '#ff2bd6', cyan: '#00f0ff', white: '#d7e6ee',
brightBlack: '#5a7680', brightRed: '#ff4f70', brightGreen: '#5cffc0', brightYellow: '#fff45c', brightBlue: '#5cd3ff', brightMagenta: '#ff6be4', brightCyan: '#6bf7ff', brightWhite: '#ffffff' };
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
window.APP_THEMES = {
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
@@ -37,5 +51,9 @@ 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: 'Light', 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', light: true },
xp: { name: 'Windows XP', bg: '#ece9d8', side: '#0a5fdc', card: '#ffffff', accent: '#316ac5', text: '#000000', term: 'xp', light: true },
win11: { name: 'Windows 11', bg: '#f3f3f3', side: '#ebebeb', card: '#ffffff', accent: '#0067c0', text: '#1b1b1b', term: 'campbell', light: true },
macos: { name: 'macOS', bg: '#ffffff', side: '#e8e8ed', card: '#f5f5f7', accent: '#007aff', text: '#1d1d1f', term: 'macos', light: true },
cyberpunk: { name: 'Cyberpunk 2077', bg: '#0a0a0f', side: '#07070b', card: '#15151f', accent: '#fcee0a', text: '#eafcff', term: 'cyberpunk' },
};
+16
View File
@@ -0,0 +1,16 @@
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body { background: var(--bg); color: var(--text); font-family: var(--font); display: grid; place-items: center; min-height: 100vh; padding: 16px; }
.box { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 32px 28px 22px; text-align: center; box-shadow: 0 20px 60px #0006; }
.logo { width: 72px; height: 72px; border-radius: 18px; }
h1 { margin: 10px 0 4px; font-size: 26px; }
h1 span { color: var(--green); }
p { color: var(--muted); margin: 0 0 22px; font-size: 14px; }
form { display: flex; flex-direction: column; gap: 14px; text-align: left; }
label span { display: block; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
input { width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; font: inherit; outline: none; }
input:focus { border-color: var(--accent); }
button { margin-top: 6px; background: var(--accent); color: var(--on-accent); border: 0; border-radius: 10px; padding: 12px; font: 600 15px var(--font); cursor: pointer; }
button:disabled { opacity: .6; }
.err { color: var(--red); font-size: 13px; }
.ver { margin-top: 18px; color: var(--faint); font-size: 11px; }
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MrTerm</title>
<link rel="icon" href="/favicon.png" />
<link rel="stylesheet" href="/app/src/renderer/app-themes.css" />
<link rel="stylesheet" href="/web/login.css" />
</head>
<body>
<main class="box">
<img src="/favicon.png" alt="" class="logo" />
<h1>Mr<span>Term</span></h1>
<p id="intro"></p>
<form id="form" autocomplete="on">
<label><span data-t="Username">Username</span><input name="name" autocomplete="username" required autofocus /></label>
<label><span data-t="Password">Password</span><input name="password" type="password" autocomplete="current-password" required /></label>
<label id="repeatRow" hidden><span data-t="Repeat password">Repeat password</span><input name="repeat" type="password" autocomplete="new-password" /></label>
<div id="err" class="err" hidden></div>
<button id="submit" type="submit"></button>
</form>
<div class="ver" id="ver"></div>
</main>
<script src="/app/src/i18n.js"></script>
<script src="/web/login.js"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
// Anmeldung bzw. Ersteinrichtung (Admin-Konto) der Web-Version
(async () => {
'use strict';
I18N.setLanguage('auto', navigator.language);
const T = I18N.t;
const $ = (s) => document.querySelector(s);
document.querySelectorAll('[data-t]').forEach((e) => { e.textContent = T(e.dataset.t); });
const st = await fetch('/api/state').then((r) => r.json());
if (st.user) { location.href = '/app/src/renderer/index.html'; return; }
const setup = st.setup;
$('#ver').textContent = `MrTerm Web ${st.version}`;
$('#intro').textContent = setup ? T('Welcome! Create the administrator account for this MrTerm server.') : T('Sign in to your MrTerm server.');
$('#submit').textContent = setup ? T('Create account') : T('Sign in');
$('#repeatRow').hidden = !setup;
if (setup) $('[name=password]').autocomplete = 'new-password';
$('#form').onsubmit = async (e) => {
e.preventDefault();
const f = Object.fromEntries(new FormData(e.target));
const err = $('#err');
err.hidden = true;
if (setup && f.password !== f.repeat) { err.textContent = T('The passwords do not match.'); err.hidden = false; return; }
$('#submit').disabled = true;
try {
const r = await fetch(setup ? '/api/setup' : '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: JSON.stringify({ name: f.name, password: f.password }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || r.statusText);
location.href = '/app/src/renderer/index.html';
} catch (ex) {
err.textContent = T(ex.message);
err.hidden = false;
$('#submit').disabled = false;
}
};
})();
+109
View File
@@ -0,0 +1,109 @@
// window.api für die Web-Version: gleiche Schnittstelle wie src/preload.js (Electron),
// aber über einen WebSocket zum MrTerm-Web-Server. Dateidialoge, Zwischenablage und Links laufen im Browser.
(() => {
'use strict';
const listeners = new Map();
const waits = new Map();
let ws, seq = 0, queue = [], retry = 0, banner;
const emit = (ch, args) => listeners.get(ch)?.forEach((fn) => { try { fn(...args); } catch (e) { console.error(e); } });
function showBanner(text) {
if (!document.body) return;
if (!banner) {
banner = document.createElement('div');
banner.style.cssText = 'position:fixed;left:50%;top:10px;transform:translateX(-50%);z-index:9999;background:var(--card,#222);color:var(--text,#eee);border:1px solid var(--border,#444);border-radius:10px;padding:8px 14px;font:13px system-ui;box-shadow:0 6px 24px #0006';
document.body.append(banner);
}
banner.textContent = text;
banner.hidden = !text;
}
function connect() {
ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
ws.onopen = () => { retry = 0; showBanner(''); for (const m of queue) ws.send(m); queue = []; };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.t === 'reply') {
const w = waits.get(m.id);
if (!w) return;
waits.delete(m.id);
m.ok ? w.resolve(m.v) : w.reject(new Error(m.v));
} else if (m.t === 'evt') emit(m.ch, m.args || []);
};
ws.onclose = (e) => {
for (const w of waits.values()) w.reject(new Error('Connection to the MrTerm server lost.'));
waits.clear();
if (e.code === 4001) { location.href = '/'; return; }
// Sitzung abgelaufen? Dann zur Anmeldung, sonst neu verbinden
fetch('/api/me', { headers: { 'X-MrTerm': '1' } }).then((r) => {
if (r.status === 401) { location.href = '/'; return; }
showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …');
setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000));
}).catch(() => { showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …'); setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)); });
};
}
const post = (m) => { const s = JSON.stringify(m); if (ws.readyState === 1) ws.send(s); else queue.push(s); };
const remote = (ch, args) => new Promise((resolve, reject) => { const id = ++seq; waits.set(id, { resolve, reject }); post({ t: 'call', id, ch, args }); });
// Datei im Browser auswählen und als Text lesen
function pickFile(accept) {
return new Promise((resolve) => {
const inp = document.createElement('input');
inp.type = 'file';
if (accept) inp.accept = accept;
inp.style.display = 'none';
inp.onchange = async () => { const f = inp.files[0]; inp.remove(); resolve(f ? { name: f.name, content: await f.text() } : null); };
inp.addEventListener('cancel', () => { inp.remove(); resolve(null); });
document.body.append(inp);
inp.click();
});
}
function downloadText(name, text) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = Object.assign(document.createElement('a'), { href: url, download: name });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Kanäle, die im Browser selbst erledigt werden
const local = {
// Ohne HTTPS gibt es navigator.clipboard nicht: Kopieren per execCommand, Einfügen dann nur per Strg+V
'clipboard:write': (t) => {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(String(t));
const ta = Object.assign(document.createElement('textarea'), { value: String(t) });
ta.style.cssText = 'position:fixed;opacity:0';
document.body.append(ta); ta.select(); document.execCommand('copy'); ta.remove();
},
'clipboard:read': () => {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.readText();
throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.');
},
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; },
'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); },
'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; },
'import:pickFile': async () => pickFile(),
'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); },
};
window.api = {
platform: 'web',
call: (ch, ...args) => (local[ch] ? Promise.resolve().then(() => local[ch](...args)) : remote(ch, args)),
on(ch, fn) {
if (!listeners.has(ch)) listeners.set(ch, new Set());
listeners.get(ch).add(fn);
return () => listeners.get(ch).delete(fn);
},
win: { min() {}, max() {}, close() {} },
ssh: {
write: (id, d) => post({ t: 'send', ch: 'ssh:write', args: [id, d] }),
resize: (id, c, r) => post({ t: 'send', ch: 'ssh:resize', args: [id, c, r] }),
close: (id) => post({ t: 'send', ch: 'ssh:close', args: [id] }),
},
rdp: { bounds() {}, show() {}, hide() {}, focus() {}, close() {} },
replySecret: (reqId, v) => post({ t: 'send', ch: 'secret:reply', args: [reqId, v] }),
pairReply: (reqId, ok) => post({ t: 'send', ch: 'sync:pairReply', args: [reqId, ok] }),
};
document.documentElement.classList.add('web');
connect();
})();
+376
View File
@@ -0,0 +1,376 @@
// MrTerm Web: die Desktop-Oberfläche im Browser, selbst gehostet (Docker).
// - Benutzerkonten; jeder Tresor ist mit einem eigenen Datenschlüssel (DEK) verschlüsselt,
// den nur das Login-Passwort des Benutzers entpackt (scrypt → AES-256-GCM).
// - Oberfläche ↔ Server über WebSocket (/ws), gleiche Kanäle wie Electron/Android (src/core/backend.js).
// - SFTP-Up-/Downloads über HTTP (/web/upload, /web/download).
//
// Umgebung: PORT (8080), DATA_DIR (/data), TRUST_PROXY=1 (X-Forwarded-Proto/-For auswerten, z. B. hinter Traefik)
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const { Store } = require('../main/store');
const { createBackend } = require('../core/backend');
const i18n = require('../i18n');
const ROOT = process.env.MRTERM_ROOT || path.resolve(__dirname, '..', '..');
const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'web-data'));
const PORT = Number(process.env.PORT) || 8080;
const TRUST_PROXY = process.env.TRUST_PROXY === '1';
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { return '0.0.0'; } })();
const SESSION_IDLE = 12 * 3600e3;
const SESSION_MAX = 7 * 24 * 3600e3;
const COOKIE = 'mrterm_sid';
process.on('uncaughtException', (e) => console.error('Uncaught exception:', e));
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
fs.mkdirSync(path.join(DATA, 'users'), { recursive: true, mode: 0o700 });
// ============================================================ Krypto-Helfer
const scrypt = (pw, salt, N = 2 ** 15) => new Promise((resolve, reject) =>
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
function box(key, plain) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([c.update(plain), c.final()]);
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
}
function unbox(key, b) {
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64'));
d.setAuthTag(Buffer.from(b.tag, 'base64'));
return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]);
}
// Verschlüsselung des Tresors mit dem DEK (Schnittstelle wie Electrons safeStorage)
const vaultCrypto = (dek) => ({
isEncryptionAvailable: () => true,
encryptString(s) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', dek, iv);
const ct = Buffer.concat([c.update(String(s), 'utf8'), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]);
},
decryptString(buf) {
const d = crypto.createDecipheriv('aes-256-gcm', dek, buf.subarray(0, 12));
d.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
},
});
// ============================================================ Benutzer
const USERS_FILE = path.join(DATA, 'users.json');
let users = (() => { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')).users || []; } catch { return []; } })();
function saveUsers() {
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify({ users }, null, 2), { mode: 0o600 });
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
}
const validName = (n) => /^[A-Za-z0-9._@-]{1,64}$/.test(String(n || ''));
const validPw = (p) => typeof p === 'string' && p.length >= 8 && p.length <= 1024;
const publicUser = (u) => ({ id: u.id, name: u.name, admin: !!u.admin });
async function createUser(name, password, admin) {
if (!validName(name)) throw new Error('Usernames may contain letters, digits and . _ @ - (max. 64).');
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
if (users.some((u) => u.name.toLowerCase() === name.toLowerCase())) throw new Error('This username is already taken.');
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
const dek = crypto.randomBytes(32);
const u = {
id: crypto.randomUUID(), name, admin: !!admin, createdAt: Date.now(),
auth: { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') },
kek: { salt: kekSalt.toString('base64') },
dek: box(await scrypt(password, kekSalt), dek),
};
users.push(u);
saveUsers();
return u;
}
// Prüft das Passwort und liefert den entpackten DEK (oder null)
async function verify(u, password) {
const hash = await scrypt(password, Buffer.from(u.auth.salt, 'base64'));
if (!crypto.timingSafeEqual(hash, Buffer.from(u.auth.hash, 'base64'))) return null;
try { return unbox(await scrypt(password, Buffer.from(u.kek.salt, 'base64')), u.dek); } catch { return null; }
}
async function setPassword(u, dek, password) {
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
u.auth = { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') };
u.kek = { salt: kekSalt.toString('base64') };
u.dek = box(await scrypt(password, kekSalt), dek);
saveUsers();
}
// ============================================================ Sitzungen
const sessions = new Map(); // token -> { userId, dek, created, last }
function newSession(u, dek) {
const token = crypto.randomBytes(32).toString('base64url');
sessions.set(token, { userId: u.id, dek, created: Date.now(), last: Date.now() });
return token;
}
function sessionOf(req) {
const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`));
const s = m && sessions.get(m[1]);
if (!s) return null;
const now = Date.now();
if (now - s.last > SESSION_IDLE || now - s.created > SESSION_MAX || !users.some((u) => u.id === s.userId)) { sessions.delete(m[1]); return null; }
s.last = now;
return { token: m[1], ...s, user: users.find((u) => u.id === s.userId) };
}
setInterval(() => { for (const [t, s] of sessions) if (Date.now() - s.last > SESSION_IDLE || Date.now() - s.created > SESSION_MAX) sessions.delete(t); }, 600e3).unref();
const secure = (req) => req.socket.encrypted || (TRUST_PROXY && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
const cookie = (req, token, maxAge) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? '; Secure' : ''}`;
const clientIp = (req) => (TRUST_PROXY && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || '';
// Anmeldeversuche begrenzen: 10 Fehlversuche je IP in 15 Minuten
const failures = new Map();
const blocked = (ip) => { const f = failures.get(ip); return f && f.n >= 10 && Date.now() - f.first < 900e3; };
const fail = (ip) => { const f = failures.get(ip); if (!f || Date.now() - f.first > 900e3) failures.set(ip, { n: 1, first: Date.now() }); else f.n++; };
// ============================================================ Laufzeit je Benutzer (Tresor + Backend)
const runtimes = new Map(); // userId -> { store, be, sockets, timer }
function runtimeFor(sess) {
let rt = runtimes.get(sess.userId);
if (rt) { clearTimeout(rt.timer); return rt; }
const dir = path.join(DATA, 'users', sess.userId);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const store = new Store({ dir, crypto: vaultCrypto(sess.dek) });
store.load();
const sockets = new Set();
const send = (ch, ...args) => { const msg = JSON.stringify({ t: 'evt', ch, args }); for (const ws of sockets) if (ws.readyState === 1) ws.send(msg); };
const be = createBackend({ store, send, platform: 'web', version: VERSION });
// Desktop-Funktionen, die es im Browser nicht gibt: neutrale Antworten, damit die Oberfläche nicht stolpert
be.handle('vpn:status', () => ({}));
be.handle('fw:active', () => []);
be.handle('rdp:detect', () => ({}));
be.handle('rdp:embedSupported', () => ({ ok: false, reason: 'web' }));
be.handle('update:check', () => ({ available: false }));
be.handle('sync:status', () => ({ enabled: false, running: false, peers: [], nearby: [], share: { keys: [], hosts: [], vpns: [] } }));
rt = { store, be, sockets, timer: null };
runtimes.set(sess.userId, rt);
return rt;
}
// Letzter Browser-Tab zu: SSH-Verbindungen nach kurzer Zeit schließen und den Tresor aus dem Speicher nehmen
function release(userId) {
const rt = runtimes.get(userId);
if (!rt || rt.sockets.size) return;
rt.timer = setTimeout(() => { if (!rt.sockets.size) { rt.be.close(); runtimes.delete(userId); } }, 60e3);
}
function dropUser(userId) {
for (const [t, s] of sessions) if (s.userId === userId) sessions.delete(t);
const rt = runtimes.get(userId);
if (rt) { for (const ws of rt.sockets) ws.close(4001, 'signed out'); rt.be.close(); runtimes.delete(userId); }
}
// ============================================================ HTTP
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
function headers(extra = {}) {
return { 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store', ...extra };
}
const json = (res, code, obj, extra = {}) => { res.writeHead(code, headers({ 'Content-Type': 'application/json', ...extra })); res.end(JSON.stringify(obj)); };
const redirect = (res, to) => { res.writeHead(302, headers({ Location: to })); res.end(); };
// Freigegebene statische Dateien: URL-Präfix → Verzeichnis
const STATIC = [
['/app/src/renderer/', path.join(ROOT, 'src', 'renderer')],
['/app/src/i18n.js', path.join(ROOT, 'src', 'i18n.js')],
['/app/node_modules/@xterm/', path.join(ROOT, 'node_modules', '@xterm')],
['/web/', path.join(ROOT, 'src', 'web', 'public')],
];
function staticFile(urlPath) {
for (const [prefix, dir] of STATIC) {
if (urlPath === prefix && !prefix.endsWith('/')) return dir;
if (!prefix.endsWith('/') || !urlPath.startsWith(prefix)) continue;
const file = path.resolve(dir, '.' + path.posix.normalize('/' + decodeURIComponent(urlPath.slice(prefix.length))));
if (!file.startsWith(dir + path.sep)) return null;
if (dir.endsWith('@xterm') && !/[\\/](lib|css)[\\/][^\\/]+$/.test(file)) return null;
if (dir.endsWith('renderer') && /\.(rdm|md)$/.test(file)) return null;
return file;
}
return null;
}
function serveFile(res, file, transform) {
fs.stat(file, (err, st) => {
if (err || !st.isFile()) return json(res, 404, { error: 'Not found' });
const type = MIME[path.extname(file)] || 'application/octet-stream';
const cache = type.startsWith('text/html') ? 'no-store' : 'private, max-age=300';
if (transform) {
const body = transform(fs.readFileSync(file, 'utf8'));
res.writeHead(200, headers({ 'Content-Type': type, 'Cache-Control': cache }));
return res.end(body);
}
res.writeHead(200, headers({ 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': cache }));
fs.createReadStream(file).pipe(res);
});
}
function readJson(req, limit = 64 * 1024) {
return new Promise((resolve, reject) => {
let size = 0; const parts = [];
req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('Request too large')); req.destroy(); } else parts.push(c); });
req.on('end', () => { try { resolve(parts.length ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); } catch { reject(new Error('Invalid JSON')); } });
req.on('error', reject);
});
}
const APP_URL = '/app/src/renderer/index.html';
async function api(req, res, url, sess) {
const p = url.pathname;
// Schreibende Anfragen nur mit eigenem Header (Browser senden ihn nicht seitenübergreifend ohne CORS-Freigabe)
if (req.method === 'POST' && req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
if (p === '/api/state' && req.method === 'GET') return json(res, 200, { setup: !users.length, user: sess ? publicUser(sess.user) : null, version: VERSION });
if (p === '/api/setup' && req.method === 'POST') {
if (users.length) return json(res, 409, { error: 'Setup is already complete.' });
const b = await readJson(req);
const u = await createUser(b.name, b.password, true);
const dek = await verify(u, b.password);
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
}
if (p === '/api/login' && req.method === 'POST') {
const ip = clientIp(req);
if (blocked(ip)) return json(res, 429, { error: 'Too many failed attempts. Try again in 15 minutes.' });
const b = await readJson(req);
const u = users.find((x) => x.name.toLowerCase() === String(b.name || '').toLowerCase());
const dek = u ? await verify(u, String(b.password || '')) : (await scrypt('x', crypto.randomBytes(16)), null);
if (!dek) { fail(ip); return json(res, 401, { error: 'Wrong username or password.' }); }
failures.delete(ip);
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
}
if (!sess) return json(res, 401, { error: 'Not signed in' });
if (p === '/api/me') return json(res, 200, publicUser(sess.user));
if (p === '/api/logout' && req.method === 'POST') {
sessions.delete(sess.token);
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, '', 0) });
}
if (p === '/api/password' && req.method === 'POST') {
const b = await readJson(req);
if (!(await verify(sess.user, String(b.old || '')))) return json(res, 400, { error: 'The current password is wrong.' });
await setPassword(sess.user, sess.dek, b.password);
for (const [t, s] of sessions) if (s.userId === sess.userId && t !== sess.token) sessions.delete(t);
return json(res, 200, { ok: true });
}
if (p.startsWith('/api/users')) {
if (!sess.user.admin) return json(res, 403, { error: 'Administrators only.' });
if (p === '/api/users' && req.method === 'GET') return json(res, 200, { users: users.map(publicUser) });
if (p === '/api/users' && req.method === 'POST') {
const b = await readJson(req);
return json(res, 200, publicUser(await createUser(b.name, b.password, !!b.admin)));
}
if (p === '/api/users/delete' && req.method === 'POST') {
const b = await readJson(req);
const u = users.find((x) => x.id === b.id);
if (!u) return json(res, 404, { error: 'User not found.' });
if (u.id === sess.userId) return json(res, 400, { error: 'You cannot delete yourself.' });
if (u.admin && users.filter((x) => x.admin).length === 1) return json(res, 400, { error: 'The last administrator cannot be deleted.' });
dropUser(u.id);
users = users.filter((x) => x.id !== u.id);
saveUsers();
fs.rmSync(path.join(DATA, 'users', u.id), { recursive: true, force: true });
return json(res, 200, { ok: true });
}
}
return json(res, 404, { error: 'Not found' });
}
// SFTP-Dateien des angemeldeten Benutzers streamen
function sftpFor(sess, url) {
const rt = runtimes.get(sess.userId);
const sid = url.searchParams.get('sid') || '';
const file = url.searchParams.get('path') || '';
if (!rt || !file.startsWith('/')) throw new Error('Invalid request');
return { sftp: rt.be.ssh.sftpOf(sid), file };
}
function download(req, res, sess, url) {
let t;
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
t.sftp.stat(t.file, (err, st) => {
if (err) return json(res, 404, { error: err.message });
const name = path.posix.basename(t.file);
res.writeHead(200, headers({
'Content-Type': 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': 'no-store',
'Content-Disposition': `attachment; filename="${name.replace(/[^\x20-\x7e]|["\\]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`,
}));
const rs = t.sftp.createReadStream(t.file);
rs.on('error', () => res.destroy());
rs.pipe(res);
});
}
function upload(req, res, sess, url) {
let t;
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
const ws = t.sftp.createWriteStream(t.file);
let done = false;
const finish = (err) => { if (done) return; done = true; err ? json(res, 500, { error: err.message }) : json(res, 200, { ok: true }); };
ws.on('error', finish);
ws.on('close', () => finish());
req.on('error', finish);
req.pipe(ws);
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://x');
const p = url.pathname;
const sess = sessionOf(req);
if (p === '/healthz') return json(res, 200, { ok: true });
if (p.startsWith('/api/')) return await api(req, res, url, sess);
if (p === '/web/download' && req.method === 'GET') return sess ? download(req, res, sess, url) : json(res, 401, { error: 'Not signed in' });
if (p === '/web/upload' && req.method === 'POST') {
if (!sess) return json(res, 401, { error: 'Not signed in' });
if (req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
return upload(req, res, sess, url);
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed' });
if (p === '/' || p === '/login') return sess && p === '/' ? redirect(res, APP_URL) : serveFile(res, path.join(ROOT, 'src', 'web', 'public', 'login.html'));
if (p === '/favicon.ico' || p === '/favicon.png') return serveFile(res, path.join(ROOT, 'src', 'renderer', 'logo.png'));
if (p === APP_URL) {
if (!sess) return redirect(res, '/');
// Browser-Anbindung (window.api) vor den übrigen Skripten laden
return serveFile(res, staticFile(p), (html) => html.replace('<script ', '<script src="/web/web-api.js"></script>\n <script '));
}
const file = staticFile(p);
if (!file) return json(res, 404, { error: 'Not found' });
// Nur die Anmeldeseite und ihre Dateien sind ohne Sitzung erreichbar
const PUBLIC = ['/web/login.js', '/web/login.css', '/app/src/i18n.js', '/app/src/renderer/app-themes.css', '/app/src/renderer/logo.png'];
if (!sess && !PUBLIC.includes(p)) return json(res, 401, { error: 'Not signed in' });
return serveFile(res, file);
} catch (e) {
console.error(e);
if (!res.headersSent) json(res, 500, { error: e.message || 'Server error' });
}
});
// ============================================================ WebSocket
const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, 'http://x');
const sess = sessionOf(req);
// Nur Verbindungen von der eigenen Seite (Schutz vor Cross-Site-WebSocket-Hijacking)
const origin = req.headers.origin ? new URL(req.headers.origin).host : '';
const host = (TRUST_PROXY && req.headers['x-forwarded-host']) || req.headers.host;
if (url.pathname !== '/ws' || !sess || origin !== host) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); return socket.destroy(); }
wss.handleUpgrade(req, socket, head, (ws) => {
const rt = runtimeFor(sess);
rt.sockets.add(ws);
const lang = String(req.headers['accept-language'] || 'en').split(',')[0];
ws.on('message', async (raw) => {
let m;
try { m = JSON.parse(raw.toString('utf8')); } catch { return; }
if (!sessions.has(sess.token)) return ws.close(4001, 'signed out');
if (m.t === 'send') return rt.be.notify(String(m.ch), Array.isArray(m.args) ? m.args : []);
if (m.t !== 'call') return;
try {
i18n.setLanguage(rt.store.get().settings.language, lang);
const v = await rt.be.call(String(m.ch), Array.isArray(m.args) ? m.args : []);
ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: true, v: v === undefined ? null : v }));
} catch (e) {
if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: false, v: e?.message || String(e) }));
}
});
ws.on('close', () => { rt.sockets.delete(ws); release(sess.userId); });
ws.send(JSON.stringify({ t: 'hello', user: publicUser(sess.user), version: VERSION }));
});
});
server.on('error', (e) => { console.error(`Cannot listen on port ${PORT}: ${e.message}`); process.exit(1); });
server.listen(PORT, () => console.log(`MrTerm Web ${VERSION} on port ${PORT} (data: ${DATA})${users.length ? '' : ' – open it in the browser to create the admin account'}`));