diff --git a/.gitignore b/.gitignore index c9b3ba0..d6839ae 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 4e40824..9a2ca6d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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. +[Get it on 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) + MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**. ![MrTerm host overview with groups](docs/screenshots/hosts.png) @@ -27,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-.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`) @@ -196,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`. diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/mobile/android/.gitignore @@ -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 diff --git a/mobile/android/app/.gitignore b/mobile/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle new file mode 100644 index 0000000..6963fe4 --- /dev/null +++ b/mobile/android/app/build.gradle @@ -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") +} diff --git a/mobile/android/app/capacitor.build.gradle b/mobile/android/app/capacitor.build.gradle new file mode 100644 index 0000000..776fe4a --- /dev/null +++ b/mobile/android/app/capacitor.build.gradle @@ -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() +} diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/mobile/android/app/proguard-rules.pro @@ -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 diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..540218b --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/java/de/mrterm/app/MainActivity.java b/mobile/android/app/src/main/java/de/mrterm/app/MainActivity.java new file mode 100644 index 0000000..ff2a56e --- /dev/null +++ b/mobile/android/app/src/main/java/de/mrterm/app/MainActivity.java @@ -0,0 +1,38 @@ +package de.mrterm.app; + +import android.os.Bundle; +import android.view.View; +import android.webkit.WebView; + +import androidx.activity.OnBackPressedCallback; +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: Bei offener Tastatur den WebView um die Tastaturhöhe verkleinern, + // damit Terminal und Zusatztasten sichtbar bleiben + View parent = (View) web.getParent(); + ViewCompat.setOnApplyWindowInsetsListener(parent, (v, insets) -> { + int ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + v.setPadding(0, 0, 0, ime); + web.evaluateJavascript("window.mrtermIme && window.mrtermIme(" + (ime > 0) + ")", null); + return insets; + }); + } +} diff --git a/mobile/android/app/src/main/java/de/mrterm/app/MrTermNative.java b/mobile/android/app/src/main/java/de/mrterm/app/MrTermNative.java new file mode 100644 index 0000000..b954803 --- /dev/null +++ b/mobile/android/app/src/main/java/de/mrterm/app/MrTermNative.java @@ -0,0 +1,244 @@ +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(); + } + + @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); + } +} diff --git a/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..040ea6e Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f0d6474 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..e8a7ccb Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..61334f4 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..2e12d5d Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..4174451 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..9c91bb5 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..2f323b3 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..8ed92e6 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..0d524df Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/splash.png b/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..562e98b Binary files /dev/null and b/mobile/android/app/src/main/res/drawable/splash.png differ diff --git a/mobile/android/app/src/main/res/layout/activity_main.xml b/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4668582 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fbc628e Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..175a161 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..4f49f1f Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f997b90 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..bed88f1 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e2c5879 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b3cbe69 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a995714 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7fc8bc6 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..75ec2b2 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9482e44 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..a5d3781 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..bb95986 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8df6b9d Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..20d61f4 --- /dev/null +++ b/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #111728 + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2eea50f --- /dev/null +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + MrTerm + MrTerm + de.mrterm.app + de.mrterm.app + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/xml/file_paths.xml b/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/mobile/android/build.gradle @@ -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 +} diff --git a/mobile/android/capacitor.settings.gradle b/mobile/android/capacitor.settings.gradle new file mode 100644 index 0000000..3f66e53 --- /dev/null +++ b/mobile/android/capacitor.settings.gradle @@ -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') diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/mobile/android/gradle.properties @@ -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 diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.jar b/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/mobile/android/gradlew b/mobile/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/mobile/android/gradlew @@ -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" "$@" diff --git a/mobile/android/gradlew.bat b/mobile/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/mobile/android/gradlew.bat @@ -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 diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/mobile/android/settings.gradle @@ -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' \ No newline at end of file diff --git a/mobile/android/variables.gradle b/mobile/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/mobile/android/variables.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' +} \ No newline at end of file diff --git a/mobile/build.js b/mobile/build.js new file mode 100644 index 0000000..41c1cbf --- /dev/null +++ b/mobile/build.js @@ -0,0 +1,94 @@ +// 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-.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 }); +} + +function buildWeb() { + 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(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', '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); }); diff --git a/mobile/capacitor.config.json b/mobile/capacitor.config.json new file mode 100644 index 0000000..1b53e30 --- /dev/null +++ b/mobile/capacitor.config.json @@ -0,0 +1,9 @@ +{ + "appId": "de.mrterm.app", + "appName": "MrTerm", + "webDir": "www", + "android": { "backgroundColor": "#14161d" }, + "plugins": { + "CapacitorNodeJS": { "nodeDir": "nodejs", "startMode": "manual" } + } +} diff --git a/mobile/dev/electron-dev.js b/mobile/dev/electron-dev.js new file mode 100644 index 0000000..670ef61 --- /dev/null +++ b/mobile/dev/electron-dev.js @@ -0,0 +1,61 @@ +// 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 }); + require(path.join(WWW, 'nodejs', 'index.js')); +}); +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)); diff --git a/mobile/dev/preload.js b/mobile/dev/preload.js new file mode 100644 index 0000000..e5e13d4 --- /dev/null +++ b/mobile/dev/preload.js @@ -0,0 +1,14 @@ +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: { + start: (o) => ipcRenderer.invoke('dev:start', o.env), + 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'].map((m) => [m, native(m)])), +}); diff --git a/mobile/nodejs/electron-shim.js b/mobile/nodejs/electron-shim.js new file mode 100644 index 0000000..3a262aa --- /dev/null +++ b/mobile/nodejs/electron-shim.js @@ -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, +}; diff --git a/mobile/nodejs/server.js b/mobile/nodejs/server.js new file mode 100644 index 0000000..098c3a2 --- /dev/null +++ b/mobile/nodejs/server.js @@ -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'); diff --git a/mobile/package-lock.json b/mobile/package-lock.json new file mode 100644 index 0000000..9a1a0cf --- /dev/null +++ b/mobile/package-lock.json @@ -0,0 +1,1731 @@ +{ + "name": "mrterm-mobile", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mrterm-mobile", + "version": "0.0.0", + "dependencies": { + "@capacitor/android": "^8.5.2", + "@capacitor/core": "^8.5.2", + "capacitor-nodejs": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz" + }, + "devDependencies": { + "@capacitor/cli": "^8.5.2", + "esbuild": "^0.28.2" + } + }, + "node_modules/@capacitor/android": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz", + "integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.5.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz", + "integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xcode": "^3.0.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz", + "integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/capacitor-nodejs": { + "version": "1.0.0-beta.10", + "resolved": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz", + "integrity": "sha512-5swca3bhfcLSKrRVYReNKR/gXwoG15bjTOOSjQRDSd6JgC9Yi1NeDng2dFGz24vqhTBt4huVQHe6lmmwfGEzHA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..7c73b9a --- /dev/null +++ b/mobile/package.json @@ -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" + } +} diff --git a/mobile/web/app.js b/mobile/web/app.js new file mode 100644 index 0000000..c839cd4 --- /dev/null +++ b/mobile/web/app.js @@ -0,0 +1,1020 @@ +// MrTerm für Android: Hosts, Terminal, Schlüssel, Snippets, LAN-Sync und App-Sperre. +// Die Logik (SSH, Tresor, Sync) läuft im Node-Backend (nodejs/server.js), diese Datei ist nur die Oberfläche. +'use strict'; +const T = (s, v) => I18N.t(s, v); +const $ = (s, r = document) => r.querySelector(s); +const $$ = (s, r = document) => [...r.querySelectorAll(s)]; +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }; +const uid = () => crypto.randomUUID(); + +// ============================================================ Plattform +// In der App: Capacitor-Plugins. Zum Entwickeln am Desktop stellt dev/electron-dev.js window.DevBridge bereit. +const Cap = window.Capacitor; +const NodeJS = window.DevBridge?.NodeJS || Cap.registerPlugin('CapacitorNodeJS'); +const Native = window.DevBridge?.Native || Cap.registerPlugin('MrTermNative'); + +const waits = new Map(); +const events = new Map(); +let seq = 0; +const api = { + call(ch, ...args) { + return new Promise((resolve, reject) => { + const id = ++seq; + waits.set(id, { resolve, reject }); + NodeJS.send({ eventName: 'call', args: [id, ch, args] }); + }); + }, + send(ch, ...args) { NodeJS.send({ eventName: 'send', args: [ch, args] }); }, + on(ch, fn) { if (!events.has(ch)) events.set(ch, new Set()); events.get(ch).add(fn); return () => events.get(ch).delete(fn); }, +}; +NodeJS.addListener('reply', ({ args: [id, ok, value] }) => { + const w = waits.get(id); + if (!w) return; + waits.delete(id); + ok ? w.resolve(value) : w.reject(new Error(value)); +}); +NodeJS.addListener('evt', ({ args: [ch, a] }) => events.get(ch)?.forEach((fn) => { try { fn(...a); } catch (e) { console.error(e); } })); + +// Aufruf mit Fehlermeldung als Toast +async function call(ch, ...args) { + try { return await api.call(ch, ...args); } catch (e) { toast(e.message, 'error'); throw e; } +} + +// ============================================================ Icons +const I = { + plus: '', + search: '', + more: '', + folder: '', + term: '', + edit: '', + trash: '', + copy: '', + key: '', + code: '', + lock: '', + sync: '', + eye: '', + play: '', + x: '', + paste: '', + zoomIn: '', + zoomOut: '', + refresh: '', + finger: '', + shield: '', + devices: '', + down: '', + info: '', + up: '', left: '', right: '', downA: '', +}; +const icon = (n) => `${I[n] || ''}`; + +// ============================================================ Zustand +const S = { vault: null, lock: null, sync: null, view: 'hosts', sessions: [], active: null, filter: '', collapsed: new Set(), bio: { available: false, enrolled: false } }; +const settings = () => S.vault?.settings || {}; +const COLORS = ['#6e7bff', '#3ecf8e', '#ffb347', '#ff5f6d', '#c792ea', '#56d6d6', '#f78c6c', '#82aaff']; +function colorFor(x) { + if (x.color) return x.color; + let n = 0; + for (const c of String(x.label || x.address || '')) n = (n * 31 + c.charCodeAt(0)) >>> 0; + return COLORS[n % COLORS.length]; +} +const hostName = (x) => x.label || x.address; +const hostSub = (x) => `${x.username ? `${x.username}@` : ''}${x.address}${x.port && Number(x.port) !== 22 ? `:${x.port}` : ''}`; + +// ============================================================ Bausteine: Toast, Dialog, Sheet, Seite +function toast(msg, type = '') { + const t = h(`
${esc(msg)}
`); + $('#toasts').append(t); + setTimeout(() => t.remove(), type === 'error' ? 5000 : 2600); +} + +// Hardware-Zurück schließt die oberste Ebene +const backStack = []; +function pushLayer(el, onClose) { + $('#layer').append(el); + const entry = { el, close: () => { el.remove(); const i = backStack.indexOf(entry); if (i >= 0) backStack.splice(i, 1); onClose?.(); } }; + backStack.push(entry); + return entry.close; +} + +// Dialog: buttons [{label, value, cls}], value 'form' liefert die Eingabefelder als Objekt +function modal({ title, text, body = '', buttons = [{ label: T('OK'), value: true, cls: 'primary' }], dismiss = true }) { + return new Promise((resolve) => { + const el = h(`

${esc(title)}

${text ? `

${esc(text)}

` : ''}
${body}
`); + let done = false; + const finish = (v) => { if (done) return; done = true; close(); resolve(v); }; + const close = pushLayer(el, () => finish(null)); + const form = $('form', el); + const collect = () => Object.fromEntries($$('input,select,textarea', form).filter((i) => i.name).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value])); + for (const b of buttons) { + const btn = h(``); + btn.onclick = () => finish(b.value === 'form' ? collect() : b.value); + $('.btns', el).append(btn); + } + form.onsubmit = (e) => { e.preventDefault(); const p = buttons.find((b) => b.cls?.includes('primary')); if (p) finish(p.value === 'form' ? collect() : p.value); }; + if (dismiss) el.addEventListener('click', (e) => { if (e.target === el) finish(null); }); + wirePw(el); + setTimeout(() => $('input:not([type=checkbox])', form)?.focus(), 60); + }); +} +const confirmBox = async (title, text, ok = T('Delete'), cls = 'danger') => + (await modal({ title, text, buttons: [{ label: T('Cancel'), value: false }, { label: ok, value: true, cls: `primary ${cls === 'danger' ? '' : ''}` }] })) === true; + +// Auswahlliste von unten: [{label, icon, danger, run}] +function sheet(title, items) { + const el = h(`
${title ? `
${esc(title)}
` : ''}
`); + const close = pushLayer(el); + for (const it of items.filter(Boolean)) { + const b = h(``); + b.onclick = () => { close(); it.run(); }; + $('.sheet', el).append(b); + } + el.addEventListener('click', (e) => { if (e.target === el) close(); }); +} + +// Vollbild-Formular mit Speichern-Knopf +function page(title, body, { save, saveLabel = T('Save'), extra } = {}) { + const el = h(`

${esc(title)}

`); + const close = pushLayer(el); + $('[data-back]', el).onclick = close; + if (save) { + const b = h(``); + b.onclick = async () => { + b.disabled = true; + try { if ((await save(collectForm($('.page-body', el)))) !== false) close(); } catch (e) { toast(e.message, 'error'); } + b.disabled = false; + }; + $('.page-head', el).append(b); + } + if (extra) $('.page-head', el).insertBefore(extra, $('.page-head .btn', el)); + $('.page-body', el).append(...[].concat(body)); + wirePw(el); + return { el, close }; +} +const collectForm = (root) => Object.fromEntries($$('input,select,textarea', root).filter((i) => i.name).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value])); + +function field(label, name, value = '', o = {}) { + let input; + if (o.type === 'select') input = ``; + else if (o.type === 'textarea') input = ``; + else if (o.type === 'password') input = `
`; + else input = ``; + return `
${input}${o.hint ? `
${esc(o.hint)}
` : ''}
`; +} +function wirePw(root) { + $$('[data-eye]', root).forEach((b) => { b.onclick = () => { const i = b.previousElementSibling; i.type = i.type === 'password' ? 'text' : 'password'; }; }); +} +const switchEl = (checked) => ``; + +// ============================================================ Start +async function boot() { + const info = await Native.info().catch(() => ({ device: 'Android', locale: navigator.language, version: '0.0.0' })); + S.info = info; + I18N.setLanguage('auto', info.locale); + const ready = new Promise((r) => { const off = NodeJS.addListener('ready', () => r()); void off; }); + const { key } = await Native.vaultKey(); + await NodeJS.start({ env: { MRTERM_VAULT_KEY: key, MRTERM_DEVICE: info.device, MRTERM_LOCALE: info.locale, MRTERM_VERSION: info.version } }); + await Promise.race([ready, new Promise((r) => setTimeout(r, 8000))]); + try { Object.assign(S.bio, await Native.bioAvailable()); } catch {} + Native.multicast({ on: true }).catch(() => {}); + $('#boot').hidden = true; + await reload(); +} + +async function reload() { + S.lock = await api.call('lock:status'); + I18N.setLanguage(S.lock.meta.language || 'auto', S.info.locale); + applyStatic(); + if (S.lock.locked) return showLock(); + S.vault = await api.call('vault:get'); + I18N.setLanguage(settings().language || 'auto', S.info.locale); + applyStatic(); + S.sync = await api.call('sync:status').catch(() => null); + $('#main').hidden = false; + render(); +} + +// Statische Texte in index.html übersetzen +function applyStatic() { + $$('[data-t]').forEach((e) => { e.dataset.t ||= e.textContent; e.textContent = T(e.dataset.t); }); + document.documentElement.lang = I18N.lang; +} + +$$('#tabs button').forEach((b) => (b.onclick = () => { S.view = b.dataset.view; render(); $('#view').scrollTop = 0; })); + +function render() { + $$('#tabs button').forEach((b) => b.classList.toggle('active', b.dataset.view === S.view)); + const v = $('#view'); + v.innerHTML = ''; + ({ hosts: viewHosts, keys: viewKeys, snippets: viewSnippets, settings: viewSettings })[S.view](v); +} + +// ============================================================ Hosts +function groupPath(id) { + const parts = []; + const seen = new Set(); + let g = S.vault.groups.find((x) => x.id === id); + while (g && !seen.has(g.id)) { seen.add(g.id); parts.unshift(g.label); g = S.vault.groups.find((x) => x.id === g.parentId); } + return parts.join(' / '); +} + +function viewHosts(v) { + const top = h(`

MrTerm

`); + if (S.sync?.enabled) { + const on = S.sync.peers.some((p) => p.online); + const b = h(``); + b.onclick = async () => { try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} }; + top.append(b); + } + if (S.lock.enabled) { + const b = h(``); + b.onclick = lockNow; + top.append(b); + } + v.append(top); + + const search = h(``); + const input = $('input', search); + input.value = S.filter; + input.oninput = () => { S.filter = input.value; drawList(); }; + input.onkeydown = (e) => { if (e.key === 'Enter') quickConnect(input.value); }; + v.append(search); + + if (S.sessions.length) { + const bar = h('
'); + for (const s of S.sessions) { + const c = h(``); + c.onclick = () => showSession(s); + bar.append(c); + } + v.append(bar); + } + + const box = h('
'); + v.append(box); + const fab = h(``); + fab.onclick = () => sheet(null, [ + { label: T('New host'), icon: 'term', run: () => editHost({}) }, + { label: T('New group'), icon: 'folder', run: () => editGroup({}) }, + ]); + v.append(fab); + + function drawList() { + box.innerHTML = ''; + const q = S.filter.trim().toLowerCase(); + const hosts = S.vault.hosts.filter((x) => !q || [x.label, x.address, x.username, ...(x.tags || [])].some((f) => String(f || '').toLowerCase().includes(q))); + if (!S.vault.hosts.length) { + box.append(h(`
${icon('term')}

${esc(T('No hosts yet'))}

${esc(T('Add your first server or pair this phone with MrTerm on your computer to take over your hosts.'))}
+
`)); + $('[data-a=new]', box).onclick = () => editHost({}); + $('[data-a=sync]', box).onclick = () => { S.view = 'settings'; render(); }; + return; + } + if (!hosts.length && q) { + box.append(h(`
${esc(T('No matching hosts. Press Enter to connect to “{q}”.', { q: S.filter.trim() }))}
`)); + return; + } + // Gruppen in Pfadreihenfolge, Hosts ohne Gruppe zuerst + const byGroup = new Map(); + for (const x of hosts) { + const k = x.groupId && S.vault.groups.some((g) => g.id === x.groupId) ? x.groupId : ''; + if (!byGroup.has(k)) byGroup.set(k, []); + byGroup.get(k).push(x); + } + const keys = [...byGroup.keys()].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : groupPath(a).localeCompare(groupPath(b)))); + for (const k of keys) { + const list = byGroup.get(k).sort((a, b) => hostName(a).localeCompare(hostName(b))); + const g = S.vault.groups.find((x) => x.id === k); + const collapsed = !q && S.collapsed.has(k); + const head = h(`
${k ? `${icon('folder')}` : ''}${esc(k ? groupPath(k) : T('Hosts'))}${list.length}${k ? `${icon('down')}` : ''}
`); + if (k) { + head.onclick = () => { S.collapsed.has(k) ? S.collapsed.delete(k) : S.collapsed.add(k); drawList(); }; + head.oncontextmenu = (e) => { e.preventDefault(); groupMenu(g); }; + let t; head.addEventListener('touchstart', () => { t = setTimeout(() => groupMenu(g), 550); }, { passive: true }); + head.addEventListener('touchend', () => clearTimeout(t)); head.addEventListener('touchmove', () => clearTimeout(t), { passive: true }); + } + box.append(head); + if (collapsed) continue; + const ul = h('
'); + for (const x of list) ul.append(hostItem(x)); + box.append(ul); + } + } + drawList(); +} + +function hostItem(x) { + const rdp = x.protocol === 'rdp'; + const live = S.sessions.some((s) => s.host.id === x.id && s.alive); + const el = h(`
${esc((hostName(x)[0] || '?').toUpperCase())}
+
${esc(hostName(x))}
${rdp ? esc(T('RDP is not available on Android')) : esc(hostSub(x))}
+ ${live ? '' : ''}
`); + el.onclick = (e) => { + if (e.target.closest('.more')) return hostMenu(x); + if (rdp) return toast(T('RDP is not available on Android')); + openTerminal(x); + }; + return el; +} + +function hostMenu(x) { + const ssh = x.protocol !== 'rdp'; + sheet(hostName(x), [ + ssh && { label: T('Connect'), icon: 'term', run: () => openTerminal(x) }, + { label: T('Edit'), icon: 'edit', run: () => editHost(x) }, + { label: T('Duplicate'), icon: 'copy', run: async () => { const { id, createdAt, updatedAt, ...rest } = x; await call('vault:upsert', 'hosts', { ...rest, label: hostName(x) + T(' (copy)') }); reload(); } }, + { label: T('Copy address'), icon: 'copy', run: () => copyText(hostSub(x)) }, + { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hostName(x) }))) { await call('vault:remove', 'hosts', x.id); reload(); } } }, + ]); +} + +function groupMenu(g) { + sheet(g.label, [ + { label: T('Edit'), icon: 'edit', run: () => editGroup(g) }, + { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { await call('vault:remove', 'groups', g.id); reload(); } } }, + ]); +} + +function groupOptions(exclude) { + return [['', T('— No group —')], ...S.vault.groups.filter((g) => g.id !== exclude).map((g) => [g.id, groupPath(g.id)]).sort((a, b) => a[1].localeCompare(b[1]))]; +} + +function editGroup(g) { + page(g.id ? T('Edit group') : T('New group'), h(`
${field(T('Name'), 'label', g.label)}${field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: groupOptions(g.id) })}
`), { + save: async (v) => { + if (!v.label.trim()) throw new Error(T('Please enter a name.')); + await call('vault:upsert', 'groups', { ...g, label: v.label.trim(), parentId: v.parentId || null }); + reload(); + }, + }); +} + +function editHost(x) { + const isNew = !x.id; + const keys = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])]; + const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((o) => o.id !== x.id && o.protocol !== 'rdp').map((o) => [o.id, hostName(o)])]; + const body = h(`
+
${field(T('Address'), 'address', x.address, { placeholder: '192.168.0.10', inputmode: 'url' })}${field(T('Port'), 'port', x.port || '', { placeholder: '22', inputmode: 'numeric' })}
+ ${field(T('Label'), 'label', x.label)} + ${field(T('Username'), 'username', x.username, { placeholder: 'root' })} + ${field(T('Password'), 'password', x.password, { type: 'password', placeholder: T('optional') })} + ${field(T('SSH key'), 'keyId', x.keyId, { type: 'select', options: keys })} + ${field(T('Group'), 'groupId', x.groupId, { type: 'select', options: groupOptions() })} + ${field(T('Jump host (ProxyJump)'), 'jumpHostId', x.jumpHostId, { type: 'select', options: jumps })} + ${field(T('Startup command'), 'startupCommand', x.startupCommand, { placeholder: T('e.g. tmux attach || tmux') })} + ${field(T('Tags'), 'tags', (x.tags || []).join(', '), { placeholder: 'prod, web, db' })} +
`); + const del = isNew ? null : h(``); + const pg = page(isNew ? T('New host') : T('Edit host'), body, { + extra: del, + save: async (v) => { + if (!v.address.trim()) throw new Error(T('Please enter an address.')); + const item = { + ...x, ...v, protocol: x.protocol || 'ssh', + address: v.address.trim(), port: v.port ? Number(v.port) : undefined, + tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean), + groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null, + }; + const saved = await call('vault:upsert', 'hosts', item); + if (item.password && item.password !== x.password) askShareSecret('hosts', saved, hostName(item)); + reload(); + }, + }); + if (del) del.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hostName(x) }))) { await call('vault:remove', 'hosts', x.id); pg.close(); reload(); } }; +} + +// user@host:port als temporäre Verbindung +function quickConnect(q) { + const m = String(q).trim().match(/^(?:ssh:\/\/)?(?:([^@\s]+)@)?([^\s:@]+)(?::(\d+))?$/); + if (!m) return; + openTerminal({ label: m[2], address: m[2], username: m[1] || '', port: m[3] ? Number(m[3]) : 22, protocol: 'ssh', quick: true }); +} + +// ============================================================ Terminal +const KEYS = [ + { id: 'esc', label: 'Esc', seq: '\x1b' }, { id: 'tab', label: 'Tab', seq: '\t' }, + { id: 'ctrl', label: 'Ctrl', mod: true }, { id: 'alt', label: 'Alt', mod: true }, + { id: 'left', icon: 'left', seq: '\x1b[D', app: '\x1bOD' }, { id: 'up', icon: 'up', seq: '\x1b[A', app: '\x1bOA' }, + { id: 'down', icon: 'downA', seq: '\x1b[B', app: '\x1bOB' }, { id: 'right', icon: 'right', seq: '\x1b[C', app: '\x1bOC' }, + { label: '|', seq: '|' }, { label: '-', seq: '-' }, { label: '/', seq: '/' }, { label: '~', seq: '~' }, + { label: 'Home', seq: '\x1b[H' }, { label: 'End', seq: '\x1b[F' }, { label: 'PgUp', seq: '\x1b[5~' }, { label: 'PgDn', seq: '\x1b[6~' }, + { label: '^C', seq: '\x03' }, { label: '^D', seq: '\x04' }, { label: '^Z', seq: '\x1a' }, { label: '^R', seq: '\x12' }, +]; +const mods = { ctrl: 0, alt: 0 }; // 0 aus, 1 einmal, 2 festgestellt + +function termTheme() { return TERM_THEMES[settings().terminalTheme] || TERM_THEMES.mrterm; } +const fontSize = () => Number(settings().mobileFontSize) || 12; + +class Session { + constructor(host) { + this.id = uid(); + this.host = host; + this.title = hostName(host); + this.alive = false; + this.connecting = false; + this.el = h(''); + $('#terms').append(this.el); + this.term = new Terminal({ + fontFamily: 'ui-monospace, "JetBrains Mono", "DejaVu Sans Mono", "Droid Sans Mono", monospace', + fontSize: fontSize(), theme: termTheme(), scrollback: 5000, cursorBlink: true, allowProposedApi: true, + }); + this.fit = new FitAddon.FitAddon(); + this.term.loadAddon(this.fit); + this.term.open(this.el); + const ta = this.term.textarea; + ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocorrect', 'off'); ta.setAttribute('spellcheck', 'false'); ta.setAttribute('autocomplete', 'off'); + this.term.onData((d) => this.input(d)); + this.term.onResize(({ cols, rows }) => { if (this.alive) api.send('ssh:resize', this.id, cols, rows); }); + this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); }); + this.pinch(this.el); + S.sessions.push(this); + showSession(this); + this.connect(); + } + + async connect() { + this.connecting = true; + this.overlay?.remove(); + this.overlay = h(`
${esc(T('Connecting …'))}
`); + this.el.append(this.overlay); + updateTermHead(); + this.refit(); + try { + const ref = this.host.quick ? { ...this.host } : this.host.id; + await api.call('ssh:open', this.id, ref, { cols: this.term.cols, rows: this.term.rows }); + this.alive = true; + this.overlay.remove(); this.overlay = null; + api.send('ssh:resize', this.id, this.term.cols, this.term.rows); + if (S.active === this) this.term.focus(); + } catch (e) { + this.alive = false; + $('.spinner', this.overlay)?.remove(); + $('.msg', this.overlay).innerHTML = `
${esc(e.message)}
`; + const b = h(``); + b.onclick = () => this.connect(); + this.overlay.append(b); + } + this.connecting = false; + updateTermHead(); + } + + input(d) { + if (mods.ctrl && d.length === 1) { + const c = d.toUpperCase().charCodeAt(0); + if (c >= 64 && c <= 95) d = String.fromCharCode(c - 64); + else if (d === ' ') d = '\x00'; + if (mods.ctrl === 1) mods.ctrl = 0; + } + if (mods.alt) { d = '\x1b' + d; if (mods.alt === 1) mods.alt = 0; } + drawKeybar(); + if (!this.alive) { if (d === '\r' && !this.connecting) this.connect(); return; } + api.send('ssh:write', this.id, d); + } + + key(k) { + if (k.seq === undefined) return; + const app = k.app && this.term.modes.applicationCursorKeysMode; + this.input(app ? k.app : k.seq); + } + + onEvent(type, payload) { + if (type === 'data') this.term.write(payload); + else if (type === 'status' && this.overlay) $('.msg', this.overlay).textContent = payload; + else if (type === 'error') this.term.write(`\r\n\x1b[31m${payload}\x1b[0m\r\n`); + else if (type === 'closed') { + if (!this.alive) return; + this.alive = false; + this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); + updateTermHead(); + } + } + + refit() { + if (this.el.hidden) return; + try { this.fit.fit(); } catch {} + } + + setFont(size) { + size = Math.max(7, Math.min(28, Math.round(size))); + if (size === this.term.options.fontSize) return; + S.sessions.forEach((s) => { s.term.options.fontSize = size; }); + this.refit(); + clearTimeout(this.fontSave); + this.fontSave = setTimeout(() => { api.call('vault:settings', { mobileFontSize: size }).catch(() => {}); S.vault.settings.mobileFontSize = size; }, 600); + } + + // Zwei-Finger-Zoom ändert die Schriftgröße + pinch(el) { + let d0 = 0, f0 = 0; + const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); + el.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { d0 = dist(e.touches); f0 = this.term.options.fontSize; } }, { passive: true }); + el.addEventListener('touchmove', (e) => { if (e.touches.length === 2 && d0) { e.preventDefault(); this.setFont(f0 * (dist(e.touches) / d0)); } }, { passive: false }); + el.addEventListener('touchend', () => { d0 = 0; }, { passive: true }); + } + + close() { + this.unsub(); + api.send('ssh:close', this.id); + this.term.dispose(); + this.el.remove(); + S.sessions = S.sessions.filter((s) => s !== this); + } +} + +function openTerminal(host) { return new Session(host); } + +function showSession(s) { + S.active = s; + S.sessions.forEach((x) => { x.el.hidden = x !== s; }); + $('#termScreen').hidden = false; + $('#main').hidden = true; + document.documentElement.style.setProperty('--term-bg', termTheme().background); + updateTermHead(); + drawKeybar(); + requestAnimationFrame(() => { s.refit(); if (s.alive) s.term.focus(); }); +} + +function hideTerminal() { + $('#termScreen').hidden = true; + $('#main').hidden = false; + S.active?.term.blur(); + S.active = null; + render(); +} + +function updateTermHead() { + const s = S.active; + if (!s) return; + $('#termTitle .name').textContent = s.title; + $('#termTitle .dot').className = `dot ${s.alive ? 'on' : s.connecting ? '' : 'err'}`; + $('#termTitle .count')?.remove(); + if (S.sessions.length > 1) $('#termTitle .name').after(h(`${S.sessions.length}`)); +} + +function drawKeybar() { + const bar = $('#keybar'); + bar.innerHTML = ''; + for (const k of KEYS) { + const on = k.mod && mods[k.id]; + const b = h(``); + // pointerdown + preventDefault: Tastatur bleibt offen, Fokus bleibt im Terminal + b.addEventListener('pointerdown', (e) => { + e.preventDefault(); + if (!S.active) return; + if (k.mod) { mods[k.id] = (mods[k.id] + 1) % 3; drawKeybar(); return; } + S.active.key(k); + }); + bar.append(b); + } +} + +$('#termBack').onclick = hideTerminal; +$('#termKbd').onclick = () => { + const s = S.active; + if (!s) return; + if (document.activeElement === s.term.textarea) s.term.blur(); else s.term.focus(); +}; +$('#termTitle').onclick = () => { + sheet(T('Sessions'), [ + ...S.sessions.map((s) => ({ label: `${s === S.active ? '● ' : ''}${s.title}${s.alive ? '' : ` (${T('disconnected')})`}`, icon: 'term', run: () => showSession(s) })), + { label: T('New connection'), icon: 'plus', run: () => { hideTerminal(); } }, + ]); +}; +$('#termMenu').onclick = () => { + const s = S.active; + if (!s) return; + sheet(s.title, [ + { label: T('Snippets'), icon: 'code', run: () => pickSnippet(s) }, + { label: T('Paste'), icon: 'paste', run: async () => { const { text } = await Native.paste(); if (text) s.alive ? api.send('ssh:write', s.id, text) : null; } }, + { label: T('Copy selection'), icon: 'copy', run: () => { const t = s.term.getSelection(); if (t) copyText(t); else toast(T('Nothing selected. Long-press the terminal to select text.')); } }, + { label: T('Larger font'), icon: 'zoomIn', run: () => s.setFont(s.term.options.fontSize + 1) }, + { label: T('Smaller font'), icon: 'zoomOut', run: () => s.setFont(s.term.options.fontSize - 1) }, + s.alive ? { label: T('Disconnect'), icon: 'x', run: () => api.send('ssh:close', s.id) } : { label: T('Reconnect'), icon: 'refresh', run: () => s.connect() }, + { label: T('Close session'), icon: 'trash', danger: true, run: () => { + s.close(); + const next = S.sessions[S.sessions.length - 1]; + next ? showSession(next) : hideTerminal(); + } }, + ]); +}; + +// Bildschirmtastatur verkleinert den sichtbaren Bereich: Terminal an visualViewport anpassen +function fitViewport() { + const vv = window.visualViewport; + const scr = $('#termScreen'); + if (vv) scr.style.height = `${vv.height}px`; + S.active?.refit(); +} +window.visualViewport?.addEventListener('resize', fitViewport); +window.addEventListener('resize', fitViewport); + +function pickSnippet(s) { + if (!S.vault.snippets.length) return toast(T('No snippets')); + sheet(T('Snippets'), S.vault.snippets.map((sn) => ({ label: sn.label, icon: 'code', run: () => { if (s.alive) api.send('ssh:write', s.id, sn.command.endsWith('\n') ? sn.command.replace(/\n$/, '\r') : sn.command); s.term.focus(); } }))); +} + +// ============================================================ Schlüssel +function viewKeys(v) { + v.append(h(`

${esc(T('Keys'))}

`)); + const fab = h(``); + fab.onclick = () => sheet(null, [ + { label: T('Generate key'), icon: 'key', run: generateKey }, + { label: T('Import key'), icon: 'paste', run: () => editKey({}) }, + ]); + if (!S.vault.keys.length) { + v.append(h(`
${icon('key')}

${esc(T('No keys'))}

${esc(T('Generate a new SSH key or import an existing one.'))}
`)); + v.append(fab); + return; + } + const list = h('
'); + for (const k of S.vault.keys) { + const type = (k.publicKey || '').split(' ')[0].replace('ssh-', '') || 'key'; + const el = h(`
${icon('key')}
${esc(k.label)}
${esc(type)} · ${esc((k.publicKey || '').split(' ')[1]?.slice(-16) || '')}
`); + el.onclick = () => sheet(k.label, [ + { label: T('Copy public key'), icon: 'copy', run: () => copyText(k.publicKey) }, + { label: T('Edit'), icon: 'edit', run: () => editKey(k) }, + { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete key?'), k.label)) { await call('vault:remove', 'keys', k.id); reload(); } } }, + ]); + list.append(el); + } + v.append(list, fab); +} + +function generateKey() { + page(T('Generate key'), h(`
+ ${field(T('Name'), 'label', `${S.info.device.replace(/\s+/g, '-').toLowerCase()}`)} + ${field(T('Type'), 'type', 'ed25519', { type: 'select', options: [['ed25519', `Ed25519 (${T('recommended')})`], ['ecdsa', 'ECDSA'], ['rsa', 'RSA 4096']] })} + ${field('Passphrase (optional)', 'passphrase', '', { type: 'password' })} +
`), { + saveLabel: T('Generate'), + save: async (v) => { + if (!v.label.trim()) throw new Error(T('Please enter a name.')); + const k = await api.call('key:generate', { type: v.type, passphrase: v.passphrase, comment: v.label.trim() }); + const saved = await call('vault:upsert', 'keys', { label: v.label.trim(), privateKey: k.privateKey, publicKey: k.publicKey, passphrase: v.passphrase || undefined }); + await reload(); + askShareSecret('keys', saved, saved.label); + const r = await modal({ title: T('Key created'), body: `

${esc(T('Add the public key to ~/.ssh/authorized_keys on your servers.'))}

${esc(k.publicKey)}
`, buttons: [{ label: T('Close'), value: false }, { label: T('Copy public key'), value: true, cls: 'primary' }] }); + if (r) copyText(k.publicKey); + }, + }); +} + +function editKey(k) { + page(k.id ? T('Edit key') : T('Import key'), h(`
+ ${field(T('Name'), 'label', k.label)} + ${field('Private Key', 'privateKey', k.privateKey, { type: 'textarea', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----' })} + ${field('Passphrase', 'passphrase', k.passphrase, { type: 'password' })} + ${field('Public Key', 'publicKey', k.publicKey, { type: 'textarea', hint: T('Derived from the private key automatically if left empty.') })} +
`), { + save: async (v) => { + if (!v.privateKey.trim()) throw new Error(T('Private key is missing.')); + const parsed = await api.call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase }); + const saved = await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey, passphrase: v.passphrase || undefined }); + await reload(); + if (!k.id) askShareSecret('keys', saved, saved.label); + }, + }); +} + +// ============================================================ Snippets +function viewSnippets(v) { + v.append(h(`

${esc(T('Snippets'))}

`)); + const fab = h(``); + fab.onclick = () => editSnippet({}); + if (!S.vault.snippets.length) { + v.append(h(`
${icon('code')}

${esc(T('No snippets'))}

${esc(T('Save frequently used commands and send them to a terminal with one tap.'))}
`)); + v.append(fab); + return; + } + const list = h('
'); + for (const s of S.vault.snippets) { + const el = h(`
${icon('code')}
${esc(s.label)}
${esc(s.command.split('\n')[0])}
`); + el.onclick = () => sheet(s.label, [ + S.sessions.some((x) => x.alive) && { label: T('Run in terminal'), icon: 'play', run: () => { const t = S.sessions.find((x) => x.alive); showSession(t); api.send('ssh:write', t.id, s.command.replace(/\n$/, '\r')); } }, + { label: T('Copy'), icon: 'copy', run: () => copyText(s.command) }, + { label: T('Edit'), icon: 'edit', run: () => editSnippet(s) }, + { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete snippet?'), s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } } }, + ]); + list.append(el); + } + v.append(list, fab); +} + +function editSnippet(s) { + page(s.id ? T('Edit snippet') : T('New snippet'), h(`
${field(T('Name'), 'label', s.label)}${field(T('Command'), 'command', s.command, { type: 'textarea', placeholder: 'sudo apt update && sudo apt upgrade -y' })}
${esc(T('End the command with a line break to run it immediately.'))}
`), { + save: async (v) => { + if (!v.command.trim()) throw new Error(T('Please enter a command.')); + await call('vault:upsert', 'snippets', { ...s, ...v, label: v.label || v.command.split('\n')[0].slice(0, 40) }); + reload(); + }, + }); +} + +// ============================================================ Einstellungen +function viewSettings(v) { + v.append(h(`

${esc(T('Settings'))}

`)); + const st = settings(); + + // Sprache & Terminal + const gen = h(`

${esc(T('General'))}

+ ${field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System language')], ...Object.entries(I18N.LANGUAGES)] })} + ${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'mrterm', { type: 'select', options: Object.entries(TERM_THEMES).map(([id, t]) => [id, t.name]) })} + ${field(T('Font size'), 'mobileFontSize', fontSize(), { type: 'select', options: [8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20].map((n) => [n, `${n} px`]) })} +
`); + $('[name=language]', gen).onchange = async (e) => { await call('vault:settings', { language: e.target.value }); await reload(); }; + $('[name=terminalTheme]', gen).onchange = async (e) => { + await call('vault:settings', { terminalTheme: e.target.value }); S.vault.settings.terminalTheme = e.target.value; + S.sessions.forEach((s) => { s.term.options.theme = termTheme(); }); + }; + $('[name=mobileFontSize]', gen).onchange = async (e) => { + const n = Number(e.target.value); await call('vault:settings', { mobileFontSize: n }); S.vault.settings.mobileFontSize = n; + S.sessions.forEach((s) => { s.term.options.fontSize = n; }); + }; + v.append(gen); + + v.append(lockCard()); + const sc = h('
'); + v.append(sc); + renderSyncCard(sc); + + // Bekannte Hosts + const kh = h(`

${esc(T('Known hosts'))}

${esc(T('{n} saved host keys', { n: Object.keys(S.vault.knownHosts || {}).length }))}
`); + $('button', kh).onclick = showKnownHosts; + v.append(kh); + + v.append(h(`

MrTerm

${esc(T('Version {v}', { v: S.info.version }))} · Android
${esc(T('Updates are installed with Obtainium.'))}

`)); +} + +function showKnownHosts() { + const body = h('
'); + const draw = () => { + body.innerHTML = ''; + const entries = Object.entries(S.vault.knownHosts || {}); + if (!entries.length) { body.append(h(`
${esc(T('No known hosts yet.'))}
`)); return; } + const list = h('
'); + for (const [id, kh] of entries) { + const el = h(`
${icon('shield')}
${esc(id)}
${esc(kh.fingerprint || '')}
`); + $('.more', el).onclick = async () => { if (await confirmBox(T('Remove host key?'), id, T('Remove'))) { await call('vault:forgetHost', id); S.vault = await api.call('vault:get'); draw(); } }; + list.append(el); + } + body.append(list); + }; + draw(); + page(T('Known hosts'), body); +} + +// ---------- App-Sperre ---------- +const AUTOLOCK = [[0, 'Immediately'], [60, 'After 1 minute'], [300, 'After 5 minutes'], [-1, 'Never']]; +function lockCard() { + const L = S.lock; + const c = h(`

${esc(T('App lock'))}

${esc(T('Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.'))}

`); + const pw = h(`
${esc(T('Password'))}
${esc(L.hasPassword ? T('Set') : T('Not set'))}
`); + const setPw = h(``); + setPw.onclick = async () => { + const r = await modal({ title: T('Set password'), body: `${field(T('New password'), 'a', '', { type: 'password' })}${field(T('Repeat password'), 'b', '', { type: 'password' })}`, buttons: [{ label: T('Cancel'), value: null }, { label: T('Save'), value: 'form', cls: 'primary' }] }); + if (!r) return; + if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error'); + try { S.lock = await call('lock:setPassword', r.a); toast(T('Saved'), 'ok'); render(); } catch {} + }; + pw.append(setPw); + c.append(pw); + if (L.hasPassword) { + const rm = h(`
${esc(T('Remove app lock'))}
`); + $('button', rm).onclick = async () => { + if (!(await confirmBox(T('Remove app lock?'), T('MrTerm will open without a password.'), T('Remove')))) return; + S.lock = await call('lock:removePassword'); + Native.bioDisable().catch(() => {}); + render(); + }; + c.append(rm); + if (S.bio.available) { + const bio = h(`
${esc(T('Unlock with fingerprint'))}
${esc(T('Uses the fingerprint or face unlock of this phone.'))}
${switchEl(L.bio)}
`); + $('input', bio).onchange = async (e) => { + try { + if (e.target.checked) { + const { secret } = await Native.bioEnroll({ title: T('Confirm fingerprint'), cancel: T('Cancel') }); + S.lock = await call('lock:setBio', secret); + } else { + S.lock = await call('lock:setBio', null); + await Native.bioDisable(); + } + } catch (err) { if (err.message !== 'cancelled') toast(err.message, 'error'); } + render(); + }; + c.append(bio); + } + const al = h(`
${esc(T('Lock when leaving the app'))}
`); + $('select', al).onchange = async (e) => { const n = Number(e.target.value); await call('vault:settings', { mobileAutoLock: n }); S.vault.settings.mobileAutoLock = n; }; + c.append(al); + } + return c; +} + +async function lockNow() { + S.lock = await api.call('lock:lock'); + if (S.lock.locked) showLock(); +} + +function showLock() { + $('#main').hidden = true; + $('#termScreen').hidden = true; + $$('#layer > *').forEach((e) => e.remove()); + backStack.length = 0; + const el = $('#lockScreen'); + el.hidden = false; + el.innerHTML = `

${esc(T('MrTerm is locked'))}

${esc(T('Enter your password to unlock.'))}

+
${field(T('Password'), 'pw', '', { type: 'password' })}
+ ${S.lock.bio && S.bio.enrolled !== false ? `` : ''}`; + wirePw(el); + const done = async () => { + el.hidden = true; + el.innerHTML = ''; + await reload(); + if (S.active) showSession(S.active); + }; + $('form', el).onsubmit = async (e) => { + e.preventDefault(); + const pw = $('[name=pw]', el).value; + try { await api.call('lock:unlockPassword', pw); await done(); } catch (err) { toast(err.message, 'error'); $('[name=pw]', el).select(); } + }; + const bioUnlock = async () => { + try { + const { secret } = await Native.bioUnlock({ title: T('Unlock MrTerm'), cancel: T('Use password') }); + await api.call('lock:unlockBio', secret); + await done(); + } catch (err) { + if (err.message === 'invalidated') toast(T('Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.'), 'error'); + else if (err.message !== 'cancelled') toast(err.message, 'error'); + $('[name=pw]', el)?.focus(); + } + }; + const bb = $('.bio-btn', el); + if (bb) { bb.onclick = bioUnlock; setTimeout(bioUnlock, 250); } else setTimeout(() => $('[name=pw]', el).focus(), 100); +} + +// Automatisch sperren, wenn die App länger im Hintergrund war; Sync-Sockets im Hintergrund schließen +let hiddenAt = 0; +document.addEventListener('visibilitychange', async () => { + if (!S.lock || !S.vault) return; + if (document.hidden) { + hiddenAt = Date.now(); + Native.multicast({ on: false }).catch(() => {}); + return; + } + Native.multicast({ on: true }).catch(() => {}); + const after = Number(settings().mobileAutoLock ?? 60); + if (S.lock.enabled && !S.lock.locked && after >= 0 && Date.now() - hiddenAt >= after * 1000) await lockNow(); + else api.call('app:resume').catch(() => {}); +}); + +// ============================================================ Synchronisation +async function renderSyncCard(card) { + const st = S.sync || (S.sync = await api.call('sync:status').catch(() => null)); + if (!st) return; + card.innerHTML = `

${esc(T('Synchronization'))}

${esc(T('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.'))}

`; + const en = h(`
${esc(T('Enable LAN synchronization'))}
${switchEl(st.enabled)}
`); + $('input', en).onchange = async (e) => { S.sync = await call('sync:enable', e.target.checked, st.deviceName || S.info.device); renderSyncCard(card); }; + card.append(en); + if (!st.enabled) return; + const name = h(`
${field(T('Device name'), 'devName', st.deviceName)}
`); + $('input', name).onchange = async (e) => { S.sync = await call('sync:enable', true, e.target.value.trim()); }; + card.append(name); + if (!st.peers.length) card.append(h(`
${esc(T('No paired devices yet.'))}
`)); + for (const p of st.peers) { + const row = h(`
${esc(p.name)}
${esc(p.online ? T('online') : T('offline'))}${p.lastSync ? ` · ${esc(T('last sync {time}', { time: new Date(p.lastSync).toLocaleString(I18N.lang) }))}` : ''}${p.error ? ` · ${esc(p.error)}` : ''}
`); + $('button', row).onclick = async () => { if (await confirmBox(T('Unpair device?'), T('“{name}” will no longer synchronize with this device.', { name: p.name }), T('Unpair'))) { S.sync = await call('sync:unpair', p.id); renderSyncCard(card); } }; + card.append(row); + } + const acts = h('
'); + const pair = h(``); pair.onclick = openPairing; + const now = h(``); + now.onclick = async () => { now.disabled = true; try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} now.disabled = false; }; + const sec = h(``); sec.onclick = chooseSharedSecrets; + acts.append(pair, now, sec); + card.append(acts); + card.append(h(`
${esc(T('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.'))}
`)); +} + +async function openPairing() { + S.sync = await call('sync:pairable', true); + const body = h(`

${esc(T('Open “Pair new device” on the other device too, then select it here. Both devices show a code that you confirm on both sides.'))}

+
`); + const pg = page(T('Pair new device'), body); + const draw = () => { + const box = $('.pair-list', body); + const near = S.sync?.nearby || []; + box.innerHTML = near.length ? '' : `
${esc(T('Searching for devices …'))}
`; + if (!near.length) return; + const list = h('
'); + for (const d of near) { + const row = h(`
${icon('devices')}
${esc(d.name)}
${esc(d.address)}${d.pairable ? '' : ` · ${esc(T('not ready for pairing'))}`}
`); + const b = $('button', row); + b.onclick = async () => { + b.disabled = true; b.innerHTML = ''; + try { await call('sync:pair', d.id); pg.close(); } catch {} + b.disabled = false; b.textContent = T('Pair'); + }; + list.append(row); + } + box.append(list); + }; + draw(); + const timer = setInterval(() => { if (!pg.el.isConnected) { clearInterval(timer); api.call('sync:pairable', false).then((s) => { S.sync = s; }).catch(() => {}); return; } draw(); }, 1500); + $('[data-probe]', body).onclick = async () => { const ip = $('[name=ip]', body).value.trim(); if (ip) { try { await call('sync:probe', ip); toast(T('Device found'), 'ok'); draw(); } catch {} } }; +} + +async function chooseSharedSecrets() { + const sh = S.sync?.share || { keys: [], hosts: [], vpns: [] }; + const sect = (title, c, items, label) => items.length ? `

${esc(title)}

${items.map((x) => ``).join('')}` : ''; + const hostsPw = S.vault.hosts.filter((x) => x.password); + const none = !S.vault.keys.length && !hostsPw.length && !S.vault.vpns.length; + const body = h(`

${esc(T('Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.'))}

+
${sect(T('SSH keys'), 'keys', S.vault.keys, (x) => x.label)}${sect(T('Host passwords'), 'hosts', hostsPw, (x) => `${hostName(x)} (${x.username || '?'}@${x.address})`)}${sect(T('VPN configurations'), 'vpns', S.vault.vpns, (x) => x.label)}
+ ${none ? `
${esc(T('There are no secrets on this device yet.'))}
` : ''}
`); + page(T('Choose shared secrets'), body, { + save: async (v) => { + const picked = { keys: [], hosts: [], vpns: [] }; + for (const [k, on] of Object.entries(v)) { const [c, id] = k.split('|'); if (on && picked[c]) picked[c].push(id); } + S.sync = await call('sync:share', picked); + toast(T('Saved'), 'ok'); + }, + }); +} + +// Neues Geheimnis bei aktiver Synchronisation: fragen, ob es geteilt werden soll (Antwort wird gemerkt) +async function askShareSecret(c, item, label) { + try { + const st = S.sync || (S.sync = await api.call('sync:status')); + if (!st.enabled || !st.peers.length || !item?.id) return; + if ((st.share[c] || []).includes(item.id) || (st.share.declined?.[c] || []).includes(item.id)) return; + const what = { keys: T('SSH key'), hosts: T('Host password'), vpns: T('VPN configuration') }[c]; + const r = await modal({ title: T('Share with paired devices?'), text: `${what}: ${label || ''}`, + body: `

${esc(T('Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.', { names: st.peers.map((p) => p.name).join(', ') }))}

`, + buttons: [{ label: T("Don't share"), value: 'no' }, { label: T('Share'), value: 'yes', cls: 'primary' }] }); + if (!r) return; + S.sync = await api.call('sync:shareItem', c, item.id, r === 'yes'); + } catch {} +} + +api.on('sync:state', (st) => { + S.sync = st; + const card = $('.sync-card'); + if (card && !card.contains(document.activeElement)) renderSyncCard(card); +}); +api.on('sync:changed', async () => { + if (S.lock?.locked || !$('#lockScreen').hidden) return; + S.vault = await api.call('vault:get'); + if (!$('#main').hidden && !$('#layer').children.length) render(); +}); +api.on('sync:paired', (p) => { toast(T('Paired with {name}', { name: p.name }), 'ok'); chooseSharedSecrets(); }); +api.on('sync:pairPrompt', async (req) => { + const r = await modal({ title: T('Confirm pairing'), body: `

${esc(T('Pairing with “{name}”. Does the other device show the same code?', { name: req.name }))}

${esc(req.code.slice(0, 3))} ${esc(req.code.slice(3))}
`, + buttons: [{ label: T('Codes differ'), value: false, cls: 'danger' }, { label: T('Codes match'), value: true, cls: 'primary' }], dismiss: false }); + api.send('ask:reply', req.reqId, r === true); +}); + +// ============================================================ Rückfragen des Backends +api.on('hostkey:request', async (req) => { + const changed = !!req.previous; + const r = await modal({ + title: changed ? T('Host key has changed!') : T('Unknown host'), + body: `

${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}

+
${esc(req.fingerprint)}
${changed ? `

${esc(T('Previously stored:'))}

${esc(req.previous)}
` : ''}`, + buttons: [{ label: T('Cancel'), value: false }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }], + dismiss: false, + }); + api.send('ask:reply', req.reqId, r === true); +}); +api.on('secret:request', async (req) => { + const r = await modal({ + title: req.title || T('Authentication'), text: req.host, + body: field(req.prompt || T('Password'), 'v', '', { type: req.echo ? 'text' : 'password' }), + buttons: [{ label: T('Cancel'), value: null }, { label: T('Log in'), value: 'form', cls: 'primary' }], + }); + api.send('ask:reply', req.reqId, r ? r.v : null); +}); +api.on('toast', (m, t) => toast(m, t)); + +// ============================================================ Hilfen +function copyText(t) { + Native.copy({ text: t }).then(() => toast(T('Copied'), 'ok')).catch(() => toast(T('Copy failed'), 'error')); +} + +// Android-Zurück: oberste Ebene schließen, dann Terminal verlassen, dann App in den Hintergrund +function onBack() { + const top = backStack[backStack.length - 1]; + if (top) return top.close(); + if (!$('#termScreen').hidden) return hideTerminal(); + if (S.view !== 'hosts') { S.view = 'hosts'; render(); return; } + Native.background?.().catch(() => {}); +} +window.mrtermBack = onBack; // wird von MainActivity bei der Zurück-Taste aufgerufen +// MainActivity meldet die Bildschirmtastatur (WebView wird dann um ihre Höhe verkleinert) +window.mrtermIme = (open) => { document.body.classList.toggle('kbd-open', open); setTimeout(fitViewport, 50); }; + +boot().catch((e) => { + $('#boot').innerHTML = `
${esc(e.message || e)}
`; +}); diff --git a/mobile/web/index.html b/mobile/web/index.html new file mode 100644 index 0000000..debf687 --- /dev/null +++ b/mobile/web/index.html @@ -0,0 +1,47 @@ + + + + + + + MrTerm + + + + +
+ + + + + + + + +
+
+ + + + + + + + diff --git a/mobile/web/styles.css b/mobile/web/styles.css new file mode 100644 index 0000000..29831c7 --- /dev/null +++ b/mobile/web/styles.css @@ -0,0 +1,190 @@ +:root { + --bg: #14161d; + --surface: #1c1f29; + --surface2: #252936; + --line: #2c3040; + --text: #e6e8ef; + --muted: #9aa0b4; + --faint: #5d6377; + --accent: #6e7bff; + --accent-soft: #6e7bff26; + --green: #3ecf8e; + --red: #ff5f6d; + --orange: #ffb347; + --radius: 14px; + /* Capacitor setzt --safe-area-inset-* bei Edge-to-Edge; env() als Rückfall */ + --top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px)); + --bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px)); + color-scheme: dark; +} +* { 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 system-ui, -apple-system, 'Segoe 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(--radius); 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: 18px; background: var(--accent); color: #fff; 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(--radius); 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); font-weight: 600; } +.btn svg { width: 18px; height: 18px; } +.btn.primary { background: var(--accent); color: #fff; } +.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: #fff; } + +.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: #fff; } +#keybar button.lock { box-shadow: inset 0 0 0 2px #fff; } +body.kbd-open #keybar { padding-bottom: 6px; } +body.kbd-open #tabs, body.kbd-open .fab { display: none; } diff --git a/scripts/release-all.sh b/scripts/release-all.sh index 5e4e7ae..5bf1453 100755 --- a/scripts/release-all.sh +++ b/scripts/release-all.sh @@ -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 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 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[@]}" diff --git a/scripts/release.js b/scripts/release.js index ce4d121..9e798b9 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -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); diff --git a/src/i18n.js b/src/i18n.js index 4beba53..edc97f0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -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', diff --git a/src/main/ssh.js b/src/main/ssh.js index aa7b82a..e0a5cdd 100644 --- a/src/main/ssh.js +++ b/src/main/ssh.js @@ -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, diff --git a/src/main/sync.js b/src/main/sync.js index 2f4846d..72c1654 100644 --- a/src/main/sync.js +++ b/src/main/sync.js @@ -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);