Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b02dc078a | ||
|
|
ecaf579ccd | ||
|
|
4176cc0a01 | ||
|
|
20506ee889 | ||
|
|
6ebdc5126f | ||
|
|
08c6d65e29 | ||
|
|
8c8dc0d4c0 | ||
|
|
a279ef88b2 | ||
|
|
6efa74aa7e | ||
|
|
0b43182019 | ||
|
|
93d7130bfa | ||
|
|
408b4bd9d0 | ||
|
|
7d4991c99c | ||
|
|
10acdaeb0b | ||
|
|
daff240aa6 | ||
|
|
66d4350221 | ||
|
|
6c6e25df36 | ||
|
|
32bf502ffa | ||
|
|
a2a0893b8c | ||
|
|
45b0be8557 | ||
|
|
c143053319 | ||
|
|
58709e09f2 | ||
|
|
27091ab5d1 | ||
|
|
6a6971e1cb | ||
|
|
edea4ce0d2 | ||
|
|
b431428260 | ||
|
|
aeb6c590db | ||
|
|
428e74b2d8 | ||
|
|
b94f18d2e2 | ||
|
|
7cff93419d | ||
|
|
1980d3044b | ||
|
|
4542aff75f | ||
|
|
430c18b84e | ||
|
|
98c7763c47 |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
mobile
|
||||||
|
build
|
||||||
|
docs
|
||||||
|
.git
|
||||||
|
.gitea-token
|
||||||
|
web-data
|
||||||
@@ -2,3 +2,10 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
package.json
|
package.json
|
||||||
.gitea-token
|
.gitea-token
|
||||||
|
!mobile/package.json
|
||||||
|
# Android-App: Build-Ausgaben, Plugin-Tarball (lädt build.js), Signaturschlüssel
|
||||||
|
mobile/vendor/
|
||||||
|
mobile/www/
|
||||||
|
mobile/android/keystore.properties
|
||||||
|
mobile/android/*.jks
|
||||||
|
web-data/
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# MrTerm Web – die MrTerm-Oberfläche im Browser, selbst gehostet.
|
||||||
|
# docker compose up -d → http://<server>:8080 (beim ersten Aufruf Admin-Konto anlegen)
|
||||||
|
|
||||||
|
# ---- Build: Server samt ssh2/ws in eine Datei bündeln
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
COPY src ./src
|
||||||
|
RUN npx esbuild src/web/server.js --bundle --platform=node --target=node22 --minify --legal-comments=none \
|
||||||
|
--external:electron --external:cpu-features --external:bufferutil --external:utf-8-validate \
|
||||||
|
--outfile=dist/server.js
|
||||||
|
|
||||||
|
# ---- Laufzeit: nur Node, gebündelter Server und die Dateien der Oberfläche
|
||||||
|
FROM node:22-alpine
|
||||||
|
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data MRTERM_ROOT=/app
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
COPY --from=build /src/dist/server.js dist/server.js
|
||||||
|
COPY src/i18n.js src/i18n.js
|
||||||
|
COPY src/renderer src/renderer
|
||||||
|
COPY src/web/public src/web/public
|
||||||
|
COPY --from=build /src/node_modules/@xterm node_modules/@xterm
|
||||||
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
USER node
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
A modern SSH, SFTP and RDP client for Windows and Arch Linux / CachyOS. All your servers live in one place and open in tabs: terminals, file transfers and remote desktops.
|
A modern SSH, SFTP and RDP client for Windows and Arch Linux / CachyOS. All your servers live in one place and open in tabs: terminals, file transfers and remote desktops.
|
||||||
|
|
||||||
|
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D)
|
||||||
|
|
||||||
MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**.
|
MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Download & installation
|
## Download & installation
|
||||||
|
|
||||||
Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases).
|
Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases).
|
||||||
@@ -25,6 +29,23 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
|
|||||||
```
|
```
|
||||||
`freerdp` is needed for RDP connections. `gnome-keyring` or `kwallet` lets MrTerm encrypt your saved passwords and keys.
|
`freerdp` is needed for RDP connections. `gnome-keyring` or `kwallet` lets MrTerm encrypt your saved passwords and keys.
|
||||||
|
|
||||||
|
**Android**
|
||||||
|
- Install with [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D): tap the badge above on your phone, or add `https://git.mrblake.cc/MrBlake/MrTerm` in Obtainium and choose **Forgejo (Codeberg)** as the source. Obtainium then keeps MrTerm up to date.
|
||||||
|
- Or download `MrTerm-<version>.apk` from the releases page and install it directly.
|
||||||
|
|
||||||
|
## Android app
|
||||||
|
|
||||||
|
The Android app is made for phones and focuses on what you need on the go:
|
||||||
|
|
||||||
|
- **Hosts** with groups, search and Quick Connect (`user@host:port`)
|
||||||
|
- **SSH terminal** in full screen with an extra key row (Esc, Tab, Ctrl, Alt, arrow keys, `|`, `~` …), pinch-to-zoom and several open sessions
|
||||||
|
- **Keys**: generate or import SSH keys and copy the public key
|
||||||
|
- **Snippets** you can send to the terminal with one tap
|
||||||
|
- **LAN sync** with MrTerm on your computer: pair once, and your hosts, groups and snippets appear on the phone. You choose which passwords and keys are shared
|
||||||
|
- **App lock** with a password and fingerprint unlock. The vault is encrypted with a key kept in the Android Keystore
|
||||||
|
|
||||||
|
RDP, SFTP, port forwarding and VPN are only available in the desktop app.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Hosts**: nested groups, tags, colors, search and Quick Connect (`user@host:port`, `rdp://host`)
|
- **Hosts**: nested groups, tags, colors, search and Quick Connect (`user@host:port`, `rdp://host`)
|
||||||
@@ -41,13 +62,60 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
|
|||||||
- **Known hosts**: MrTerm warns you if a server's host key changes
|
- **Known hosts**: MrTerm warns you if a server's host key changes
|
||||||
- **History** of recent connections
|
- **History** of recent connections
|
||||||
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
|
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
|
||||||
- **Backup** export and import
|
- **Backup** of everything (hosts, keys, passwords, snippets, forwards, VPNs, known hosts, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import can merge or replace
|
||||||
|
- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker)
|
||||||
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
|
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
|
||||||
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
|
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
|
||||||
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
|
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
|
||||||
- **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive
|
- **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive
|
||||||
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
|
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **SSH terminal** with tabs and quick access to SFTP, Docker, Firewall and Network | **Host settings**: authentication, keys, jump hosts and VPN |
|
||||||
|
|  |  |
|
||||||
|
| **Docker & Podman** containers with CPU and memory usage | **Firewall** rules for UFW and iptables |
|
||||||
|
|  |  |
|
||||||
|
| **Network**: interfaces, bonds (LACP), bridges, gateway and DNS | **VPN**: WireGuard and OpenVPN, connected automatically per host |
|
||||||
|
|  | |
|
||||||
|
| **Settings**: language, app lock, LAN sync and themes | |
|
||||||
|
|
||||||
|
## Web version (Docker)
|
||||||
|
|
||||||
|
MrTerm also runs as a self-hosted web app, similar to Termix: you use the familiar MrTerm interface in the browser, from any computer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O https://git.mrblake.cc/MrBlake/MrTerm/raw/branch/main/docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<your-server>:8080` and create the administrator account. As an administrator you can add more users under **Settings → Account**.
|
||||||
|
|
||||||
|
- **Included:** hosts and groups, SSH terminal tabs, SFTP file manager (upload by button or drag & drop, download to your computer), keys, snippets, known hosts, Docker, firewall and network management, themes.
|
||||||
|
- **Not included:** RDP, VPN, port forwarding, LAN sync and the app lock. These need direct access to your computer.
|
||||||
|
- **Security:** every user has their own vault, encrypted with a key that only their login password can unlock. The server stores no readable passwords or keys. If a user forgets their password, their vault cannot be recovered.
|
||||||
|
- **HTTPS:** put MrTerm behind a reverse proxy with HTTPS (Traefik, Caddy, nginx …) before you use it over the internet, and set `TRUST_PROXY=1` in `docker-compose.yml`. The proxy must forward WebSockets.
|
||||||
|
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
|
||||||
|
- **SSH connections** start from the server, so the server must be able to reach your hosts.
|
||||||
|
|
||||||
|
### Updating the web version
|
||||||
|
|
||||||
|
Run these commands in the folder that contains your `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --pull --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This fetches the latest MrTerm from the repository, rebuilds the image and restarts the container. Your users and vaults stay in the `mrterm-data` volume and are kept. Everyone has to sign in again after the restart.
|
||||||
|
|
||||||
|
To check which version is running, look at the bottom of the sign-in page. If a release note mentions changes to `docker-compose.yml`, download it again first (`curl -O …` as above) and copy over your own changes, such as ports or `TRUST_PROXY`.
|
||||||
|
|
||||||
|
To remove images left over from earlier builds: `docker image prune`.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
||||||
@@ -181,7 +249,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`.
|
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`.
|
**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`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# MrTerm Web
|
||||||
|
# docker compose up -d
|
||||||
|
# Danach http://<server>:8080 öffnen und das Admin-Konto anlegen.
|
||||||
|
# Hinter einem Reverse-Proxy mit HTTPS (empfohlen): TRUST_PROXY=1 setzen und den Port nur lokal freigeben.
|
||||||
|
services:
|
||||||
|
mrterm:
|
||||||
|
build:
|
||||||
|
# Baut direkt aus dem Repository – kein Klonen nötig. Alternativ "context: ." in einem Klon.
|
||||||
|
context: https://git.mrblake.cc/MrBlake/MrTerm.git
|
||||||
|
image: mrterm-web
|
||||||
|
container_name: mrterm
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Berlin
|
||||||
|
# - TRUST_PROXY=1
|
||||||
|
volumes:
|
||||||
|
- mrterm-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mrterm-data:
|
||||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.mrterm.app"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "de.mrterm.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
// Version kommt aus der package.json des Desktop-Projekts (build.js setzt -PmrtermVersion)
|
||||||
|
def v = (project.findProperty('mrtermVersion') ?: '0.0.1').toString()
|
||||||
|
def p = v.tokenize('.-')
|
||||||
|
versionCode (p[0].toInteger() * 10000 + p[1].toInteger() * 100 + p[2].toInteger())
|
||||||
|
versionName v
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Signatur: keystore.properties (nicht im Repo) mit storeFile, storePassword, keyAlias, keyPassword
|
||||||
|
def ksFile = rootProject.file('keystore.properties')
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
if (ksFile.exists()) {
|
||||||
|
def ks = new Properties()
|
||||||
|
ksFile.withInputStream { ks.load(it) }
|
||||||
|
storeFile rootProject.file(ks['storeFile'])
|
||||||
|
storePassword ks['storePassword']
|
||||||
|
keyAlias ks['keyAlias']
|
||||||
|
keyPassword ks['keyPassword']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Eine APK je Prozessorarchitektur (libnode ist groß); Obtainium wählt die passende automatisch
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
enable true
|
||||||
|
reset()
|
||||||
|
include 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
universalApk false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
if (ksFile.exists()) signingConfig signingConfigs.release
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
implementation "androidx.biometric:biometric:1.1.0"
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-nodejs')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package de.mrterm.app;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
|
||||||
|
import androidx.activity.OnBackPressedCallback;
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(MrTermNative.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
WebView web = getBridge().getWebView();
|
||||||
|
|
||||||
|
// Zurück-Taste an die Oberfläche geben (schließt Dialoge, verlässt das Terminal …)
|
||||||
|
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
|
||||||
|
@Override
|
||||||
|
public void handleOnBackPressed() {
|
||||||
|
web.evaluateJavascript("window.mrtermBack && window.mrtermBack()", null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge-to-Edge: Ränder für Status- und Navigationsleiste setzt diese Ansicht. Den Platz für die Tastatur
|
||||||
|
// schafft Android/WebView selbst – hier nur melden, ob sie offen ist (sonst würde doppelt verkleinert).
|
||||||
|
View parent = (View) web.getParent();
|
||||||
|
parent.setBackgroundColor(0xFF14161D);
|
||||||
|
final boolean[] imeOpen = { false };
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(parent, (v, insets) -> {
|
||||||
|
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
|
||||||
|
boolean open = insets.isVisible(WindowInsetsCompat.Type.ime());
|
||||||
|
v.setPadding(bars.left, bars.top, bars.right, open ? 0 : bars.bottom);
|
||||||
|
if (open != imeOpen[0]) {
|
||||||
|
imeOpen[0] = open;
|
||||||
|
web.evaluateJavascript("window.mrtermIme && window.mrtermIme(" + open + ")", null);
|
||||||
|
}
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package de.mrterm.app;
|
||||||
|
|
||||||
|
import android.content.ClipData;
|
||||||
|
import android.content.ClipboardManager;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.net.wifi.WifiManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.security.keystore.KeyGenParameterSpec;
|
||||||
|
import android.security.keystore.KeyPermanentlyInvalidatedException;
|
||||||
|
import android.security.keystore.KeyProperties;
|
||||||
|
import android.util.Base64;
|
||||||
|
|
||||||
|
import androidx.biometric.BiometricManager;
|
||||||
|
import androidx.biometric.BiometricPrompt;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.fragment.app.FragmentActivity;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.KeyGenerator;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plattformfunktionen für MrTerm:
|
||||||
|
* - Tresorschlüssel: zufällige 32 Byte, verpackt mit einem nicht exportierbaren AES-Schlüssel im Android Keystore
|
||||||
|
* - Fingerabdruck: Geheimnis, das nur nach erfolgreicher biometrischer Bestätigung entschlüsselt werden kann
|
||||||
|
* - Multicast-Lock (sonst verwirft WLAN die UDP-Broadcasts der LAN-Synchronisation), Zwischenablage, Gerätename
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "MrTermNative")
|
||||||
|
public class MrTermNative extends Plugin {
|
||||||
|
private static final String KS = "AndroidKeyStore";
|
||||||
|
private static final String VAULT_ALIAS = "mrterm_vault";
|
||||||
|
private static final String BIO_ALIAS = "mrterm_bio";
|
||||||
|
private WifiManager.MulticastLock multicastLock;
|
||||||
|
|
||||||
|
private SharedPreferences prefs() {
|
||||||
|
return getContext().getSharedPreferences("mrterm", Context.MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String b64(byte[] b) { return Base64.encodeToString(b, Base64.NO_WRAP); }
|
||||||
|
private static byte[] unb64(String s) { return Base64.decode(s, Base64.NO_WRAP); }
|
||||||
|
|
||||||
|
private SecretKey keystoreKey(String alias, boolean userAuth) throws Exception {
|
||||||
|
KeyStore ks = KeyStore.getInstance(KS);
|
||||||
|
ks.load(null);
|
||||||
|
if (ks.containsAlias(alias)) return (SecretKey) ks.getKey(alias, null);
|
||||||
|
KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KS);
|
||||||
|
KeyGenParameterSpec.Builder spec = new KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256);
|
||||||
|
if (userAuth) {
|
||||||
|
spec.setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true);
|
||||||
|
if (Build.VERSION.SDK_INT >= 30) spec.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG);
|
||||||
|
}
|
||||||
|
kg.init(spec.build());
|
||||||
|
return kg.generateKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteKey(String alias) {
|
||||||
|
try { KeyStore ks = KeyStore.getInstance(KS); ks.load(null); ks.deleteEntry(alias); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tresorschlüssel ----------
|
||||||
|
@PluginMethod
|
||||||
|
public void vaultKey(PluginCall call) {
|
||||||
|
try {
|
||||||
|
SecretKey k = keystoreKey(VAULT_ALIAS, false);
|
||||||
|
String stored = prefs().getString("vaultKey", null);
|
||||||
|
byte[] plain;
|
||||||
|
if (stored == null) {
|
||||||
|
plain = new byte[32];
|
||||||
|
new SecureRandom().nextBytes(plain);
|
||||||
|
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
c.init(Cipher.ENCRYPT_MODE, k);
|
||||||
|
prefs().edit().putString("vaultKey", b64(c.getIV()) + ":" + b64(c.doFinal(plain))).apply();
|
||||||
|
} else {
|
||||||
|
String[] p = stored.split(":");
|
||||||
|
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
c.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, unb64(p[0])));
|
||||||
|
plain = c.doFinal(unb64(p[1]));
|
||||||
|
}
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("key", b64(plain));
|
||||||
|
call.resolve(r);
|
||||||
|
} catch (Exception e) {
|
||||||
|
call.reject("Keystore: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Fingerabdruck ----------
|
||||||
|
@PluginMethod
|
||||||
|
public void bioAvailable(PluginCall call) {
|
||||||
|
int s = BiometricManager.from(getContext()).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG);
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("available", s == BiometricManager.BIOMETRIC_SUCCESS);
|
||||||
|
r.put("enrolled", prefs().getString("bio", null) != null);
|
||||||
|
call.resolve(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void bioEnroll(PluginCall call) {
|
||||||
|
try {
|
||||||
|
deleteKey(BIO_ALIAS);
|
||||||
|
SecretKey k = keystoreKey(BIO_ALIAS, true);
|
||||||
|
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
c.init(Cipher.ENCRYPT_MODE, k);
|
||||||
|
prompt(call, c, call.getString("title", "MrTerm"), call.getString("cancel", "Cancel"), (cipher) -> {
|
||||||
|
byte[] secret = new byte[32];
|
||||||
|
new SecureRandom().nextBytes(secret);
|
||||||
|
byte[] ct = cipher.doFinal(secret);
|
||||||
|
prefs().edit().putString("bio", b64(cipher.getIV()) + ":" + b64(ct)).apply();
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("secret", b64(secret));
|
||||||
|
call.resolve(r);
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
call.reject(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void bioUnlock(PluginCall call) {
|
||||||
|
String stored = prefs().getString("bio", null);
|
||||||
|
if (stored == null) { call.reject("not-enrolled"); return; }
|
||||||
|
try {
|
||||||
|
String[] p = stored.split(":");
|
||||||
|
SecretKey k = keystoreKey(BIO_ALIAS, true);
|
||||||
|
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
c.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, unb64(p[0])));
|
||||||
|
prompt(call, c, call.getString("title", "MrTerm"), call.getString("cancel", "Cancel"), (cipher) -> {
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("secret", b64(cipher.doFinal(unb64(p[1]))));
|
||||||
|
call.resolve(r);
|
||||||
|
});
|
||||||
|
} catch (KeyPermanentlyInvalidatedException e) {
|
||||||
|
// Neuer Fingerabdruck registriert → Schlüssel ungültig, Nutzer muss neu einrichten
|
||||||
|
prefs().edit().remove("bio").apply();
|
||||||
|
deleteKey(BIO_ALIAS);
|
||||||
|
call.reject("invalidated");
|
||||||
|
} catch (Exception e) {
|
||||||
|
call.reject(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void bioDisable(PluginCall call) {
|
||||||
|
prefs().edit().remove("bio").apply();
|
||||||
|
deleteKey(BIO_ALIAS);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface CipherUse { void run(Cipher c) throws Exception; }
|
||||||
|
|
||||||
|
private void prompt(PluginCall call, Cipher cipher, String title, String cancel, CipherUse use) {
|
||||||
|
FragmentActivity act = getActivity();
|
||||||
|
act.runOnUiThread(() -> {
|
||||||
|
BiometricPrompt bp = new BiometricPrompt(act, ContextCompat.getMainExecutor(act), new BiometricPrompt.AuthenticationCallback() {
|
||||||
|
@Override
|
||||||
|
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
|
||||||
|
try { use.run(result.getCryptoObject().getCipher()); }
|
||||||
|
catch (Exception e) { call.reject(e.getMessage()); }
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onAuthenticationError(int code, CharSequence msg) {
|
||||||
|
call.reject(code == BiometricPrompt.ERROR_USER_CANCELED || code == BiometricPrompt.ERROR_NEGATIVE_BUTTON ? "cancelled" : msg.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setNegativeButtonText(cancel)
|
||||||
|
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||||
|
.build();
|
||||||
|
bp.authenticate(info, new BiometricPrompt.CryptoObject(cipher));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sonstiges ----------
|
||||||
|
@PluginMethod
|
||||||
|
public void info(PluginCall call) {
|
||||||
|
String name = null;
|
||||||
|
try { name = Settings.Global.getString(getContext().getContentResolver(), "device_name"); } catch (Exception ignored) {}
|
||||||
|
if (name == null || name.isEmpty()) name = Build.MANUFACTURER + " " + Build.MODEL;
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("device", name);
|
||||||
|
r.put("locale", Locale.getDefault().toLanguageTag());
|
||||||
|
try { r.put("version", getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0).versionName); }
|
||||||
|
catch (Exception e) { r.put("version", "0.0.0"); }
|
||||||
|
call.resolve(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void multicast(PluginCall call) {
|
||||||
|
boolean on = Boolean.TRUE.equals(call.getBoolean("on", true));
|
||||||
|
WifiManager wm = (WifiManager) getContext().getApplicationContext().getSystemService(Context.WIFI_SERVICE);
|
||||||
|
if (on && multicastLock == null && wm != null) {
|
||||||
|
multicastLock = wm.createMulticastLock("mrterm-sync");
|
||||||
|
multicastLock.setReferenceCounted(false);
|
||||||
|
multicastLock.acquire();
|
||||||
|
} else if (!on && multicastLock != null) {
|
||||||
|
multicastLock.release();
|
||||||
|
multicastLock = null;
|
||||||
|
}
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Design: Hintergrund hinter Status-/Navigationsleiste und dunkle bzw. helle Symbole
|
||||||
|
@PluginMethod
|
||||||
|
public void bars(PluginCall call) {
|
||||||
|
final boolean light = Boolean.TRUE.equals(call.getBoolean("light", false));
|
||||||
|
final String color = call.getString("color", "#14161d");
|
||||||
|
getActivity().runOnUiThread(() -> {
|
||||||
|
try {
|
||||||
|
android.view.View parent = (android.view.View) getBridge().getWebView().getParent();
|
||||||
|
parent.setBackgroundColor(android.graphics.Color.parseColor(color));
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat c = androidx.core.view.WindowCompat.getInsetsController(getActivity().getWindow(), parent);
|
||||||
|
c.setAppearanceLightStatusBars(light);
|
||||||
|
c.setAppearanceLightNavigationBars(light);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
});
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void background(PluginCall call) {
|
||||||
|
getActivity().runOnUiThread(() -> getActivity().moveTaskToBack(true));
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void copy(PluginCall call) {
|
||||||
|
ClipboardManager cm = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||||
|
cm.setPrimaryClip(ClipData.newPlainText("MrTerm", call.getString("text", "")));
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void paste(PluginCall call) {
|
||||||
|
ClipboardManager cm = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||||
|
String text = "";
|
||||||
|
if (cm.hasPrimaryClip() && cm.getPrimaryClip().getItemCount() > 0) {
|
||||||
|
CharSequence t = cm.getPrimaryClip().getItemAt(0).coerceToText(getContext());
|
||||||
|
if (t != null) text = t.toString();
|
||||||
|
}
|
||||||
|
JSObject r = new JSObject();
|
||||||
|
r.put("text", text);
|
||||||
|
call.resolve(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 794 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#111728</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">MrTerm</string>
|
||||||
|
<string name="title_activity_main">MrTerm</string>
|
||||||
|
<string name="package_name">de.mrterm.app</string>
|
||||||
|
<string name="custom_url_scheme">de.mrterm.app</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-nodejs'
|
||||||
|
project(':capacitor-nodejs').projectDir = new File('../node_modules/capacitor-nodejs/android')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 24
|
||||||
|
compileSdkVersion = 36
|
||||||
|
targetSdkVersion = 36
|
||||||
|
androidxActivityVersion = '1.11.0'
|
||||||
|
androidxAppCompatVersion = '1.7.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||||
|
androidxCoreVersion = '1.17.0'
|
||||||
|
androidxFragmentVersion = '1.8.9'
|
||||||
|
coreSplashScreenVersion = '1.2.0'
|
||||||
|
androidxWebkitVersion = '1.14.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.3.0'
|
||||||
|
androidxEspressoCoreVersion = '3.7.0'
|
||||||
|
cordovaAndroidVersion = '14.0.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Baut die Android-App.
|
||||||
|
// node build.js --setup Node.js-Plugin herunterladen (Prüfsumme) und Abhängigkeiten installieren
|
||||||
|
// node build.js Weboberfläche + Node-Backend nach www/ bauen und ins Android-Projekt synchronisieren
|
||||||
|
// node build.js --apk zusätzlich signierte Release-APK nach ../dist/MrTerm-<version>.apk bauen
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const WWW = path.join(__dirname, 'www');
|
||||||
|
const version = require(path.join(ROOT, 'package.json')).version;
|
||||||
|
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: 'inherit', cwd: __dirname, ...opts });
|
||||||
|
|
||||||
|
const PLUGIN = {
|
||||||
|
url: 'https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.10/capacitor-nodejs.tgz',
|
||||||
|
file: path.join(__dirname, 'vendor', 'capacitor-nodejs-1.0.0-beta.10.tgz'),
|
||||||
|
sha256: 'cc47cba4190d5e0ea8e2b33f4e9bd47e159570c01571c51214467e4c21e87bd3',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
if (!fs.existsSync(PLUGIN.file)) {
|
||||||
|
console.log('Lade capacitor-nodejs …');
|
||||||
|
const res = await fetch(PLUGIN.url);
|
||||||
|
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
|
||||||
|
fs.mkdirSync(path.dirname(PLUGIN.file), { recursive: true });
|
||||||
|
fs.writeFileSync(PLUGIN.file, Buffer.from(await res.arrayBuffer()));
|
||||||
|
}
|
||||||
|
const sum = crypto.createHash('sha256').update(fs.readFileSync(PLUGIN.file)).digest('hex');
|
||||||
|
if (sum !== PLUGIN.sha256) { fs.rmSync(PLUGIN.file); throw new Error(`Prüfsumme von capacitor-nodejs stimmt nicht (${sum})`); }
|
||||||
|
run('npm', ['install']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(from, to) {
|
||||||
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||||
|
fs.cpSync(from, to, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// capacitor-nodejs baut seine Brückenbibliothek mit 4-KB-Seitenausrichtung; neuere Geräte (z. B. Pixel mit
|
||||||
|
// 16-KB-Speicherseiten) laden sie dann nicht. Linker-Option einmalig ergänzen.
|
||||||
|
function patchPlugin() {
|
||||||
|
const f = path.join(__dirname, 'node_modules', 'capacitor-nodejs', 'android', 'CMakeLists.txt');
|
||||||
|
const s = fs.readFileSync(f, 'utf8');
|
||||||
|
if (!s.includes('max-page-size=16384')) fs.writeFileSync(f, `${s}\ntarget_link_options(native-lib PRIVATE "-Wl,-z,max-page-size=16384")\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeb() {
|
||||||
|
patchPlugin();
|
||||||
|
fs.rmSync(WWW, { recursive: true, force: true });
|
||||||
|
copy(path.join(__dirname, 'web'), WWW);
|
||||||
|
const nm = path.join(ROOT, 'node_modules', '@xterm');
|
||||||
|
copy(path.join(nm, 'xterm', 'lib', 'xterm.js'), path.join(WWW, 'lib', 'xterm.js'));
|
||||||
|
copy(path.join(nm, 'xterm', 'css', 'xterm.css'), path.join(WWW, 'lib', 'xterm.css'));
|
||||||
|
copy(path.join(nm, 'addon-fit', 'lib', 'addon-fit.js'), path.join(WWW, 'lib', 'addon-fit.js'));
|
||||||
|
copy(path.join(__dirname, 'node_modules', '@capacitor', 'core', 'dist', 'capacitor.js'), path.join(WWW, 'lib', 'capacitor.js'));
|
||||||
|
copy(path.join(ROOT, 'src', 'i18n.js'), path.join(WWW, 'lib', 'i18n.js'));
|
||||||
|
copy(path.join(ROOT, 'src', 'renderer', 'themes.js'), path.join(WWW, 'lib', 'themes.js'));
|
||||||
|
copy(path.join(ROOT, 'src', 'renderer', 'app-themes.css'), path.join(WWW, 'lib', 'app-themes.css'));
|
||||||
|
copy(path.join(ROOT, 'src', 'renderer', 'logo.png'), path.join(WWW, 'logo.png'));
|
||||||
|
|
||||||
|
// Node-Backend inkl. ssh2 in eine Datei bündeln (nodejs-mobile = Node 18)
|
||||||
|
require('esbuild').buildSync({
|
||||||
|
entryPoints: [path.join(__dirname, 'nodejs', 'server.js')],
|
||||||
|
outfile: path.join(WWW, 'nodejs', 'index.js'),
|
||||||
|
bundle: true, platform: 'node', target: 'node18', minify: true, legalComments: 'none',
|
||||||
|
external: ['bridge', 'cpu-features'],
|
||||||
|
alias: { electron: path.join(__dirname, 'nodejs', 'electron-shim.js') },
|
||||||
|
logLevel: 'warning',
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(WWW, 'nodejs', 'package.json'), JSON.stringify({ name: 'mrterm-backend', main: 'index.js' }));
|
||||||
|
run('npx', ['cap', 'sync', 'android']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolchain() {
|
||||||
|
const base = path.join(os.homedir(), '.local', 'share', 'android-toolchain');
|
||||||
|
const env = { ...process.env };
|
||||||
|
env.JAVA_HOME ||= path.join(base, 'jdk');
|
||||||
|
env.ANDROID_HOME ||= path.join(base, 'sdk');
|
||||||
|
if (!fs.existsSync(env.JAVA_HOME) || !fs.existsSync(env.ANDROID_HOME)) throw new Error('JDK/Android-SDK nicht gefunden: JAVA_HOME und ANDROID_HOME setzen');
|
||||||
|
fs.writeFileSync(path.join(__dirname, 'android', 'local.properties'), `sdk.dir=${env.ANDROID_HOME}\n`);
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApk() {
|
||||||
|
if (!fs.existsSync(path.join(__dirname, 'android', 'keystore.properties'))) {
|
||||||
|
throw new Error('android/keystore.properties fehlt (Signaturschlüssel). Ohne ihn können Updates nicht installiert werden.');
|
||||||
|
}
|
||||||
|
run('./gradlew', ['--no-daemon', '-q', 'assembleRelease', `-PmrtermVersion=${version}`], { cwd: path.join(__dirname, 'android'), env: toolchain() });
|
||||||
|
const dir = path.join(__dirname, 'android', 'app', 'build', 'outputs', 'apk', 'release');
|
||||||
|
for (const abi of ['arm64-v8a', 'armeabi-v7a']) {
|
||||||
|
const out = path.join(ROOT, 'dist', `MrTerm-${version}-${abi}.apk`);
|
||||||
|
copy(path.join(dir, `app-${abi}-release.apk`), out);
|
||||||
|
console.log(`APK: ${out}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('--setup')) await setup();
|
||||||
|
else {
|
||||||
|
buildWeb();
|
||||||
|
if (args.includes('--apk')) buildApk();
|
||||||
|
}
|
||||||
|
})().catch((e) => { console.error(e.message || e); process.exit(1); });
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"appId": "de.mrterm.app",
|
||||||
|
"appName": "MrTerm",
|
||||||
|
"webDir": "www",
|
||||||
|
"android": { "backgroundColor": "#14161d" },
|
||||||
|
"plugins": {
|
||||||
|
"CapacitorNodeJS": { "nodeDir": "nodejs", "startMode": "manual" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Entwicklung ohne Handy: startet die gebaute Oberfläche (www/) in einem Fenster in Handygröße
|
||||||
|
// und das Node-Backend im Electron-Hauptprozess, verbunden über eine nachgebaute Capacitor-Bridge.
|
||||||
|
// cd mobile && node build.js && ../node_modules/.bin/electron dev/electron-dev.js [--user-data-dir=…]
|
||||||
|
const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const Module = require('module');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const WWW = path.join(__dirname, '..', 'www');
|
||||||
|
const toWeb = new EventEmitter();
|
||||||
|
const fromWeb = new EventEmitter();
|
||||||
|
let win;
|
||||||
|
|
||||||
|
// require('bridge') im Backend auf diese Nachbildung umleiten
|
||||||
|
const bridge = {
|
||||||
|
channel: { send: (name, ...args) => toWeb.emit('msg', name, args), addListener: (name, fn) => fromWeb.on(name, fn) },
|
||||||
|
getDataPath: () => path.join(app.getPath('userData'), 'mobile-data'),
|
||||||
|
};
|
||||||
|
const resolve = Module._resolveFilename;
|
||||||
|
Module._resolveFilename = function (req, ...rest) { return req === 'bridge' ? 'bridge' : resolve.call(this, req, ...rest); };
|
||||||
|
require.cache.bridge = { id: 'bridge', filename: 'bridge', loaded: true, exports: bridge };
|
||||||
|
|
||||||
|
toWeb.on('msg', (name, args) => { if (win && !win.isDestroyed()) win.webContents.send('dev:node', name, args); });
|
||||||
|
ipcMain.on('dev:send', (_e, name, args) => fromWeb.emit(name, ...args));
|
||||||
|
ipcMain.handle('dev:start', (_e, env) => {
|
||||||
|
Object.assign(process.env, env, { DATADIR: bridge.getDataPath() });
|
||||||
|
require('fs').mkdirSync(process.env.DATADIR, { recursive: true });
|
||||||
|
setTimeout(() => { started = true; require(path.join(WWW, 'nodejs', 'index.js')); }, 500);
|
||||||
|
});
|
||||||
|
let started;
|
||||||
|
ipcMain.handle('dev:ready', async () => { while (!started) await new Promise((r) => setTimeout(r, 100)); });
|
||||||
|
ipcMain.handle('dev:native', (_e, method, arg) => {
|
||||||
|
if (method === 'vaultKey') return { key: crypto.createHash('sha256').update(app.getPath('userData')).digest('base64') };
|
||||||
|
if (method === 'info') return { device: 'Dev Phone', locale: process.env.MRTERM_DEV_LOCALE || 'en-US', version: require('../../package.json').version };
|
||||||
|
if (method === 'bioAvailable') return { available: true, enrolled: false };
|
||||||
|
if (method === 'bioEnroll' || method === 'bioUnlock') return { secret: Buffer.alloc(32, 1).toString('base64') };
|
||||||
|
if (method === 'copy') return clipboard.writeText(arg.text);
|
||||||
|
if (method === 'paste') return { text: clipboard.readText() };
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
win = new BrowserWindow({
|
||||||
|
width: 412, height: 870, backgroundColor: '#14161d',
|
||||||
|
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
|
||||||
|
});
|
||||||
|
win.loadFile(path.join(WWW, 'index.html'));
|
||||||
|
if (process.env.MRTERM_SHOT) {
|
||||||
|
const steps = JSON.parse(require('fs').readFileSync(process.env.MRTERM_SHOT, 'utf8'));
|
||||||
|
win.webContents.on('console-message', (e) => console.log('[r]', e.message));
|
||||||
|
win.webContents.once('did-finish-load', async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
for (const s of steps) {
|
||||||
|
if (s.js) await win.webContents.executeJavaScript(`(async()=>{${s.js}})();0`).catch((e) => console.log('js', e.message));
|
||||||
|
await new Promise((r) => setTimeout(r, s.wait || 900));
|
||||||
|
if (s.shot) require('fs').writeFileSync(path.join(process.env.MRTERM_SHOT_OUT, s.shot), (await win.webContents.capturePage()).toPNG());
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on('window-all-closed', () => app.exit(0));
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
const listeners = new Map();
|
||||||
|
ipcRenderer.on('dev:node', (_e, name, args) => listeners.get(name)?.forEach((fn) => fn({ args })));
|
||||||
|
const native = (m) => (arg) => ipcRenderer.invoke('dev:native', m, arg);
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('DevBridge', {
|
||||||
|
NodeJS: {
|
||||||
|
// Wie auf Android: start() kehrt erst zurück, wenn Node beendet wird
|
||||||
|
start: (o) => { ipcRenderer.invoke('dev:start', o.env); return new Promise(() => {}); },
|
||||||
|
whenReady: () => ipcRenderer.invoke('dev:ready'),
|
||||||
|
send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
|
||||||
|
addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
|
||||||
|
},
|
||||||
|
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background', 'bars'].map((m) => [m, native(m)])),
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Ersatz für die von store.js genutzten Electron-APIs unter Android.
|
||||||
|
// safeStorage verschlüsselt mit einem 256-Bit-Schlüssel, den die App im Android Keystore verpackt aufbewahrt
|
||||||
|
// und beim Start als MRTERM_VAULT_KEY (base64) an den Node-Prozess übergibt.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const dataDir = () => process.env.DATADIR || require('bridge').getDataPath();
|
||||||
|
const key = () => {
|
||||||
|
const k = Buffer.from(process.env.MRTERM_VAULT_KEY || '', 'base64');
|
||||||
|
return k.length === 32 ? k : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeStorage = {
|
||||||
|
isEncryptionAvailable: () => !!key(),
|
||||||
|
encryptString(plain) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||||
|
const ct = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
|
||||||
|
return Buffer.concat([iv, c.getAuthTag(), ct]);
|
||||||
|
},
|
||||||
|
decryptString(buf) {
|
||||||
|
const d = crypto.createDecipheriv('aes-256-gcm', key(), buf.subarray(0, 12));
|
||||||
|
d.setAuthTag(buf.subarray(12, 28));
|
||||||
|
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
app: { getPath: () => dataDir(), getVersion: () => process.env.MRTERM_VERSION || '0.0.0', getLocale: () => process.env.MRTERM_LOCALE || 'en' },
|
||||||
|
safeStorage,
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Node-Backend der Android-App. Die Kanäle kommen aus src/core/backend.js (gemeinsam mit der Web-Version);
|
||||||
|
// hier nur, was Android betrifft: Bridge-Anbindung, Entsperren per Fingerabdruck, Pause/Fortsetzen.
|
||||||
|
// Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente];
|
||||||
|
// Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente].
|
||||||
|
const { channel } = require('bridge');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Store } = require('../../src/main/store');
|
||||||
|
const { createBackend } = require('../../src/core/backend');
|
||||||
|
const i18n = require('../../src/i18n');
|
||||||
|
|
||||||
|
const send = (ch, ...args) => channel.send('evt', ch, args);
|
||||||
|
process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.message || String(e), 'error'); });
|
||||||
|
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||||
|
|
||||||
|
const store = new Store();
|
||||||
|
const applyLanguage = (lang = store.get().settings.language) => i18n.setLanguage(lang, process.env.MRTERM_LOCALE || 'en');
|
||||||
|
const be = createBackend({ store, send, platform: 'android', version: process.env.MRTERM_VERSION || '0.0.0', withSync: true, onLanguage: applyLanguage });
|
||||||
|
|
||||||
|
channel.addListener('call', async (reqId, ch, args = []) => {
|
||||||
|
try { channel.send('reply', reqId, true, await be.call(ch, args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
|
||||||
|
});
|
||||||
|
channel.addListener('send', (ch, args = []) => be.notify(ch, args));
|
||||||
|
|
||||||
|
// ---------- Fingerabdruck (Geheimnis aus dem Android Keystore, nur zusätzlich zum Passwort) ----------
|
||||||
|
const bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32));
|
||||||
|
be.handle('lock:unlockBio', (secret) => {
|
||||||
|
if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.'));
|
||||||
|
try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your password.')); }
|
||||||
|
applyLanguage();
|
||||||
|
be.sync.start().catch(() => {});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
be.handle('lock:setBio', (secret) => {
|
||||||
|
be.requireUnlocked();
|
||||||
|
if (!store.lock?.password) throw new Error(i18n.t('Set a password first.'));
|
||||||
|
store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null;
|
||||||
|
store.save();
|
||||||
|
return be.lockStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
|
||||||
|
be.handle('app:pause', () => be.sync.stop());
|
||||||
|
be.handle('app:resume', () => be.sync.start().catch(() => {}));
|
||||||
|
|
||||||
|
store.load();
|
||||||
|
applyLanguage();
|
||||||
|
be.sync.start().catch(() => {});
|
||||||
|
channel.send('ready');
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "mrterm-mobile",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "MrTerm for Android",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.js",
|
||||||
|
"apk": "node build.js --apk"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/cli": "^8.5.2",
|
||||||
|
"esbuild": "^0.28.2"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.28.2": true
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^8.5.2",
|
||||||
|
"@capacitor/core": "^8.5.2",
|
||||||
|
"capacitor-nodejs": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>MrTerm</title>
|
||||||
|
<link rel="stylesheet" href="lib/xterm.css" />
|
||||||
|
<link rel="stylesheet" href="lib/app-themes.css" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="boot"><img src="logo.png" alt="" /><div class="spinner"></div></div>
|
||||||
|
|
||||||
|
<!-- Hauptansicht: Inhalt + untere Navigation -->
|
||||||
|
<div id="main" hidden>
|
||||||
|
<main id="view"></main>
|
||||||
|
<nav id="tabs">
|
||||||
|
<button data-view="hosts"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg><span data-t>Hosts</span></button>
|
||||||
|
<button data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span data-t>Keys</span></button>
|
||||||
|
<button data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span data-t>Snippets</span></button>
|
||||||
|
<button data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z"/></svg><span data-t>Settings</span></button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal im Vollbild -->
|
||||||
|
<div id="termScreen" hidden>
|
||||||
|
<header class="term-head">
|
||||||
|
<button class="icon" id="termBack" aria-label="Back"><svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg></button>
|
||||||
|
<button class="term-title" id="termTitle"><span class="dot"></span><span class="name"></span><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
|
||||||
|
<button class="icon" id="termKbd" aria-label="Keyboard"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg></button>
|
||||||
|
<button class="icon" id="termMenu" aria-label="Menu"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
|
||||||
|
</header>
|
||||||
|
<div id="terms"></div>
|
||||||
|
<div id="keybar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lockScreen" hidden></div>
|
||||||
|
<div id="layer"></div>
|
||||||
|
<div id="toasts"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Fehler beim Laden sichtbar machen statt endlos den Ladekreis zu zeigen
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
const b = document.getElementById('boot');
|
||||||
|
if (b && !b.hidden) b.innerHTML = '<div style="color:#ff5f6d;padding:24px;text-align:center;font:14px system-ui">' + String(e.message || e.error).replace(/</g, '<') + '</div>';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="lib/capacitor.js"></script>
|
||||||
|
<script src="lib/i18n.js"></script>
|
||||||
|
<script src="lib/themes.js"></script>
|
||||||
|
<script src="lib/xterm.js"></script>
|
||||||
|
<script src="lib/addon-fit.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
:root {
|
||||||
|
/* Farben kommen aus lib/app-themes.css (gemeinsam mit dem Desktop, Auswahl über data-theme) */
|
||||||
|
--surface: var(--card);
|
||||||
|
--surface2: var(--card-hover);
|
||||||
|
--line: var(--border);
|
||||||
|
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
--r: calc(var(--r) + 4px);
|
||||||
|
/* Status-/Navigationsleiste rechnet MainActivity als Rand um den WebView heraus */
|
||||||
|
--top: 0px;
|
||||||
|
--bottom: 0px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
:root.light { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden; }
|
||||||
|
body { font: 15px/1.4 var(--font); font-family: var(--font), system-ui, Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
||||||
|
input, select, textarea { font: inherit; color: var(--text); -webkit-user-select: text; user-select: text; }
|
||||||
|
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
|
||||||
|
.mono { font-family: ui-monospace, 'JetBrains Mono', 'DejaVu Sans Mono', monospace; }
|
||||||
|
|
||||||
|
/* ---------- Start ---------- */
|
||||||
|
#boot { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 28px; }
|
||||||
|
#boot img { width: 84px; height: 84px; }
|
||||||
|
.spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--surface2); border-top-color: var(--accent); animation: spin .8s linear infinite; }
|
||||||
|
.spinner.sm { width: 16px; height: 16px; border-width: 2px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ---------- Grundlayout ---------- */
|
||||||
|
#main { position: fixed; inset: 0; display: flex; flex-direction: column; }
|
||||||
|
#view { flex: 1; overflow-y: auto; padding: calc(var(--top) + 8px) 16px 96px; -webkit-overflow-scrolling: touch; }
|
||||||
|
#tabs { display: flex; background: var(--surface); border-top: 1px solid var(--line); padding-bottom: var(--bottom); }
|
||||||
|
#tabs button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 9px 0 8px; color: var(--faint); font-size: 11.5px; font-weight: 600; }
|
||||||
|
#tabs button.active { color: var(--accent); }
|
||||||
|
|
||||||
|
.top { display: flex; align-items: center; gap: 10px; min-height: 52px; }
|
||||||
|
.top h1 { font-size: 26px; margin: 0; flex: 1; letter-spacing: -.3px; }
|
||||||
|
.top h1 .brand { color: var(--green); }
|
||||||
|
.icon { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: var(--muted); }
|
||||||
|
.icon:active { background: var(--surface2); }
|
||||||
|
|
||||||
|
.search { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 0 12px; margin: 6px 0 14px; }
|
||||||
|
.search svg { color: var(--faint); width: 18px; height: 18px; }
|
||||||
|
.search input { flex: 1; background: none; border: 0; outline: 0; padding: 12px 0; }
|
||||||
|
|
||||||
|
.section { margin: 18px 4px 8px; font-size: 12px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
|
||||||
|
.section svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
.list { background: var(--surface); border-radius: var(--r); overflow: hidden; }
|
||||||
|
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 62px; position: relative; }
|
||||||
|
.item + .item::before { content: ''; position: absolute; top: 0; left: 64px; right: 0; border-top: 1px solid var(--line); }
|
||||||
|
.item:active { background: var(--surface2); }
|
||||||
|
.item .meta { flex: 1; min-width: 0; }
|
||||||
|
.item .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.item .sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.item .more { color: var(--faint); width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; margin-right: -6px; }
|
||||||
|
.item.disabled { opacity: .55; }
|
||||||
|
.avatar { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; font-weight: 800; color: #fff; flex: none; font-size: 16px; }
|
||||||
|
.avatar svg { width: 20px; height: 20px; }
|
||||||
|
.avatar.soft { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
||||||
|
.dot.on { background: var(--green); }
|
||||||
|
.dot.err { background: var(--red); }
|
||||||
|
|
||||||
|
.sessions { display: flex; gap: 8px; overflow-x: auto; margin: 0 -16px 4px; padding: 2px 16px 8px; scrollbar-width: none; }
|
||||||
|
.sessions::-webkit-scrollbar { display: none; }
|
||||||
|
.chip { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; padding: 8px 14px; white-space: nowrap; font-weight: 600; font-size: 14px; }
|
||||||
|
|
||||||
|
.empty { text-align: center; padding: 60px 24px; color: var(--muted); }
|
||||||
|
.empty .avatar { margin: 0 auto 16px; width: 60px; height: 60px; border-radius: 18px; }
|
||||||
|
.empty .avatar svg { width: 28px; height: 28px; }
|
||||||
|
.empty h3 { color: var(--text); margin: 0 0 6px; }
|
||||||
|
|
||||||
|
.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: calc(var(--radius) + 8px); background: var(--accent); color: var(--on-accent); display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
|
||||||
|
.fab svg { width: 26px; height: 26px; }
|
||||||
|
|
||||||
|
/* ---------- Karten / Einstellungen ---------- */
|
||||||
|
.card { background: var(--surface); border-radius: var(--r); padding: 14px 16px; margin-bottom: 14px; }
|
||||||
|
.card h3 { margin: 0 0 4px; font-size: 16px; }
|
||||||
|
.card p, .hint { color: var(--muted); font-size: 13.5px; margin: 4px 0 10px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
|
||||||
|
.row + .row { border-top: 1px solid var(--line); }
|
||||||
|
.row .grow { flex: 1; min-width: 0; }
|
||||||
|
.row .sub { color: var(--muted); font-size: 13px; }
|
||||||
|
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||||
|
|
||||||
|
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); font-weight: 600; }
|
||||||
|
.btn svg { width: 18px; height: 18px; }
|
||||||
|
.btn.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||||
|
.btn.danger { color: var(--red); }
|
||||||
|
.btn.block { width: 100%; }
|
||||||
|
.btn:disabled { opacity: .5; }
|
||||||
|
.btn:active { filter: brightness(1.15); }
|
||||||
|
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted); margin: 0 2px 6px; }
|
||||||
|
.field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px 13px; outline: 0; min-height: 48px; }
|
||||||
|
.field textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
|
||||||
|
.field .hint { margin: 6px 2px 0; font-size: 12.5px; }
|
||||||
|
.pw { position: relative; }
|
||||||
|
.pw input { padding-right: 48px; }
|
||||||
|
.pw button { position: absolute; right: 4px; top: 4px; width: 40px; height: 40px; display: grid; place-items: center; color: var(--faint); }
|
||||||
|
.two { display: grid; grid-template-columns: 1fr 96px; gap: 10px; }
|
||||||
|
|
||||||
|
.switch { position: relative; width: 50px; height: 30px; flex: none; }
|
||||||
|
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||||
|
.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
|
||||||
|
.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
|
||||||
|
.switch input:checked + span { background: var(--accent); }
|
||||||
|
.switch input:checked + span::after { transform: translateX(20px); background: var(--on-accent); }
|
||||||
|
|
||||||
|
.warn-box { border: 1px solid #ffb34755; background: #ffb34712; border-radius: 12px; padding: 10px 12px; color: var(--orange); font-size: 13px; margin: 8px 0; }
|
||||||
|
|
||||||
|
/* ---------- Ebenen: Seiten, Sheets, Dialoge ---------- */
|
||||||
|
#layer > * { position: fixed; inset: 0; z-index: 20; }
|
||||||
|
.page { background: var(--bg); display: flex; flex-direction: column; animation: slidein .18s ease-out; }
|
||||||
|
@keyframes slidein { from { transform: translateX(30px); opacity: 0; } }
|
||||||
|
.page-head { display: flex; align-items: center; gap: 6px; padding: calc(var(--top) + 6px) 8px 6px; border-bottom: 1px solid var(--line); background: var(--surface); }
|
||||||
|
.page-head h2 { flex: 1; font-size: 18px; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.page-head .btn { min-height: 38px; padding: 0 14px; }
|
||||||
|
.page-body { flex: 1; overflow-y: auto; padding: 16px 16px calc(24px + var(--bottom)); }
|
||||||
|
|
||||||
|
.scrim { background: #0009; display: flex; align-items: flex-end; animation: fade .15s; }
|
||||||
|
.scrim.center { align-items: center; justify-content: center; padding: 20px; }
|
||||||
|
@keyframes fade { from { opacity: 0; } }
|
||||||
|
.sheet { width: 100%; background: var(--surface); border-radius: 20px 20px 0 0; padding: 8px 8px calc(10px + var(--bottom)); max-height: 85vh; overflow-y: auto; animation: up .18s ease-out; }
|
||||||
|
@keyframes up { from { transform: translateY(40px); } }
|
||||||
|
.sheet .grab { width: 38px; height: 4px; background: var(--line); border-radius: 4px; margin: 4px auto 8px; }
|
||||||
|
.sheet .sheet-title { padding: 6px 12px 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||||
|
.sheet button.opt { display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 14px; border-radius: 12px; text-align: left; font-weight: 500; }
|
||||||
|
.sheet button.opt:active { background: var(--surface2); }
|
||||||
|
.sheet button.opt.danger { color: var(--red); }
|
||||||
|
.sheet button.opt svg { color: var(--muted); }
|
||||||
|
.sheet button.opt.danger svg { color: var(--red); }
|
||||||
|
|
||||||
|
.dialog { width: 100%; max-width: 420px; background: var(--surface); border-radius: 20px; padding: 20px; max-height: 88vh; overflow-y: auto; animation: pop .15s ease-out; }
|
||||||
|
@keyframes pop { from { transform: scale(.96); opacity: 0; } }
|
||||||
|
.dialog h3 { margin: 0 0 8px; font-size: 18px; }
|
||||||
|
.dialog p { color: var(--muted); margin: 0 0 14px; }
|
||||||
|
.dialog .btns { justify-content: flex-end; margin-top: 16px; }
|
||||||
|
.fp { font-size: 12.5px; background: var(--bg); border-radius: 10px; padding: 10px; word-break: break-all; color: var(--text); -webkit-user-select: text; user-select: text; }
|
||||||
|
.pair-code { font: 700 38px ui-monospace, monospace; letter-spacing: 6px; text-align: center; padding: 16px; background: var(--bg); border-radius: 14px; color: var(--accent); }
|
||||||
|
.checks label { display: flex; align-items: center; gap: 12px; padding: 10px 2px; }
|
||||||
|
.checks input { width: 20px; height: 20px; accent-color: var(--accent); }
|
||||||
|
.checks h4 { margin: 12px 0 2px; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
|
||||||
|
|
||||||
|
#toasts { position: fixed; left: 16px; right: 16px; bottom: calc(84px + var(--bottom)); z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||||
|
.toast { background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 12px; padding: 12px 14px; box-shadow: 0 8px 24px #0008; animation: up .2s; font-size: 14px; }
|
||||||
|
.toast.ok { border-color: var(--green); }
|
||||||
|
.toast.error { border-color: var(--red); }
|
||||||
|
|
||||||
|
/* ---------- Sperrbildschirm ---------- */
|
||||||
|
#lockScreen { position: fixed; inset: 0; z-index: 40; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 14px; }
|
||||||
|
#lockScreen img { width: 72px; height: 72px; margin-bottom: 6px; }
|
||||||
|
#lockScreen h2 { margin: 0; }
|
||||||
|
#lockScreen p { margin: 0 0 10px; color: var(--muted); text-align: center; }
|
||||||
|
#lockScreen form { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
#lockScreen .field { margin: 0; }
|
||||||
|
.bio-btn { width: 64px; height: 64px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; margin-top: 10px; }
|
||||||
|
.bio-btn svg { width: 32px; height: 32px; }
|
||||||
|
|
||||||
|
/* ---------- Terminal ---------- */
|
||||||
|
#termScreen { position: fixed; left: 0; right: 0; top: 0; height: 100%; display: flex; flex-direction: column; background: var(--term-bg, #13151c); z-index: 10; }
|
||||||
|
.term-head { display: flex; align-items: center; gap: 2px; padding: calc(var(--top) + 2px) 4px 2px; background: var(--surface); border-bottom: 1px solid var(--line); }
|
||||||
|
.term-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 8px; font-weight: 700; }
|
||||||
|
.term-title .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.term-title svg { width: 16px; height: 16px; color: var(--faint); }
|
||||||
|
.term-title .count { background: var(--surface2); color: var(--muted); border-radius: 8px; padding: 0 7px; font-size: 12px; }
|
||||||
|
#terms { flex: 1; position: relative; overflow: hidden; }
|
||||||
|
.term-wrap { position: absolute; inset: 0; padding: 4px 2px 0 6px; }
|
||||||
|
.term-wrap .xterm { height: 100%; }
|
||||||
|
.term-wrap .xterm-viewport { background-color: transparent !important; }
|
||||||
|
.term-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: var(--term-bg, #13151c); color: var(--muted); padding: 24px; text-align: center; z-index: 5; }
|
||||||
|
#keybar { display: flex; gap: 6px; overflow-x: auto; padding: 6px 6px calc(6px + var(--bottom)); background: var(--surface); border-top: 1px solid var(--line); scrollbar-width: none; }
|
||||||
|
#keybar::-webkit-scrollbar { display: none; }
|
||||||
|
#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
|
||||||
|
#keybar button svg { width: 18px; height: 18px; }
|
||||||
|
#keybar button.on { background: var(--accent); color: var(--on-accent); }
|
||||||
|
#keybar button.lock { box-shadow: inset 0 0 0 2px var(--on-accent); }
|
||||||
|
body.kbd-open #keybar { padding-bottom: 6px; }
|
||||||
|
body.kbd-open #tabs, body.kbd-open .fab { display: none; }
|
||||||
|
|
||||||
|
/* Design-Extras */
|
||||||
|
[data-theme='xp'] .page-head, [data-theme='xp'] .term-head { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; border-color: #0a3f9f; }
|
||||||
|
[data-theme='xp'] .page-head .icon, [data-theme='xp'] .term-head .icon, [data-theme='xp'] .term-title svg { color: #fff; }
|
||||||
|
[data-theme='xp'] #tabs { background: linear-gradient(#3168d5, #1d4fbf); border-color: #0a3f9f; }
|
||||||
|
[data-theme='xp'] #tabs button { color: #c7d8ff; }
|
||||||
|
[data-theme='xp'] #tabs button.active { color: #fff; }
|
||||||
|
[data-theme='xp'] .btn.primary, [data-theme='xp'] .fab { background: linear-gradient(#3d86e8, #2159b8); }
|
||||||
|
[data-theme='cyberpunk'] .btn.primary, [data-theme='cyberpunk'] .fab { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 12px) 100%, 0 100%); }
|
||||||
|
[data-theme='cyberpunk'] .btn.primary { text-transform: uppercase; letter-spacing: .6px; }
|
||||||
|
[data-theme='cyberpunk'] .top h1 { text-transform: uppercase; letter-spacing: 1px; text-shadow: 2px 0 #ff003c88, -2px 0 #00f0ff88; }
|
||||||
|
[data-theme='cyberpunk'] .list, [data-theme='cyberpunk'] .card { border-left: 2px solid var(--accent); }
|
||||||
|
[data-theme='cyberpunk'] #tabs button.active { color: var(--accent); text-shadow: 0 0 8px #fcee0a88; }
|
||||||
|
.theme-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 6px 0 14px; }
|
||||||
|
.theme-prev { border-radius: 12px; padding: 8px; border: 2px solid var(--line); font-size: 12px; font-weight: 600; text-align: left; }
|
||||||
|
.theme-prev.active { border-color: var(--accent); }
|
||||||
|
.theme-prev .dp { display: flex; gap: 4px; height: 40px; margin-bottom: 6px; }
|
||||||
|
.theme-prev .dp i { width: 12px; border-radius: 3px; }
|
||||||
|
.theme-prev .dp div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.theme-prev .dp b { flex: 1; border-radius: 3px; position: relative; }
|
||||||
|
.theme-prev .dp s { position: absolute; left: 4px; top: 4px; width: 14px; height: 6px; border-radius: 2px; }
|
||||||
|
.colors { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.colors button { width: 34px; height: 34px; border-radius: 50%; border: 3px solid transparent; }
|
||||||
|
.colors button.active { border-color: var(--text); }
|
||||||
|
|
||||||
|
/* macOS Liquid Glass */
|
||||||
|
[data-theme='macos'] #main, [data-theme='macos'] #view { background: transparent; }
|
||||||
|
[data-theme='macos'] :is(.list, .card, .search, #tabs, .page-head, .term-head, .sheet, .dialog, .toast, .chip, .btn, .fab, .field input, .field select, .field textarea, #keybar) {
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
[data-theme='macos'] :is(.list, .card, .search, .sheet, .dialog, .toast, .chip) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
|
||||||
|
[data-theme='macos'] .page, [data-theme='macos'] #lockScreen { background: var(--wallpaper); }
|
||||||
|
[data-theme='macos'] .page-body { background: transparent; }
|
||||||
|
[data-theme='macos'] #tabs, [data-theme='macos'] .page-head { background: rgba(255, 255, 255, .42); border-color: rgba(255, 255, 255, .7); }
|
||||||
|
[data-theme='macos'] .btn { border-radius: 999px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9); }
|
||||||
|
[data-theme='macos'] .btn.primary, [data-theme='macos'] .fab { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 6px 18px rgba(0, 122, 255, .3); }
|
||||||
|
[data-theme='macos'] .fab { border-radius: 50%; }
|
||||||
|
[data-theme='macos'] .scrim { background: rgba(30, 40, 70, .18); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
|
||||||
|
[data-theme='macos'] .sheet, [data-theme='macos'] .dialog { background: rgba(255, 255, 255, .62); }
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.10.2",
|
"version": "0.18.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.10.2",
|
"version": "0.18.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
"electron-builder": "^26.15.3"
|
"electron-builder": "^26.15.3",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@electron-internal/extract-zip": {
|
"node_modules/@electron-internal/extract-zip": {
|
||||||
@@ -325,6 +327,448 @@
|
|||||||
"node": ">=14.14"
|
"node": ">=14.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/fs-minipass": {
|
"node_modules/@isaacs/fs-minipass": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||||
@@ -2071,6 +2515,48 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -3992,6 +4478,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.10.2",
|
"version": "0.18.1",
|
||||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||||
"main": "src/main/main.js",
|
"main": "src/main/main.js",
|
||||||
"author": "MrBlake",
|
"author": "MrBlake",
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
},
|
},
|
||||||
|
"beforePack": "scripts/ensure-koffi.js",
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/koffi/**",
|
"node_modules/koffi/**",
|
||||||
"node_modules/@koromix/**"
|
"node_modules/@koromix/**"
|
||||||
@@ -68,12 +69,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
"electron-builder": "^26.15.3"
|
"electron-builder": "^26.15.3",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"electron@44.4.5": true,
|
"electron@44.4.5": true,
|
||||||
"electron-winstaller@5.4.0": true,
|
"electron-winstaller@5.4.0": true,
|
||||||
"ssh2@1.17.0": true,
|
"ssh2@1.17.0": true,
|
||||||
"koffi@3.3.1": true
|
"koffi@3.3.1": true,
|
||||||
|
"esbuild@0.28.2": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// electron-builder beforePack: stellt sicher, dass die native koffi-Binary der Zielplattform in node_modules liegt.
|
||||||
|
// npm installiert nur die Binary der Build-Plattform – beim Cross-Build (z. B. Windows-Paket auf Linux) fehlt sie sonst,
|
||||||
|
// und RDP im Tab fällt mit "koffi nicht verfügbar" auf ein externes Fenster zurück.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const { Arch } = require('electron-builder');
|
||||||
|
|
||||||
|
module.exports = async (context) => {
|
||||||
|
const platform = context.electronPlatformName;
|
||||||
|
const arch = Arch[context.arch];
|
||||||
|
const pkg = `@koromix/koffi-${platform}-${arch}`;
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
if (fs.existsSync(path.join(root, 'node_modules', pkg))) return;
|
||||||
|
const { version } = require(path.join(root, 'node_modules', 'koffi', 'package.json'));
|
||||||
|
console.log(` • koffi-Binary für ${platform}-${arch} fehlt – installiere ${pkg}@${version}`);
|
||||||
|
execSync(`npm install --no-save --force ${pkg}@${version}`, { cwd: root, stdio: 'inherit' });
|
||||||
|
if (!fs.existsSync(path.join(root, 'node_modules', pkg))) throw new Error(`${pkg} konnte nicht installiert werden`);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Fragt nach der Version, baut Windows (Setup + Portable, per wine) und Arch/CachyOS (pacman + AppImage)
|
# Fragt nach der Version, baut Windows (Setup + Portable, per wine), Arch/CachyOS (pacman + AppImage)
|
||||||
# und lädt alles als Release v<version> nach Gitea hoch.
|
# und Android (APK, signiert mit mobile/android/keystore.properties) und lädt alles als Release v<version> nach Gitea hoch.
|
||||||
# Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt).
|
# Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
@@ -31,5 +31,11 @@ git -c http.extraHeader="Authorization: Basic ${auth}" push origin HEAD
|
|||||||
rm -rf dist
|
rm -rf dist
|
||||||
npm run dist:linux
|
npm run dist:linux
|
||||||
npm run dist:win
|
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[@]}"
|
npm run release -- --notes "$notes" "${prerelease[@]}"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.a
|
|||||||
rel = await res.json();
|
rel = await res.json();
|
||||||
console.log('Release angelegt:', rel.html_url);
|
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.');
|
if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.');
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const old = (rel.assets || []).find((a) => a.name === f);
|
const old = (rel.assets || []).find((a) => a.name === f);
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Gemeinsame Backend-Kanäle für die Android-App und die Web-Version (Docker).
|
||||||
|
// Der Electron-Hauptprozess (src/main/main.js) registriert seine Kanäle selbst, nutzt aber dieselben Module.
|
||||||
|
//
|
||||||
|
// createBackend({ store, send, platform, version, withSync, onLanguage })
|
||||||
|
// store Store-Instanz (bereits geladen)
|
||||||
|
// send (kanal, ...args) → Ereignis an die Oberfläche
|
||||||
|
// → { call(kanal, args), notify(kanal, args), close(), ssh, sync, handle }
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { utils: sshUtils } = require('ssh2');
|
||||||
|
const { SshManager } = require('../main/ssh');
|
||||||
|
const { DockerManager } = require('../main/docker');
|
||||||
|
const { FirewallManager } = require('../main/firewall');
|
||||||
|
const { NetworkConfigManager } = require('../main/network');
|
||||||
|
const { AiAssistant } = require('../main/ai');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const backup = require('./backup');
|
||||||
|
const composeTemplates = require('../main/compose-templates');
|
||||||
|
|
||||||
|
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
|
||||||
|
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
||||||
|
const pending = new Map();
|
||||||
|
const ask = (ch, req, timeout = 0) => new Promise((resolve) => {
|
||||||
|
const reqId = crypto.randomUUID();
|
||||||
|
pending.set(reqId, resolve);
|
||||||
|
send(ch, { reqId, ...req });
|
||||||
|
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
|
||||||
|
});
|
||||||
|
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
|
||||||
|
|
||||||
|
const confirmHostKey = async (target, fingerprint, known) =>
|
||||||
|
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
|
||||||
|
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
|
||||||
|
|
||||||
|
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||||
|
const docker = new DockerManager(ssh);
|
||||||
|
const firewall = new FirewallManager(ssh);
|
||||||
|
const network = new NetworkConfigManager(ssh);
|
||||||
|
const ai = new AiAssistant(store, send);
|
||||||
|
let sync = null;
|
||||||
|
if (withSync) {
|
||||||
|
const { SyncService } = require('../main/sync');
|
||||||
|
sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
|
||||||
|
store.onChange = () => sync.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = new Map();
|
||||||
|
const handle = (ch, fn) => handlers.set(ch, fn);
|
||||||
|
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||||
|
|
||||||
|
async function call(ch, args = []) {
|
||||||
|
const fn = handlers.get(ch);
|
||||||
|
if (!fn) throw new Error(`Unknown channel ${ch}`);
|
||||||
|
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
|
||||||
|
return fn(...args);
|
||||||
|
}
|
||||||
|
const listeners = {
|
||||||
|
'ssh:write': (id, d) => ssh.write(id, d),
|
||||||
|
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
|
||||||
|
'ssh:close': (id) => ssh.close(id),
|
||||||
|
'ask:reply': answer,
|
||||||
|
// Namen der Desktop-Vorlage (preload.js)
|
||||||
|
'secret:reply': answer,
|
||||||
|
'sync:pairReply': answer,
|
||||||
|
};
|
||||||
|
const notify = (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } };
|
||||||
|
|
||||||
|
// ---------- App-Sperre (Passwort) ----------
|
||||||
|
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||||
|
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||||||
|
const lockStatus = () => {
|
||||||
|
const { language, appTheme, accent } = store.get().settings;
|
||||||
|
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, fido: [], meta: { language, appTheme, accent } };
|
||||||
|
};
|
||||||
|
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
|
||||||
|
const afterUnlock = () => { onLanguage(store.get().settings.language); sync?.start().catch(() => {}); };
|
||||||
|
handle('lock:status', lockStatus);
|
||||||
|
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
|
||||||
|
handle('lock:unlockPassword', async (pw) => {
|
||||||
|
const p = store.lock?.password;
|
||||||
|
if (!p) throw new Error(i18n.t('No password set.'));
|
||||||
|
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
|
||||||
|
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
|
||||||
|
afterUnlock();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
handle('lock:setPassword', async (pw) => {
|
||||||
|
requireUnlocked();
|
||||||
|
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
|
||||||
|
const salt = crypto.randomBytes(16), N = 2 ** 15;
|
||||||
|
const kek = await kdf(pw, salt, N);
|
||||||
|
store.lock = store.lock || { password: null, fido: [] };
|
||||||
|
store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
|
||||||
|
store.save();
|
||||||
|
return lockStatus();
|
||||||
|
});
|
||||||
|
handle('lock:removePassword', () => {
|
||||||
|
requireUnlocked();
|
||||||
|
if (store.lock) { store.lock.password = null; store.lock.bio = null; }
|
||||||
|
store.dropLockIfEmpty();
|
||||||
|
store.save();
|
||||||
|
return lockStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Tresor ----------
|
||||||
|
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
|
||||||
|
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform }));
|
||||||
|
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
||||||
|
handle('vault:remove', (col, id) => {
|
||||||
|
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
|
||||||
|
return store.remove(col, id);
|
||||||
|
});
|
||||||
|
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
|
||||||
|
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||||
|
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version }));
|
||||||
|
handle('backup:inspect', (content) => backup.inspectBackup(content));
|
||||||
|
handle('backup:import', (content, password, opts) => {
|
||||||
|
const r = backup.importBackup(store, content, password, opts);
|
||||||
|
if (opts?.settings) onLanguage(store.get().settings.language);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
|
||||||
|
handle('vault:bulkImport', (entries) => {
|
||||||
|
const groups = store.get().groups;
|
||||||
|
const ensure = (parts) => {
|
||||||
|
let parent = null;
|
||||||
|
for (const label of parts) {
|
||||||
|
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
|
||||||
|
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
|
||||||
|
parent = g.id;
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
};
|
||||||
|
let hosts = 0, skipped = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
const groupId = ensure(e.folder || []);
|
||||||
|
if (!e.host) continue;
|
||||||
|
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
|
||||||
|
if (dup) { skipped++; continue; }
|
||||||
|
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
|
||||||
|
hosts++;
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
return { hosts, skipped, groups: groups.length };
|
||||||
|
});
|
||||||
|
handle('app:version', () => version);
|
||||||
|
|
||||||
|
// ---------- Schlüssel ----------
|
||||||
|
handle('key:generate', ({ type, bits, comment, passphrase }) => {
|
||||||
|
const opts = { comment: comment || `mrterm@${platform}` };
|
||||||
|
if (type === 'rsa') opts.bits = Number(bits) || 4096;
|
||||||
|
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
|
||||||
|
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
|
||||||
|
return { privateKey: k.private, publicKey: k.public };
|
||||||
|
});
|
||||||
|
handle('key:parse', ({ privateKey, passphrase }) => {
|
||||||
|
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
|
||||||
|
if (k instanceof Error) throw k;
|
||||||
|
const key = Array.isArray(k) ? k[0] : k;
|
||||||
|
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- SSH-Terminal & SFTP ----------
|
||||||
|
function hostWithOverrides(ref) {
|
||||||
|
if (typeof ref === 'string') {
|
||||||
|
const h = store.resolveHost(ref);
|
||||||
|
if (!h) throw new Error(i18n.t('Host not found'));
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
handle('ssh:open', async (sessionId, ref, size) => {
|
||||||
|
const host = hostWithOverrides(ref);
|
||||||
|
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
|
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
handle('sftp:open', (id, ref) => ssh.openSftp(id, hostWithOverrides(ref)));
|
||||||
|
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||||
|
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||||
|
handle('sftp:close', (id) => ssh.close(id));
|
||||||
|
|
||||||
|
// ---------- Docker / Firewall / Netzwerk ----------
|
||||||
|
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id)));
|
||||||
|
handle('docker:list', (id) => docker.list(id));
|
||||||
|
handle('docker:stats', (id) => docker.stats(id));
|
||||||
|
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
|
handle('docker:close', (id) => docker.close(id));
|
||||||
|
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
|
||||||
|
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
|
||||||
|
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
|
||||||
|
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
|
||||||
|
handle('docker:templates', (url) => composeTemplates.list(url));
|
||||||
|
handle('docker:template', (index, url) => composeTemplates.build(index, url));
|
||||||
|
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
|
||||||
|
handle('firewall:list', (id, backend) => firewall.list(id, backend));
|
||||||
|
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
|
||||||
|
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
|
||||||
|
handle('firewall:close', (id) => firewall.close(id));
|
||||||
|
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id)));
|
||||||
|
handle('network:read', (id) => network.read(id));
|
||||||
|
handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
|
||||||
|
handle('network:remove', (id, model) => network.removeInterface(id, model));
|
||||||
|
handle('network:hostname', (id, name) => network.setHostname(id, name));
|
||||||
|
handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
|
||||||
|
handle('network:readFile', (id, path) => network.readFile(id, path));
|
||||||
|
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
|
||||||
|
handle('network:close', (id) => network.close(id));
|
||||||
|
|
||||||
|
// ---------- Support-Assistent (Ollama) ----------
|
||||||
|
handle('ai:models', (url) => ai.models(url));
|
||||||
|
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
|
||||||
|
handle('ai:stop', (id) => ai.stop(id));
|
||||||
|
|
||||||
|
// ---------- Synchronisation ----------
|
||||||
|
if (sync) {
|
||||||
|
handle('sync:status', () => sync.status());
|
||||||
|
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
|
||||||
|
handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
|
||||||
|
handle('sync:pair', (id) => sync.pair(id));
|
||||||
|
handle('sync:unpair', (id) => sync.unpair(id));
|
||||||
|
handle('sync:now', () => sync.syncAll());
|
||||||
|
handle('sync:share', (sel) => sync.setShare(sel));
|
||||||
|
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||||
|
handle('sync:probe', (address) => sync.probe(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
ssh.closeAll?.();
|
||||||
|
ai.stopAll();
|
||||||
|
sync?.stop();
|
||||||
|
for (const r of pending.values()) r(null);
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { call, notify, close, handle, ssh, sync, store, kdf, requireUnlocked, lockStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createBackend };
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Vollständiges Backup (Desktop, Web, Android): alle Collections, bekannte Hosts, Verlauf und Einstellungen.
|
||||||
|
// Optional mit Passwort verschlüsselt: scrypt (N=2^16, r=8, p=1) → AES-256-GCM.
|
||||||
|
// Gerätebezogenes (LAN-Sync-Kopplungen, Löschvermerke, App-Sperre) wird nicht exportiert.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const FORMAT = 'mrterm-backup';
|
||||||
|
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
|
||||||
|
const KDF = { N: 1 << 16, r: 8, p: 1 };
|
||||||
|
const scrypt = (password, salt, k) => crypto.scryptSync(String(password).normalize('NFC'), salt, 32, { ...k, maxmem: 256 * 1024 * 1024 });
|
||||||
|
|
||||||
|
function snapshot(store, { settings = true } = {}) {
|
||||||
|
const d = store.get();
|
||||||
|
const out = { knownHosts: d.knownHosts || {}, history: d.history || [] };
|
||||||
|
for (const c of COLLECTIONS) out[c] = d[c] || [];
|
||||||
|
if (settings) out.settings = d.settings;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert den Dateiinhalt (JSON-Text)
|
||||||
|
function createBackup(store, { password = '', settings = true, version = '' } = {}) {
|
||||||
|
const data = snapshot(store, { settings });
|
||||||
|
const head = { format: FORMAT, version: 2, app: version, createdAt: new Date().toISOString() };
|
||||||
|
if (!password) return JSON.stringify({ ...head, encrypted: false, data }, null, 2);
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const key = scrypt(password, salt, KDF);
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
c.setAAD(Buffer.from(FORMAT));
|
||||||
|
const ct = Buffer.concat([c.update(JSON.stringify(data)), c.final()]);
|
||||||
|
return JSON.stringify({
|
||||||
|
...head, encrypted: true,
|
||||||
|
kdf: { name: 'scrypt', salt: salt.toString('base64'), ...KDF },
|
||||||
|
cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64') },
|
||||||
|
ct: ct.toString('base64'),
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(content) {
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(String(content).replace(/^/, '')); } catch { throw new Error('INVALID'); }
|
||||||
|
if (!j || typeof j !== 'object') throw new Error('INVALID');
|
||||||
|
// Altes Format (bis 0.14): unverschlüsseltes JSON mit den Collections auf oberster Ebene
|
||||||
|
if (j.format !== FORMAT) {
|
||||||
|
if (!COLLECTIONS.some((c) => Array.isArray(j[c]))) throw new Error('INVALID');
|
||||||
|
return { encrypted: false, data: j, createdAt: null, app: '' };
|
||||||
|
}
|
||||||
|
return { encrypted: !!j.encrypted, raw: j, data: j.encrypted ? null : j.data, createdAt: j.createdAt, app: j.app };
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = (data) => Object.fromEntries([...COLLECTIONS.map((c) => [c, (data[c] || []).length]), ['knownHosts', Object.keys(data.knownHosts || {}).length], ['settings', data.settings ? 1 : 0]]);
|
||||||
|
|
||||||
|
// Vorabinfo für die Oberfläche (ohne Passwort)
|
||||||
|
function inspectBackup(content) {
|
||||||
|
const b = parse(content);
|
||||||
|
return { encrypted: b.encrypted, createdAt: b.createdAt, app: b.app, counts: b.data ? counts(b.data) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(content, password) {
|
||||||
|
const b = parse(content);
|
||||||
|
if (!b.encrypted) return b.data;
|
||||||
|
if (!password) throw new Error('PASSWORD_REQUIRED');
|
||||||
|
const { kdf, cipher, ct } = b.raw;
|
||||||
|
const key = scrypt(password, Buffer.from(kdf.salt, 'base64'), { N: kdf.N, r: kdf.r, p: kdf.p });
|
||||||
|
try {
|
||||||
|
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'base64'));
|
||||||
|
d.setAAD(Buffer.from(FORMAT));
|
||||||
|
d.setAuthTag(Buffer.from(cipher.tag, 'base64'));
|
||||||
|
return JSON.parse(Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8'));
|
||||||
|
} catch { throw new Error('WRONG_PASSWORD'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// opts: { replace: vorhandene Einträge entfernen, die nicht im Backup sind; settings: Einstellungen übernehmen }
|
||||||
|
function importBackup(store, content, password, { replace = false, settings = false } = {}) {
|
||||||
|
const data = decrypt(content, password);
|
||||||
|
const d = store.get();
|
||||||
|
store.muted = true;
|
||||||
|
try {
|
||||||
|
for (const c of COLLECTIONS) {
|
||||||
|
const items = Array.isArray(data[c]) ? data[c].filter((x) => x && typeof x === 'object') : [];
|
||||||
|
if (replace) {
|
||||||
|
const keep = new Set(items.map((x) => x.id).filter(Boolean));
|
||||||
|
for (const x of [...(d[c] || [])]) if (!keep.has(x.id)) store.remove(c, x.id);
|
||||||
|
}
|
||||||
|
for (const item of items) store.upsert(c, { ...item });
|
||||||
|
}
|
||||||
|
if (data.knownHosts && typeof data.knownHosts === 'object') d.knownHosts = replace ? { ...data.knownHosts } : { ...d.knownHosts, ...data.knownHosts };
|
||||||
|
if (Array.isArray(data.history)) {
|
||||||
|
const seen = new Set();
|
||||||
|
d.history = [...(replace ? [] : d.history || []), ...data.history].filter((h) => h && !seen.has(h.hostId) && seen.add(h.hostId)).slice(0, 30);
|
||||||
|
}
|
||||||
|
if (settings && data.settings && typeof data.settings === 'object') d.settings = { ...d.settings, ...data.settings };
|
||||||
|
} finally {
|
||||||
|
store.muted = false;
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
return counts(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createBackup, inspectBackup, importBackup };
|
||||||
@@ -151,6 +151,35 @@
|
|||||||
'Send Enter automatically': 'Enter automatisch senden',
|
'Send Enter automatically': 'Enter automatisch senden',
|
||||||
'Command is missing.': 'Befehl fehlt.',
|
'Command is missing.': 'Befehl fehlt.',
|
||||||
'No open terminal': 'Kein offenes Terminal',
|
'No open terminal': 'Kein offenes Terminal',
|
||||||
|
// Support-Assistent
|
||||||
|
'Delete chat': 'Chat löschen',
|
||||||
|
'Delete chat?': 'Chat löschen?',
|
||||||
|
'Saved chats': 'Gespeicherte Chats',
|
||||||
|
'Context:': 'Kontext:',
|
||||||
|
'Vault (no context)': 'Tresor (kein Kontext)',
|
||||||
|
'Explain this tab': 'Diesen Tab erklären',
|
||||||
|
'Look at the active tab, explain what you see and point out problems.': 'Sieh dir den aktiven Tab an, erkläre, was du siehst, und weise auf Probleme hin.',
|
||||||
|
'The content of the active tab is sent to the Ollama server.': 'Der Inhalt des aktiven Tabs wird an den Ollama-Server gesendet.',
|
||||||
|
'Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.': 'Frag nach einem Fehler oder lass dir erklären, was du siehst. Der Inhalt des aktiven Tabs wird als Kontext mitgesendet.',
|
||||||
|
'Copied – no terminal tab is active': 'Kopiert – kein Terminal-Tab aktiv',
|
||||||
|
'Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.': 'Hilft bei Fehlern in Linux-Konsole, Docker, Firewall und Netzwerk über eine lokale <b>Ollama</b>-Instanz. Öffnen über den Chat-Button in der Titelleiste oder im Tab. Der Inhalt des aktiven Tabs wird als Kontext an den Ollama-Server gesendet. Chats werden im Tresor gespeichert.',
|
||||||
|
'Support assistant (Ollama)': 'Support-Assistent (Ollama)',
|
||||||
|
'Assistant': 'Assistent',
|
||||||
|
'Support assistant': 'Support-Assistent',
|
||||||
|
'New chat': 'Neuer Chat',
|
||||||
|
'Include terminal output': 'Terminalausgabe mitsenden',
|
||||||
|
'Describe the problem … (Enter = send)': 'Problem beschreiben … (Enter = senden)',
|
||||||
|
'Explain last error': 'Letzten Fehler erklären',
|
||||||
|
'Send': 'Senden',
|
||||||
|
'Explain the last error in the terminal output and how to fix it.': 'Erkläre den letzten Fehler in der Terminalausgabe und wie ich ihn behebe.',
|
||||||
|
'Please select an Ollama model in the settings first.': 'Bitte zuerst in den Einstellungen ein Ollama-Modell auswählen.',
|
||||||
|
'Insert into terminal (not executed)': 'Ins Terminal einfügen (wird nicht ausgeführt)',
|
||||||
|
'Insert': 'Einfügen',
|
||||||
|
'Ollama server': 'Ollama-Server',
|
||||||
|
'Model': 'Modell',
|
||||||
|
'– select –': '– auswählen –',
|
||||||
|
'No models installed (ollama pull …)': 'Keine Modelle installiert (ollama pull …)',
|
||||||
|
'Load models': 'Modelle laden',
|
||||||
'No snippets yet.': 'Noch keine Snippets.',
|
'No snippets yet.': 'Noch keine Snippets.',
|
||||||
|
|
||||||
// Port Forwarding
|
// Port Forwarding
|
||||||
@@ -351,6 +380,93 @@
|
|||||||
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
||||||
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
||||||
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||||
|
'This file is not a MrTerm backup.': 'Diese Datei ist kein MrTerm-Backup.',
|
||||||
|
'This backup is encrypted. Please enter the password.': 'Dieses Backup ist verschlüsselt. Bitte gib das Passwort ein.',
|
||||||
|
'Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.': 'Exportiert alle Hosts, Gruppen, SSH-Keys, Passwörter, Snippets, Portweiterleitungen, VPNs, bekannten Hosts und Einstellungen.',
|
||||||
|
'Recommended. Without a password, keys and passwords are stored in plain text in the file.': 'Empfohlen. Ohne Passwort stehen Schlüssel und Passwörter im Klartext in der Datei.',
|
||||||
|
'Include settings': 'Einstellungen einschließen',
|
||||||
|
'Export': 'Exportieren',
|
||||||
|
'Export without password?': 'Ohne Passwort exportieren?',
|
||||||
|
'Private keys and passwords will be readable by anyone who gets the file.': 'Private Schlüssel und Passwörter sind dann für jeden lesbar, der die Datei erhält.',
|
||||||
|
'{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs': '{hosts} Hosts, {keys} Schlüssel, {snippets} Snippets, {vpns} VPNs',
|
||||||
|
'Contents are encrypted.': 'Inhalt ist verschlüsselt.',
|
||||||
|
'Import settings': 'Einstellungen übernehmen',
|
||||||
|
'Replace existing data (entries not contained in the backup are deleted)': 'Vorhandene Daten ersetzen (Einträge, die nicht im Backup sind, werden gelöscht)',
|
||||||
|
'Replace existing data?': 'Vorhandene Daten ersetzen?',
|
||||||
|
'Hosts, keys, snippets and other entries that are not in the backup will be deleted.': 'Hosts, Schlüssel, Snippets und andere Einträge, die nicht im Backup sind, werden gelöscht.',
|
||||||
|
'Replace': 'Ersetzen',
|
||||||
|
'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets',
|
||||||
|
'Stacks folder': 'Stack-Ordner',
|
||||||
|
'Folder on the host in which each stack gets its own subfolder': 'Ordner auf dem Host, in dem jeder Stack einen eigenen Unterordner bekommt',
|
||||||
|
'Template source': 'Vorlagen-Quelle',
|
||||||
|
'URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.': 'URL einer templates.json im Portainer-Format. Leer = Lissy93/portainer-templates.',
|
||||||
|
'Containers': 'Container',
|
||||||
|
'Folder': 'Ordner',
|
||||||
|
'Change folder': 'Ordner ändern',
|
||||||
|
'Creates the folder with sudo and makes it writable for {user}.': 'Legt den Ordner per sudo an und macht ihn für {user} beschreibbar.',
|
||||||
|
'Set up folder': 'Ordner einrichten',
|
||||||
|
'From Git': 'Aus Git',
|
||||||
|
'New stack': 'Neuer Stack',
|
||||||
|
'Compose file': 'Compose-Datei',
|
||||||
|
'Found via docker compose ls, outside the stacks folder': 'Über docker compose ls gefunden, außerhalb des Stack-Ordners',
|
||||||
|
'external': 'extern',
|
||||||
|
'not deployed': 'nicht gestartet',
|
||||||
|
'no compose file found': 'keine Compose-Datei gefunden',
|
||||||
|
'Deploy (up -d)': 'Starten (up -d)',
|
||||||
|
'No matches.': 'Keine Treffer.',
|
||||||
|
'No stacks yet. Create one, clone it from Git or start from a template.': 'Noch keine Stacks. Lege einen an, klone ihn aus Git oder starte mit einer Vorlage.',
|
||||||
|
'Update images (pull + up)': 'Images aktualisieren (pull + up)',
|
||||||
|
'Git pull + redeploy': 'Git pull + neu starten',
|
||||||
|
'Down (remove containers)': 'Down (Container entfernen)',
|
||||||
|
'Stack from Git': 'Stack aus Git',
|
||||||
|
'Repository URL': 'Repository-URL',
|
||||||
|
'Stack name': 'Stack-Name',
|
||||||
|
'Branch (optional)': 'Branch (optional)',
|
||||||
|
'Compose file in the repository (optional)': 'Compose-Datei im Repository (optional)',
|
||||||
|
'If empty, the first compose file found is used.': 'Leer = die erste gefundene Compose-Datei wird verwendet.',
|
||||||
|
'Deploy right after cloning': 'Nach dem Klonen direkt starten',
|
||||||
|
'The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.': 'Das Repository wird auf dem Host in den Stack-Ordner geklont. Git fragt Zugangsdaten bei Bedarf im Terminal ab; für private Repos funktioniert auch ein SSH-Deploy-Key oder ein Token in der URL.',
|
||||||
|
'Clone': 'Klonen',
|
||||||
|
'App templates': 'App-Vorlagen',
|
||||||
|
'Search templates …': 'Vorlagen durchsuchen …',
|
||||||
|
'Source: {url} (Portainer template format, changeable in the settings).': 'Quelle: {url} (Portainer-Vorlagenformat, in den Einstellungen änderbar).',
|
||||||
|
'All categories': 'Alle Kategorien',
|
||||||
|
'Delete stack?': 'Stack löschen?',
|
||||||
|
'The stack “{name}” is stopped (down) and its folder {dir} is deleted.': 'Der Stack „{name}“ wird gestoppt (down) und sein Ordner {dir} gelöscht.',
|
||||||
|
'Also delete named volumes (data!)': 'Auch benannte Volumes löschen (Daten!)',
|
||||||
|
'Remove containers?': 'Container entfernen?',
|
||||||
|
'docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.': 'docker compose down stoppt und entfernt die Container von „{name}“. Volumes und Dateien bleiben erhalten.',
|
||||||
|
'Down': 'Down',
|
||||||
|
'Back': 'Zurück',
|
||||||
|
'Save & deploy': 'Speichern & starten',
|
||||||
|
'(variables, used as ${NAME} in the compose file)': '(Variablen, in der Compose-Datei als ${NAME} nutzbar)',
|
||||||
|
'Saved, but the compose file has an error: {msg}': 'Gespeichert, aber die Compose-Datei enthält einen Fehler: {msg}',
|
||||||
|
'Docker Compose was not found on this host (docker compose plugin or docker-compose).': 'Docker Compose wurde auf diesem Host nicht gefunden (docker-compose-Plugin oder docker-compose).',
|
||||||
|
'Could not read {file}': '{file} konnte nicht gelesen werden',
|
||||||
|
'Stack names may only contain lowercase letters, digits, - and _.': 'Stack-Namen dürfen nur Kleinbuchstaben, Ziffern, - und _ enthalten.',
|
||||||
|
'A stack named “{name}” already exists.': 'Ein Stack namens „{name}“ existiert bereits.',
|
||||||
|
'No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.': 'Keine Schreibrechte in {dir}. Nutze „Ordner einrichten“ oder speichere das Host-Passwort, damit MrTerm sudo verwenden kann.',
|
||||||
|
'Templates': 'Vorlagen',
|
||||||
|
'Run': 'Ausführen',
|
||||||
|
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
|
||||||
|
'Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.': 'Trägt einen öffentlichen Schlüssel aus dem Schlüsselbund in ~/.ssh/authorized_keys des Benutzers ein.',
|
||||||
|
'Key': 'Schlüssel',
|
||||||
|
'Public key is missing.': 'Öffentlicher Schlüssel fehlt.',
|
||||||
|
'User is missing.': 'Benutzer fehlt.',
|
||||||
|
'Install QEMU guest agent': 'QEMU Guest Agent installieren',
|
||||||
|
'Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).': 'Installiert und startet qemu-guest-agent (apt, dnf, yum, pacman, zypper oder apk).',
|
||||||
|
'Use APT cache server': 'APT-Cache-Server verwenden',
|
||||||
|
'Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).': 'Leitet APT-Downloads über einen Cache-Proxy wie apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).',
|
||||||
|
'Proxy URL': 'Proxy-URL',
|
||||||
|
'Invalid URL.': 'Ungültige URL.',
|
||||||
|
'Remove APT cache server': 'APT-Cache-Server entfernen',
|
||||||
|
'Removes the APT proxy setting again.': 'Entfernt die APT-Proxy-Einstellung wieder.',
|
||||||
|
'Updates all packages with the system package manager.': 'Aktualisiert alle Pakete mit dem Paketmanager des Systems.',
|
||||||
|
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
|
||||||
|
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
|
||||||
|
'Wrong PIN.': 'Falsche PIN.',
|
||||||
|
'{n} attempts left.': 'Noch {n} Versuche.',
|
||||||
|
'The PIN of this security key is blocked. Remove and reinsert the key, or reset it.': 'Die PIN dieses Sicherheitsschlüssels ist gesperrt. Ziehe den Schlüssel ab und stecke ihn neu ein oder setze ihn zurück.',
|
||||||
|
|
||||||
// VPN
|
// VPN
|
||||||
'— No VPN —': '— Kein VPN —',
|
'— No VPN —': '— Kein VPN —',
|
||||||
@@ -597,6 +713,91 @@
|
|||||||
'Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.': 'Soll dieses Geheimnis mit deinen gekoppelten Geräten ({names}) synchronisiert werden? Du kannst das später unter Einstellungen → Synchronisation ändern.',
|
'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',
|
'Don\'t share': 'Nicht teilen',
|
||||||
'Share': 'Teilen',
|
'Share': 'Teilen',
|
||||||
|
// Android-App
|
||||||
|
'Lock': 'Sperren',
|
||||||
|
'Add your first server or pair this phone with MrTerm on your computer to take over your hosts.': 'Lege deinen ersten Server an oder kopple dieses Handy mit MrTerm auf deinem Computer, um deine Hosts zu übernehmen.',
|
||||||
|
'No matching hosts. Press Enter to connect to “{q}”.': 'Keine passenden Hosts. Drücke Enter, um dich mit „{q}“ zu verbinden.',
|
||||||
|
'RDP is not available on Android': 'RDP ist unter Android nicht verfügbar',
|
||||||
|
'Please enter a name.': 'Bitte einen Namen eingeben.',
|
||||||
|
'Sessions': 'Sitzungen',
|
||||||
|
'New connection': 'Neue Verbindung',
|
||||||
|
'Paste': 'Einfügen',
|
||||||
|
'Copy selection': 'Auswahl kopieren',
|
||||||
|
'Nothing selected. Long-press the terminal to select text.': 'Nichts ausgewählt. Halte das Terminal gedrückt, um Text auszuwählen.',
|
||||||
|
'Larger font': 'Schrift größer',
|
||||||
|
'Smaller font': 'Schrift kleiner',
|
||||||
|
'Close session': 'Sitzung schließen',
|
||||||
|
'Generate a new SSH key or import an existing one.': 'Erzeuge einen neuen SSH-Schlüssel oder importiere einen vorhandenen.',
|
||||||
|
'Key created': 'Schlüssel erstellt',
|
||||||
|
'Add the public key to ~/.ssh/authorized_keys on your servers.': 'Trage den öffentlichen Schlüssel auf deinen Servern in ~/.ssh/authorized_keys ein.',
|
||||||
|
'Save frequently used commands and send them to a terminal with one tap.': 'Speichere häufig genutzte Befehle und sende sie mit einem Tippen an ein Terminal.',
|
||||||
|
'Run in terminal': 'Im Terminal ausführen',
|
||||||
|
'Command': 'Befehl',
|
||||||
|
'End the command with a line break to run it immediately.': 'Endet der Befehl mit einem Zeilenumbruch, wird er sofort ausgeführt.',
|
||||||
|
'Please enter a command.': 'Bitte einen Befehl eingeben.',
|
||||||
|
'General': 'Allgemein',
|
||||||
|
'System language': 'Systemsprache',
|
||||||
|
'Known hosts': 'Bekannte Hosts',
|
||||||
|
'{n} saved host keys': '{n} gespeicherte Hostschlüssel',
|
||||||
|
'Show': 'Anzeigen',
|
||||||
|
'Version {v}': 'Version {v}',
|
||||||
|
'Updates are installed with Obtainium.': 'Updates werden mit Obtainium installiert.',
|
||||||
|
'No known hosts yet.': 'Noch keine bekannten Hosts.',
|
||||||
|
'Remove host key?': 'Hostschlüssel entfernen?',
|
||||||
|
'Immediately': 'Sofort',
|
||||||
|
'After 1 minute': 'Nach 1 Minute',
|
||||||
|
'After 5 minutes': 'Nach 5 Minuten',
|
||||||
|
'Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.': 'Schütze MrTerm mit einem Passwort und optional deinem Fingerabdruck. Der Tresor wird dann zusätzlich verschlüsselt.',
|
||||||
|
'Remove app lock': 'App-Sperre entfernen',
|
||||||
|
'Remove app lock?': 'App-Sperre entfernen?',
|
||||||
|
'MrTerm will open without a password.': 'MrTerm öffnet sich dann ohne Passwort.',
|
||||||
|
'Unlock with fingerprint': 'Mit Fingerabdruck entsperren',
|
||||||
|
'Uses the fingerprint or face unlock of this phone.': 'Nutzt den Fingerabdruck- oder Gesichtsscanner dieses Handys.',
|
||||||
|
'Confirm fingerprint': 'Fingerabdruck bestätigen',
|
||||||
|
'Lock when leaving the app': 'Sperren beim Verlassen der App',
|
||||||
|
'Enter your password to unlock.': 'Gib dein Passwort ein, um zu entsperren.',
|
||||||
|
'Unlock MrTerm': 'MrTerm entsperren',
|
||||||
|
'Use password': 'Passwort verwenden',
|
||||||
|
'Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.': 'Deine Fingerabdrücke haben sich geändert. Entsperre mit deinem Passwort und richte das Entsperren per Fingerabdruck neu ein.',
|
||||||
|
'Take over hosts, groups, snippets and known hosts from MrTerm on your computer. The connection is end-to-end encrypted and stays in your local network.': 'Übernimm Hosts, Gruppen, Snippets und bekannte Hosts von MrTerm auf deinem Computer. Die Verbindung ist Ende-zu-Ende verschlüsselt und bleibt in deinem lokalen Netzwerk.',
|
||||||
|
'Both devices must be in the same Wi-Fi. If nothing is found, allow UDP port 47811 and TCP port 47812 in the firewall of your computer.': 'Beide Geräte müssen im selben WLAN sein. Wird nichts gefunden, gib in der Firewall deines Computers UDP-Port 47811 und TCP-Port 47812 frei.',
|
||||||
|
'Or enter an IP address': 'Oder IP-Adresse eingeben',
|
||||||
|
'Copy failed': 'Kopieren fehlgeschlagen',
|
||||||
|
'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.',
|
||||||
|
'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.',
|
||||||
|
'Set a password first.': 'Lege zuerst ein Passwort fest.',
|
||||||
|
// Web-Version
|
||||||
|
'RDP is not available in the web version.': 'RDP ist in der Web-Version nicht verfügbar.',
|
||||||
|
'Account': 'Konto',
|
||||||
|
'Signed in as {name}': 'Angemeldet als {name}',
|
||||||
|
'Administrator': 'Administrator',
|
||||||
|
'Current password': 'Aktuelles Passwort',
|
||||||
|
'Sign out': 'Abmelden',
|
||||||
|
'Users': 'Benutzer',
|
||||||
|
'User': 'Benutzer',
|
||||||
|
'Delete user?': 'Benutzer löschen?',
|
||||||
|
'“{name}” and their vault will be permanently deleted.': '„{name}“ und der zugehörige Tresor werden endgültig gelöscht.',
|
||||||
|
'Add user': 'Benutzer hinzufügen',
|
||||||
|
'Your vault is stored on the server, encrypted with a key that only your login password can unlock.': 'Dein Tresor liegt auf dem Server, verschlüsselt mit einem Schlüssel, den nur dein Login-Passwort entsperren kann.',
|
||||||
|
'Folders cannot be downloaded in the browser: {name}': 'Ordner können im Browser nicht heruntergeladen werden: {name}',
|
||||||
|
'Welcome! Create the administrator account for this MrTerm server.': 'Willkommen! Lege das Administrator-Konto für diesen MrTerm-Server an.',
|
||||||
|
'Sign in to your MrTerm server.': 'Melde dich an deinem MrTerm-Server an.',
|
||||||
|
'Create account': 'Konto anlegen',
|
||||||
|
'Sign in': 'Anmelden',
|
||||||
|
'Administrators only.': 'Nur für Administratoren.',
|
||||||
|
'Forbidden': 'Nicht erlaubt',
|
||||||
|
'Not signed in': 'Nicht angemeldet',
|
||||||
|
'Setup is already complete.': 'Die Einrichtung ist bereits abgeschlossen.',
|
||||||
|
'The current password is wrong.': 'Das aktuelle Passwort ist falsch.',
|
||||||
|
'The last administrator cannot be deleted.': 'Der letzte Administrator kann nicht gelöscht werden.',
|
||||||
|
'Too many failed attempts. Try again in 15 minutes.': 'Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.',
|
||||||
|
'User not found.': 'Benutzer nicht gefunden.',
|
||||||
|
'Wrong username or password.': 'Benutzername oder Passwort falsch.',
|
||||||
|
'You cannot delete yourself.': 'Du kannst dich nicht selbst löschen.',
|
||||||
|
'The password must be at least 8 characters long.': 'Das Passwort muss mindestens 8 Zeichen lang sein.',
|
||||||
|
'This username is already taken.': 'Dieser Benutzername ist bereits vergeben.',
|
||||||
|
'Usernames may contain letters, digits and . _ @ - (max. 64).': 'Benutzernamen dürfen Buchstaben, Ziffern und . _ @ - enthalten (max. 64).',
|
||||||
|
'Connection lost – reconnecting …': 'Verbindung verloren – verbinde neu …',
|
||||||
// Main-Prozess
|
// Main-Prozess
|
||||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||||
'Unknown host': 'Unbekannter Host',
|
'Unknown host': 'Unbekannter Host',
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Support-Assistent über eine Ollama-Instanz (lokal oder im Netz).
|
||||||
|
// Antworten werden gestreamt: send('ai:event', chatId, 'data' | 'done' | 'error', payload)
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are the MrTerm support assistant, an experienced Linux system administrator.
|
||||||
|
The user manages Linux servers (typically Ubuntu, Debian or Proxmox) with MrTerm: SSH terminals, Docker containers
|
||||||
|
and compose stacks, firewall (UFW/iptables), network configuration and SFTP.
|
||||||
|
You may receive the content of the user's active MrTerm tab as context (terminal output, container list, compose file,
|
||||||
|
firewall rules, …). Your job:
|
||||||
|
- Explain errors briefly and clearly, name the most likely cause.
|
||||||
|
- Suggest concrete, copy-pasteable shell commands in \`\`\`bash code blocks, one step at a time.
|
||||||
|
- Warn explicitly before destructive commands (rm -rf, dd, mkfs, firewall changes that may lock out SSH, network changes).
|
||||||
|
- If information is missing, say which command would provide it.
|
||||||
|
Answer in the language of the user's question. Be concise.`;
|
||||||
|
|
||||||
|
function request(base, path, { method = 'GET', body, signal } = {}) {
|
||||||
|
const url = new URL(path, base.endsWith('/') ? base : base + '/');
|
||||||
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = lib.request(url, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, signal, timeout: 15000 }, (res) => {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
let t = ''; res.on('data', (c) => (t += c));
|
||||||
|
res.on('end', () => { let m = t; try { m = JSON.parse(t).error || t; } catch {} reject(new Error(`Ollama: ${m || res.statusCode}`)); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(res);
|
||||||
|
});
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Ollama: timeout')));
|
||||||
|
req.on('error', (e) => reject(e.code === 'ECONNREFUSED' ? new Error(`Ollama not reachable at ${base}`) : e));
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiAssistant {
|
||||||
|
constructor(store, send) {
|
||||||
|
this.store = store;
|
||||||
|
this.send = send;
|
||||||
|
this.running = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
get cfg() {
|
||||||
|
const s = this.store.get().settings;
|
||||||
|
return { url: s.aiUrl || 'http://localhost:11434', model: s.aiModel || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(url) {
|
||||||
|
const res = await request(url || this.cfg.url, 'api/tags');
|
||||||
|
let t = ''; for await (const c of res) t += c;
|
||||||
|
return (JSON.parse(t).models || []).map((m) => m.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages: [{ role: 'user' | 'assistant', content }], context: letzte Terminalausgabe
|
||||||
|
async chat(chatId, messages, context = '') {
|
||||||
|
const { url, model } = this.cfg;
|
||||||
|
if (!model) throw new Error('No Ollama model selected (Settings → Support assistant).');
|
||||||
|
this.stop(chatId);
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
this.running.set(chatId, ctrl);
|
||||||
|
const sys = SYSTEM_PROMPT + (context ? `\n\nContent of the active tab:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : '');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } });
|
||||||
|
res.setTimeout(0);
|
||||||
|
let buf = '';
|
||||||
|
for await (const chunk of res) {
|
||||||
|
buf += chunk;
|
||||||
|
let i;
|
||||||
|
while ((i = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
const m = JSON.parse(line);
|
||||||
|
if (m.error) throw new Error(`Ollama: ${m.error}`);
|
||||||
|
if (m.message?.content) this.send('ai:event', chatId, 'data', m.message.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.send('ai:event', chatId, 'done');
|
||||||
|
} catch (e) {
|
||||||
|
if (ctrl.signal.aborted) this.send('ai:event', chatId, 'done');
|
||||||
|
else this.send('ai:event', chatId, 'error', e.message || String(e));
|
||||||
|
} finally {
|
||||||
|
if (this.running.get(chatId) === ctrl) this.running.delete(chatId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(chatId) { this.running.get(chatId)?.abort(); this.running.delete(chatId); }
|
||||||
|
stopAll() { for (const c of this.running.values()) c.abort(); this.running.clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AiAssistant };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// App-Vorlagen im Portainer-Format (v2/v3), z. B. https://github.com/Lissy93/portainer-templates.
|
||||||
|
// Container-Vorlagen (type 1) werden in eine compose.yaml umgewandelt; Stack-Vorlagen (type 2/3) laden die
|
||||||
|
// Compose-Datei aus dem angegebenen GitHub-Repository. Umgebungsvariablen landen mit Standardwerten in der .env.
|
||||||
|
const DEFAULT_URL = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json';
|
||||||
|
|
||||||
|
let cache = { url: '', at: 0, list: [] };
|
||||||
|
|
||||||
|
const y = (v) => JSON.stringify(String(v)); // JSON-Strings sind gültiges YAML
|
||||||
|
const slug = (s) => String(s || 'app').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 40) || 'app';
|
||||||
|
|
||||||
|
async function list(url = DEFAULT_URL) {
|
||||||
|
url = url || DEFAULT_URL;
|
||||||
|
if (cache.url === url && Date.now() - cache.at < 3600e3) return cache.list;
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const j = await res.json();
|
||||||
|
const items = (Array.isArray(j) ? j : j.templates || []).filter((t) => t && t.title && (t.image || t.repository?.stackfile));
|
||||||
|
const listed = items.map((t, i) => ({
|
||||||
|
i, title: t.title, description: String(t.description || '').slice(0, 400), categories: t.categories || [],
|
||||||
|
kind: t.image ? 'container' : 'stack', note: t.note || '', t,
|
||||||
|
}));
|
||||||
|
cache = { url, at: Date.now(), list: listed };
|
||||||
|
return listed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für die Oberfläche ohne Rohdaten
|
||||||
|
const summary = (l) => l.map(({ t: _t, ...rest }) => rest);
|
||||||
|
|
||||||
|
function envFile(env) {
|
||||||
|
return (env || []).filter((e) => e?.name).map((e) => {
|
||||||
|
const def = e.select ? (e.select.find((x) => x.default) || e.select[0] || {}).value : e.default;
|
||||||
|
const hint = [e.label && e.label !== e.name ? e.label : '', e.description || '', e.select ? `(${e.select.map((x) => x.value).join(' | ')})` : ''].filter(Boolean).join(' – ');
|
||||||
|
return `${hint ? `# ${hint.replace(/\n/g, ' ')}\n` : ''}${e.name}=${def ?? ''}`;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerCompose(t, name) {
|
||||||
|
const L = ['services:', ` ${name}:`, ` image: ${y(t.image)}`, ` container_name: ${y(name)}`, ` restart: ${y(t.restart_policy || 'unless-stopped')}`];
|
||||||
|
if (t.hostname) L.push(` hostname: ${y(t.hostname)}`);
|
||||||
|
if (t.command) L.push(` command: ${y(t.command)}`);
|
||||||
|
if (t.privileged) L.push(' privileged: true');
|
||||||
|
if (t.interactive) L.push(' stdin_open: true', ' tty: true');
|
||||||
|
if (t.network === 'host') L.push(' network_mode: host');
|
||||||
|
if (t.ports?.length && t.network !== 'host') L.push(' ports:', ...t.ports.map((p) => ` - ${y(p)}`));
|
||||||
|
if (t.env?.length) L.push(' environment:', ...t.env.filter((e) => e?.name).map((e) => ` - ${y(`${e.name}=\${${e.name}}`)}`));
|
||||||
|
if (t.volumes?.length) {
|
||||||
|
L.push(' volumes:', ...t.volumes.filter((v) => v?.container).map((v) => {
|
||||||
|
const host = v.bind ? v.bind.replace(/^\/portainer\/Files\/AppData\/[^/]+\/?/, './data/') : `./data${v.container}`;
|
||||||
|
return ` - ${y(`${host.replace(/\/$/, '') || './data'}:${v.container}${v.readonly ? ':ro' : ''}`)}`;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (t.labels?.length) L.push(' labels:', ...t.labels.filter((l) => l?.name).map((l) => ` ${y(l.name)}: ${y(l.value ?? '')}`));
|
||||||
|
return L.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert { name, compose, env, note }
|
||||||
|
async function build(index, url) {
|
||||||
|
const l = await list(url);
|
||||||
|
const item = l.find((x) => x.i === index);
|
||||||
|
if (!item) throw new Error('Template not found');
|
||||||
|
const t = item.t;
|
||||||
|
const name = slug(t.name || t.title);
|
||||||
|
let compose;
|
||||||
|
if (t.image) compose = containerCompose(t, name);
|
||||||
|
else {
|
||||||
|
const m = /^https:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(t.repository.url || '');
|
||||||
|
if (!m) throw new Error(`Only GitHub repositories are supported (${t.repository.url}). Use “Clone from Git” instead.`);
|
||||||
|
const res = await fetch(`https://raw.githubusercontent.com/${m[1]}/${m[2]}/HEAD/${t.repository.stackfile.replace(/^\/+/, '')}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${t.repository.stackfile}`);
|
||||||
|
compose = await res.text();
|
||||||
|
}
|
||||||
|
return { name, compose, env: envFile(t.env), note: String(t.note || '').replace(/<[^>]+>/g, '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { list: async (url) => summary(await list(url)), build, DEFAULT_URL };
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Minimaler CTAP2-Client über Linux-hidraw (ohne native Module).
|
||||||
|
// Nötig, weil Chromium/Electron keine PIN-Eingabe für WebAuthn hat: Schlüssel mit gesetzter FIDO2-PIN
|
||||||
|
// (z. B. YubiKey 5 ohne makeCredUvNotRqd) verlangen die PIN beim Registrieren, und Chromium bricht dann
|
||||||
|
// nach dem Berühren mit NotAllowedError ab. Hier läuft makeCredential direkt, mit PIN-Token (Protokoll 1).
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// ---- CBOR (nur was CTAP2 braucht; Map-Schlüssel kanonisch sortiert) ----
|
||||||
|
function head(major, n) {
|
||||||
|
if (n < 24) return Buffer.from([(major << 5) | n]);
|
||||||
|
if (n < 0x100) return Buffer.from([(major << 5) | 24, n]);
|
||||||
|
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b; }
|
||||||
|
const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b;
|
||||||
|
}
|
||||||
|
function enc(v) {
|
||||||
|
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
|
||||||
|
if (typeof v === 'boolean') return Buffer.from([v ? 0xf5 : 0xf4]);
|
||||||
|
if (typeof v === 'string') { const s = Buffer.from(v, 'utf8'); return Buffer.concat([head(3, s.length), s]); }
|
||||||
|
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.concat([head(2, v.length), Buffer.from(v)]);
|
||||||
|
if (Array.isArray(v)) return Buffer.concat([head(4, v.length), ...v.map(enc)]);
|
||||||
|
if (v instanceof Map) {
|
||||||
|
const items = [...v].map(([k, x]) => [enc(k), enc(x)])
|
||||||
|
.sort(([a], [b]) => a.length - b.length || Buffer.compare(a, b));
|
||||||
|
return Buffer.concat([head(5, items.length), ...items.flat()]);
|
||||||
|
}
|
||||||
|
throw new Error('CBOR: unsupported value');
|
||||||
|
}
|
||||||
|
function dec(buf, pos = 0) {
|
||||||
|
const ib = buf[pos++], major = ib >> 5, ai = ib & 31;
|
||||||
|
let n = ai;
|
||||||
|
if (ai === 24) n = buf[pos++];
|
||||||
|
else if (ai === 25) { n = buf.readUInt16BE(pos); pos += 2; }
|
||||||
|
else if (ai === 26) { n = buf.readUInt32BE(pos); pos += 4; }
|
||||||
|
else if (ai === 27) { n = Number(buf.readBigUInt64BE(pos)); pos += 8; }
|
||||||
|
switch (major) {
|
||||||
|
case 0: return [n, pos];
|
||||||
|
case 1: return [-1 - n, pos];
|
||||||
|
case 2: return [buf.subarray(pos, pos + n), pos + n];
|
||||||
|
case 3: return [buf.toString('utf8', pos, pos + n), pos + n];
|
||||||
|
case 4: { const a = []; for (let i = 0; i < n; i++) { let x; [x, pos] = dec(buf, pos); a.push(x); } return [a, pos]; }
|
||||||
|
case 5: { const m = new Map(); for (let i = 0; i < n; i++) { let k, x; [k, pos] = dec(buf, pos); [x, pos] = dec(buf, pos); m.set(k, x); } return [m, pos]; }
|
||||||
|
case 6: return dec(buf, pos);
|
||||||
|
default: return [ai === 20 ? false : ai === 21 ? true : null, pos];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CTAPHID ----
|
||||||
|
const CMD = { CBOR: 0x10, INIT: 0x06, CANCEL: 0x11, KEEPALIVE: 0x3b, ERROR: 0x3f };
|
||||||
|
|
||||||
|
class CtapError extends Error {
|
||||||
|
constructor(code) { super(`CTAP2 error 0x${code.toString(16).padStart(2, '0')}`); this.code = code; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDevice() {
|
||||||
|
const base = '/sys/class/hidraw';
|
||||||
|
let names = [];
|
||||||
|
try { names = fs.readdirSync(base); } catch { return null; }
|
||||||
|
for (const n of names) {
|
||||||
|
try {
|
||||||
|
const rd = fs.readFileSync(path.join(base, n, 'device', 'report_descriptor'));
|
||||||
|
if (rd.includes(Buffer.from([0x06, 0xd0, 0xf1]))) return '/dev/' + n; // Usage Page 0xF1D0 (FIDO)
|
||||||
|
} catch { /* ignorieren */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Device {
|
||||||
|
static open() {
|
||||||
|
const p = findDevice();
|
||||||
|
if (!p) return null;
|
||||||
|
const d = new Device();
|
||||||
|
d.fd = fs.openSync(p, 'r+');
|
||||||
|
d.cid = Buffer.from([0xff, 0xff, 0xff, 0xff]);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
try { fs.closeSync(this.fd); } catch { /* ignorieren */ }
|
||||||
|
}
|
||||||
|
// Nur lesen, während eine Antwort erwartet wird – so hängt nach close() kein blockierender Read im Threadpool
|
||||||
|
readPacket() {
|
||||||
|
const b = Buffer.alloc(64);
|
||||||
|
return new Promise((resolve, reject) => fs.read(this.fd, b, 0, 64, null, (e, n) => (e ? reject(e) : resolve(b.subarray(0, n)))));
|
||||||
|
}
|
||||||
|
write(cmd, data) {
|
||||||
|
const pkt = (b) => { const r = Buffer.alloc(65); b.copy(r, 1); fs.writeSync(this.fd, r); };
|
||||||
|
const first = Buffer.alloc(64);
|
||||||
|
this.cid.copy(first, 0); first[4] = 0x80 | cmd; first.writeUInt16BE(data.length, 5);
|
||||||
|
data.copy(first, 7, 0, 57); pkt(first);
|
||||||
|
for (let off = 57, seq = 0; off < data.length; off += 59, seq++) {
|
||||||
|
const c = Buffer.alloc(64);
|
||||||
|
this.cid.copy(c, 0); c[4] = seq; data.copy(c, 5, off, off + 59); pkt(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async transact(cmd, data) {
|
||||||
|
this.write(cmd, data);
|
||||||
|
for (;;) {
|
||||||
|
const r = await this.readPacket();
|
||||||
|
if (!r.subarray(0, 4).equals(this.cid)) continue;
|
||||||
|
const rcmd = r[4] & 0x7f;
|
||||||
|
if (rcmd === CMD.KEEPALIVE) continue;
|
||||||
|
const len = r.readUInt16BE(5);
|
||||||
|
const parts = [r.subarray(7)];
|
||||||
|
let got = r.length - 7;
|
||||||
|
while (got < len) { const c = await this.readPacket(); parts.push(c.subarray(5)); got += c.length - 5; }
|
||||||
|
const body = Buffer.concat(parts).subarray(0, len);
|
||||||
|
if (rcmd === CMD.ERROR) throw new Error(`CTAPHID error 0x${body[0].toString(16)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
const nonce = crypto.randomBytes(8);
|
||||||
|
for (;;) {
|
||||||
|
const r = await this.transact(CMD.INIT, nonce);
|
||||||
|
if (r.subarray(0, 8).equals(nonce)) { this.cid = Buffer.from(r.subarray(8, 12)); return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel() { try { if (!this.closed) this.write(CMD.CANCEL, Buffer.alloc(0)); } catch { /* ignorieren */ } }
|
||||||
|
async cbor(cmd, params) {
|
||||||
|
const r = await this.transact(CMD.CBOR, Buffer.concat([Buffer.from([cmd]), params ? enc(params) : Buffer.alloc(0)]));
|
||||||
|
if (r[0] !== 0) throw new CtapError(r[0]);
|
||||||
|
return r.length > 1 ? dec(r, 1)[0] : new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PIN-Protokoll 1 ----
|
||||||
|
const aes = (mode, key, data) => {
|
||||||
|
const c = mode === 'enc' ? crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16)) : crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
|
||||||
|
c.setAutoPadding(false);
|
||||||
|
return Buffer.concat([c.update(data), c.final()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sharedSecret(dev) {
|
||||||
|
const ka = (await dev.cbor(0x06, new Map([[1, 1], [2, 2]]))).get(1);
|
||||||
|
const ecdh = crypto.createECDH('prime256v1');
|
||||||
|
ecdh.generateKeys();
|
||||||
|
const z = ecdh.computeSecret(Buffer.concat([Buffer.from([4]), ka.get(-2), ka.get(-3)]));
|
||||||
|
const pub = ecdh.getPublicKey();
|
||||||
|
const platformKey = new Map([[1, 2], [3, -25], [-1, 1], [-2, pub.subarray(1, 33)], [-3, pub.subarray(33, 65)]]);
|
||||||
|
return { key: crypto.createHash('sha256').update(z).digest(), platformKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinToken(dev, pin) {
|
||||||
|
const { key, platformKey } = await sharedSecret(dev);
|
||||||
|
const pinHash = crypto.createHash('sha256').update(pin, 'utf8').digest().subarray(0, 16);
|
||||||
|
const r = await dev.cbor(0x06, new Map([[1, 1], [2, 5], [3, platformKey], [6, aes('enc', key, pinHash)]]));
|
||||||
|
return aes('dec', key, r.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinRetries(dev) {
|
||||||
|
try { return (await dev.cbor(0x06, new Map([[1, 1], [2, 1]]))).get(3); } catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-Codes, die für "abgebrochen / nicht bestätigt / Zeit abgelaufen" stehen
|
||||||
|
const CANCEL_CODES = new Set([0x27, 0x2d, 0x2f, 0x3a]);
|
||||||
|
const PIN = { INVALID: 0x31, BLOCKED: 0x32, AUTH_BLOCKED: 0x34 };
|
||||||
|
|
||||||
|
// Registriert ein nicht-residentes Credential mit hmac-secret für rpId "localhost" (kompatibel zur
|
||||||
|
// WebAuthn-PRF-Abfrage in fido.js). ui: { askPin(retries, wrong) -> Promise<string|null>, touch() }
|
||||||
|
// Liefert die Credential-ID als Buffer, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
|
||||||
|
async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
|
||||||
|
let dev;
|
||||||
|
try { dev = Device.open(); } catch { return null; }
|
||||||
|
if (!dev) return null;
|
||||||
|
let timer;
|
||||||
|
const abort = () => dev.cancel();
|
||||||
|
try {
|
||||||
|
await dev.init();
|
||||||
|
const info = await dev.cbor(0x04);
|
||||||
|
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
|
||||||
|
if (!(info.get(2) || []).includes('hmac-secret')) throw new CtapError(-1);
|
||||||
|
const opts = info.get(4) || new Map();
|
||||||
|
|
||||||
|
const params = new Map([
|
||||||
|
[2, new Map([['id', 'localhost'], ['name', 'MrTerm']])],
|
||||||
|
[3, new Map([['id', crypto.randomBytes(16)], ['name', 'MrTerm'], ['displayName', 'MrTerm']])],
|
||||||
|
[4, [-7, -8, -257].map((alg) => new Map([['alg', alg], ['type', 'public-key']]))],
|
||||||
|
[6, new Map([['hmac-secret', true]])],
|
||||||
|
]);
|
||||||
|
const cdh = crypto.randomBytes(32);
|
||||||
|
params.set(1, cdh);
|
||||||
|
|
||||||
|
if (opts.get('clientPin') === true && opts.get('makeCredUvNotRqd') !== true) {
|
||||||
|
let wrong = false;
|
||||||
|
for (;;) {
|
||||||
|
const pin = await ui.askPin(await pinRetries(dev), wrong);
|
||||||
|
if (pin == null) throw new CtapError(0x2d);
|
||||||
|
try {
|
||||||
|
const tok = await pinToken(dev, pin);
|
||||||
|
params.set(8, crypto.createHmac('sha256', tok).update(cdh).digest().subarray(0, 16));
|
||||||
|
params.set(9, 1);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === PIN.INVALID) { wrong = true; continue; }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.touch(abort);
|
||||||
|
timer = setTimeout(abort, timeoutMs);
|
||||||
|
const r = await dev.cbor(0x01, params);
|
||||||
|
const authData = r.get(2);
|
||||||
|
const idLen = authData.readUInt16BE(53);
|
||||||
|
return Buffer.from(authData.subarray(55, 55 + idLen));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
dev.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit
|
||||||
|
// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert.
|
||||||
|
// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
|
||||||
|
async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) {
|
||||||
|
let dev;
|
||||||
|
try { dev = Device.open(); } catch { return null; }
|
||||||
|
if (!dev) return null;
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
await dev.init();
|
||||||
|
const info = await dev.cbor(0x04);
|
||||||
|
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
|
||||||
|
const rpId = 'localhost';
|
||||||
|
const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]);
|
||||||
|
// Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false)
|
||||||
|
let cred = null;
|
||||||
|
for (const c of creds) {
|
||||||
|
try {
|
||||||
|
await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]]));
|
||||||
|
cred = c; break;
|
||||||
|
} catch (e) { if (e.code !== 0x2e) throw e; }
|
||||||
|
}
|
||||||
|
if (!cred) throw new CtapError(0x2e);
|
||||||
|
const { key, platformKey } = await sharedSecret(dev);
|
||||||
|
const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest();
|
||||||
|
const saltEnc = aes('enc', key, salt);
|
||||||
|
const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16);
|
||||||
|
ui.touch(() => dev.cancel());
|
||||||
|
timer = setTimeout(() => dev.cancel(), timeoutMs);
|
||||||
|
const r = await dev.cbor(0x02, new Map([
|
||||||
|
[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]],
|
||||||
|
[4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])],
|
||||||
|
]));
|
||||||
|
const authData = r.get(2);
|
||||||
|
if (!(authData[32] & 0x80)) throw new CtapError(-1);
|
||||||
|
const ext = dec(authData, 37)[0].get('hmac-secret');
|
||||||
|
if (!ext) throw new CtapError(-1);
|
||||||
|
return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
dev.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN };
|
||||||
@@ -8,6 +8,12 @@ const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm
|
|||||||
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
|
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
|
||||||
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
|
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
|
||||||
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
|
const q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
||||||
|
const STACK_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
||||||
|
const COMPOSE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||||
|
const absPath = (p) => { if (typeof p !== 'string' || !p.startsWith('/') || /[\n\0]/.test(p) || p.split('/').includes('..')) throw new Error('Invalid path'); return p; };
|
||||||
|
const dirname = (p) => p.replace(/\/[^/]*$/, '') || '/';
|
||||||
|
const b64 = (t) => Buffer.from(String(t), 'utf8').toString('base64');
|
||||||
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
||||||
|
|
||||||
function execOn(conn, cmd, stdin) {
|
function execOn(conn, cmd, stdin) {
|
||||||
@@ -102,6 +108,136 @@ class DockerManager {
|
|||||||
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
|
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Compose-Stacks ----------
|
||||||
|
// Stacks liegen je in einem Unterordner von stacksDir (wie Dockge); zusätzlich werden per `compose ls`
|
||||||
|
// gefundene Projekte außerhalb davon angezeigt. Dateien werden ohne sudo geschrieben, sonst per sudo mit Host-Passwort.
|
||||||
|
async composeCmd(s) {
|
||||||
|
if (s.compose) return s.compose;
|
||||||
|
const cands = s.runtime === 'podman' ? ['podman compose', 'podman-compose'] : ['docker compose', 'docker-compose'];
|
||||||
|
for (const c of cands) {
|
||||||
|
const r = await execOn(s.conn, `${c} version >/dev/null 2>&1`);
|
||||||
|
if (!r.code) return (s.compose = c);
|
||||||
|
}
|
||||||
|
throw new Error(i18n.t('Docker Compose was not found on this host (docker compose plugin or docker-compose).'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort)
|
||||||
|
async sh(s, script) {
|
||||||
|
let r = await execOn(s.conn, `sh -c ${q(script)}`);
|
||||||
|
if (r.code && /permission denied|operation not permitted/i.test(r.err) && s.host.password) {
|
||||||
|
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.host.password}\n`);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stacks(id, stacksDir) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const dir = absPath(stacksDir);
|
||||||
|
const compose = await this.composeCmd(s);
|
||||||
|
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
|
||||||
|
const scan = `d=${q(dir)}; [ -d "$d" ] && echo "#exists"; [ -w "$d" ] && echo "#writable"; for p in "$d"/*/; do [ -d "$p" ] || continue; `
|
||||||
|
+ `f=""; for c in ${COMPOSE_NAMES.join(' ')}; do [ -f "$p$c" ] && { f="$p$c"; break; }; done; `
|
||||||
|
+ `[ -n "$f" ] || f=$(find "$p" -maxdepth 3 \\( ${find} \\) 2>/dev/null | head -n1); `
|
||||||
|
+ `g=""; [ -d "$p.git" ] && g="git:$(git -C "$p" remote get-url origin 2>/dev/null)"; `
|
||||||
|
+ `printf '%s\\t%s\\t%s\\n' "$(basename "$p")" "$f" "$g"; done`;
|
||||||
|
const [r, ls] = await Promise.all([execOn(s.conn, `sh -c ${q(scan)}`), this.runCompose(s, compose, 'ls -a --format json')]);
|
||||||
|
const lines = r.out.split('\n').filter(Boolean);
|
||||||
|
let running = [];
|
||||||
|
try { running = JSON.parse(ls.out.trim() || '[]'); } catch { /* podman-compose kennt kein ls */ }
|
||||||
|
const status = new Map(running.map((x) => [x.Name, { status: x.Status, files: String(x.ConfigFiles || '').split(',').filter(Boolean) }]));
|
||||||
|
const list = lines.filter((l) => !l.startsWith('#')).map((l) => {
|
||||||
|
const [name, file, git] = l.split('\t');
|
||||||
|
return { name, dir: `${dir.replace(/\/$/, '')}/${name}`, file: file ? file.replace(/\/\//g, '/') : '', git: git ? git.slice(4) || true : false, managed: true, status: status.get(name)?.status || '' };
|
||||||
|
});
|
||||||
|
for (const [name, st] of status) {
|
||||||
|
if (list.some((x) => x.name === name) || !st.files[0]) continue;
|
||||||
|
list.push({ name, dir: dirname(st.files[0]), file: st.files[0], git: false, managed: false, status: st.status });
|
||||||
|
}
|
||||||
|
return { dir, exists: lines.includes('#exists'), writable: lines.includes('#writable'), compose, sudo: s.sudo, stacks: list.sort((a, b) => a.name.localeCompare(b.name)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
runCompose(s, compose, args) {
|
||||||
|
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||||
|
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async readStack(id, file) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const f = absPath(file);
|
||||||
|
// .env wird mit umask 077 geschrieben – beim Speichern per sudo gehört sie root. Dann scheitert cat mit
|
||||||
|
// „Permission denied“ und sh() wiederholt das Lesen per sudo (vorher wurde der Fehler verschluckt).
|
||||||
|
const env = q(dirname(f) + '/.env');
|
||||||
|
const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { [ ! -e ${env} ] || cat ${env}; }`);
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Could not read {file}', { file: f }));
|
||||||
|
const i = r.out.lastIndexOf('\n\0');
|
||||||
|
return { compose: r.out.slice(0, i), env: r.out.slice(i + 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// { stacksDir, name, file?, compose, env } – ohne file wird ein neuer Stack angelegt. Liefert { file, warning }
|
||||||
|
async saveStack(id, { stacksDir, name, file, compose, env }) {
|
||||||
|
const s = this.get(id);
|
||||||
|
let f;
|
||||||
|
if (file) f = absPath(file);
|
||||||
|
else {
|
||||||
|
if (!STACK_NAME.test(name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
|
||||||
|
f = `${absPath(stacksDir).replace(/\/$/, '')}/${name}/compose.yaml`;
|
||||||
|
}
|
||||||
|
const d = dirname(f);
|
||||||
|
const envFile = `${d}/.env`;
|
||||||
|
const script = `${file ? '' : `[ -e ${q(d)} ] && { echo "exists" >&2; exit 3; }; `}mkdir -p ${q(d)} && echo ${b64(compose)} | base64 -d > ${q(f)} && `
|
||||||
|
+ (String(env || '').trim() ? `(umask 077; echo ${b64(env.endsWith('\n') ? env : env + '\n')} | base64 -d > ${q(envFile)})` : `rm -f ${q(envFile)}`);
|
||||||
|
const r = await this.sh(s, script);
|
||||||
|
if (r.code === 3) throw new Error(i18n.t('A stack named “{name}” already exists.', { name }));
|
||||||
|
if (r.code) {
|
||||||
|
if (/permission denied/i.test(r.err)) throw new Error(i18n.t('No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.', { dir: d }));
|
||||||
|
throw new Error(lastLine(r.err) || 'Error');
|
||||||
|
}
|
||||||
|
const compose2 = await this.composeCmd(s);
|
||||||
|
const v = await this.runCompose(s, compose2, `--project-directory ${q(d)} -f ${q(f)} config -q`);
|
||||||
|
return { file: f, warning: v.code ? lastLine(v.err) || 'compose config failed' : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehl für einen Terminal-Tab (Ausgabe live; sudo/git fragen dort selbst nach Passwörtern)
|
||||||
|
async stackCommand(id, action, st, opts = {}) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const compose = `${s.sudo ? 'sudo ' : ''}${await this.composeCmd(s)}`;
|
||||||
|
const S = (dir) => `S=$([ -w ${q(dir)} ] || echo sudo); `;
|
||||||
|
if (action === 'setup') {
|
||||||
|
const d = absPath(st.dir);
|
||||||
|
return `sudo mkdir -p ${q(d)} && sudo chown "$(id -u):$(id -g)" ${q(d)} && echo OK: ${q(d)}`;
|
||||||
|
}
|
||||||
|
if (action === 'clone') {
|
||||||
|
if (!STACK_NAME.test(st.name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
|
||||||
|
if (!/^(https?:\/\/|git@|ssh:\/\/)\S+$/.test(opts.url || '')) throw new Error(i18n.t('Invalid repository URL: {url}', { url: opts.url || '' }));
|
||||||
|
const parent = absPath(st.stacksDir).replace(/\/$/, '');
|
||||||
|
const d = `${parent}/${st.name}`;
|
||||||
|
const sub = opts.path ? absPath('/' + opts.path.replace(/^\/+/, '')).slice(1) : '';
|
||||||
|
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
|
||||||
|
const locate = sub ? `f=${q(`${d}/${sub}`)}` : `f=$(find ${q(d)} -maxdepth 3 \\( ${find} \\) | head -n1)`;
|
||||||
|
return `${S(parent)}command -v git >/dev/null || { echo "git is not installed"; exit 1; }; $S git clone ${opts.branch ? `--branch ${q(opts.branch)} ` : ''}${q(opts.url)} ${q(d)} && `
|
||||||
|
+ `{ [ "$S" ] && $S chown -R "$(id -u):$(id -g)" ${q(d)}; true; } && ${locate} && [ -f "$f" ] && echo "Compose: $f"`
|
||||||
|
+ (opts.deploy ? ` && cd "$(dirname "$f")" && ${compose} -p ${q(st.name)} up -d` : '');
|
||||||
|
}
|
||||||
|
const f = absPath(st.file);
|
||||||
|
const cd = `cd ${q(dirname(f))} && `;
|
||||||
|
const p = `${compose} -p ${q(st.name)} -f ${q(f)}`;
|
||||||
|
switch (action) {
|
||||||
|
case 'up': return `${cd}${p} up -d --remove-orphans`;
|
||||||
|
case 'stop': return `${cd}${p} stop`;
|
||||||
|
case 'restart': return `${cd}${p} restart`;
|
||||||
|
case 'down': return `${cd}${p} down`;
|
||||||
|
case 'pull': return `${cd}${p} pull && ${p} up -d --remove-orphans`;
|
||||||
|
case 'logs': return `${cd}${p} logs -f --tail 200`;
|
||||||
|
case 'gitpull': return `${cd}${S(dirname(f))}$S git pull --ff-only && ${p} up -d --remove-orphans`;
|
||||||
|
case 'delete': {
|
||||||
|
if (!st.managed) throw new Error('Only stacks in the stacks folder can be deleted');
|
||||||
|
const d = absPath(st.dir);
|
||||||
|
return `${cd}${p} down${opts.volumes ? ' -v' : ''}; cd / && ${S(dirname(d))}$S rm -rf ${q(d)} && echo "Deleted: ${d.replace(/"/g, '')}"`;
|
||||||
|
}
|
||||||
|
default: throw new Error('Invalid action');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close(id) {
|
close(id) {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
||||||
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
|
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
|
||||||
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
||||||
|
// Registrieren läuft unter Linux direkt per CTAP2 (ctap2.js), weil Electron keine PIN-Abfrage kennt und
|
||||||
|
// Schlüssel mit gesetzter PIN sonst nach dem Berühren mit NotAllowedError scheitern.
|
||||||
const { BrowserWindow, session } = require('electron');
|
const { BrowserWindow, session } = require('electron');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
|
const ctap2 = require('./ctap2');
|
||||||
|
|
||||||
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
||||||
@@ -13,7 +16,10 @@ const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
|||||||
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
||||||
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
|
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
|
||||||
button:hover{background:#343a4f}
|
button:hover{background:#343a4f}
|
||||||
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`;
|
form{display:flex;gap:8px;-webkit-app-region:no-drag} form[hidden]{display:none}
|
||||||
|
input{background:#10131b;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 10px;font:inherit;width:170px}
|
||||||
|
.row{display:flex;gap:8px}
|
||||||
|
</style></head><body><div class="ic">🔑</div><div id="t"></div><form id="f" hidden><input id="p" type="password" autocomplete="off"><button id="ok"></button></form><div class="row"><button id="c"></button></div></body></html>`;
|
||||||
|
|
||||||
let fidoSession;
|
let fidoSession;
|
||||||
function getSession() {
|
function getSession() {
|
||||||
@@ -29,17 +35,23 @@ const HELPERS = `
|
|||||||
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function ceremony(parent, text, script) {
|
async function openWindow(parent, text) {
|
||||||
const w = new BrowserWindow({
|
const w = new BrowserWindow({
|
||||||
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
|
parent, modal: !!parent, width: 420, height: 230, frame: false, resizable: false, show: false,
|
||||||
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
||||||
});
|
});
|
||||||
|
await w.loadURL('http://localhost/');
|
||||||
|
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
||||||
|
document.getElementById('ok').textContent = ${JSON.stringify(i18n.t('OK'))};
|
||||||
|
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
||||||
|
w.show();
|
||||||
|
w.focus();
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ceremony(parent, text, script) {
|
||||||
|
const w = await openWindow(parent, text);
|
||||||
try {
|
try {
|
||||||
await w.loadURL('http://localhost/');
|
|
||||||
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
|
||||||
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
|
||||||
w.show();
|
|
||||||
w.focus();
|
|
||||||
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
|
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
|
||||||
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
|
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
|
||||||
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
@@ -50,8 +62,71 @@ async function ceremony(parent, text, script) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registrierung direkt per CTAP2 (Linux). Liefert die Credential-ID (base64url) oder null, wenn kein
|
||||||
|
// FIDO2-Gerät per hidraw erreichbar ist (dann übernimmt Chromium).
|
||||||
|
async function registerNative(parent) {
|
||||||
|
const w = await openWindow(parent, i18n.t('Connecting to security key …'));
|
||||||
|
let abort = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
w.once('closed', () => { cancelled = true; abort(); });
|
||||||
|
const js = (code) => (w.isDestroyed() ? Promise.resolve(null) : w.webContents.executeJavaScript(code).catch(() => null));
|
||||||
|
const setText = (t) => js(`document.getElementById('t').textContent = ${JSON.stringify(t)}; 0;`);
|
||||||
|
const ui = {
|
||||||
|
async askPin(retries, wrong) {
|
||||||
|
let t = i18n.t('Enter the PIN of your security key.');
|
||||||
|
if (wrong) t = i18n.t('Wrong PIN.') + ' ' + t;
|
||||||
|
if (retries != null) t += ' ' + i18n.t('{n} attempts left.', { n: retries });
|
||||||
|
await setText(t);
|
||||||
|
const closed = new Promise((resolve) => w.once('closed', () => resolve(null)));
|
||||||
|
return Promise.race([closed, js(`new Promise((resolve) => {
|
||||||
|
const f = document.getElementById('f'), p = document.getElementById('p');
|
||||||
|
f.hidden = false; p.value = ''; p.focus();
|
||||||
|
f.onsubmit = (e) => { e.preventDefault(); f.hidden = true; resolve(p.value); };
|
||||||
|
})`)]);
|
||||||
|
},
|
||||||
|
touch(a) { abort = a; if (cancelled) a(); setText(i18n.t('Touch your security key to register it.')); },
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const id = await ctap2.makeCredential(ui);
|
||||||
|
return id && b64url(id);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
|
if (e.code === ctap2.PIN.BLOCKED || e.code === ctap2.PIN.AUTH_BLOCKED) throw new Error(i18n.t('The PIN of this security key is blocked. Remove and reinsert the key, or reset it.'));
|
||||||
|
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (!w.isDestroyed()) w.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist
|
||||||
|
async function deriveNative(parent, creds, text) {
|
||||||
|
const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.'));
|
||||||
|
let abort = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
w.once('closed', () => { cancelled = true; abort(); });
|
||||||
|
try {
|
||||||
|
const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } },
|
||||||
|
creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') })));
|
||||||
|
return r && { credId: b64url(r.credId), secret: r.secret };
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
|
if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.'));
|
||||||
|
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (!w.isDestroyed()) w.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64url = (buf) => Buffer.from(buf).toString('base64url');
|
||||||
|
|
||||||
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
||||||
async function register(parent) {
|
async function register(parent) {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const id = await registerNative(parent);
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
||||||
const cred = await navigator.credentials.create({ publicKey: {
|
const cred = await navigator.credentials.create({ publicKey: {
|
||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
@@ -69,6 +144,10 @@ async function register(parent) {
|
|||||||
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
||||||
// Liefert { credId, secret: Buffer(32) }
|
// Liefert { credId, secret: Buffer(32) }
|
||||||
async function derive(parent, creds, text) {
|
async function derive(parent, creds, text) {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const r = await deriveNative(parent, creds, text);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
||||||
const creds = ${JSON.stringify(creds)};
|
const creds = ${JSON.stringify(creds)};
|
||||||
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const { DockerManager } = require('./docker');
|
|||||||
const { FirewallManager } = require('./firewall');
|
const { FirewallManager } = require('./firewall');
|
||||||
const { NetworkConfigManager } = require('./network');
|
const { NetworkConfigManager } = require('./network');
|
||||||
const { SyncService } = require('./sync');
|
const { SyncService } = require('./sync');
|
||||||
|
const backup = require('../core/backup');
|
||||||
|
const composeTemplates = require('./compose-templates');
|
||||||
|
const { AiAssistant } = require('./ai');
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
@@ -75,6 +78,7 @@ const vpn = new VpnManager(store, app.getPath('userData'));
|
|||||||
const docker = new DockerManager(ssh);
|
const docker = new DockerManager(ssh);
|
||||||
const firewall = new FirewallManager(ssh);
|
const firewall = new FirewallManager(ssh);
|
||||||
const network = new NetworkConfigManager(ssh);
|
const network = new NetworkConfigManager(ssh);
|
||||||
|
const ai = new AiAssistant(store, send);
|
||||||
|
|
||||||
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
|
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
|
||||||
const pairReplies = new Map();
|
const pairReplies = new Map();
|
||||||
@@ -232,6 +236,12 @@ handle('docker:stats', (id) => docker.stats(id));
|
|||||||
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
handle('docker:close', (id) => docker.close(id));
|
handle('docker:close', (id) => docker.close(id));
|
||||||
|
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
|
||||||
|
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
|
||||||
|
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
|
||||||
|
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
|
||||||
|
handle('docker:templates', (url) => composeTemplates.list(url));
|
||||||
|
handle('docker:template', (index, url) => composeTemplates.build(index, url));
|
||||||
|
|
||||||
// ---------- Firewall ----------
|
// ---------- Firewall ----------
|
||||||
handle('firewall:open', async (id, hostRef) => {
|
handle('firewall:open', async (id, hostRef) => {
|
||||||
@@ -270,6 +280,11 @@ handle('sync:share', (sel) => sync.setShare(sel));
|
|||||||
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||||
handle('sync:probe', (address) => sync.probe(address));
|
handle('sync:probe', (address) => sync.probe(address));
|
||||||
|
|
||||||
|
// ---------- Support-Assistent (Ollama) ----------
|
||||||
|
handle('ai:models', (url) => ai.models(url));
|
||||||
|
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
|
||||||
|
handle('ai:stop', (id) => ai.stop(id));
|
||||||
|
|
||||||
// ---------- VPN ----------
|
// ---------- VPN ----------
|
||||||
handle('vpn:status', () => vpn.status());
|
handle('vpn:status', () => vpn.status());
|
||||||
handle('vpn:up', (id) => vpn.up(id));
|
handle('vpn:up', (id) => vpn.up(id));
|
||||||
@@ -280,19 +295,19 @@ handle('vault:settings', (s) => {
|
|||||||
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
|
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
|
||||||
});
|
});
|
||||||
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||||
handle('vault:export', async () => {
|
// Backup: vollständig, optional per Passwort verschlüsselt (src/core/backup.js)
|
||||||
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
|
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version: app.getVersion() }));
|
||||||
if (r.canceled) return false;
|
handle('backup:inspect', (content) => backup.inspectBackup(content));
|
||||||
fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 });
|
handle('backup:import', (content, password, opts) => {
|
||||||
return r.filePath;
|
const r = backup.importBackup(store, content, password, opts);
|
||||||
|
if (opts?.settings) applyLanguage();
|
||||||
|
return r;
|
||||||
});
|
});
|
||||||
handle('vault:import', async () => {
|
handle('backup:saveFile', async (content, name) => {
|
||||||
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
|
const r = await dialog.showSaveDialog(win, { defaultPath: name, filters: [{ name: 'MrTerm Backup', extensions: ['json'] }] });
|
||||||
if (r.canceled) return false;
|
if (r.canceled) return false;
|
||||||
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8'));
|
fs.writeFileSync(r.filePath, content, { mode: 0o600 });
|
||||||
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
|
return r.filePath;
|
||||||
for (const item of data[col] || []) store.upsert(col, item);
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import aus ~/.ssh/config
|
// Import aus ~/.ssh/config
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class SshManager {
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
host: host.address,
|
host: host.address,
|
||||||
port: Number(host.port) || 22,
|
port: Number(host.port) || 22,
|
||||||
username: host.username || os.userInfo().username,
|
username: host.username || (() => { try { return os.userInfo().username; } catch { return 'root'; } })(),
|
||||||
readyTimeout: 20000,
|
readyTimeout: 20000,
|
||||||
keepaliveInterval: (this.store.get().settings.keepAlive || 0) * 1000,
|
keepaliveInterval: (this.store.get().settings.keepAlive || 0) * 1000,
|
||||||
tryKeyboard: true,
|
tryKeyboard: true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
|
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
|
||||||
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
|
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
|
||||||
const { app, safeStorage } = require('electron');
|
// Im Web-Server gibt es kein Electron: dort übergibt der Aufrufer Verzeichnis und Verschlüsselung (opts)
|
||||||
|
const electron = (() => { try { const e = require('electron'); return typeof e === 'object' ? e : {}; } catch { return {}; } })();
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -15,6 +16,8 @@ const DEFAULTS = {
|
|||||||
vpns: [],
|
vpns: [],
|
||||||
knownHosts: {},
|
knownHosts: {},
|
||||||
history: [],
|
history: [],
|
||||||
|
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
|
||||||
|
aiChats: [],
|
||||||
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
||||||
tombstones: [],
|
tombstones: [],
|
||||||
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
||||||
@@ -38,6 +41,10 @@ const DEFAULTS = {
|
|||||||
updatePrerelease: false,
|
updatePrerelease: false,
|
||||||
autoLock: 0,
|
autoLock: 0,
|
||||||
language: 'auto',
|
language: 'auto',
|
||||||
|
dockerStacksDir: '/opt/stacks',
|
||||||
|
dockerTemplatesUrl: '',
|
||||||
|
aiUrl: 'http://localhost:11434',
|
||||||
|
aiModel: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,8 +64,10 @@ function unbox(key, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
constructor() {
|
// opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage
|
||||||
this.dir = app.getPath('userData');
|
constructor(opts = {}) {
|
||||||
|
this.dir = opts.dir || electron.app.getPath('userData');
|
||||||
|
this.crypto = opts.crypto || electron.safeStorage;
|
||||||
this.file = path.join(this.dir, 'vault.dat');
|
this.file = path.join(this.dir, 'vault.dat');
|
||||||
this.data = structuredClone(DEFAULTS);
|
this.data = structuredClone(DEFAULTS);
|
||||||
this.encrypted = false;
|
this.encrypted = false;
|
||||||
@@ -97,9 +106,9 @@ class Store {
|
|||||||
|
|
||||||
canEncrypt() {
|
canEncrypt() {
|
||||||
try {
|
try {
|
||||||
if (!safeStorage.isEncryptionAvailable()) return false;
|
if (!this.crypto.isEncryptionAvailable()) return false;
|
||||||
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
|
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
|
||||||
if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend?.() === 'basic_text') return false;
|
if (process.platform === 'linux' && this.crypto.getSelectedStorageBackend?.() === 'basic_text') return false;
|
||||||
return true;
|
return true;
|
||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
@@ -110,7 +119,7 @@ class Store {
|
|||||||
const raw = fs.readFileSync(this.file);
|
const raw = fs.readFileSync(this.file);
|
||||||
let json;
|
let json;
|
||||||
if (raw.slice(0, 4).toString() === 'ENC1') {
|
if (raw.slice(0, 4).toString() === 'ENC1') {
|
||||||
json = safeStorage.decryptString(raw.slice(4));
|
json = this.crypto.decryptString(raw.slice(4));
|
||||||
this.encrypted = true;
|
this.encrypted = true;
|
||||||
} else {
|
} else {
|
||||||
json = raw.toString('utf8');
|
json = raw.toString('utf8');
|
||||||
@@ -136,7 +145,7 @@ class Store {
|
|||||||
}
|
}
|
||||||
const tmp = this.file + '.tmp';
|
const tmp = this.file + '.tmp';
|
||||||
if (this.canEncrypt()) {
|
if (this.canEncrypt()) {
|
||||||
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)]));
|
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), this.crypto.encryptString(json)]));
|
||||||
this.encrypted = true;
|
this.encrypted = true;
|
||||||
} else {
|
} else {
|
||||||
fs.writeFileSync(tmp, json, { mode: 0o600 });
|
fs.writeFileSync(tmp, json, { mode: 0o600 });
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ class SyncService {
|
|||||||
d.sync ||= { enabled: false, peers: [], share: { keys: [], hosts: [], vpns: [] } };
|
d.sync ||= { enabled: false, peers: [], share: { keys: [], hosts: [], vpns: [] } };
|
||||||
const s = d.sync;
|
const s = d.sync;
|
||||||
if (!s.deviceId) s.deviceId = crypto.randomUUID();
|
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: [] };
|
s.peers ||= []; s.share ||= { keys: [], hosts: [], vpns: [] };
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,9 @@ class SyncService {
|
|||||||
// ------------------------------------------------ Finden
|
// ------------------------------------------------ Finden
|
||||||
broadcastAddrs() {
|
broadcastAddrs() {
|
||||||
const out = new Set(['255.255.255.255']);
|
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 || []) {
|
for (const a of list || []) {
|
||||||
if (a.family !== 'IPv4' || a.internal) continue;
|
if (a.family !== 'IPv4' || a.internal) continue;
|
||||||
const ip = a.address.split('.').map(Number), mask = a.netmask.split('.').map(Number);
|
const ip = a.address.split('.').map(Number), mask = a.netmask.split('.').map(Number);
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* App-Designs: gemeinsame Farbvariablen für Desktop (src/renderer) und Android-App (mobile/web) */
|
||||||
|
:root {
|
||||||
|
--bg: #13151c;
|
||||||
|
--bg-2: #181b24;
|
||||||
|
--panel: #1c1f2a;
|
||||||
|
--card: #212533;
|
||||||
|
--card-hover: #282d3d;
|
||||||
|
--border: #2b3040;
|
||||||
|
--text: #e6e8ef;
|
||||||
|
--muted: #8b91a5;
|
||||||
|
--faint: #5d6377;
|
||||||
|
--accent: #6e7bff;
|
||||||
|
--accent-2: #8b95ff;
|
||||||
|
--green: #3ecf8e;
|
||||||
|
--red: #ff5f6d;
|
||||||
|
--orange: #ffb454;
|
||||||
|
--accent-3: #b36bff;
|
||||||
|
--chrome: #0f1117;
|
||||||
|
--chrome-line: #1f2330;
|
||||||
|
--hover: #1a1d27;
|
||||||
|
--nav-active: #1f2233;
|
||||||
|
--icon-bg: #2a2f45;
|
||||||
|
--folder: #6ea8ff;
|
||||||
|
--row-line: #1c1f29;
|
||||||
|
--overlay: #13151ce6;
|
||||||
|
--tint: 255 255 255;
|
||||||
|
--radius: 10px;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--font: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
|
||||||
|
}
|
||||||
|
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
|
||||||
|
[data-theme='navy'] {
|
||||||
|
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
|
||||||
|
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
|
||||||
|
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
|
||||||
|
}
|
||||||
|
[data-theme='nord'] {
|
||||||
|
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
|
||||||
|
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
|
||||||
|
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
|
||||||
|
}
|
||||||
|
[data-theme='dracula'] {
|
||||||
|
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
|
||||||
|
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
|
||||||
|
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
|
||||||
|
}
|
||||||
|
[data-theme='mocha'] {
|
||||||
|
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
|
||||||
|
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
|
||||||
|
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
|
||||||
|
}
|
||||||
|
[data-theme='forest'] {
|
||||||
|
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
|
||||||
|
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
|
||||||
|
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
|
||||||
|
}
|
||||||
|
[data-theme='light'] {
|
||||||
|
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
|
||||||
|
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
|
||||||
|
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
|
||||||
|
--tint: 0 0 0;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Windows XP (Luna) */
|
||||||
|
[data-theme='xp'] {
|
||||||
|
--bg: #ece9d8; --bg-2: #e3dfcb; --panel: #ffffff; --card: #ffffff; --card-hover: #f1efe2; --border: #aca899;
|
||||||
|
--text: #000000; --muted: #4a4a4a; --faint: #7f7f7f; --accent: #316ac5; --accent-3: #3a9a3a; --green: #2b8a2b; --red: #c81e1e; --orange: #d87400;
|
||||||
|
--chrome: #d6dff7; --chrome-line: #7a96df; --hover: #e8eefc; --nav-active: #c1d2ee; --icon-bg: #dde7f8; --folder: #e0a92a; --row-line: #ece9d8; --overlay: #ece9d8e6;
|
||||||
|
--tint: 0 0 0; --radius: 3px; --font: Tahoma, Verdana, 'Segoe UI', sans-serif;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
}
|
||||||
|
/* Windows 11 (hell, Mica) */
|
||||||
|
[data-theme='win11'] {
|
||||||
|
--bg: #f3f3f3; --bg-2: #eeeeee; --panel: #fbfbfb; --card: #ffffff; --card-hover: #f6f6f6; --border: #e5e5e5;
|
||||||
|
--text: #1b1b1b; --muted: #5f5f5f; --faint: #8a8a8a; --accent: #0067c0; --accent-3: #8764b8; --green: #0f7b0f; --red: #c42b1c; --orange: #9d5d00;
|
||||||
|
--chrome: #ebebeb; --chrome-line: #e0e0e0; --hover: #eaeaea; --nav-active: #e0e0e0; --icon-bg: #e6eef8; --folder: #e8a33d; --row-line: #efefef; --overlay: #f3f3f3e6;
|
||||||
|
--tint: 0 0 0; --radius: 8px; --font: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
}
|
||||||
|
/* macOS 26 „Liquid Glass“: milchige, durchscheinende Flächen über einem farbigen Hintergrund */
|
||||||
|
[data-theme='macos'] {
|
||||||
|
--bg: rgba(255, 255, 255, .34); --bg-2: rgba(255, 255, 255, .28); --panel: rgba(255, 255, 255, .55); --card: rgba(255, 255, 255, .48);
|
||||||
|
--card-hover: rgba(255, 255, 255, .68); --border: rgba(255, 255, 255, .7);
|
||||||
|
--text: #1d1d1f; --muted: #55555c; --faint: #8a8a92; --accent: #007aff; --accent-3: #af52de; --green: #248a3d; --red: #e0352b; --orange: #c96b00;
|
||||||
|
--chrome: rgba(255, 255, 255, .3); --chrome-line: rgba(255, 255, 255, .55); --hover: rgba(255, 255, 255, .45); --nav-active: rgba(255, 255, 255, .72);
|
||||||
|
--icon-bg: rgba(0, 122, 255, .12); --folder: #1a8cff; --row-line: rgba(255, 255, 255, .5); --overlay: rgba(240, 242, 250, .55);
|
||||||
|
--tint: 0 0 0; --radius: 14px; --font: -apple-system, 'SF Pro Text', 'Helvetica Neue', 'Inter', system-ui, sans-serif;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
--glass-blur: blur(26px) saturate(185%);
|
||||||
|
--glass-edge: inset 0 1px 0 rgba(255, 255, 255, .95), inset 0 -1px 0 rgba(255, 255, 255, .3), 0 10px 30px rgba(30, 40, 80, .10);
|
||||||
|
}
|
||||||
|
/* Hintergrund, durch den das Glas schimmert */
|
||||||
|
[data-theme='macos'] {
|
||||||
|
--wallpaper:
|
||||||
|
radial-gradient(at 12% 18%, #9cc2ff 0, transparent 46%),
|
||||||
|
radial-gradient(at 88% 12%, #f7b6dd 0, transparent 42%),
|
||||||
|
radial-gradient(at 72% 88%, #98e2d2 0, transparent 46%),
|
||||||
|
radial-gradient(at 18% 92%, #ffd6a0 0, transparent 42%),
|
||||||
|
#eef1f8;
|
||||||
|
}
|
||||||
|
html[data-theme='macos'], html[data-theme='macos'] body { background: var(--wallpaper); background-attachment: fixed; }
|
||||||
|
/* Cyberpunk 2077 */
|
||||||
|
[data-theme='cyberpunk'] {
|
||||||
|
--bg: #0a0a0f; --bg-2: #0e0e15; --panel: #111119; --card: #15151f; --card-hover: #1e1c10; --border: #3d3a14;
|
||||||
|
--text: #eafcff; --muted: #8fb8c8; --faint: #5a7680; --accent: #fcee0a; --accent-3: #00f0ff; --green: #00ff9f; --red: #ff003c; --orange: #ff9e00;
|
||||||
|
--chrome: #07070b; --chrome-line: #2a2808; --hover: #17161b; --nav-active: #232008; --icon-bg: #26240b; --folder: #00f0ff; --row-line: #17161f; --overlay: #0a0a0fe6;
|
||||||
|
--radius: 2px; --on-accent: #0a0a0f; --font: 'Rajdhani', 'Orbitron', 'Bahnschrift', 'DIN Alternate', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--accent-2: #fff45c;
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@
|
|||||||
const $ = (sel, root = document) => root.querySelector(sel);
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
const uid = () => crypto.randomUUID();
|
// crypto.randomUUID gibt es nur in sicheren Kontexten (HTTPS/localhost) – die Web-Version läuft oft per http://<server>
|
||||||
|
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)));
|
||||||
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
||||||
const T = I18N.t;
|
const T = I18N.t;
|
||||||
|
// Web-Version (Docker, src/web): gleiche Oberfläche im Browser; ohne RDP, VPN, Port-Forwarding und lokale Dateien
|
||||||
|
const WEB = api.platform === 'web';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
|
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
|
||||||
@@ -43,6 +46,7 @@ const ICONS = {
|
|||||||
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
|
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
|
||||||
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
|
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
|
||||||
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
|
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
|
||||||
|
ai: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg>',
|
||||||
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
||||||
};
|
};
|
||||||
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||||
@@ -147,6 +151,7 @@ async function reload() {
|
|||||||
S.vault = await call('vault:get');
|
S.vault = await call('vault:get');
|
||||||
I18N.setLanguage(settings().language, navigator.language);
|
I18N.setLanguage(settings().language, navigator.language);
|
||||||
applyStatic();
|
applyStatic();
|
||||||
|
initAi();
|
||||||
const b = $('#vaultBadge');
|
const b = $('#vaultBadge');
|
||||||
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
|
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
|
||||||
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
|
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
|
||||||
@@ -407,6 +412,7 @@ function connectHost(hst) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openRdp(hst) {
|
async function openRdp(hst) {
|
||||||
|
if (WEB) return toast(T('RDP is not available in the web version.'), 'error');
|
||||||
if (settings().rdpEmbed !== false) {
|
if (settings().rdpEmbed !== false) {
|
||||||
const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message }));
|
const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message }));
|
||||||
if (s.ok) return new RdpSession(hst);
|
if (s.ok) return new RdpSession(hst);
|
||||||
@@ -654,7 +660,7 @@ function viewSnippets(page) {
|
|||||||
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
|
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
|
||||||
const c = h('<div class="content"></div>'); page.append(c);
|
const c = h('<div class="content"></div>'); page.append(c);
|
||||||
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
|
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
|
||||||
if (!list.length) return c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet()));
|
if (!list.length) { c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet())); c.append(templateList()); return; }
|
||||||
const l = h('<div class="list"></div>');
|
const l = h('<div class="list"></div>');
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
||||||
@@ -667,7 +673,7 @@ function viewSnippets(page) {
|
|||||||
};
|
};
|
||||||
l.append(card);
|
l.append(card);
|
||||||
}
|
}
|
||||||
c.append(l);
|
c.append(l, templateList());
|
||||||
}
|
}
|
||||||
function editSnippet(s = {}) {
|
function editSnippet(s = {}) {
|
||||||
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
|
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
|
||||||
@@ -681,6 +687,86 @@ function editSnippet(s = {}) {
|
|||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Eingebaute Vorlagen (nicht im Vault). Parameter werden vor dem Ausführen abgefragt; build() liefert eine Zeile.
|
||||||
|
// $S = sudo, außer man ist bereits root.
|
||||||
|
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
||||||
|
const SUDO = 'S=$([ "$(id -u)" = 0 ] || echo sudo);';
|
||||||
|
const SNIPPET_TEMPLATES = [
|
||||||
|
{
|
||||||
|
label: () => T('Authorize SSH key for a user'),
|
||||||
|
desc: () => T('Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.'),
|
||||||
|
params: () => [
|
||||||
|
S.vault.keys.some((k) => k.publicKey)
|
||||||
|
? field(T('Key'), 'key', '', { type: 'select', options: S.vault.keys.filter((k) => k.publicKey).map((k) => [k.id, k.label]) })
|
||||||
|
: field('Public Key', 'pub', '', { type: 'textarea', placeholder: 'ssh-ed25519 AAAA…' }),
|
||||||
|
field(T('User'), 'user', 'root'),
|
||||||
|
],
|
||||||
|
build: (v) => {
|
||||||
|
const pub = (v.key ? S.vault.keys.find((k) => k.id === v.key)?.publicKey : v.pub || '').trim();
|
||||||
|
if (!pub) throw new Error(T('Public key is missing.'));
|
||||||
|
if (!v.user.trim()) throw new Error(T('User is missing.'));
|
||||||
|
return `${SUDO} u=${shq(v.user.trim())}; k=${shq(pub)}; d="$(getent passwd "$u" | cut -d: -f6)/.ssh"; `
|
||||||
|
+ `$S install -d -m 700 -o "$u" -g "$(id -gn "$u")" "$d" && { $S grep -qxF "$k" "$d/authorized_keys" 2>/dev/null || echo "$k" | $S tee -a "$d/authorized_keys" >/dev/null; } `
|
||||||
|
+ `&& $S chown "$u": "$d/authorized_keys" && $S chmod 600 "$d/authorized_keys" && echo "OK: $d/authorized_keys"`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('Install QEMU guest agent'),
|
||||||
|
desc: () => T('Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).'),
|
||||||
|
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get install -y qemu-guest-agent; `
|
||||||
|
+ 'elif command -v dnf >/dev/null; then $S dnf install -y qemu-guest-agent; elif command -v yum >/dev/null; then $S yum install -y qemu-guest-agent; '
|
||||||
|
+ 'elif command -v pacman >/dev/null; then $S pacman -S --needed --noconfirm qemu-guest-agent; elif command -v zypper >/dev/null; then $S zypper -n install qemu-guest-agent; '
|
||||||
|
+ 'elif command -v apk >/dev/null; then $S apk add qemu-guest-agent && $S rc-update add qemu-guest-agent && $S rc-service qemu-guest-agent start; fi; '
|
||||||
|
+ 'command -v systemctl >/dev/null && { $S systemctl enable --now qemu-guest-agent 2>/dev/null || $S systemctl start qemu-guest-agent; }; '
|
||||||
|
+ 'command -v systemctl >/dev/null && systemctl is-active qemu-guest-agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('Use APT cache server'),
|
||||||
|
desc: () => T('Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).'),
|
||||||
|
params: () => [field(T('Proxy URL'), 'url', 'http://', { placeholder: 'http://192.168.1.10:3142' })],
|
||||||
|
build: (v) => {
|
||||||
|
const url = v.url.trim();
|
||||||
|
if (!/^https?:\/\/[^\s/]+/.test(url)) throw new Error(T('Invalid URL.'));
|
||||||
|
return `${SUDO} echo ${shq(`Acquire::http::Proxy "${url}";`)} | $S tee /etc/apt/apt.conf.d/00proxy && $S apt-get update`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('Remove APT cache server'),
|
||||||
|
desc: () => T('Removes the APT proxy setting again.'),
|
||||||
|
build: () => `${SUDO} $S rm -f /etc/apt/apt.conf.d/00proxy && $S apt-get update`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => T('System update'),
|
||||||
|
desc: () => T('Updates all packages with the system package manager.'),
|
||||||
|
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get -y full-upgrade; elif command -v dnf >/dev/null; then $S dnf -y upgrade; `
|
||||||
|
+ 'elif command -v pacman >/dev/null; then $S pacman -Syu --noconfirm; elif command -v zypper >/dev/null; then $S zypper -n update; elif command -v apk >/dev/null; then $S apk upgrade --update; fi',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fragt die Parameter einer Vorlage ab und schickt den Befehl ins Terminal
|
||||||
|
async function runTemplate(t, session) {
|
||||||
|
const params = t.params ? t.params() : [];
|
||||||
|
let v = {};
|
||||||
|
if (params.length) {
|
||||||
|
const body = `<p style="margin-top:0;color:var(--muted)">${esc(t.desc())}</p>${params.map((el) => el.outerHTML).join('')}`;
|
||||||
|
v = await modal({ title: t.label(), body, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Run'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!v) return;
|
||||||
|
}
|
||||||
|
let command;
|
||||||
|
try { command = t.build(v); } catch (e) { return toast(e.message, 'error'); }
|
||||||
|
runSnippet({ command, autoRun: true }, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateList(session) {
|
||||||
|
const d = h(`<details class="snip-tpl"><summary>${T('Templates')}</summary><div class="tpl-items"></div></details>`);
|
||||||
|
for (const t of SNIPPET_TEMPLATES) {
|
||||||
|
const it = h(`<div class="it"><div>${esc(t.label())}</div><div class="hint">${esc(t.desc())}</div></div>`);
|
||||||
|
it.onclick = () => runTemplate(t, session);
|
||||||
|
$('.tpl-items', d).append(it);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
function runSnippet(s, session) {
|
function runSnippet(s, session) {
|
||||||
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
|
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
|
||||||
if (!t) return toast(T('No open terminal'), 'error');
|
if (!t) return toast(T('No open terminal'), 'error');
|
||||||
@@ -863,6 +949,60 @@ function viewHistory(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================ Einstellungen
|
// ============================================================ Einstellungen
|
||||||
|
// ---------- Web-Version: Konto und Benutzerverwaltung (HTTP-API des Web-Servers)
|
||||||
|
async function webApi(path, body) {
|
||||||
|
const r = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(T(j.error || r.statusText));
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
function webAccountCard() {
|
||||||
|
const card = h(`<div class="settings-card"><h3>${T('Account')}</h3><div class="acc"></div></div>`);
|
||||||
|
const draw = async () => {
|
||||||
|
const box = $('.acc', card);
|
||||||
|
let me;
|
||||||
|
try { me = await webApi('/api/me'); } catch (e) { box.textContent = e.message; return; }
|
||||||
|
box.innerHTML = `<p style="color:var(--muted);margin-top:0">${esc(T('Signed in as {name}', { name: me.name }))}${me.admin ? ` · ${esc(T('Administrator'))}` : ''}</p><div class="row" style="flex-wrap:wrap"></div>`;
|
||||||
|
const pw = h(`<button class="btn">${esc(T('Change password'))}</button>`);
|
||||||
|
pw.onclick = async () => {
|
||||||
|
const r = await modal({ title: T('Change password'), body: `<div class="field"><label>${esc(T('Current password'))}</label><input name="old" type="password"/></div><div class="field"><label>${esc(T('New password'))}</label><input name="a" type="password"/></div><div class="field"><label>${esc(T('Repeat password'))}</label><input name="b" type="password"/></div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
|
||||||
|
try { await webApi('/api/password', { old: r.old, password: r.a }); toast(T('Saved'), 'ok'); } catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
const out = h(`<button class="btn">${esc(T('Sign out'))}</button>`);
|
||||||
|
out.onclick = async () => { await webApi('/api/logout', {}).catch(() => {}); location.href = '/'; };
|
||||||
|
$('.row', box).append(pw, out);
|
||||||
|
if (!me.admin) return;
|
||||||
|
const users = await webApi('/api/users').catch(() => ({ users: [] }));
|
||||||
|
const list = h(`<div style="margin-top:16px"><h4 style="margin:0 0 8px">${esc(T('Users'))}</h4></div>`);
|
||||||
|
for (const u of users.users) {
|
||||||
|
const row = h(`<div class="lock-row"><div class="grow"><b>${esc(u.name)}</b><div class="sub">${u.admin ? esc(T('Administrator')) : esc(T('User'))}</div></div></div>`);
|
||||||
|
if (u.id !== me.id) {
|
||||||
|
const del = h(`<button class="btn ghost" title="${esc(T('Delete'))}">${ICONS.trash}</button>`);
|
||||||
|
del.onclick = async () => {
|
||||||
|
if (!(await confirmBox(T('Delete user?'), T('“{name}” and their vault will be permanently deleted.', { name: u.name })))) return;
|
||||||
|
try { await webApi('/api/users/delete', { id: u.id }); draw(); } catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
row.append(del);
|
||||||
|
}
|
||||||
|
list.append(row);
|
||||||
|
}
|
||||||
|
const add = h(`<button class="btn" style="margin-top:10px">${ICONS.plus}${esc(T('Add user'))}</button>`);
|
||||||
|
add.onclick = async () => {
|
||||||
|
const r = await modal({ title: T('Add user'), body: `<div class="field"><label>${esc(T('Username'))}</label><input name="name" autocomplete="off"/></div><div class="field"><label>${esc(T('Password'))}</label><input name="password" type="password" autocomplete="new-password"/></div><label class="check"><input type="checkbox" name="admin"/>${esc(T('Administrator'))}</label>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add user'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
try { await webApi('/api/users', r); toast(T('Saved'), 'ok'); draw(); } catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
list.append(add);
|
||||||
|
box.append(list);
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
async function viewSettings(page) {
|
async function viewSettings(page) {
|
||||||
const st = settings();
|
const st = settings();
|
||||||
page.append(h(`<div class="toolbar"><h2 style="margin:0;font-size:18px">${T('Settings')}</h2></div>`));
|
page.append(h(`<div class="toolbar"><h2 style="margin:0;font-size:18px">${T('Settings')}</h2></div>`));
|
||||||
@@ -913,6 +1053,28 @@ async function viewSettings(page) {
|
|||||||
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
|
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Support-Assistent (Ollama)
|
||||||
|
const aiCard = h(`<div class="settings-card"><h3>${T('Support assistant')}</h3><p style="color:var(--muted);margin-top:0">${T('Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.')}</p></div>`);
|
||||||
|
const aiUrl = field(T('Ollama server'), 'aiUrl', st.aiUrl || 'http://localhost:11434', { placeholder: 'http://localhost:11434' });
|
||||||
|
const aiModel = field(T('Model'), 'aiModel', st.aiModel, { type: 'select', options: [[st.aiModel || '', st.aiModel || T('– select –')]] });
|
||||||
|
const loadModels = async () => {
|
||||||
|
const sel = $('select', aiModel);
|
||||||
|
try {
|
||||||
|
const list = await api.call('ai:models', $('input', aiUrl).value.trim());
|
||||||
|
sel.innerHTML = '';
|
||||||
|
if (!list.length) sel.append(new Option(T('No models installed (ollama pull …)'), ''));
|
||||||
|
list.forEach((m) => sel.append(new Option(m, m)));
|
||||||
|
sel.value = list.includes(st.aiModel) ? st.aiModel : list[0] || '';
|
||||||
|
if (sel.value !== (settings().aiModel || '')) save({ aiModel: sel.value });
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
const aiReload = h(`<button class="btn">${ICONS.refresh}${T('Load models')}</button>`);
|
||||||
|
aiReload.onclick = loadModels;
|
||||||
|
$('input', aiUrl).onchange = () => save({ aiUrl: $('input', aiUrl).value.trim() }).then(loadModels);
|
||||||
|
$('select', aiModel).onchange = () => save({ aiModel: $('select', aiModel).value });
|
||||||
|
aiCard.append(aiUrl, aiModel, aiReload);
|
||||||
|
if (st.aiModel) loadModels();
|
||||||
|
|
||||||
const rdpCard = h(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
|
const rdpCard = h(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
|
||||||
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
|
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
|
||||||
emb.onchange = async () => {
|
emb.onchange = async () => {
|
||||||
@@ -931,11 +1093,20 @@ async function viewSettings(page) {
|
|||||||
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
|
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
|
const dockerCard = h(`<div class="settings-card"><h3>Docker Compose</h3></div>`);
|
||||||
|
const dockerFields = h('<div></div>');
|
||||||
|
dockerFields.append(
|
||||||
|
field(T('Stacks folder'), 'dockerStacksDir', st.dockerStacksDir || '/opt/stacks', { hint: T('Folder on the host in which each stack gets its own subfolder') }),
|
||||||
|
field(T('Template source'), 'dockerTemplatesUrl', st.dockerTemplatesUrl || '', { placeholder: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', hint: T('URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.') }),
|
||||||
|
);
|
||||||
|
dockerFields.addEventListener('change', () => save(formValues(dockerFields)));
|
||||||
|
dockerCard.append(dockerFields);
|
||||||
|
|
||||||
|
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
|
||||||
const btns = [
|
const btns = [
|
||||||
[T('Import ~/.ssh/config'), importSshConfig],
|
...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]),
|
||||||
[T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }],
|
[T('Export backup'), exportBackup],
|
||||||
[T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }],
|
[T('Import backup'), importBackup],
|
||||||
];
|
];
|
||||||
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
|
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
|
||||||
|
|
||||||
@@ -985,7 +1156,8 @@ async function viewSettings(page) {
|
|||||||
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
|
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
|
||||||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
|
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
||||||
c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard);
|
||||||
|
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================ App-Sperre
|
// ============================================================ App-Sperre
|
||||||
@@ -1277,13 +1449,75 @@ async function importRdm() {
|
|||||||
$('[data-view=hosts]').click();
|
$('[data-view=hosts]').click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================ Backup
|
||||||
|
const BACKUP_ERRORS = () => ({ INVALID: T('This file is not a MrTerm backup.'), WRONG_PASSWORD: T('Wrong password.'), PASSWORD_REQUIRED: T('This backup is encrypted. Please enter the password.') });
|
||||||
|
const backupError = (e) => BACKUP_ERRORS()[e.message] || e.message;
|
||||||
|
|
||||||
|
async function exportBackup() {
|
||||||
|
let err = '';
|
||||||
|
for (;;) {
|
||||||
|
const r = await modal({
|
||||||
|
title: T('Export backup'),
|
||||||
|
body: `<p style="margin-top:0;color:var(--muted)">${T('Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.')}</p>
|
||||||
|
${err ? `<p style="color:var(--red)">${esc(err)}</p>` : ''}
|
||||||
|
${field(T('Password'), 'pw', '', { type: 'password', hint: T('Recommended. Without a password, keys and passwords are stored in plain text in the file.') }).outerHTML}
|
||||||
|
${field(T('Repeat password'), 'pw2', '', { type: 'password' }).outerHTML}
|
||||||
|
${check(T('Include settings'), 'settings', true).outerHTML}`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Export'), value: 'form', cls: 'primary' }],
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
if (r.pw !== r.pw2) { err = T('The passwords do not match.'); continue; }
|
||||||
|
if (r.pw && r.pw.length < 8) { err = T('The password must be at least 8 characters long.'); continue; }
|
||||||
|
if (!r.pw && !(await confirmBox(T('Export without password?'), T('Private keys and passwords will be readable by anyone who gets the file.'), T('Export')))) continue;
|
||||||
|
try {
|
||||||
|
const content = await api.call('backup:export', { password: r.pw, settings: r.settings });
|
||||||
|
const name = `mrterm-backup-${new Date().toISOString().slice(0, 10)}${r.pw ? '-encrypted' : ''}.json`;
|
||||||
|
const p = await call('backup:saveFile', content, name);
|
||||||
|
if (p) toast(T('Exported to {path}', { path: p }), 'ok');
|
||||||
|
} catch (e) { toast(backupError(e), 'error'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBackup() {
|
||||||
|
const f = await call('import:pickFile', 'MrTerm Backup', ['json']);
|
||||||
|
if (!f) return;
|
||||||
|
let info;
|
||||||
|
try { info = await api.call('backup:inspect', f.content); } catch (e) { return toast(backupError(e), 'error'); }
|
||||||
|
const c = info.counts;
|
||||||
|
const summary = c ? T('{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs', c) : T('Contents are encrypted.');
|
||||||
|
let err = '';
|
||||||
|
for (;;) {
|
||||||
|
const r = await modal({
|
||||||
|
title: T('Import backup'),
|
||||||
|
body: `<p style="margin-top:0;color:var(--muted)">${esc(f.name)}${info.createdAt ? ' · ' + esc(new Date(info.createdAt).toLocaleString()) : ''}<br>${esc(summary)}</p>
|
||||||
|
${err ? `<p style="color:var(--red)">${esc(err)}</p>` : ''}
|
||||||
|
${info.encrypted ? field(T('Password'), 'pw', '', { type: 'password' }).outerHTML : ''}
|
||||||
|
${check(T('Import settings'), 'settings', false).outerHTML}
|
||||||
|
${check(T('Replace existing data (entries not contained in the backup are deleted)'), 'replace', false).outerHTML}`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Import'), value: 'form', cls: 'primary' }],
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
if (r.replace && !(await confirmBox(T('Replace existing data?'), T('Hosts, keys, snippets and other entries that are not in the backup will be deleted.'), T('Replace')))) continue;
|
||||||
|
try {
|
||||||
|
const n = await api.call('backup:import', f.content, r.pw || '', { replace: r.replace, settings: r.settings });
|
||||||
|
toast(T('Import complete: {hosts} hosts, {keys} keys, {snippets} snippets', n), 'ok');
|
||||||
|
reload();
|
||||||
|
return;
|
||||||
|
} catch (e) { err = backupError(e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================ Updates
|
// ============================================================ Updates
|
||||||
let updateInfo = null;
|
let updateInfo = null;
|
||||||
|
let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln
|
||||||
async function showUpdate(info) {
|
async function showUpdate(info) {
|
||||||
updateInfo = info;
|
updateInfo = info;
|
||||||
const b = $('#updateBadge');
|
const b = $('#updateBadge');
|
||||||
b.style.display = '';
|
b.style.display = '';
|
||||||
b.querySelector('span').textContent = `Update ${info.version}`;
|
b.querySelector('span').textContent = `Update ${info.version}`;
|
||||||
|
if (updateDialogOpen) return;
|
||||||
|
updateDialogOpen = true;
|
||||||
const r = await modal({
|
const r = await modal({
|
||||||
title: T('MrTerm {v} is available', { v: info.version }),
|
title: T('MrTerm {v} is available', { v: info.version }),
|
||||||
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
|
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
|
||||||
@@ -1291,7 +1525,7 @@ async function showUpdate(info) {
|
|||||||
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
|
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
|
||||||
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
|
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
|
||||||
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
|
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
|
||||||
});
|
}).finally(() => { updateDialogOpen = false; });
|
||||||
if (r === 'web') return api.call('shell:open', info.url);
|
if (r === 'web') return api.call('shell:open', info.url);
|
||||||
if (r !== true) return;
|
if (r !== true) return;
|
||||||
toast(T('Downloading update …'));
|
toast(T('Downloading update …'));
|
||||||
@@ -1335,6 +1569,7 @@ function activateTab(id) {
|
|||||||
});
|
});
|
||||||
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
renderTree();
|
renderTree();
|
||||||
|
if ($('#aiPanel').classList.contains('open')) aiUpdateCtx();
|
||||||
}
|
}
|
||||||
function closeTab(id) {
|
function closeTab(id) {
|
||||||
const i = S.tabs.findIndex((t) => t.id === id);
|
const i = S.tabs.findIndex((t) => t.id === id);
|
||||||
@@ -1359,6 +1594,7 @@ class TerminalSession {
|
|||||||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</button>`}
|
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</button>`}
|
||||||
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
|
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||||||
|
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}${T('Assistant')}</button>
|
||||||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="term-wrap"><div class="term"></div>
|
<div class="term-wrap"><div class="term"></div>
|
||||||
@@ -1442,6 +1678,14 @@ class TerminalSession {
|
|||||||
it.onclick = () => runSnippet(s, this);
|
it.onclick = () => runSnippet(s, this);
|
||||||
box.append(it);
|
box.append(it);
|
||||||
});
|
});
|
||||||
|
box.append(templateList(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzte Zeilen des Terminals als Kontext für den Assistenten
|
||||||
|
aiContext(lines = 120) {
|
||||||
|
const b = this.term.buffer.active, out = [];
|
||||||
|
for (let i = Math.max(0, b.length - lines); i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');
|
||||||
|
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFind(open) {
|
toggleFind(open) {
|
||||||
@@ -1518,8 +1762,158 @@ class TerminalSession {
|
|||||||
}
|
}
|
||||||
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
|
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
|
||||||
}
|
}
|
||||||
|
// Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
|
||||||
|
function aiMarkdown(md) {
|
||||||
|
return md.split(/```/).map((part, i) => {
|
||||||
|
if (i % 2) {
|
||||||
|
const code = part.replace(/^[\w-]*\n/, '');
|
||||||
|
return `<div class="code"><div class="acts"><button class="btn ghost sm" data-cmd="copy">${T('Copy')}</button><button class="btn ghost sm" data-cmd="paste" title="${T('Insert into terminal (not executed)')}">${T('Insert')}</button></div><pre><code>${esc(code)}</code></pre></div>`;
|
||||||
|
}
|
||||||
|
return esc(part).replace(/`([^`\n]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
function openTerminal(host) { return new TerminalSession(host); }
|
function openTerminal(host) { return new TerminalSession(host); }
|
||||||
|
|
||||||
|
// ============================================================ Support-Assistent (tabübergreifend, Chats im Tresor)
|
||||||
|
const AI = { chat: null, unsub: null, busy: false };
|
||||||
|
const aiChats = () => (S.vault.aiChats ||= []);
|
||||||
|
const activeSession = () => S.tabs.find((t) => t.id === S.active);
|
||||||
|
|
||||||
|
function aiToggle(open) {
|
||||||
|
const p = $('#aiPanel'), o = open ?? !p.classList.contains('open');
|
||||||
|
p.classList.toggle('open', o);
|
||||||
|
if (o) { aiRender(); $('textarea', p).focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontext aus dem aktiven Tab: Terminalausgabe, Docker-/Firewall-/Netzwerkansicht …
|
||||||
|
function aiContext() {
|
||||||
|
const t = activeSession();
|
||||||
|
if (!t) return '';
|
||||||
|
const host = t.host ? `, host ${t.host.username ? t.host.username + '@' : ''}${t.host.address}` : '';
|
||||||
|
const body = String((t.aiContext ? t.aiContext() : t.el.innerText) || '').trim();
|
||||||
|
return body ? `Active MrTerm tab: ${t.title} (${t.kind}${host})\n\n${body.slice(-12000)}` : '';
|
||||||
|
}
|
||||||
|
function aiUpdateCtx() {
|
||||||
|
const t = activeSession();
|
||||||
|
$('#aiPanel .ctx-name').textContent = t ? t.title : T('Vault (no context)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiRender() {
|
||||||
|
const p = $('#aiPanel'), sel = $('.chats', p), box = $('.msgs', p);
|
||||||
|
sel.innerHTML = '';
|
||||||
|
sel.append(new Option(T('New chat'), ''));
|
||||||
|
[...aiChats()].sort((a, b) => b.updatedAt - a.updatedAt).forEach((c) => sel.append(new Option(c.title, c.id)));
|
||||||
|
sel.value = AI.chat?.id && aiChats().includes(AI.chat) ? AI.chat.id : '';
|
||||||
|
box.innerHTML = '';
|
||||||
|
if (!AI.chat?.messages.length) box.append(h(`<div class="hint">${T('Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.')}</div>`));
|
||||||
|
for (const m of AI.chat?.messages || []) {
|
||||||
|
if (m.role === 'user') box.append(h(`<div class="m user">${m.tab ? `<small>${esc(m.tab)}</small>` : ''}${esc(m.content)}</div>`));
|
||||||
|
else { const d = h('<div class="m bot"></div>'); d.innerHTML = aiMarkdown(m.content); box.append(d); }
|
||||||
|
}
|
||||||
|
$('[data-ai=del]', p).disabled = !AI.chat?.id || !aiChats().includes(AI.chat);
|
||||||
|
aiUpdateCtx();
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aiSave() {
|
||||||
|
const c = AI.chat;
|
||||||
|
if (!c) return;
|
||||||
|
if (!aiChats().includes(c)) aiChats().push(c);
|
||||||
|
c.updatedAt = Date.now();
|
||||||
|
try { await call('vault:upsert', 'aiChats', c); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiSetBusy(b) {
|
||||||
|
AI.busy = b;
|
||||||
|
const btn = $('#aiPanel [type=submit]');
|
||||||
|
btn.textContent = b ? T('Stop') : T('Send');
|
||||||
|
btn.classList.toggle('danger', b);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aiAsk(text) {
|
||||||
|
const p = $('#aiPanel');
|
||||||
|
if (!p.classList.contains('open')) aiToggle(true);
|
||||||
|
if (AI.busy) return;
|
||||||
|
if (!settings().aiModel) { toast(T('Please select an Ollama model in the settings first.'), 'error'); return; }
|
||||||
|
if (!AI.chat) AI.chat = { id: uid(), title: text.replace(/\s+/g, ' ').slice(0, 60), messages: [] };
|
||||||
|
const chat = AI.chat, t = activeSession();
|
||||||
|
chat.messages.push({ role: 'user', content: text, tab: t?.title || '' });
|
||||||
|
aiRender();
|
||||||
|
const box = $('.msgs', p);
|
||||||
|
$('.hint', box)?.remove();
|
||||||
|
const out = h('<div class="m bot"><div class="spinner"></div></div>'); box.append(out);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
const reply = { role: 'assistant', content: '' };
|
||||||
|
aiSetBusy(true);
|
||||||
|
const finish = async (err) => {
|
||||||
|
AI.unsub?.(); AI.unsub = null; aiSetBusy(false);
|
||||||
|
if (err) { out.classList.add('err'); out.textContent = err; chat.messages.pop(); }
|
||||||
|
else if (reply.content) { chat.messages.push(reply); await aiSave(); if (AI.chat === chat) aiRender(); }
|
||||||
|
else out.remove();
|
||||||
|
};
|
||||||
|
AI.unsub = api.on('ai:event', (cid, type, payload) => {
|
||||||
|
if (cid !== chat.id) return;
|
||||||
|
if (type === 'data') {
|
||||||
|
reply.content += payload;
|
||||||
|
if (AI.chat !== chat) return;
|
||||||
|
const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
|
||||||
|
out.innerHTML = aiMarkdown(reply.content);
|
||||||
|
if (stick) box.scrollTop = box.scrollHeight;
|
||||||
|
} else finish(type === 'error' ? payload : null);
|
||||||
|
});
|
||||||
|
const ctx = $('[name=ctx]', p).checked ? aiContext() : '';
|
||||||
|
try { await api.call('ai:chat', chat.id, chat.messages.slice(-20).map(({ role, content }) => ({ role, content })), ctx); }
|
||||||
|
catch (e) { finish(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAi() {
|
||||||
|
const p = $('#aiPanel');
|
||||||
|
p.innerHTML = `<div class="head"><span>${ICONS.ai}${T('Support assistant')}</span>
|
||||||
|
<span><button class="btn ghost sm" data-ai="new" title="${T('New chat')}">${ICONS.plus}</button><button class="btn ghost sm" data-ai="del" title="${T('Delete chat')}">${ICONS.trash}</button><button class="btn ghost sm" data-ai-toggle title="${T('Close')}">${ICONS.close}</button></span></div>
|
||||||
|
<div class="bar"><select class="chats" title="${T('Saved chats')}"></select></div>
|
||||||
|
<div class="msgs"></div>
|
||||||
|
<form class="ask"><label class="check" title="${T('The content of the active tab is sent to the Ollama server.')}"><input type="checkbox" name="ctx" checked/>${T('Context:')} <b class="ctx-name"></b></label>
|
||||||
|
<textarea rows="3" placeholder="${T('Describe the problem … (Enter = send)')}"></textarea>
|
||||||
|
<div class="row"><button type="button" class="btn sm" data-ai="explain">${T('Explain this tab')}</button><button class="btn primary sm" type="submit">${T('Send')}</button></div></form>`;
|
||||||
|
const ta = $('textarea', p);
|
||||||
|
$('form', p).onsubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (AI.busy) { api.call('ai:stop', AI.chat?.id); return; }
|
||||||
|
if (ta.value.trim()) { aiAsk(ta.value.trim()); ta.value = ''; }
|
||||||
|
};
|
||||||
|
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!AI.busy) $('form', p).requestSubmit(); } });
|
||||||
|
$('.chats', p).onchange = (e) => {
|
||||||
|
if (AI.busy) api.call('ai:stop', AI.chat?.id);
|
||||||
|
AI.chat = aiChats().find((c) => c.id === e.target.value) || null;
|
||||||
|
aiRender();
|
||||||
|
};
|
||||||
|
p.addEventListener('click', async (e) => {
|
||||||
|
const a = e.target.closest('[data-ai]')?.dataset.ai;
|
||||||
|
if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); }
|
||||||
|
if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) {
|
||||||
|
if (AI.busy) api.call('ai:stop', AI.chat.id);
|
||||||
|
S.vault.aiChats = aiChats().filter((c) => c !== AI.chat);
|
||||||
|
await call('vault:remove', 'aiChats', AI.chat.id).catch(() => {});
|
||||||
|
AI.chat = null; aiRender();
|
||||||
|
}
|
||||||
|
if (a === 'explain') {
|
||||||
|
const t = activeSession();
|
||||||
|
aiAsk(t?.kind === 'ssh' ? T('Explain the last error in the terminal output and how to fix it.') : T('Look at the active tab, explain what you see and point out problems.'));
|
||||||
|
}
|
||||||
|
const b = e.target.closest('[data-cmd]');
|
||||||
|
if (!b) return;
|
||||||
|
const code = b.closest('.code').querySelector('code').textContent.replace(/\n+$/, '');
|
||||||
|
const t = activeSession();
|
||||||
|
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
|
||||||
|
if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); }
|
||||||
|
else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); }
|
||||||
|
});
|
||||||
|
if ($('#aiPanel').classList.contains('open')) aiRender();
|
||||||
|
if (AI.init) return;
|
||||||
|
AI.init = true;
|
||||||
|
document.addEventListener('click', (e) => { if (e.target.closest('[data-ai-toggle]')) aiToggle(); });
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================ RDP-Session (eingebettet im Tab)
|
// ============================================================ RDP-Session (eingebettet im Tab)
|
||||||
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
|
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
|
||||||
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
|
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
|
||||||
@@ -1643,7 +2037,7 @@ class FilePane {
|
|||||||
<input class="path" spellcheck="false"/>
|
<input class="path" spellcheck="false"/>
|
||||||
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||||
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
|
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
|
||||||
<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button></div>
|
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div>
|
||||||
<div class="files"></div></div>`);
|
<div class="files"></div></div>`);
|
||||||
this.el.addEventListener('click', (e) => {
|
this.el.addEventListener('click', (e) => {
|
||||||
const p = e.target.closest('[data-p]')?.dataset.p;
|
const p = e.target.closest('[data-p]')?.dataset.p;
|
||||||
@@ -1652,16 +2046,34 @@ class FilePane {
|
|||||||
if (p === 'refresh') this.go(this.cwd);
|
if (p === 'refresh') this.go(this.cwd);
|
||||||
if (p === 'mkdir') this.mkdir();
|
if (p === 'mkdir') this.mkdir();
|
||||||
if (p === 'xfer') this.transferSelected();
|
if (p === 'xfer') this.transferSelected();
|
||||||
|
if (p === 'upload') this.pickUpload();
|
||||||
});
|
});
|
||||||
$('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); });
|
$('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); });
|
||||||
const files = $('.files', this.el);
|
const files = $('.files', this.el);
|
||||||
files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } });
|
files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } });
|
||||||
// Drag & Drop zwischen den Panes
|
// Drag & Drop zwischen den Panes
|
||||||
this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } });
|
// Web-Version: Dateien aus dem Betriebssystem in den Server-Bereich ziehen = hochladen
|
||||||
|
const osFiles = (e) => WEB && [...(e.dataTransfer?.types || [])].includes('Files');
|
||||||
|
this.el.addEventListener('dragover', (e) => { if ((S.drag && S.drag.pane !== this) || osFiles(e)) { e.preventDefault(); this.el.classList.add('dragover'); } });
|
||||||
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
|
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
|
||||||
this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; });
|
this.el.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault(); this.el.classList.remove('dragover');
|
||||||
|
if (osFiles(e)) return this.uploadFiles([...e.dataTransfer.files]);
|
||||||
|
if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected();
|
||||||
|
S.drag = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
|
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
|
||||||
|
pickUpload() {
|
||||||
|
const inp = h('<input type="file" multiple style="display:none"/>');
|
||||||
|
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
|
||||||
|
document.body.append(inp);
|
||||||
|
inp.click();
|
||||||
|
}
|
||||||
|
async uploadFiles(files) {
|
||||||
|
for (const f of files) await this.sftp.webTransfer('upload', f, `${this.cwd.replace(/\/$/, '')}/${f.name}`, f.name);
|
||||||
|
this.go(this.cwd);
|
||||||
|
}
|
||||||
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
|
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
|
||||||
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
|
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
|
||||||
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
|
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
|
||||||
@@ -1756,6 +2168,14 @@ class FilePane {
|
|||||||
async transferSelected() {
|
async transferSelected() {
|
||||||
const names = [...this.sel];
|
const names = [...this.sel];
|
||||||
if (!names.length) return toast(T('Nothing selected'));
|
if (!names.length) return toast(T('Nothing selected'));
|
||||||
|
if (WEB) {
|
||||||
|
for (const n of names) {
|
||||||
|
const f = this.files.find((x) => x.name === n);
|
||||||
|
if (f?.isDir) { toast(T('Folders cannot be downloaded in the browser: {name}', { name: n })); continue; }
|
||||||
|
this.sftp.webTransfer('download', null, await this.join(this.cwd, n), n);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = this.other;
|
const target = this.other;
|
||||||
for (const n of names) {
|
for (const n of names) {
|
||||||
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
|
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
|
||||||
@@ -1771,17 +2191,18 @@ class SftpSession {
|
|||||||
this.kind = 'sftp'; this.id = uid(); this.host = host;
|
this.kind = 'sftp'; this.id = uid(); this.host = host;
|
||||||
this.title = `SFTP · ${host.label || host.address}`;
|
this.title = `SFTP · ${host.label || host.address}`;
|
||||||
this.el = h(`<div class="session"><div class="sftp"></div><div class="transfers"></div></div>`);
|
this.el = h(`<div class="session"><div class="sftp"></div><div class="transfers"></div></div>`);
|
||||||
this.local = new FilePane(this, 'local');
|
this.local = WEB ? null : new FilePane(this, 'local');
|
||||||
this.remote = new FilePane(this, 'remote');
|
this.remote = new FilePane(this, 'remote');
|
||||||
const wrap = $('.sftp', this.el);
|
const wrap = $('.sftp', this.el);
|
||||||
wrap.append(this.local.el, h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
|
if (this.local) wrap.append(this.local.el);
|
||||||
|
wrap.append(h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
|
||||||
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
|
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
|
||||||
this.xfers = new Map();
|
this.xfers = new Map();
|
||||||
addTab(this);
|
addTab(this);
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
async open() {
|
async open() {
|
||||||
this.local.init(await api.call('local:home'));
|
if (this.local) this.local.init(await api.call('local:home'));
|
||||||
try {
|
try {
|
||||||
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
|
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
|
||||||
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
|
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
|
||||||
@@ -1810,6 +2231,32 @@ class SftpSession {
|
|||||||
setTimeout(() => row.remove(), 10000);
|
setTimeout(() => row.remove(), 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Web-Version: Upload per HTTP (mit Fortschritt), Download als Browser-Download
|
||||||
|
webTransfer(dir, file, remotePath, name) {
|
||||||
|
const q = `sid=${encodeURIComponent(this.id)}&path=${encodeURIComponent(remotePath)}`;
|
||||||
|
if (dir === 'download') {
|
||||||
|
const a = h(`<a href="/web/download?${q}" download="${esc(name)}" style="display:none"></a>`);
|
||||||
|
document.body.append(a); a.click(); a.remove();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const tid = uid();
|
||||||
|
const row = h(`<div class="transfer"><span>${ICONS.upload}</span><span class="nm">${esc(name)}</span><span class="pct">0%</span><div class="bar"><i></i></div></div>`);
|
||||||
|
$('.transfers', this.el).prepend(row);
|
||||||
|
this.xfers.set(tid, row);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const x = new XMLHttpRequest();
|
||||||
|
x.open('POST', `/web/upload?${q}`);
|
||||||
|
x.setRequestHeader('X-MrTerm', '1');
|
||||||
|
x.upload.onprogress = (e) => this.progress(tid, e.loaded, e.total);
|
||||||
|
x.onload = () => {
|
||||||
|
if (x.status < 300) { row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done'); setTimeout(() => row.remove(), 5000); }
|
||||||
|
else { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); const m = (() => { try { return JSON.parse(x.responseText).error; } catch { return x.statusText; } })(); toast(`${name}: ${m}`, 'error'); setTimeout(() => row.remove(), 10000); }
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
x.onerror = () => { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); resolve(); };
|
||||||
|
x.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
progress(tid, done, total) {
|
progress(tid, done, total) {
|
||||||
const row = this.xfers.get(tid); if (!row) return;
|
const row = this.xfers.get(tid); if (!row) return;
|
||||||
const p = total ? Math.round((done / total) * 100) : 0;
|
const p = total ? Math.round((done / total) * 100) : 0;
|
||||||
@@ -1831,14 +2278,26 @@ class DockerSession {
|
|||||||
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
|
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
|
||||||
this.el = h(`<div class="session docker">
|
this.el = h(`<div class="session docker">
|
||||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||||
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search containers …'))}"/></label>
|
<div class="seg sm"><button class="active" data-v="containers">${T('Containers')}</button><button data-v="stacks">Stacks</button></div>
|
||||||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
|
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search …'))}"/></label>
|
||||||
|
<label class="check stopped-opt" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
|
||||||
|
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
|
||||||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
||||||
this.body = $('.docker-body', this.el);
|
this.body = $('.docker-body', this.el);
|
||||||
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
|
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
|
||||||
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); });
|
this.view = 'containers'; this.stackInfo = null; this.editing = null;
|
||||||
|
this.el.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-a=refresh]')) this.refresh(true);
|
||||||
|
const v = e.target.closest('.seg [data-v]')?.dataset.v;
|
||||||
|
if (v && v !== this.view) {
|
||||||
|
this.view = v; this.editing = null;
|
||||||
|
$$('.seg [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v));
|
||||||
|
$('.stopped-opt', this.el).style.display = v === 'containers' ? '' : 'none';
|
||||||
|
if (v === 'stacks') this.loadStacks(true); else this.draw();
|
||||||
|
}
|
||||||
|
});
|
||||||
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
|
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
|
||||||
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||||
addTab(this);
|
addTab(this);
|
||||||
@@ -1873,7 +2332,11 @@ class DockerSession {
|
|||||||
async refresh(withStats) {
|
async refresh(withStats) {
|
||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); }
|
try {
|
||||||
|
this.apply(await api.call('docker:list', this.id));
|
||||||
|
if (withStats) this.loadStats();
|
||||||
|
if (this.view === 'stacks' && !this.editing) await this.loadStacks();
|
||||||
|
} catch (e) { if (withStats) toast(e.message, 'error'); }
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1882,6 +2345,7 @@ class DockerSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
draw() {
|
draw() {
|
||||||
|
if (this.view === 'stacks') return this.editing ? null : this.drawStacks();
|
||||||
const running = (c) => c.state === 'running';
|
const running = (c) => c.state === 'running';
|
||||||
const list = this.containers
|
const list = this.containers
|
||||||
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
|
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
|
||||||
@@ -1934,6 +2398,212 @@ class DockerSession {
|
|||||||
await this.refresh(true);
|
await this.refresh(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Compose-Stacks
|
||||||
|
get stacksDir() { return settings().dockerStacksDir || '/opt/stacks'; }
|
||||||
|
|
||||||
|
async loadStacks(showErrors) {
|
||||||
|
try { this.stackInfo = await api.call('docker:stacks', this.id, this.stacksDir); this.stackError = ''; }
|
||||||
|
catch (e) { this.stackError = e.message; if (showErrors) this.stackInfo = null; }
|
||||||
|
if (this.view === 'stacks' && !this.editing) this.drawStacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStacks() {
|
||||||
|
const info = this.stackInfo;
|
||||||
|
if (!info) {
|
||||||
|
this.body.innerHTML = `<div class="pane-empty">${this.stackError ? `<div style="color:var(--red);max-width:520px;text-align:center">${esc(this.stackError)}</div>` : '<span class="spinner sm"></span>'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = info.stacks.filter((x) => !this.filter || [x.name, x.file, typeof x.git === 'string' ? x.git : ''].some((f) => String(f).toLowerCase().includes(this.filter)));
|
||||||
|
const scroll = this.body.scrollTop;
|
||||||
|
const needSetup = !info.exists || !info.writable;
|
||||||
|
this.body.innerHTML = `<div class="stack-bar">
|
||||||
|
<span class="muted">${T('Folder')}: <code>${esc(info.dir)}</code></span>
|
||||||
|
<button class="btn ghost sm" data-s="dir" title="${T('Change folder')}">${ICONS.edit}</button>
|
||||||
|
${needSetup ? `<button class="btn sm" data-s="setup" title="${esc(T('Creates the folder with sudo and makes it writable for {user}.', { user: this.host.username || '$USER' }))}">${ICONS.newdir}${T('Set up folder')}</button>` : ''}
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="btn sm" data-s="templates">${ICONS.download}${T('Templates')}</button>
|
||||||
|
<button class="btn sm" data-s="git">${ICONS.code}${T('From Git')}</button>
|
||||||
|
<button class="btn primary sm" data-s="new">${ICONS.plus}${T('New stack')}</button>
|
||||||
|
</div>
|
||||||
|
${list.length ? `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Compose file')}</th><th></th></tr></thead><tbody>
|
||||||
|
${list.map((x, i) => {
|
||||||
|
const on = /running/.test(x.status);
|
||||||
|
return `<tr data-i="${i}"><td><span class="dot ${on ? 'on' : x.status ? '' : 'none'}"></span></td>
|
||||||
|
<td class="name"><div><b>${esc(x.name)}${x.git ? ` <span class="badge" title="${esc(typeof x.git === 'string' ? x.git : 'git')}">git</span>` : ''}${x.managed ? '' : ` <span class="badge" title="${esc(T('Found via docker compose ls, outside the stacks folder'))}">${T('external')}</span>`}</b></div></td>
|
||||||
|
<td class="muted">${esc(x.status || T('not deployed'))}</td><td class="muted ports" title="${esc(x.file)}">${esc(x.file || T('no compose file found'))}</td>
|
||||||
|
<td class="acts">${x.file ? `
|
||||||
|
<button class="btn ghost sm" data-k="edit" title="${T('Edit')}">${ICONS.edit}</button>
|
||||||
|
<button class="btn ghost sm" data-k="up" title="${T('Deploy (up -d)')}">${ICONS.play}</button>
|
||||||
|
${on ? `<button class="btn ghost sm" data-k="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-k="stop" title="${T('Stop')}">${ICONS.stop}</button>` : ''}
|
||||||
|
<button class="btn ghost sm" data-k="logs" title="${T('Logs')}">${ICONS.logs}</button>` : ''}
|
||||||
|
${x.managed ? `<button class="btn ghost sm" data-k="delete" title="${T('Delete')}">${ICONS.trash}</button>` : ''}
|
||||||
|
</td></tr>`;
|
||||||
|
}).join('')}</tbody></table>`
|
||||||
|
: `<div class="pane-empty" style="height:auto;padding:60px 0"><div style="color:var(--muted)">${this.filter ? T('No matches.') : T('No stacks yet. Create one, clone it from Git or start from a template.')}</div></div>`}`;
|
||||||
|
this.body.scrollTop = scroll;
|
||||||
|
$$('[data-s]', this.body).forEach((b) => { b.onclick = () => this.stackBar(b.dataset.s); });
|
||||||
|
$$('tr[data-i]', this.body).forEach((tr) => {
|
||||||
|
const x = list[+tr.dataset.i];
|
||||||
|
tr.onclick = (e) => { const k = e.target.closest('[data-k]')?.dataset.k; if (k) this.stackAct(k, x); };
|
||||||
|
tr.ondblclick = (e) => { if (!e.target.closest('[data-k]') && x.file) this.stackAct('edit', x); };
|
||||||
|
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||||||
|
...(x.file ? [
|
||||||
|
{ label: T('Edit'), icon: 'edit', run: () => this.stackAct('edit', x) },
|
||||||
|
{ label: T('Deploy (up -d)'), icon: 'play', run: () => this.stackAct('up', x) },
|
||||||
|
{ label: T('Update images (pull + up)'), icon: 'download', run: () => this.stackAct('pull', x) },
|
||||||
|
...(x.git ? [{ label: T('Git pull + redeploy'), icon: 'code', run: () => this.stackAct('gitpull', x) }] : []),
|
||||||
|
{ label: T('Restart'), icon: 'refresh', run: () => this.stackAct('restart', x) },
|
||||||
|
{ label: T('Stop'), icon: 'stop', run: () => this.stackAct('stop', x) },
|
||||||
|
{ label: T('Down (remove containers)'), icon: 'close', run: () => this.stackAct('down', x) },
|
||||||
|
{ label: T('Logs'), icon: 'logs', run: () => this.stackAct('logs', x) },
|
||||||
|
'-', { label: T('Copy path'), icon: 'copy', run: () => api.call('clipboard:write', x.file) },
|
||||||
|
] : []),
|
||||||
|
...(x.managed ? ['-', { label: T('Delete'), icon: 'trash', danger: true, run: () => this.stackAct('delete', x) }] : []),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehl in einem Terminal-Tab ausführen (Live-Ausgabe, sudo/git-Abfragen möglich)
|
||||||
|
async stackTerminal(action, st, opts, label) {
|
||||||
|
const cmd = await call('docker:stackCommand', this.id, action, st, opts);
|
||||||
|
openTerminal({ ...this.host, execCommand: cmd, label: `${label} · ${st.name || st.dir}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stackBar(a) {
|
||||||
|
if (a === 'new') return this.editStack({ name: '', compose: 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n', env: '' });
|
||||||
|
if (a === 'dir') {
|
||||||
|
const d = await promptBox(T('Stacks folder'), T('Folder on the host in which each stack gets its own subfolder'), this.stacksDir);
|
||||||
|
if (!d || !d.startsWith('/')) return;
|
||||||
|
await call('vault:settings', { dockerStacksDir: d.replace(/\/+$/, '') || '/' });
|
||||||
|
S.vault.settings.dockerStacksDir = d.replace(/\/+$/, '') || '/';
|
||||||
|
return this.loadStacks(true);
|
||||||
|
}
|
||||||
|
if (a === 'setup') return this.stackTerminal('setup', { dir: this.stacksDir }, {}, T('Set up folder'));
|
||||||
|
if (a === 'git') {
|
||||||
|
const r = await modal({
|
||||||
|
title: T('Stack from Git'),
|
||||||
|
body: `${field(T('Repository URL'), 'url', '', { placeholder: 'https://github.com/user/repo.git' }).outerHTML}
|
||||||
|
${row(field(T('Stack name'), 'name', '', { placeholder: 'my-app' }), field(T('Branch (optional)'), 'branch', '')).outerHTML}
|
||||||
|
${field(T('Compose file in the repository (optional)'), 'path', '', { placeholder: 'docker-compose.yml', hint: T('If empty, the first compose file found is used.') }).outerHTML}
|
||||||
|
${check(T('Deploy right after cloning'), 'deploy', true).outerHTML}
|
||||||
|
<div class="hint" style="color:var(--muted);font-size:12px">${T('The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.')}</div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Clone'), value: 'form', cls: 'primary' }],
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
const name = (r.name || r.url.split('/').pop().replace(/\.git$/, '')).toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
||||||
|
return this.stackTerminal('clone', { name, stacksDir: this.stacksDir }, { url: r.url.trim(), branch: r.branch.trim(), path: r.path.trim(), deploy: r.deploy }, 'git clone').catch(() => {});
|
||||||
|
}
|
||||||
|
if (a === 'templates') return this.pickTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pickTemplate() {
|
||||||
|
const tplUrl = settings().dockerTemplatesUrl || '';
|
||||||
|
const done = modal({
|
||||||
|
title: T('App templates'),
|
||||||
|
body: `<div class="tpl-top"><label class="search sm" style="flex:1">${ICONS.search}<input class="tpl-q" placeholder="${esc(T('Search templates …'))}"/></label><select class="tpl-cat"></select></div>
|
||||||
|
<div class="tpl-list"><div class="pane-empty" style="padding:40px 0"><span class="spinner sm"></span></div></div>
|
||||||
|
<div class="hint" style="color:var(--faint);font-size:11px;margin-top:6px">${T('Source: {url} (Portainer template format, changeable in the settings).', { url: esc(tplUrl || 'Lissy93/portainer-templates') })}</div>`,
|
||||||
|
buttons: [{ label: T('Close'), value: null, cls: 'ghost' }],
|
||||||
|
noEnter: true,
|
||||||
|
});
|
||||||
|
const root = $$('#modalRoot .modal-bg').pop();
|
||||||
|
$('.modal', root).classList.add('wide');
|
||||||
|
let list = [];
|
||||||
|
try { list = await api.call('docker:templates', tplUrl); } catch (e) { $('.tpl-list', root).innerHTML = `<div style="color:var(--red);padding:20px">${esc(e.message)}</div>`; return done; }
|
||||||
|
const cats = [...new Set(list.flatMap((t) => t.categories))].sort();
|
||||||
|
$('.tpl-cat', root).innerHTML = `<option value="">${T('All categories')}</option>${cats.map((c) => `<option>${esc(c)}</option>`).join('')}`;
|
||||||
|
const render = () => {
|
||||||
|
const q = $('.tpl-q', root).value.toLowerCase(), cat = $('.tpl-cat', root).value;
|
||||||
|
const f = list.filter((t) => (!cat || t.categories.includes(cat)) && (!q || `${t.title} ${t.description}`.toLowerCase().includes(q))).slice(0, 200);
|
||||||
|
$('.tpl-list', root).innerHTML = f.map((t) => `<div class="tpl-it" data-i="${t.i}"><div><b>${esc(t.title)}</b> <span class="badge">${t.kind === 'stack' ? 'Stack' : 'Container'}</span></div><div class="d">${esc(t.description)}</div></div>`).join('') || `<div style="color:var(--muted);padding:20px">${T('No matches.')}</div>`;
|
||||||
|
};
|
||||||
|
$('.tpl-q', root).oninput = render;
|
||||||
|
$('.tpl-cat', root).onchange = render;
|
||||||
|
$('.tpl-list', root).onclick = async (e) => {
|
||||||
|
const it = e.target.closest('.tpl-it');
|
||||||
|
if (!it) return;
|
||||||
|
it.style.opacity = '.5';
|
||||||
|
try {
|
||||||
|
const t = await api.call('docker:template', +it.dataset.i, tplUrl);
|
||||||
|
$('.mfoot .btn', root).click();
|
||||||
|
this.editStack({ name: t.name, compose: t.compose, env: t.env, note: t.note });
|
||||||
|
} catch (err) { it.style.opacity = ''; toast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
$('.tpl-q', root).focus();
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stackAct(k, x) {
|
||||||
|
try {
|
||||||
|
if (k === 'edit') {
|
||||||
|
const r = await call('docker:stackRead', this.id, x.file);
|
||||||
|
return this.editStack({ ...x, ...r });
|
||||||
|
}
|
||||||
|
if (k === 'delete') {
|
||||||
|
const r = await modal({
|
||||||
|
title: T('Delete stack?'),
|
||||||
|
text: T('The stack “{name}” is stopped (down) and its folder {dir} is deleted.', { name: x.name, dir: x.dir }),
|
||||||
|
body: check(T('Also delete named volumes (data!)'), 'volumes', false).outerHTML,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Delete'), value: 'form', cls: 'danger' }],
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
return await this.stackTerminal('delete', x, { volumes: r.volumes }, T('Delete'));
|
||||||
|
}
|
||||||
|
if (k === 'down' && !(await confirmBox(T('Remove containers?'), T('docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.', { name: x.name }), T('Down')))) return;
|
||||||
|
const labels = { up: 'up', pull: 'pull', gitpull: 'git pull', restart: 'restart', stop: 'stop', down: 'down', logs: T('Logs') };
|
||||||
|
await this.stackTerminal(k, x, {}, labels[k] || k);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
aiContext() {
|
||||||
|
if (!this.editing || !$('.se-compose', this.body)) return this.body.innerText;
|
||||||
|
const env = $('.se-env', this.body).value.replace(/^(\s*[^#=\s]+\s*=).*$/gm, '$1***');
|
||||||
|
return `Editing compose stack "${this.editing.name || ''}"\n\ncompose.yaml:\n${$('.se-compose', this.body).value}\n\n.env (values hidden):\n${env}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor für compose.yaml und .env; st.file fehlt bei neuen Stacks
|
||||||
|
editStack(st) {
|
||||||
|
this.editing = st;
|
||||||
|
const isNew = !st.file;
|
||||||
|
this.body.innerHTML = `<div class="stack-editor">
|
||||||
|
<div class="se-head">
|
||||||
|
<button class="btn ghost sm" data-e="back">← ${T('Back')}</button>
|
||||||
|
${isNew ? `<input class="se-name" placeholder="${esc(T('Stack name'))}" value="${esc(st.name || '')}" spellcheck="false"/>` : `<b>${esc(st.name)}</b><span class="muted mono" style="font-size:12px">${esc(st.file)}</span>`}
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="btn sm" data-e="save">${T('Save')}</button>
|
||||||
|
<button class="btn primary sm" data-e="deploy">${ICONS.play}${T('Save & deploy')}</button>
|
||||||
|
</div>
|
||||||
|
${st.note ? `<div class="se-note">${esc(st.note)}</div>` : ''}
|
||||||
|
<div class="se-grid">
|
||||||
|
<div class="se-col"><label>${isNew ? 'compose.yaml' : esc(st.file.split('/').pop())}</label><textarea class="se-compose mono" spellcheck="false"></textarea></div>
|
||||||
|
<div class="se-col env"><label>.env <span class="muted">${T('(variables, used as ${NAME} in the compose file)')}</span></label><textarea class="se-env mono" spellcheck="false" placeholder="KEY=value"></textarea></div>
|
||||||
|
</div></div>`;
|
||||||
|
const ta = $('.se-compose', this.body), env = $('.se-env', this.body);
|
||||||
|
ta.value = st.compose || ''; env.value = st.env || '';
|
||||||
|
for (const t of [ta, env]) t.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); t.setRangeText(' ', t.selectionStart, t.selectionEnd, 'end'); }
|
||||||
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(false); }
|
||||||
|
});
|
||||||
|
const save = async (deploy) => {
|
||||||
|
const name = isNew ? $('.se-name', this.body).value.trim() : st.name;
|
||||||
|
const btns = $$('[data-e]', this.body); btns.forEach((b) => { b.disabled = true; });
|
||||||
|
try {
|
||||||
|
const r = await call('docker:stackSave', this.id, { stacksDir: this.stacksDir, name, file: st.file, compose: ta.value, env: env.value });
|
||||||
|
if (r.warning) toast(T('Saved, but the compose file has an error: {msg}', { msg: r.warning }), 'error');
|
||||||
|
else toast(T('Saved'), 'ok');
|
||||||
|
Object.assign(st, { file: r.file, name, dir: r.file.replace(/\/[^/]*$/, ''), managed: true, compose: ta.value, env: env.value });
|
||||||
|
if (deploy && !r.warning) await this.stackTerminal('up', st, {}, 'up');
|
||||||
|
if (isNew || deploy) { this.editing = null; await this.loadStacks(true); return; }
|
||||||
|
} catch {}
|
||||||
|
btns.forEach((b) => { b.disabled = false; });
|
||||||
|
};
|
||||||
|
$('[data-e=back]', this.body).onclick = () => { this.editing = null; this.loadStacks(true); };
|
||||||
|
$('[data-e=save]', this.body).onclick = () => save(false);
|
||||||
|
$('[data-e=deploy]', this.body).onclick = () => save(true);
|
||||||
|
(isNew && !st.name ? $('.se-name', this.body) : ta).focus();
|
||||||
|
}
|
||||||
|
|
||||||
onShow() { if (this.containers.length) this.refresh(); }
|
onShow() { if (this.containers.length) this.refresh(); }
|
||||||
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
||||||
}
|
}
|
||||||
@@ -1949,6 +2619,7 @@ class FirewallSession {
|
|||||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||||
<div class="seg sm backends"></div>
|
<div class="seg sm backends"></div>
|
||||||
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
|
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
|
||||||
|
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
|
||||||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||||
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
|
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2142,6 +2813,7 @@ class NetworkSession {
|
|||||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
|
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
|
||||||
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
|
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
|
||||||
|
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
|
||||||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||||
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
|
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2466,6 +3138,19 @@ document.addEventListener('keydown', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Web-Version: Hostschlüssel bestätigen (in Electron zeigt der Hauptprozess dafür einen Systemdialog)
|
||||||
|
api.on('hostkey:request', async (req) => {
|
||||||
|
const changed = !!req.previous;
|
||||||
|
const r = await modal({
|
||||||
|
title: changed ? T('Host key has changed!') : T('Unknown host'),
|
||||||
|
body: `<p style="margin-top:0">${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}</p>
|
||||||
|
<div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.fingerprint)}</div>
|
||||||
|
${changed ? `<p>${esc(T('Previously stored:'))}</p><div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.previous)}</div>` : ''}`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }],
|
||||||
|
noEnter: changed,
|
||||||
|
});
|
||||||
|
api.replySecret(req.reqId, r === true);
|
||||||
|
});
|
||||||
api.on('secret:request', async (req) => {
|
api.on('secret:request', async (req) => {
|
||||||
const r = await modal({
|
const r = await modal({
|
||||||
title: req.title || T('Authentication'),
|
title: req.title || T('Authentication'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" />
|
||||||
<title>MrTerm</title>
|
<title>MrTerm</title>
|
||||||
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" />
|
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" />
|
||||||
|
<link rel="stylesheet" href="app-themes.css" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
||||||
<div class="drag-fill"></div>
|
<div class="drag-fill"></div>
|
||||||
|
<button id="aiBtn" class="icon-btn" data-ai-toggle title="Support assistant (Ollama)" data-i18n-title="Support assistant (Ollama)"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg></button>
|
||||||
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
|
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
|
||||||
<div class="win-ctrls">
|
<div class="win-ctrls">
|
||||||
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
|
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
<div id="sessions"></div>
|
<div id="sessions"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<aside id="aiPanel" class="ai-panel"></aside>
|
||||||
<aside id="drawer" class="drawer"></aside>
|
<aside id="drawer" class="drawer"></aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,71 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #13151c;
|
|
||||||
--bg-2: #181b24;
|
|
||||||
--panel: #1c1f2a;
|
|
||||||
--card: #212533;
|
|
||||||
--card-hover: #282d3d;
|
|
||||||
--border: #2b3040;
|
|
||||||
--text: #e6e8ef;
|
|
||||||
--muted: #8b91a5;
|
|
||||||
--faint: #5d6377;
|
|
||||||
--accent: #6e7bff;
|
|
||||||
--accent-2: #8b95ff;
|
|
||||||
--green: #3ecf8e;
|
|
||||||
--red: #ff5f6d;
|
|
||||||
--orange: #ffb454;
|
|
||||||
--accent-3: #b36bff;
|
|
||||||
--chrome: #0f1117;
|
|
||||||
--chrome-line: #1f2330;
|
|
||||||
--hover: #1a1d27;
|
|
||||||
--nav-active: #1f2233;
|
|
||||||
--icon-bg: #2a2f45;
|
|
||||||
--folder: #6ea8ff;
|
|
||||||
--row-line: #1c1f29;
|
|
||||||
--overlay: #13151ce6;
|
|
||||||
--tint: 255 255 255;
|
|
||||||
--radius: 10px;
|
|
||||||
--titlebar: 42px;
|
--titlebar: 42px;
|
||||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
/* ---------- App-Designs ---------- */
|
[data-theme='light'] .card, [data-theme='xp'] .card, [data-theme='win11'] .card, [data-theme='macos'] .card { border-color: var(--border); }
|
||||||
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
|
[data-theme='light'] .avatar .proto, [data-theme='xp'] .avatar .proto, [data-theme='win11'] .avatar .proto, [data-theme='macos'] .avatar .proto { background: var(--panel); }
|
||||||
[data-theme='navy'] {
|
[data-theme='light'] ::-webkit-scrollbar-thumb, [data-theme='xp'] ::-webkit-scrollbar-thumb, [data-theme='win11'] ::-webkit-scrollbar-thumb, [data-theme='macos'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
|
||||||
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
|
|
||||||
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
|
|
||||||
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
|
|
||||||
}
|
|
||||||
[data-theme='nord'] {
|
|
||||||
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
|
|
||||||
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
|
|
||||||
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
|
|
||||||
}
|
|
||||||
[data-theme='dracula'] {
|
|
||||||
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
|
|
||||||
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
|
|
||||||
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
|
|
||||||
}
|
|
||||||
[data-theme='mocha'] {
|
|
||||||
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
|
|
||||||
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
|
|
||||||
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
|
|
||||||
}
|
|
||||||
[data-theme='forest'] {
|
|
||||||
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
|
|
||||||
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
|
|
||||||
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
|
|
||||||
}
|
|
||||||
[data-theme='light'] {
|
|
||||||
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
|
|
||||||
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
|
|
||||||
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
|
|
||||||
--tint: 0 0 0;
|
|
||||||
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
|
||||||
}
|
|
||||||
[data-theme='light'] .card { border-color: var(--border); }
|
|
||||||
[data-theme='light'] .avatar .proto { background: var(--panel); }
|
|
||||||
[data-theme='light'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
||||||
button { font: inherit; color: inherit; cursor: pointer; }
|
button { font: inherit; color: inherit; cursor: pointer; }
|
||||||
@@ -154,7 +95,7 @@ body.in-session #sidebar { display: none; }
|
|||||||
.search svg { color: var(--faint); }
|
.search svg { color: var(--faint); }
|
||||||
.btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); font-weight: 600; white-space: nowrap; }
|
.btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); font-weight: 600; white-space: nowrap; }
|
||||||
.btn:hover { background: var(--card-hover); }
|
.btn:hover { background: var(--card-hover); }
|
||||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||||
.btn.primary:hover { background: var(--accent-2); }
|
.btn.primary:hover { background: var(--accent-2); }
|
||||||
.btn.danger { color: var(--red); }
|
.btn.danger { color: var(--red); }
|
||||||
.btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
.btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
||||||
@@ -221,7 +162,7 @@ body.in-session #sidebar { display: none; }
|
|||||||
.form h4 { margin: 20px 0 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent-2); }
|
.form h4 { margin: 20px 0 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent-2); }
|
||||||
.seg { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; margin-bottom: 14px; }
|
.seg { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; margin-bottom: 14px; }
|
||||||
.seg button { flex: 1; border: none; background: none; padding: 7px; border-radius: 6px; color: var(--muted); font-weight: 600; }
|
.seg button { flex: 1; border: none; background: none; padding: 7px; border-radius: 6px; color: var(--muted); font-weight: 600; }
|
||||||
.seg button.active { background: var(--accent); color: white; }
|
.seg button.active { background: var(--accent); color: var(--on-accent); }
|
||||||
.check { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; cursor: pointer; }
|
.check { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; cursor: pointer; }
|
||||||
.check input { accent-color: var(--accent); width: 16px; height: 16px; }
|
.check input { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||||
.colors { display: flex; gap: 8px; flex-wrap: wrap; }
|
.colors { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
@@ -250,7 +191,38 @@ body.in-session #sidebar { display: none; }
|
|||||||
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
||||||
.snip-panel .it:hover { background: var(--card-hover); }
|
.snip-panel .it:hover { background: var(--card-hover); }
|
||||||
|
.snip-tpl { margin-top: 10px; }
|
||||||
|
.snip-tpl summary { cursor: pointer; color: var(--muted); font-weight: 600; padding: 6px 2px; user-select: none; }
|
||||||
|
.snip-tpl .tpl-items { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
||||||
|
.snip-tpl .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
||||||
|
.snip-tpl .it:hover { background: var(--card-hover); }
|
||||||
|
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
||||||
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
||||||
|
/* Support-Assistent (rechts neben allen Tabs) */
|
||||||
|
.ai-panel { width: 400px; border-left: 1px solid var(--border); background: var(--bg-2); display: none; flex-direction: column; flex: none; min-height: 0; }
|
||||||
|
.ai-panel.open { display: flex; }
|
||||||
|
.ai-panel .head { padding: 8px 12px; font-weight: 600; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.ai-panel .head > span:first-child { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.ai-panel .head svg { width: 16px; height: 16px; }
|
||||||
|
.ai-panel .bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.ai-panel .bar select { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; color: inherit; }
|
||||||
|
.ai-panel .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; }
|
||||||
|
.ai-panel .msgs .hint { color: var(--muted); padding: 8px; }
|
||||||
|
.ai-panel .m { border-radius: 8px; padding: 8px 10px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
|
.ai-panel .m.user { background: var(--accent); color: #fff; align-self: flex-end; max-width: 90%; white-space: pre-wrap; }
|
||||||
|
.ai-panel .m.user small { display: block; opacity: .75; font-size: 10px; margin-bottom: 2px; }
|
||||||
|
.ai-panel .m.bot { background: var(--card); }
|
||||||
|
.ai-panel .m.err { color: var(--red, #ff6b6b); }
|
||||||
|
.ai-panel .m code { font-family: var(--mono, monospace); font-size: 12px; background: var(--bg); padding: 1px 4px; border-radius: 4px; }
|
||||||
|
.ai-panel .code { margin: 6px 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
.ai-panel .code .acts { display: flex; justify-content: flex-end; gap: 2px; padding: 2px; border-bottom: 1px solid var(--border); }
|
||||||
|
.ai-panel .code pre { margin: 0; padding: 8px; overflow: auto; }
|
||||||
|
.ai-panel .code pre code { background: none; padding: 0; }
|
||||||
|
.ai-panel .ask { border-top: 1px solid var(--border); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ai-panel .ask .check { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||||
|
.ai-panel .ask textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; resize: vertical; color: inherit; font: inherit; outline: none; }
|
||||||
|
.ai-panel .ask .row { justify-content: space-between; }
|
||||||
|
.ai-panel .spinner { width: 16px; height: 16px; }
|
||||||
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
|
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
|
||||||
.findbar.open { display: flex; }
|
.findbar.open { display: flex; }
|
||||||
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
||||||
@@ -389,6 +361,29 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
|
|
||||||
/* Firewall */
|
/* Firewall */
|
||||||
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.session.docker .sbar .seg { margin: 0 6px 0 0; flex: none; }
|
||||||
|
.docker-table .dot.none { background: transparent; border: 1px solid var(--faint); }
|
||||||
|
.docker-table .badge, .tpl-it .badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--icon-bg); color: var(--muted); margin-left: 4px; vertical-align: middle; }
|
||||||
|
.stack-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; position: sticky; top: 0; background: var(--bg); z-index: 2; }
|
||||||
|
.stack-bar .muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.stack-bar + .docker-table th { top: 45px; }
|
||||||
|
.stack-editor { display: flex; flex-direction: column; height: 100%; }
|
||||||
|
.stack-editor .se-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
.stack-editor .se-name { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 5px 8px; width: 200px; font: inherit; }
|
||||||
|
.stack-editor .se-note { padding: 8px 12px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--border); max-height: 80px; overflow: auto; }
|
||||||
|
.stack-editor .se-grid { flex: 1; display: grid; grid-template-columns: 2fr 1fr; gap: 10px; padding: 10px; min-height: 0; }
|
||||||
|
.stack-editor .se-col { display: flex; flex-direction: column; min-height: 0; gap: 6px; }
|
||||||
|
.stack-editor .se-col label { font-size: 12px; color: var(--muted); font-weight: 600; }
|
||||||
|
.stack-editor textarea { flex: 1; resize: none; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 13px; line-height: 1.45; tab-size: 2; white-space: pre; min-height: 200px; }
|
||||||
|
@media (max-width: 800px) { .stack-editor .se-grid { grid-template-columns: 1fr; } }
|
||||||
|
.modal.wide { width: min(760px, 94vw); max-width: none; }
|
||||||
|
.tpl-top { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.tpl-top .search { margin: 0; }
|
||||||
|
.tpl-top select { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 0 8px; }
|
||||||
|
.tpl-list { max-height: 55vh; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.tpl-it { background: var(--card); border-radius: 8px; padding: 9px 11px; cursor: pointer; }
|
||||||
|
.tpl-it:hover { background: var(--card-hover); }
|
||||||
|
.tpl-it .d { color: var(--muted); font-size: 12px; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
.fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
.fw-def { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin-left: 8px; }
|
.fw-def { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin-left: 8px; }
|
||||||
.fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }
|
.fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }
|
||||||
@@ -443,3 +438,28 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
.fw-hint b { color: var(--text); }
|
.fw-hint b { color: var(--text); }
|
||||||
.fw-hint p { margin: 4px 0 8px; color: var(--muted); }
|
.fw-hint p { margin: 4px 0 8px; color: var(--muted); }
|
||||||
.fw-hint pre, .fw-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 0 0 10px; color: var(--text); white-space: pre-wrap; user-select: text; }
|
.fw-hint pre, .fw-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 0 0 10px; color: var(--text); white-space: pre-wrap; user-select: text; }
|
||||||
|
|
||||||
|
/* Design-Extras: XP-Titelleiste, Cyberpunk-Neon */
|
||||||
|
[data-theme='xp'] #titlebar { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; }
|
||||||
|
[data-theme='xp'] #titlebar .brand, [data-theme='xp'] #titlebar .win-ctrls button, [data-theme='xp'] #titlebar .tab:not(.active) { color: #fff; }
|
||||||
|
[data-theme='xp'] #titlebar .tab.active { color: var(--text); }
|
||||||
|
[data-theme='xp'] .btn.primary { background: linear-gradient(#3d86e8, #2159b8); border-color: #1b4a9a; }
|
||||||
|
[data-theme='cyberpunk'] .btn.primary { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 10px) 100%, 0 100%); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
[data-theme='cyberpunk'] #titlebar { border-bottom: 1px solid #fcee0a55; }
|
||||||
|
[data-theme='cyberpunk'] .card:hover { box-shadow: inset 2px 0 0 var(--accent-3); }
|
||||||
|
[data-theme='xp'] .brand-term, [data-theme='win11'] .brand-term, [data-theme='macos'] .brand-term { color: #14a37a; }
|
||||||
|
/* macOS Liquid Glass */
|
||||||
|
[data-theme='macos'] #main, [data-theme='macos'] .pane { background: transparent; }
|
||||||
|
[data-theme='macos'] :is(#titlebar, #sidebar, .toolbar, .card, .settings-card, .modal, .drawer, .ctx, .palette, .search, .toast, .net-card, .findbar, .pane th, .tab.active, .btn, .seg, .field input, .field select, .field textarea) {
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
[data-theme='macos'] :is(.card, .settings-card, .modal, .drawer, .ctx, .palette, .toast, .net-card, .search) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
|
||||||
|
[data-theme='macos'] #sidebar { border-right: 1px solid var(--chrome-line); box-shadow: inset -1px 0 0 rgba(255, 255, 255, .6); }
|
||||||
|
[data-theme='macos'] #titlebar { border-bottom: 1px solid var(--chrome-line); }
|
||||||
|
[data-theme='macos'] .btn { border-radius: 999px; border-color: rgba(255, 255, 255, .75); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
|
||||||
|
[data-theme='macos'] .btn.primary { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); border-color: rgba(255, 255, 255, .45); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 4px 14px rgba(0, 122, 255, .3); }
|
||||||
|
[data-theme='macos'] .modal-bg { -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
||||||
|
[data-theme='macos'] .nav.active { box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
|
||||||
|
|
||||||
|
/* Web-Version (Browser): Desktop-Funktionen ausblenden */
|
||||||
|
.web .win-ctrls, .web #lockBtn, .web .nav[data-view='vpns'], .web .nav[data-view='forwards'], .web #updateBadge { display: none !important; }
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ TERM_THEMES.mocha = { name: 'Catppuccin Mocha', background: '#1e1e2e', foregroun
|
|||||||
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
|
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
|
||||||
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
|
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
|
||||||
|
|
||||||
|
// Terminal-Schemata zu den System-Designs
|
||||||
|
TERM_THEMES.xp = { name: 'Windows XP (cmd.exe)', background: '#000000', foreground: '#c0c0c0', cursor: '#c0c0c0', cursorAccent: '#000000', selectionBackground: '#c0c0c066',
|
||||||
|
black: '#000000', red: '#800000', green: '#008000', yellow: '#808000', blue: '#000080', magenta: '#800080', cyan: '#008080', white: '#c0c0c0',
|
||||||
|
brightBlack: '#808080', brightRed: '#ff0000', brightGreen: '#00ff00', brightYellow: '#ffff00', brightBlue: '#0000ff', brightMagenta: '#ff00ff', brightCyan: '#00ffff', brightWhite: '#ffffff' };
|
||||||
|
TERM_THEMES.campbell = { name: 'Windows 11 (Campbell)', background: '#0c0c0c', foreground: '#cccccc', cursor: '#ffffff', cursorAccent: '#0c0c0c', selectionBackground: '#ffffff40',
|
||||||
|
black: '#0c0c0c', red: '#c50f1f', green: '#13a10e', yellow: '#c19c00', blue: '#0037da', magenta: '#881798', cyan: '#3a96dd', white: '#cccccc',
|
||||||
|
brightBlack: '#767676', brightRed: '#e74856', brightGreen: '#16c60c', brightYellow: '#f9f1a5', brightBlue: '#3b78ff', brightMagenta: '#b4009e', brightCyan: '#61d6d6', brightWhite: '#f2f2f2' };
|
||||||
|
TERM_THEMES.macos = { name: 'macOS Terminal', background: '#ffffff', foreground: '#1d1d1f', cursor: '#7f7f7f', cursorAccent: '#ffffff', selectionBackground: '#b4d7ff',
|
||||||
|
black: '#000000', red: '#c23621', green: '#25bc24', yellow: '#adad27', blue: '#492ee1', magenta: '#d338d3', cyan: '#33bbc8', white: '#cbcccd',
|
||||||
|
brightBlack: '#818383', brightRed: '#fc391f', brightGreen: '#31e722', brightYellow: '#eaec23', brightBlue: '#5833ff', brightMagenta: '#f935f8', brightCyan: '#14f0f0', brightWhite: '#e9ebeb' };
|
||||||
|
TERM_THEMES.cyberpunk = { name: 'Cyberpunk 2077', background: '#0a0a0f', foreground: '#eafcff', cursor: '#fcee0a', cursorAccent: '#0a0a0f', selectionBackground: '#00f0ff44',
|
||||||
|
black: '#15151f', red: '#ff003c', green: '#00ff9f', yellow: '#fcee0a', blue: '#00b8ff', magenta: '#ff2bd6', cyan: '#00f0ff', white: '#d7e6ee',
|
||||||
|
brightBlack: '#5a7680', brightRed: '#ff4f70', brightGreen: '#5cffc0', brightYellow: '#fff45c', brightBlue: '#5cd3ff', brightMagenta: '#ff6be4', brightCyan: '#6bf7ff', brightWhite: '#ffffff' };
|
||||||
|
|
||||||
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
|
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
|
||||||
window.APP_THEMES = {
|
window.APP_THEMES = {
|
||||||
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
|
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
|
||||||
@@ -37,5 +51,9 @@ window.APP_THEMES = {
|
|||||||
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
|
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
|
||||||
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
|
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
|
||||||
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
|
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
|
||||||
light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' },
|
light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light', light: true },
|
||||||
|
xp: { name: 'Windows XP', bg: '#ece9d8', side: '#0a5fdc', card: '#ffffff', accent: '#316ac5', text: '#000000', term: 'xp', light: true },
|
||||||
|
win11: { name: 'Windows 11', bg: '#f3f3f3', side: '#ebebeb', card: '#ffffff', accent: '#0067c0', text: '#1b1b1b', term: 'campbell', light: true },
|
||||||
|
macos: { name: 'macOS', bg: '#ffffff', side: '#e8e8ed', card: '#f5f5f7', accent: '#007aff', text: '#1d1d1f', term: 'macos', light: true },
|
||||||
|
cyberpunk: { name: 'Cyberpunk 2077', bg: '#0a0a0f', side: '#07070b', card: '#15151f', accent: '#fcee0a', text: '#eafcff', term: 'cyberpunk' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; min-height: 100%; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: var(--font); display: grid; place-items: center; min-height: 100vh; padding: 16px; }
|
||||||
|
.box { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 32px 28px 22px; text-align: center; box-shadow: 0 20px 60px #0006; }
|
||||||
|
.logo { width: 72px; height: 72px; border-radius: 18px; }
|
||||||
|
h1 { margin: 10px 0 4px; font-size: 26px; }
|
||||||
|
h1 span { color: var(--green); }
|
||||||
|
p { color: var(--muted); margin: 0 0 22px; font-size: 14px; }
|
||||||
|
form { display: flex; flex-direction: column; gap: 14px; text-align: left; }
|
||||||
|
label span { display: block; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
input { width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; font: inherit; outline: none; }
|
||||||
|
input:focus { border-color: var(--accent); }
|
||||||
|
button { margin-top: 6px; background: var(--accent); color: var(--on-accent); border: 0; border-radius: 10px; padding: 12px; font: 600 15px var(--font); cursor: pointer; }
|
||||||
|
button:disabled { opacity: .6; }
|
||||||
|
.err { color: var(--red); font-size: 13px; }
|
||||||
|
.ver { margin-top: 18px; color: var(--faint); font-size: 11px; }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>MrTerm</title>
|
||||||
|
<link rel="icon" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/app/src/renderer/app-themes.css" />
|
||||||
|
<link rel="stylesheet" href="/web/login.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="box">
|
||||||
|
<img src="/favicon.png" alt="" class="logo" />
|
||||||
|
<h1>Mr<span>Term</span></h1>
|
||||||
|
<p id="intro"></p>
|
||||||
|
<form id="form" autocomplete="on">
|
||||||
|
<label><span data-t="Username">Username</span><input name="name" autocomplete="username" required autofocus /></label>
|
||||||
|
<label><span data-t="Password">Password</span><input name="password" type="password" autocomplete="current-password" required /></label>
|
||||||
|
<label id="repeatRow" hidden><span data-t="Repeat password">Repeat password</span><input name="repeat" type="password" autocomplete="new-password" /></label>
|
||||||
|
<div id="err" class="err" hidden></div>
|
||||||
|
<button id="submit" type="submit"></button>
|
||||||
|
</form>
|
||||||
|
<div class="ver" id="ver"></div>
|
||||||
|
</main>
|
||||||
|
<script src="/app/src/i18n.js"></script>
|
||||||
|
<script src="/web/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||