Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c143053319 | ||
|
|
58709e09f2 | ||
|
|
27091ab5d1 | ||
|
|
6a6971e1cb | ||
|
|
edea4ce0d2 | ||
|
|
b431428260 | ||
|
|
aeb6c590db | ||
|
|
428e74b2d8 | ||
|
|
b94f18d2e2 | ||
|
|
7cff93419d | ||
|
|
1980d3044b | ||
|
|
4542aff75f | ||
|
|
430c18b84e | ||
|
|
98c7763c47 |
@@ -2,3 +2,9 @@ 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
|
||||
|
||||
@@ -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**.
|
||||
|
||||

|
||||
|
||||
## 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`)
|
||||
@@ -48,6 +69,19 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
|
||||
- **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** with tabs and quick access to SFTP, Docker, Firewall and Network | **Host settings**: authentication, keys, jump hosts and VPN |
|
||||
|  |  |
|
||||
| **Docker & Podman** containers with CPU and memory usage | **Firewall** rules for UFW and iptables |
|
||||
|  |  |
|
||||
| **Network**: interfaces, bonds (LACP), bridges, gateway and DNS | **VPN**: WireGuard and OpenVPN, connected automatically per host |
|
||||
|  | |
|
||||
| **Settings**: language, app lock, LAN sync and themes | |
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
||||
@@ -181,7 +215,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`.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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); });
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"appId": "de.mrterm.app",
|
||||
"appName": "MrTerm",
|
||||
"webDir": "www",
|
||||
"android": { "backgroundColor": "#14161d" },
|
||||
"plugins": {
|
||||
"CapacitorNodeJS": { "nodeDir": "nodejs", "startMode": "manual" }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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)])),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
// Node-Backend der Android-App. Nutzt dieselben Module wie der Electron-Hauptprozess
|
||||
// (Tresor, SSH, LAN-Sync, Docker, Firewall, Netzwerk) und spricht über die Capacitor-Bridge mit der Oberfläche.
|
||||
// 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 { utils: sshUtils } = require('ssh2');
|
||||
const { Store } = require('../../src/main/store');
|
||||
const { SshManager } = require('../../src/main/ssh');
|
||||
const { DockerManager } = require('../../src/main/docker');
|
||||
const { FirewallManager } = require('../../src/main/firewall');
|
||||
const { NetworkConfigManager } = require('../../src/main/network');
|
||||
const { SyncService } = require('../../src/main/sync');
|
||||
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 = () => i18n.setLanguage(store.get().settings.language, process.env.MRTERM_LOCALE || 'en');
|
||||
|
||||
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
||||
const pending = new Map();
|
||||
function ask(ch, req, timeout = 0) {
|
||||
return 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);
|
||||
const sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
|
||||
store.onChange = () => sync.schedule();
|
||||
|
||||
const handlers = new Map();
|
||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||
const handle = (ch, fn) => handlers.set(ch, fn);
|
||||
|
||||
channel.addListener('call', async (reqId, ch, args = []) => {
|
||||
try {
|
||||
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.'));
|
||||
channel.send('reply', reqId, true, await fn(...args));
|
||||
} catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
|
||||
});
|
||||
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,
|
||||
};
|
||||
channel.addListener('send', (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } });
|
||||
|
||||
// ---------- App-Sperre: Passwort, zusätzlich Fingerabdruck (Geheimnis aus dem Android Keystore) ----------
|
||||
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 bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32));
|
||||
function lockStatus() {
|
||||
const { language, appTheme, accent } = store.get().settings;
|
||||
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, meta: { language, appTheme, accent } };
|
||||
}
|
||||
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
|
||||
const afterUnlock = () => { applyLanguage(); 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: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.')); }
|
||||
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; } // Fingerabdruck nur zusätzlich zum Passwort
|
||||
store.dropLockIfEmpty();
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:setBio', (secret) => {
|
||||
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 lockStatus();
|
||||
});
|
||||
|
||||
// ---------- Tresor ----------
|
||||
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
|
||||
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: 'android' }));
|
||||
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) applyLanguage(); });
|
||||
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||
handle('app:version', () => process.env.MRTERM_VERSION || '0.0.0');
|
||||
|
||||
// ---------- Schlüssel ----------
|
||||
handle('key:generate', ({ type, bits, comment, passphrase }) => {
|
||||
const opts = { comment: comment || 'mrterm@android' };
|
||||
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 ----------
|
||||
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;
|
||||
});
|
||||
|
||||
// ---------- Docker / Firewall / Netzwerk (gleiche Module wie am Desktop) ----------
|
||||
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:close', (id) => network.close(id));
|
||||
|
||||
// ---------- Synchronisation ----------
|
||||
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));
|
||||
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
|
||||
handle('app:pause', () => sync.stop());
|
||||
handle('app:resume', () => sync.start().catch(() => {}));
|
||||
|
||||
store.load();
|
||||
applyLanguage();
|
||||
sync.start().catch(() => {});
|
||||
channel.send('ready');
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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, '<') + '</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>
|
||||
@@ -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); }
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.10.2",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.10.2",
|
||||
"version": "0.12.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"productName": "MrTerm",
|
||||
"version": "0.10.2",
|
||||
"version": "0.12.0",
|
||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||
"main": "src/main/main.js",
|
||||
"author": "MrBlake",
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -597,6 +597,59 @@
|
||||
'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.',
|
||||
// Main-Prozess
|
||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||
'Unknown host': 'Unbekannter Host',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,25 @@ 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); }
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||