Compare commits
12
Commits
b94f18d2e2
...
v0.13.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c6e25df36 | ||
|
|
32bf502ffa | ||
|
|
a2a0893b8c | ||
|
|
45b0be8557 | ||
|
|
c143053319 | ||
|
|
58709e09f2 | ||
|
|
27091ab5d1 | ||
|
|
6a6971e1cb | ||
|
|
edea4ce0d2 | ||
|
|
b431428260 | ||
|
|
aeb6c590db | ||
|
|
428e74b2d8 |
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
mobile
|
||||
build
|
||||
docs
|
||||
.git
|
||||
.gitea-token
|
||||
web-data
|
||||
@@ -8,3 +8,4 @@ mobile/vendor/
|
||||
mobile/www/
|
||||
mobile/android/keystore.properties
|
||||
mobile/android/*.jks
|
||||
web-data/
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# MrTerm Web – die MrTerm-Oberfläche im Browser, selbst gehostet.
|
||||
# docker compose up -d → http://<server>:8080 (beim ersten Aufruf Admin-Konto anlegen)
|
||||
|
||||
# ---- Build: Server samt ssh2/ws in eine Datei bündeln
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||
COPY src ./src
|
||||
RUN npx esbuild src/web/server.js --bundle --platform=node --target=node22 --minify --legal-comments=none \
|
||||
--external:electron --external:cpu-features --external:bufferutil --external:utf-8-validate \
|
||||
--outfile=dist/server.js
|
||||
|
||||
# ---- Laufzeit: nur Node, gebündelter Server und die Dateien der Oberfläche
|
||||
FROM node:22-alpine
|
||||
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data MRTERM_ROOT=/app
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY --from=build /src/dist/server.js dist/server.js
|
||||
COPY src/i18n.js src/i18n.js
|
||||
COPY src/renderer src/renderer
|
||||
COPY src/web/public src/web/public
|
||||
COPY --from=build /src/node_modules/@xterm node_modules/@xterm
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
USER node
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -63,6 +63,7 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app.
|
||||
- **History** of recent connections
|
||||
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
|
||||
- **Backup** export and import
|
||||
- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker)
|
||||
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
|
||||
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
|
||||
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
|
||||
@@ -82,6 +83,24 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app.
|
||||
|  | |
|
||||
| **Settings**: language, app lock, LAN sync and themes | |
|
||||
|
||||
## Web version (Docker)
|
||||
|
||||
MrTerm also runs as a self-hosted web app, similar to Termix: you use the familiar MrTerm interface in the browser, from any computer.
|
||||
|
||||
```bash
|
||||
curl -O https://git.mrblake.cc/MrBlake/MrTerm/raw/branch/main/docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then open `http://<your-server>:8080` and create the administrator account. As an administrator you can add more users under **Settings → Account**.
|
||||
|
||||
- **Included:** hosts and groups, SSH terminal tabs, SFTP file manager (upload by button or drag & drop, download to your computer), keys, snippets, known hosts, Docker, firewall and network management, themes.
|
||||
- **Not included:** RDP, VPN, port forwarding, LAN sync and the app lock. These need direct access to your computer.
|
||||
- **Security:** every user has their own vault, encrypted with a key that only their login password can unlock. The server stores no readable passwords or keys. If a user forgets their password, their vault cannot be recovered.
|
||||
- **HTTPS:** put MrTerm behind a reverse proxy with HTTPS (Traefik, Caddy, nginx …) before you use it over the internet, and set `TRUST_PROXY=1` in `docker-compose.yml`. The proxy must forward WebSockets.
|
||||
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
|
||||
- **SSH connections** start from the server, so the server must be able to reach your hosts.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
||||
|
||||
@@ -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:
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
@@ -25,13 +26,19 @@ public class MainActivity extends BridgeActivity {
|
||||
}
|
||||
});
|
||||
|
||||
// Edge-to-Edge: Bei offener Tastatur den WebView um die Tastaturhöhe verkleinern,
|
||||
// damit Terminal und Zusatztasten sichtbar bleiben
|
||||
// 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) -> {
|
||||
int ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;
|
||||
v.setPadding(0, 0, 0, ime);
|
||||
web.evaluateJavascript("window.mrtermIme && window.mrtermIme(" + (ime > 0) + ")", null);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,6 +216,23 @@ public class MrTermNative extends Plugin {
|
||||
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));
|
||||
|
||||
@@ -53,8 +53,10 @@ function buildWeb() {
|
||||
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)
|
||||
|
||||
@@ -12,5 +12,5 @@ contextBridge.exposeInMainWorld('DevBridge', {
|
||||
send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
|
||||
addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
|
||||
},
|
||||
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background'].map((m) => [m, native(m)])),
|
||||
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background', 'bars'].map((m) => [m, native(m)])),
|
||||
});
|
||||
|
||||
+17
-168
@@ -1,16 +1,11 @@
|
||||
// Node-Backend der Android-App. Nutzt dieselben Module wie der Electron-Hauptprozess
|
||||
// (Tresor, SSH, LAN-Sync, Docker, Firewall, Netzwerk) und spricht über die Capacitor-Bridge mit der Oberfläche.
|
||||
// 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 { utils: sshUtils } = require('ssh2');
|
||||
const { Store } = require('../../src/main/store');
|
||||
const { SshManager } = require('../../src/main/ssh');
|
||||
const { DockerManager } = require('../../src/main/docker');
|
||||
const { FirewallManager } = require('../../src/main/firewall');
|
||||
const { NetworkConfigManager } = require('../../src/main/network');
|
||||
const { SyncService } = require('../../src/main/sync');
|
||||
const { createBackend } = require('../../src/core/backend');
|
||||
const i18n = require('../../src/i18n');
|
||||
|
||||
const send = (ch, ...args) => channel.send('evt', ch, args);
|
||||
@@ -18,182 +13,36 @@ process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.mess
|
||||
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||
|
||||
const store = new Store();
|
||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, process.env.MRTERM_LOCALE || 'en');
|
||||
|
||||
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
||||
const pending = new Map();
|
||||
function ask(ch, req, timeout = 0) {
|
||||
return new Promise((resolve) => {
|
||||
const reqId = crypto.randomUUID();
|
||||
pending.set(reqId, resolve);
|
||||
send(ch, { reqId, ...req });
|
||||
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
|
||||
});
|
||||
}
|
||||
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
|
||||
|
||||
const confirmHostKey = async (target, fingerprint, known) =>
|
||||
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
|
||||
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
|
||||
|
||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||
const docker = new DockerManager(ssh);
|
||||
const firewall = new FirewallManager(ssh);
|
||||
const network = new NetworkConfigManager(ssh);
|
||||
const sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
|
||||
store.onChange = () => sync.schedule();
|
||||
|
||||
const handlers = new Map();
|
||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||
const handle = (ch, fn) => handlers.set(ch, fn);
|
||||
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 {
|
||||
const fn = handlers.get(ch);
|
||||
if (!fn) throw new Error(`Unknown channel ${ch}`);
|
||||
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
|
||||
channel.send('reply', reqId, true, await fn(...args));
|
||||
} catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
|
||||
try { channel.send('reply', reqId, true, await be.call(ch, args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
|
||||
});
|
||||
const listeners = {
|
||||
'ssh:write': (id, d) => ssh.write(id, d),
|
||||
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
|
||||
'ssh:close': (id) => ssh.close(id),
|
||||
'ask:reply': answer,
|
||||
};
|
||||
channel.addListener('send', (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } });
|
||||
channel.addListener('send', (ch, args = []) => be.notify(ch, args));
|
||||
|
||||
// ---------- App-Sperre: Passwort, zusätzlich Fingerabdruck (Geheimnis aus dem Android Keystore) ----------
|
||||
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||||
// ---------- 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));
|
||||
function lockStatus() {
|
||||
const { language, appTheme, accent } = store.get().settings;
|
||||
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, meta: { language, appTheme, accent } };
|
||||
}
|
||||
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
|
||||
const afterUnlock = () => { applyLanguage(); sync.start().catch(() => {}); };
|
||||
|
||||
handle('lock:status', lockStatus);
|
||||
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
|
||||
handle('lock:unlockPassword', async (pw) => {
|
||||
const p = store.lock?.password;
|
||||
if (!p) throw new Error(i18n.t('No password set.'));
|
||||
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
|
||||
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
|
||||
afterUnlock();
|
||||
return true;
|
||||
});
|
||||
handle('lock:unlockBio', (secret) => {
|
||||
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.')); }
|
||||
afterUnlock();
|
||||
applyLanguage();
|
||||
be.sync.start().catch(() => {});
|
||||
return true;
|
||||
});
|
||||
handle('lock:setPassword', async (pw) => {
|
||||
requireUnlocked();
|
||||
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
|
||||
const salt = crypto.randomBytes(16), N = 2 ** 15;
|
||||
const kek = await kdf(pw, salt, N);
|
||||
store.lock = store.lock || { password: null, fido: [] };
|
||||
store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:removePassword', () => {
|
||||
requireUnlocked();
|
||||
if (store.lock) { store.lock.password = null; store.lock.bio = null; } // Fingerabdruck nur zusätzlich zum Passwort
|
||||
store.dropLockIfEmpty();
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:setBio', (secret) => {
|
||||
requireUnlocked();
|
||||
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 lockStatus();
|
||||
return be.lockStatus();
|
||||
});
|
||||
|
||||
// ---------- Tresor ----------
|
||||
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
|
||||
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: 'android' }));
|
||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
||||
handle('vault:remove', (col, id) => {
|
||||
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
|
||||
return store.remove(col, id);
|
||||
});
|
||||
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) applyLanguage(); });
|
||||
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||
handle('app:version', () => process.env.MRTERM_VERSION || '0.0.0');
|
||||
|
||||
// ---------- Schlüssel ----------
|
||||
handle('key:generate', ({ type, bits, comment, passphrase }) => {
|
||||
const opts = { comment: comment || 'mrterm@android' };
|
||||
if (type === 'rsa') opts.bits = Number(bits) || 4096;
|
||||
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
|
||||
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
|
||||
return { privateKey: k.private, publicKey: k.public };
|
||||
});
|
||||
handle('key:parse', ({ privateKey, passphrase }) => {
|
||||
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
|
||||
if (k instanceof Error) throw k;
|
||||
const key = Array.isArray(k) ? k[0] : k;
|
||||
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
|
||||
});
|
||||
|
||||
// ---------- SSH-Terminal ----------
|
||||
function hostWithOverrides(ref) {
|
||||
if (typeof ref === 'string') {
|
||||
const h = store.resolveHost(ref);
|
||||
if (!h) throw new Error(i18n.t('Host not found'));
|
||||
return h;
|
||||
}
|
||||
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
|
||||
return ref;
|
||||
}
|
||||
handle('ssh:open', async (sessionId, ref, size) => {
|
||||
const host = hostWithOverrides(ref);
|
||||
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||
return true;
|
||||
});
|
||||
|
||||
// ---------- Docker / Firewall / Netzwerk (gleiche Module wie am Desktop) ----------
|
||||
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id)));
|
||||
handle('docker:list', (id) => docker.list(id));
|
||||
handle('docker:stats', (id) => docker.stats(id));
|
||||
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||
handle('docker:close', (id) => docker.close(id));
|
||||
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
|
||||
handle('firewall:list', (id, backend) => firewall.list(id, backend));
|
||||
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
|
||||
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
|
||||
handle('firewall:close', (id) => firewall.close(id));
|
||||
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id)));
|
||||
handle('network:read', (id) => network.read(id));
|
||||
handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
|
||||
handle('network:remove', (id, model) => network.removeInterface(id, model));
|
||||
handle('network:hostname', (id, name) => network.setHostname(id, name));
|
||||
handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
|
||||
handle('network:close', (id) => network.close(id));
|
||||
|
||||
// ---------- Synchronisation ----------
|
||||
handle('sync:status', () => sync.status());
|
||||
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
|
||||
handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
|
||||
handle('sync:pair', (id) => sync.pair(id));
|
||||
handle('sync:unpair', (id) => sync.unpair(id));
|
||||
handle('sync:now', () => sync.syncAll());
|
||||
handle('sync:share', (sel) => sync.setShare(sel));
|
||||
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||
handle('sync:probe', (address) => sync.probe(address));
|
||||
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
|
||||
handle('app:pause', () => sync.stop());
|
||||
handle('app:resume', () => sync.start().catch(() => {}));
|
||||
be.handle('app:pause', () => be.sync.stop());
|
||||
be.handle('app:resume', () => be.sync.start().catch(() => {}));
|
||||
|
||||
store.load();
|
||||
applyLanguage();
|
||||
sync.start().catch(() => {});
|
||||
be.sync.start().catch(() => {});
|
||||
channel.send('ready');
|
||||
|
||||
+51
-10
@@ -193,10 +193,12 @@ async function reload() {
|
||||
S.lock = await api.call('lock:status');
|
||||
I18N.setLanguage(S.lock.meta.language || 'auto', S.info.locale);
|
||||
applyStatic();
|
||||
applyTheme(S.lock.meta);
|
||||
if (S.lock.locked) return showLock();
|
||||
S.vault = await api.call('vault:get');
|
||||
I18N.setLanguage(settings().language || 'auto', S.info.locale);
|
||||
applyStatic();
|
||||
applyTheme();
|
||||
S.sync = await api.call('sync:status').catch(() => null);
|
||||
$('#main').hidden = false;
|
||||
render();
|
||||
@@ -410,7 +412,33 @@ const KEYS = [
|
||||
];
|
||||
const mods = { ctrl: 0, alt: 0 }; // 0 aus, 1 einmal, 2 festgestellt
|
||||
|
||||
function termTheme() { return TERM_THEMES[settings().terminalTheme] || TERM_THEMES.mrterm; }
|
||||
function termTheme() {
|
||||
const id = settings().terminalTheme;
|
||||
if (!id || id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
|
||||
return TERM_THEMES[id] || TERM_THEMES.mrterm;
|
||||
}
|
||||
|
||||
// App-Design (gemeinsam mit dem Desktop: lib/app-themes.css + APP_THEMES)
|
||||
function applyTheme(st = settings()) {
|
||||
const root = document.documentElement;
|
||||
const t = APP_THEMES[st.appTheme] || APP_THEMES.midnight;
|
||||
if (st.appTheme && st.appTheme !== 'midnight' && APP_THEMES[st.appTheme]) root.dataset.theme = st.appTheme; else delete root.dataset.theme;
|
||||
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
|
||||
root.classList.toggle('light', !!t.light);
|
||||
S.sessions?.forEach((x) => { x.term.options.theme = termTheme(); });
|
||||
document.documentElement.style.setProperty('--term-bg', termTheme().background);
|
||||
syncBars();
|
||||
}
|
||||
// Statusleiste passend zur sichtbaren Fläche (Terminal oder App)
|
||||
function syncBars() {
|
||||
const t = APP_THEMES[settings().appTheme] || APP_THEMES.midnight;
|
||||
const inTerm = !$('#termScreen').hidden;
|
||||
const bg = inTerm ? getComputedStyle(document.querySelector('.term-head')).backgroundColor : getComputedStyle(document.body).backgroundColor;
|
||||
const rgb = bg.match(/\d+/g)?.map(Number) || [20, 22, 29];
|
||||
const hex = '#' + rgb.slice(0, 3).map((n) => n.toString(16).padStart(2, '0')).join('');
|
||||
const light = inTerm ? (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 > 150 : !!t.light;
|
||||
Native.bars?.({ light, color: hex }).catch(() => {});
|
||||
}
|
||||
const fontSize = () => Number(settings().mobileFontSize) || 12;
|
||||
|
||||
class Session {
|
||||
@@ -537,6 +565,7 @@ function showSession(s) {
|
||||
$('#termScreen').hidden = false;
|
||||
$('#main').hidden = true;
|
||||
document.documentElement.style.setProperty('--term-bg', termTheme().background);
|
||||
syncBars();
|
||||
updateTermHead();
|
||||
drawKeybar();
|
||||
requestAnimationFrame(() => { s.refit(); if (s.alive) s.term.focus(); });
|
||||
@@ -548,6 +577,7 @@ function hideTerminal() {
|
||||
S.active?.term.blur();
|
||||
S.active = null;
|
||||
render();
|
||||
syncBars();
|
||||
}
|
||||
|
||||
function updateTermHead() {
|
||||
@@ -723,20 +753,31 @@ function viewSettings(v) {
|
||||
v.append(h(`<div class="top"><h1>${esc(T('Settings'))}</h1></div>`));
|
||||
const st = settings();
|
||||
|
||||
// Sprache & Terminal
|
||||
// Sprache, Design & Terminal
|
||||
const gen = h(`<div class="card"><h3>${esc(T('General'))}</h3>
|
||||
${field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System language')], ...Object.entries(I18N.LANGUAGES)] })}
|
||||
${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'mrterm', { type: 'select', options: Object.entries(TERM_THEMES).map(([id, t]) => [id, t.name]) })}
|
||||
<div class="field"><label>Design</label><div class="theme-grid"></div></div>
|
||||
<div class="field"><label>${esc(T('Accent color'))}</label><div class="colors"></div></div>
|
||||
${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'auto', { type: 'select', options: [['auto', T('Match theme')], ...Object.entries(TERM_THEMES).map(([id, t]) => [id, t.name])] })}
|
||||
${field(T('Font size'), 'mobileFontSize', fontSize(), { type: 'select', options: [8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20].map((n) => [n, `${n} px`]) })}
|
||||
</div>`);
|
||||
$('[name=language]', gen).onchange = async (e) => { await call('vault:settings', { language: e.target.value }); await reload(); };
|
||||
$('[name=terminalTheme]', gen).onchange = async (e) => {
|
||||
await call('vault:settings', { terminalTheme: e.target.value }); S.vault.settings.terminalTheme = e.target.value;
|
||||
S.sessions.forEach((s) => { s.term.options.theme = termTheme(); });
|
||||
};
|
||||
const save = async (patch) => { await call('vault:settings', patch); Object.assign(S.vault.settings, patch); };
|
||||
$('[name=language]', gen).onchange = async (e) => { await save({ language: e.target.value }); await reload(); };
|
||||
for (const [id, t] of Object.entries(APP_THEMES)) {
|
||||
const p = h(`<button class="theme-prev ${(st.appTheme || 'midnight') === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
|
||||
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div>${esc(T(t.name))}</button>`);
|
||||
p.onclick = async () => { await save({ appTheme: id }); applyTheme(); render(); };
|
||||
$('.theme-grid', gen).append(p);
|
||||
}
|
||||
['', ...COLORS.slice(0, 6), '#21c7a8', '#cba6f7', '#fcee0a'].forEach((col) => {
|
||||
const b = h(`<button title="${esc(col || T('Theme default'))}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
|
||||
b.onclick = async () => { await save({ accent: col }); applyTheme(); render(); };
|
||||
$('.colors', gen).append(b);
|
||||
});
|
||||
$('[name=terminalTheme]', gen).onchange = async (e) => { await save({ terminalTheme: e.target.value }); applyTheme(); };
|
||||
$('[name=mobileFontSize]', gen).onchange = async (e) => {
|
||||
const n = Number(e.target.value); await call('vault:settings', { mobileFontSize: n }); S.vault.settings.mobileFontSize = n;
|
||||
S.sessions.forEach((s) => { s.term.options.fontSize = n; });
|
||||
const n = Number(e.target.value); await save({ mobileFontSize: n });
|
||||
S.sessions.forEach((x) => { x.term.options.fontSize = n; });
|
||||
};
|
||||
v.append(gen);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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>
|
||||
@@ -38,6 +39,14 @@
|
||||
<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>
|
||||
|
||||
+58
-25
@@ -1,25 +1,19 @@
|
||||
:root {
|
||||
--bg: #14161d;
|
||||
--surface: #1c1f29;
|
||||
--surface2: #252936;
|
||||
--line: #2c3040;
|
||||
--text: #e6e8ef;
|
||||
--muted: #9aa0b4;
|
||||
--faint: #5d6377;
|
||||
--accent: #6e7bff;
|
||||
--accent-soft: #6e7bff26;
|
||||
--green: #3ecf8e;
|
||||
--red: #ff5f6d;
|
||||
--orange: #ffb347;
|
||||
--radius: 14px;
|
||||
/* Capacitor setzt --safe-area-inset-* bei Edge-to-Edge; env() als Rückfall */
|
||||
--top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px));
|
||||
--bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px));
|
||||
/* 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 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
|
||||
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; }
|
||||
@@ -54,7 +48,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
||||
.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
|
||||
.section svg { width: 16px; height: 16px; }
|
||||
|
||||
.list { background: var(--surface); border-radius: var(--radius); overflow: hidden; }
|
||||
.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); }
|
||||
@@ -79,11 +73,11 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
||||
.empty .avatar svg { width: 28px; height: 28px; }
|
||||
.empty h3 { color: var(--text); margin: 0 0 6px; }
|
||||
|
||||
.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: 18px; background: var(--accent); color: #fff; display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
|
||||
.fab { 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(--radius); padding: 14px 16px; margin-bottom: 14px; }
|
||||
.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; }
|
||||
@@ -92,9 +86,9 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
||||
.row .sub { color: var(--muted); font-size: 13px; }
|
||||
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); font-weight: 600; }
|
||||
.btn { 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: #fff; }
|
||||
.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; }
|
||||
@@ -116,7 +110,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
||||
.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
|
||||
.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
|
||||
.switch input:checked + span { background: var(--accent); }
|
||||
.switch input:checked + span::after { transform: translateX(20px); background: #fff; }
|
||||
.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; }
|
||||
|
||||
@@ -184,7 +178,46 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
||||
#keybar::-webkit-scrollbar { display: none; }
|
||||
#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
|
||||
#keybar button svg { width: 18px; height: 18px; }
|
||||
#keybar button.on { background: var(--accent); color: #fff; }
|
||||
#keybar button.lock { box-shadow: inset 0 0 0 2px #fff; }
|
||||
#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); }
|
||||
|
||||
Generated
+511
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.11.1",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.11.1",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -18,7 +18,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
"electron-builder": "^26.15.3"
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.2",
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
@@ -325,6 +327,448 @@
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -2071,6 +2515,48 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -3992,6 +4478,28 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
|
||||
+6
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"productName": "MrTerm",
|
||||
"version": "0.11.1",
|
||||
"version": "0.13.1",
|
||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||
"main": "src/main/main.js",
|
||||
"author": "MrBlake",
|
||||
@@ -68,12 +68,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
"electron-builder": "^26.15.3"
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.2",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron@44.4.5": true,
|
||||
"electron-winstaller@5.4.0": true,
|
||||
"ssh2@1.17.0": true,
|
||||
"koffi@3.3.1": true
|
||||
"koffi@3.3.1": true,
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// Gemeinsame Backend-Kanäle für die Android-App und die Web-Version (Docker).
|
||||
// Der Electron-Hauptprozess (src/main/main.js) registriert seine Kanäle selbst, nutzt aber dieselben Module.
|
||||
//
|
||||
// createBackend({ store, send, platform, version, withSync, onLanguage })
|
||||
// store Store-Instanz (bereits geladen)
|
||||
// send (kanal, ...args) → Ereignis an die Oberfläche
|
||||
// → { call(kanal, args), notify(kanal, args), close(), ssh, sync, handle }
|
||||
const crypto = require('crypto');
|
||||
const { utils: sshUtils } = require('ssh2');
|
||||
const { SshManager } = require('../main/ssh');
|
||||
const { DockerManager } = require('../main/docker');
|
||||
const { FirewallManager } = require('../main/firewall');
|
||||
const { NetworkConfigManager } = require('../main/network');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
|
||||
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
||||
const pending = new Map();
|
||||
const ask = (ch, req, timeout = 0) => new Promise((resolve) => {
|
||||
const reqId = crypto.randomUUID();
|
||||
pending.set(reqId, resolve);
|
||||
send(ch, { reqId, ...req });
|
||||
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
|
||||
});
|
||||
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
|
||||
|
||||
const confirmHostKey = async (target, fingerprint, known) =>
|
||||
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
|
||||
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
|
||||
|
||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||
const docker = new DockerManager(ssh);
|
||||
const firewall = new FirewallManager(ssh);
|
||||
const network = new NetworkConfigManager(ssh);
|
||||
let sync = null;
|
||||
if (withSync) {
|
||||
const { SyncService } = require('../main/sync');
|
||||
sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
|
||||
store.onChange = () => sync.schedule();
|
||||
}
|
||||
|
||||
const handlers = new Map();
|
||||
const handle = (ch, fn) => handlers.set(ch, fn);
|
||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||
|
||||
async function call(ch, args = []) {
|
||||
const fn = handlers.get(ch);
|
||||
if (!fn) throw new Error(`Unknown channel ${ch}`);
|
||||
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
|
||||
return fn(...args);
|
||||
}
|
||||
const listeners = {
|
||||
'ssh:write': (id, d) => ssh.write(id, d),
|
||||
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
|
||||
'ssh:close': (id) => ssh.close(id),
|
||||
'ask:reply': answer,
|
||||
// Namen der Desktop-Vorlage (preload.js)
|
||||
'secret:reply': answer,
|
||||
'sync:pairReply': answer,
|
||||
};
|
||||
const notify = (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } };
|
||||
|
||||
// ---------- App-Sperre (Passwort) ----------
|
||||
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||||
const lockStatus = () => {
|
||||
const { language, appTheme, accent } = store.get().settings;
|
||||
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, fido: [], meta: { language, appTheme, accent } };
|
||||
};
|
||||
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
|
||||
const afterUnlock = () => { onLanguage(store.get().settings.language); sync?.start().catch(() => {}); };
|
||||
handle('lock:status', lockStatus);
|
||||
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
|
||||
handle('lock:unlockPassword', async (pw) => {
|
||||
const p = store.lock?.password;
|
||||
if (!p) throw new Error(i18n.t('No password set.'));
|
||||
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
|
||||
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
|
||||
afterUnlock();
|
||||
return true;
|
||||
});
|
||||
handle('lock:setPassword', async (pw) => {
|
||||
requireUnlocked();
|
||||
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
|
||||
const salt = crypto.randomBytes(16), N = 2 ** 15;
|
||||
const kek = await kdf(pw, salt, N);
|
||||
store.lock = store.lock || { password: null, fido: [] };
|
||||
store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:removePassword', () => {
|
||||
requireUnlocked();
|
||||
if (store.lock) { store.lock.password = null; store.lock.bio = null; }
|
||||
store.dropLockIfEmpty();
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
|
||||
// ---------- Tresor ----------
|
||||
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
|
||||
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform }));
|
||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
||||
handle('vault:remove', (col, id) => {
|
||||
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
|
||||
return store.remove(col, id);
|
||||
});
|
||||
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
|
||||
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||
handle('vault:exportData', () => publicData());
|
||||
handle('vault:importData', (data) => {
|
||||
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
|
||||
for (const item of data?.[col] || []) store.upsert(col, item);
|
||||
return true;
|
||||
});
|
||||
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
|
||||
handle('vault:bulkImport', (entries) => {
|
||||
const groups = store.get().groups;
|
||||
const ensure = (parts) => {
|
||||
let parent = null;
|
||||
for (const label of parts) {
|
||||
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
|
||||
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
|
||||
parent = g.id;
|
||||
}
|
||||
return parent;
|
||||
};
|
||||
let hosts = 0, skipped = 0;
|
||||
for (const e of entries) {
|
||||
const groupId = ensure(e.folder || []);
|
||||
if (!e.host) continue;
|
||||
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
|
||||
if (dup) { skipped++; continue; }
|
||||
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
|
||||
hosts++;
|
||||
}
|
||||
store.save();
|
||||
return { hosts, skipped, groups: groups.length };
|
||||
});
|
||||
handle('app:version', () => version);
|
||||
|
||||
// ---------- Schlüssel ----------
|
||||
handle('key:generate', ({ type, bits, comment, passphrase }) => {
|
||||
const opts = { comment: comment || `mrterm@${platform}` };
|
||||
if (type === 'rsa') opts.bits = Number(bits) || 4096;
|
||||
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
|
||||
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
|
||||
return { privateKey: k.private, publicKey: k.public };
|
||||
});
|
||||
handle('key:parse', ({ privateKey, passphrase }) => {
|
||||
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
|
||||
if (k instanceof Error) throw k;
|
||||
const key = Array.isArray(k) ? k[0] : k;
|
||||
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
|
||||
});
|
||||
|
||||
// ---------- SSH-Terminal & SFTP ----------
|
||||
function hostWithOverrides(ref) {
|
||||
if (typeof ref === 'string') {
|
||||
const h = store.resolveHost(ref);
|
||||
if (!h) throw new Error(i18n.t('Host not found'));
|
||||
return h;
|
||||
}
|
||||
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
|
||||
return ref;
|
||||
}
|
||||
handle('ssh:open', async (sessionId, ref, size) => {
|
||||
const host = hostWithOverrides(ref);
|
||||
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||
return true;
|
||||
});
|
||||
handle('sftp:open', (id, ref) => ssh.openSftp(id, hostWithOverrides(ref)));
|
||||
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||
handle('sftp:close', (id) => ssh.close(id));
|
||||
|
||||
// ---------- Docker / Firewall / Netzwerk ----------
|
||||
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id)));
|
||||
handle('docker:list', (id) => docker.list(id));
|
||||
handle('docker:stats', (id) => docker.stats(id));
|
||||
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||
handle('docker:close', (id) => docker.close(id));
|
||||
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
|
||||
handle('firewall:list', (id, backend) => firewall.list(id, backend));
|
||||
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
|
||||
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
|
||||
handle('firewall:close', (id) => firewall.close(id));
|
||||
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id)));
|
||||
handle('network:read', (id) => network.read(id));
|
||||
handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
|
||||
handle('network:remove', (id, model) => network.removeInterface(id, model));
|
||||
handle('network:hostname', (id, name) => network.setHostname(id, name));
|
||||
handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
|
||||
handle('network:readFile', (id, path) => network.readFile(id, path));
|
||||
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
|
||||
handle('network:close', (id) => network.close(id));
|
||||
|
||||
// ---------- Synchronisation ----------
|
||||
if (sync) {
|
||||
handle('sync:status', () => sync.status());
|
||||
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
|
||||
handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
|
||||
handle('sync:pair', (id) => sync.pair(id));
|
||||
handle('sync:unpair', (id) => sync.unpair(id));
|
||||
handle('sync:now', () => sync.syncAll());
|
||||
handle('sync:share', (sel) => sync.setShare(sel));
|
||||
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||
handle('sync:probe', (address) => sync.probe(address));
|
||||
}
|
||||
|
||||
function close() {
|
||||
ssh.closeAll?.();
|
||||
sync?.stop();
|
||||
for (const r of pending.values()) r(null);
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
return { call, notify, close, handle, ssh, sync, store, kdf, requireUnlocked, lockStatus };
|
||||
}
|
||||
|
||||
module.exports = { createBackend };
|
||||
+32
@@ -650,6 +650,38 @@
|
||||
'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.',
|
||||
'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.',
|
||||
'Set a password first.': 'Lege zuerst ein Passwort fest.',
|
||||
// Web-Version
|
||||
'RDP is not available in the web version.': 'RDP ist in der Web-Version nicht verfügbar.',
|
||||
'Account': 'Konto',
|
||||
'Signed in as {name}': 'Angemeldet als {name}',
|
||||
'Administrator': 'Administrator',
|
||||
'Current password': 'Aktuelles Passwort',
|
||||
'Sign out': 'Abmelden',
|
||||
'Users': 'Benutzer',
|
||||
'User': 'Benutzer',
|
||||
'Delete user?': 'Benutzer löschen?',
|
||||
'“{name}” and their vault will be permanently deleted.': '„{name}“ und der zugehörige Tresor werden endgültig gelöscht.',
|
||||
'Add user': 'Benutzer hinzufügen',
|
||||
'Your vault is stored on the server, encrypted with a key that only your login password can unlock.': 'Dein Tresor liegt auf dem Server, verschlüsselt mit einem Schlüssel, den nur dein Login-Passwort entsperren kann.',
|
||||
'Folders cannot be downloaded in the browser: {name}': 'Ordner können im Browser nicht heruntergeladen werden: {name}',
|
||||
'Welcome! Create the administrator account for this MrTerm server.': 'Willkommen! Lege das Administrator-Konto für diesen MrTerm-Server an.',
|
||||
'Sign in to your MrTerm server.': 'Melde dich an deinem MrTerm-Server an.',
|
||||
'Create account': 'Konto anlegen',
|
||||
'Sign in': 'Anmelden',
|
||||
'Administrators only.': 'Nur für Administratoren.',
|
||||
'Forbidden': 'Nicht erlaubt',
|
||||
'Not signed in': 'Nicht angemeldet',
|
||||
'Setup is already complete.': 'Die Einrichtung ist bereits abgeschlossen.',
|
||||
'The current password is wrong.': 'Das aktuelle Passwort ist falsch.',
|
||||
'The last administrator cannot be deleted.': 'Der letzte Administrator kann nicht gelöscht werden.',
|
||||
'Too many failed attempts. Try again in 15 minutes.': 'Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.',
|
||||
'User not found.': 'Benutzer nicht gefunden.',
|
||||
'Wrong username or password.': 'Benutzername oder Passwort falsch.',
|
||||
'You cannot delete yourself.': 'Du kannst dich nicht selbst löschen.',
|
||||
'The password must be at least 8 characters long.': 'Das Passwort muss mindestens 8 Zeichen lang sein.',
|
||||
'This username is already taken.': 'Dieser Benutzername ist bereits vergeben.',
|
||||
'Usernames may contain letters, digits and . _ @ - (max. 64).': 'Benutzernamen dürfen Buchstaben, Ziffern und . _ @ - enthalten (max. 64).',
|
||||
'Connection lost – reconnecting …': 'Verbindung verloren – verbinde neu …',
|
||||
// Main-Prozess
|
||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||
'Unknown host': 'Unbekannter Host',
|
||||
|
||||
+10
-7
@@ -1,6 +1,7 @@
|
||||
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
|
||||
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
|
||||
const { app, safeStorage } = require('electron');
|
||||
// Im Web-Server gibt es kein Electron: dort übergibt der Aufrufer Verzeichnis und Verschlüsselung (opts)
|
||||
const electron = (() => { try { const e = require('electron'); return typeof e === 'object' ? e : {}; } catch { return {}; } })();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
@@ -57,8 +58,10 @@ function unbox(key, b) {
|
||||
}
|
||||
|
||||
class Store {
|
||||
constructor() {
|
||||
this.dir = app.getPath('userData');
|
||||
// opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage
|
||||
constructor(opts = {}) {
|
||||
this.dir = opts.dir || electron.app.getPath('userData');
|
||||
this.crypto = opts.crypto || electron.safeStorage;
|
||||
this.file = path.join(this.dir, 'vault.dat');
|
||||
this.data = structuredClone(DEFAULTS);
|
||||
this.encrypted = false;
|
||||
@@ -97,9 +100,9 @@ class Store {
|
||||
|
||||
canEncrypt() {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return false;
|
||||
if (!this.crypto.isEncryptionAvailable()) return false;
|
||||
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
|
||||
if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend?.() === 'basic_text') return false;
|
||||
if (process.platform === 'linux' && this.crypto.getSelectedStorageBackend?.() === 'basic_text') return false;
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -110,7 +113,7 @@ class Store {
|
||||
const raw = fs.readFileSync(this.file);
|
||||
let json;
|
||||
if (raw.slice(0, 4).toString() === 'ENC1') {
|
||||
json = safeStorage.decryptString(raw.slice(4));
|
||||
json = this.crypto.decryptString(raw.slice(4));
|
||||
this.encrypted = true;
|
||||
} else {
|
||||
json = raw.toString('utf8');
|
||||
@@ -136,7 +139,7 @@ class Store {
|
||||
}
|
||||
const tmp = this.file + '.tmp';
|
||||
if (this.canEncrypt()) {
|
||||
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)]));
|
||||
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), this.crypto.encryptString(json)]));
|
||||
this.encrypted = true;
|
||||
} else {
|
||||
fs.writeFileSync(tmp, json, { mode: 0o600 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+135
-10
@@ -5,9 +5,12 @@
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const uid = () => crypto.randomUUID();
|
||||
// crypto.randomUUID gibt es nur in sicheren Kontexten (HTTPS/localhost) – die Web-Version läuft oft per http://<server>
|
||||
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)));
|
||||
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
|
||||
const T = I18N.t;
|
||||
// Web-Version (Docker, src/web): gleiche Oberfläche im Browser; ohne RDP, VPN, Port-Forwarding und lokale Dateien
|
||||
const WEB = api.platform === 'web';
|
||||
|
||||
const ICONS = {
|
||||
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
|
||||
@@ -407,6 +410,7 @@ function connectHost(hst) {
|
||||
}
|
||||
|
||||
async function openRdp(hst) {
|
||||
if (WEB) return toast(T('RDP is not available in the web version.'), 'error');
|
||||
if (settings().rdpEmbed !== false) {
|
||||
const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message }));
|
||||
if (s.ok) return new RdpSession(hst);
|
||||
@@ -863,6 +867,60 @@ function viewHistory(page) {
|
||||
}
|
||||
|
||||
// ============================================================ Einstellungen
|
||||
// ---------- Web-Version: Konto und Benutzerverwaltung (HTTP-API des Web-Servers)
|
||||
async function webApi(path, body) {
|
||||
const r = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(T(j.error || r.statusText));
|
||||
return j;
|
||||
}
|
||||
function webAccountCard() {
|
||||
const card = h(`<div class="settings-card"><h3>${T('Account')}</h3><div class="acc"></div></div>`);
|
||||
const draw = async () => {
|
||||
const box = $('.acc', card);
|
||||
let me;
|
||||
try { me = await webApi('/api/me'); } catch (e) { box.textContent = e.message; return; }
|
||||
box.innerHTML = `<p style="color:var(--muted);margin-top:0">${esc(T('Signed in as {name}', { name: me.name }))}${me.admin ? ` · ${esc(T('Administrator'))}` : ''}</p><div class="row" style="flex-wrap:wrap"></div>`;
|
||||
const pw = h(`<button class="btn">${esc(T('Change password'))}</button>`);
|
||||
pw.onclick = async () => {
|
||||
const r = await modal({ title: T('Change password'), body: `<div class="field"><label>${esc(T('Current password'))}</label><input name="old" type="password"/></div><div class="field"><label>${esc(T('New password'))}</label><input name="a" type="password"/></div><div class="field"><label>${esc(T('Repeat password'))}</label><input name="b" type="password"/></div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
|
||||
try { await webApi('/api/password', { old: r.old, password: r.a }); toast(T('Saved'), 'ok'); } catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
const out = h(`<button class="btn">${esc(T('Sign out'))}</button>`);
|
||||
out.onclick = async () => { await webApi('/api/logout', {}).catch(() => {}); location.href = '/'; };
|
||||
$('.row', box).append(pw, out);
|
||||
if (!me.admin) return;
|
||||
const users = await webApi('/api/users').catch(() => ({ users: [] }));
|
||||
const list = h(`<div style="margin-top:16px"><h4 style="margin:0 0 8px">${esc(T('Users'))}</h4></div>`);
|
||||
for (const u of users.users) {
|
||||
const row = h(`<div class="lock-row"><div class="grow"><b>${esc(u.name)}</b><div class="sub">${u.admin ? esc(T('Administrator')) : esc(T('User'))}</div></div></div>`);
|
||||
if (u.id !== me.id) {
|
||||
const del = h(`<button class="btn ghost" title="${esc(T('Delete'))}">${ICONS.trash}</button>`);
|
||||
del.onclick = async () => {
|
||||
if (!(await confirmBox(T('Delete user?'), T('“{name}” and their vault will be permanently deleted.', { name: u.name })))) return;
|
||||
try { await webApi('/api/users/delete', { id: u.id }); draw(); } catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
row.append(del);
|
||||
}
|
||||
list.append(row);
|
||||
}
|
||||
const add = h(`<button class="btn" style="margin-top:10px">${ICONS.plus}${esc(T('Add user'))}</button>`);
|
||||
add.onclick = async () => {
|
||||
const r = await modal({ title: T('Add user'), body: `<div class="field"><label>${esc(T('Username'))}</label><input name="name" autocomplete="off"/></div><div class="field"><label>${esc(T('Password'))}</label><input name="password" type="password" autocomplete="new-password"/></div><label class="check"><input type="checkbox" name="admin"/>${esc(T('Administrator'))}</label>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add user'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
try { await webApi('/api/users', r); toast(T('Saved'), 'ok'); draw(); } catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
list.append(add);
|
||||
box.append(list);
|
||||
};
|
||||
draw();
|
||||
return card;
|
||||
}
|
||||
|
||||
async function viewSettings(page) {
|
||||
const st = settings();
|
||||
page.append(h(`<div class="toolbar"><h2 style="margin:0;font-size:18px">${T('Settings')}</h2></div>`));
|
||||
@@ -931,9 +989,9 @@ async function viewSettings(page) {
|
||||
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
|
||||
}).catch(() => {});
|
||||
|
||||
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
|
||||
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
|
||||
const btns = [
|
||||
[T('Import ~/.ssh/config'), importSshConfig],
|
||||
...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]),
|
||||
[T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }],
|
||||
[T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }],
|
||||
];
|
||||
@@ -985,7 +1043,8 @@ async function viewSettings(page) {
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
|
||||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
||||
c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
||||
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, importCard, dataCard, keysCard);
|
||||
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
||||
}
|
||||
|
||||
// ============================================================ App-Sperre
|
||||
@@ -1643,7 +1702,7 @@ class FilePane {
|
||||
<input class="path" spellcheck="false"/>
|
||||
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
|
||||
<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button></div>
|
||||
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div>
|
||||
<div class="files"></div></div>`);
|
||||
this.el.addEventListener('click', (e) => {
|
||||
const p = e.target.closest('[data-p]')?.dataset.p;
|
||||
@@ -1652,16 +1711,34 @@ class FilePane {
|
||||
if (p === 'refresh') this.go(this.cwd);
|
||||
if (p === 'mkdir') this.mkdir();
|
||||
if (p === 'xfer') this.transferSelected();
|
||||
if (p === 'upload') this.pickUpload();
|
||||
});
|
||||
$('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); });
|
||||
const files = $('.files', this.el);
|
||||
files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } });
|
||||
// Drag & Drop zwischen den Panes
|
||||
this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } });
|
||||
// Web-Version: Dateien aus dem Betriebssystem in den Server-Bereich ziehen = hochladen
|
||||
const osFiles = (e) => WEB && [...(e.dataTransfer?.types || [])].includes('Files');
|
||||
this.el.addEventListener('dragover', (e) => { if ((S.drag && S.drag.pane !== this) || osFiles(e)) { e.preventDefault(); this.el.classList.add('dragover'); } });
|
||||
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
|
||||
this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; });
|
||||
this.el.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); this.el.classList.remove('dragover');
|
||||
if (osFiles(e)) return this.uploadFiles([...e.dataTransfer.files]);
|
||||
if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected();
|
||||
S.drag = null;
|
||||
});
|
||||
}
|
||||
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
|
||||
pickUpload() {
|
||||
const inp = h('<input type="file" multiple style="display:none"/>');
|
||||
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
|
||||
document.body.append(inp);
|
||||
inp.click();
|
||||
}
|
||||
async uploadFiles(files) {
|
||||
for (const f of files) await this.sftp.webTransfer('upload', f, `${this.cwd.replace(/\/$/, '')}/${f.name}`, f.name);
|
||||
this.go(this.cwd);
|
||||
}
|
||||
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
|
||||
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
|
||||
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
|
||||
@@ -1756,6 +1833,14 @@ class FilePane {
|
||||
async transferSelected() {
|
||||
const names = [...this.sel];
|
||||
if (!names.length) return toast(T('Nothing selected'));
|
||||
if (WEB) {
|
||||
for (const n of names) {
|
||||
const f = this.files.find((x) => x.name === n);
|
||||
if (f?.isDir) { toast(T('Folders cannot be downloaded in the browser: {name}', { name: n })); continue; }
|
||||
this.sftp.webTransfer('download', null, await this.join(this.cwd, n), n);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const target = this.other;
|
||||
for (const n of names) {
|
||||
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
|
||||
@@ -1771,17 +1856,18 @@ class SftpSession {
|
||||
this.kind = 'sftp'; this.id = uid(); this.host = host;
|
||||
this.title = `SFTP · ${host.label || host.address}`;
|
||||
this.el = h(`<div class="session"><div class="sftp"></div><div class="transfers"></div></div>`);
|
||||
this.local = new FilePane(this, 'local');
|
||||
this.local = WEB ? null : new FilePane(this, 'local');
|
||||
this.remote = new FilePane(this, 'remote');
|
||||
const wrap = $('.sftp', this.el);
|
||||
wrap.append(this.local.el, h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
|
||||
if (this.local) wrap.append(this.local.el);
|
||||
wrap.append(h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div>`));
|
||||
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
|
||||
this.xfers = new Map();
|
||||
addTab(this);
|
||||
this.open();
|
||||
}
|
||||
async open() {
|
||||
this.local.init(await api.call('local:home'));
|
||||
if (this.local) this.local.init(await api.call('local:home'));
|
||||
try {
|
||||
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
|
||||
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
|
||||
@@ -1810,6 +1896,32 @@ class SftpSession {
|
||||
setTimeout(() => row.remove(), 10000);
|
||||
}
|
||||
}
|
||||
// Web-Version: Upload per HTTP (mit Fortschritt), Download als Browser-Download
|
||||
webTransfer(dir, file, remotePath, name) {
|
||||
const q = `sid=${encodeURIComponent(this.id)}&path=${encodeURIComponent(remotePath)}`;
|
||||
if (dir === 'download') {
|
||||
const a = h(`<a href="/web/download?${q}" download="${esc(name)}" style="display:none"></a>`);
|
||||
document.body.append(a); a.click(); a.remove();
|
||||
return Promise.resolve();
|
||||
}
|
||||
const tid = uid();
|
||||
const row = h(`<div class="transfer"><span>${ICONS.upload}</span><span class="nm">${esc(name)}</span><span class="pct">0%</span><div class="bar"><i></i></div></div>`);
|
||||
$('.transfers', this.el).prepend(row);
|
||||
this.xfers.set(tid, row);
|
||||
return new Promise((resolve) => {
|
||||
const x = new XMLHttpRequest();
|
||||
x.open('POST', `/web/upload?${q}`);
|
||||
x.setRequestHeader('X-MrTerm', '1');
|
||||
x.upload.onprogress = (e) => this.progress(tid, e.loaded, e.total);
|
||||
x.onload = () => {
|
||||
if (x.status < 300) { row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done'); setTimeout(() => row.remove(), 5000); }
|
||||
else { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); const m = (() => { try { return JSON.parse(x.responseText).error; } catch { return x.statusText; } })(); toast(`${name}: ${m}`, 'error'); setTimeout(() => row.remove(), 10000); }
|
||||
resolve();
|
||||
};
|
||||
x.onerror = () => { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); resolve(); };
|
||||
x.send(file);
|
||||
});
|
||||
}
|
||||
progress(tid, done, total) {
|
||||
const row = this.xfers.get(tid); if (!row) return;
|
||||
const p = total ? Math.round((done / total) * 100) : 0;
|
||||
@@ -2466,6 +2578,19 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Web-Version: Hostschlüssel bestätigen (in Electron zeigt der Hauptprozess dafür einen Systemdialog)
|
||||
api.on('hostkey:request', async (req) => {
|
||||
const changed = !!req.previous;
|
||||
const r = await modal({
|
||||
title: changed ? T('Host key has changed!') : T('Unknown host'),
|
||||
body: `<p style="margin-top:0">${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}</p>
|
||||
<div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.fingerprint)}</div>
|
||||
${changed ? `<p>${esc(T('Previously stored:'))}</p><div class="mono" style="word-break:break-all;background:var(--panel);padding:8px 10px;border-radius:8px">${esc(req.previous)}</div>` : ''}`,
|
||||
buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }],
|
||||
noEnter: changed,
|
||||
});
|
||||
api.replySecret(req.reqId, r === true);
|
||||
});
|
||||
api.on('secret:request', async (req) => {
|
||||
const r = await modal({
|
||||
title: req.title || T('Authentication'),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" />
|
||||
<title>MrTerm</title>
|
||||
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" />
|
||||
<link rel="stylesheet" href="app-themes.css" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+31
-65
@@ -1,71 +1,12 @@
|
||||
:root {
|
||||
--bg: #13151c;
|
||||
--bg-2: #181b24;
|
||||
--panel: #1c1f2a;
|
||||
--card: #212533;
|
||||
--card-hover: #282d3d;
|
||||
--border: #2b3040;
|
||||
--text: #e6e8ef;
|
||||
--muted: #8b91a5;
|
||||
--faint: #5d6377;
|
||||
--accent: #6e7bff;
|
||||
--accent-2: #8b95ff;
|
||||
--green: #3ecf8e;
|
||||
--red: #ff5f6d;
|
||||
--orange: #ffb454;
|
||||
--accent-3: #b36bff;
|
||||
--chrome: #0f1117;
|
||||
--chrome-line: #1f2330;
|
||||
--hover: #1a1d27;
|
||||
--nav-active: #1f2233;
|
||||
--icon-bg: #2a2f45;
|
||||
--folder: #6ea8ff;
|
||||
--row-line: #1c1f29;
|
||||
--overlay: #13151ce6;
|
||||
--tint: 255 255 255;
|
||||
--radius: 10px;
|
||||
--titlebar: 42px;
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
/* ---------- App-Designs ---------- */
|
||||
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
|
||||
[data-theme='navy'] {
|
||||
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
|
||||
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
|
||||
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
|
||||
}
|
||||
[data-theme='nord'] {
|
||||
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
|
||||
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
|
||||
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
|
||||
}
|
||||
[data-theme='dracula'] {
|
||||
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
|
||||
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
|
||||
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
|
||||
}
|
||||
[data-theme='mocha'] {
|
||||
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
|
||||
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
|
||||
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
|
||||
}
|
||||
[data-theme='forest'] {
|
||||
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
|
||||
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
|
||||
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
|
||||
}
|
||||
[data-theme='light'] {
|
||||
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
|
||||
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
|
||||
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
|
||||
--tint: 0 0 0;
|
||||
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||
}
|
||||
[data-theme='light'] .card { border-color: var(--border); }
|
||||
[data-theme='light'] .avatar .proto { background: var(--panel); }
|
||||
[data-theme='light'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
|
||||
[data-theme='light'] .card, [data-theme='xp'] .card, [data-theme='win11'] .card, [data-theme='macos'] .card { border-color: var(--border); }
|
||||
[data-theme='light'] .avatar .proto, [data-theme='xp'] .avatar .proto, [data-theme='win11'] .avatar .proto, [data-theme='macos'] .avatar .proto { background: var(--panel); }
|
||||
[data-theme='light'] ::-webkit-scrollbar-thumb, [data-theme='xp'] ::-webkit-scrollbar-thumb, [data-theme='win11'] ::-webkit-scrollbar-thumb, [data-theme='macos'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
||||
button { font: inherit; color: inherit; cursor: pointer; }
|
||||
@@ -154,7 +95,7 @@ body.in-session #sidebar { display: none; }
|
||||
.search svg { color: var(--faint); }
|
||||
.btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); font-weight: 600; white-space: nowrap; }
|
||||
.btn:hover { background: var(--card-hover); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.btn.primary:hover { background: var(--accent-2); }
|
||||
.btn.danger { color: var(--red); }
|
||||
.btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
||||
@@ -221,7 +162,7 @@ body.in-session #sidebar { display: none; }
|
||||
.form h4 { margin: 20px 0 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent-2); }
|
||||
.seg { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; margin-bottom: 14px; }
|
||||
.seg button { flex: 1; border: none; background: none; padding: 7px; border-radius: 6px; color: var(--muted); font-weight: 600; }
|
||||
.seg button.active { background: var(--accent); color: white; }
|
||||
.seg button.active { background: var(--accent); color: var(--on-accent); }
|
||||
.check { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; cursor: pointer; }
|
||||
.check input { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||
.colors { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -443,3 +384,28 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
.fw-hint b { color: var(--text); }
|
||||
.fw-hint p { margin: 4px 0 8px; color: var(--muted); }
|
||||
.fw-hint pre, .fw-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 0 0 10px; color: var(--text); white-space: pre-wrap; user-select: text; }
|
||||
|
||||
/* Design-Extras: XP-Titelleiste, Cyberpunk-Neon */
|
||||
[data-theme='xp'] #titlebar { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; }
|
||||
[data-theme='xp'] #titlebar .brand, [data-theme='xp'] #titlebar .win-ctrls button, [data-theme='xp'] #titlebar .tab:not(.active) { color: #fff; }
|
||||
[data-theme='xp'] #titlebar .tab.active { color: var(--text); }
|
||||
[data-theme='xp'] .btn.primary { background: linear-gradient(#3d86e8, #2159b8); border-color: #1b4a9a; }
|
||||
[data-theme='cyberpunk'] .btn.primary { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 10px) 100%, 0 100%); text-transform: uppercase; letter-spacing: .5px; }
|
||||
[data-theme='cyberpunk'] #titlebar { border-bottom: 1px solid #fcee0a55; }
|
||||
[data-theme='cyberpunk'] .card:hover { box-shadow: inset 2px 0 0 var(--accent-3); }
|
||||
[data-theme='xp'] .brand-term, [data-theme='win11'] .brand-term, [data-theme='macos'] .brand-term { color: #14a37a; }
|
||||
/* macOS Liquid Glass */
|
||||
[data-theme='macos'] #main, [data-theme='macos'] .pane { background: transparent; }
|
||||
[data-theme='macos'] :is(#titlebar, #sidebar, .toolbar, .card, .settings-card, .modal, .drawer, .ctx, .palette, .search, .toast, .net-card, .findbar, .pane th, .tab.active, .btn, .seg, .field input, .field select, .field textarea) {
|
||||
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
[data-theme='macos'] :is(.card, .settings-card, .modal, .drawer, .ctx, .palette, .toast, .net-card, .search) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
|
||||
[data-theme='macos'] #sidebar { border-right: 1px solid var(--chrome-line); box-shadow: inset -1px 0 0 rgba(255, 255, 255, .6); }
|
||||
[data-theme='macos'] #titlebar { border-bottom: 1px solid var(--chrome-line); }
|
||||
[data-theme='macos'] .btn { border-radius: 999px; border-color: rgba(255, 255, 255, .75); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
|
||||
[data-theme='macos'] .btn.primary { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); border-color: rgba(255, 255, 255, .45); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 4px 14px rgba(0, 122, 255, .3); }
|
||||
[data-theme='macos'] .modal-bg { -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
||||
[data-theme='macos'] .nav.active { box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
|
||||
|
||||
/* Web-Version (Browser): Desktop-Funktionen ausblenden */
|
||||
.web .win-ctrls, .web #lockBtn, .web .nav[data-view='vpns'], .web .nav[data-view='forwards'], .web #updateBadge { display: none !important; }
|
||||
|
||||
+19
-1
@@ -29,6 +29,20 @@ TERM_THEMES.mocha = { name: 'Catppuccin Mocha', background: '#1e1e2e', foregroun
|
||||
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
|
||||
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
|
||||
|
||||
// Terminal-Schemata zu den System-Designs
|
||||
TERM_THEMES.xp = { name: 'Windows XP (cmd.exe)', background: '#000000', foreground: '#c0c0c0', cursor: '#c0c0c0', cursorAccent: '#000000', selectionBackground: '#c0c0c066',
|
||||
black: '#000000', red: '#800000', green: '#008000', yellow: '#808000', blue: '#000080', magenta: '#800080', cyan: '#008080', white: '#c0c0c0',
|
||||
brightBlack: '#808080', brightRed: '#ff0000', brightGreen: '#00ff00', brightYellow: '#ffff00', brightBlue: '#0000ff', brightMagenta: '#ff00ff', brightCyan: '#00ffff', brightWhite: '#ffffff' };
|
||||
TERM_THEMES.campbell = { name: 'Windows 11 (Campbell)', background: '#0c0c0c', foreground: '#cccccc', cursor: '#ffffff', cursorAccent: '#0c0c0c', selectionBackground: '#ffffff40',
|
||||
black: '#0c0c0c', red: '#c50f1f', green: '#13a10e', yellow: '#c19c00', blue: '#0037da', magenta: '#881798', cyan: '#3a96dd', white: '#cccccc',
|
||||
brightBlack: '#767676', brightRed: '#e74856', brightGreen: '#16c60c', brightYellow: '#f9f1a5', brightBlue: '#3b78ff', brightMagenta: '#b4009e', brightCyan: '#61d6d6', brightWhite: '#f2f2f2' };
|
||||
TERM_THEMES.macos = { name: 'macOS Terminal', background: '#ffffff', foreground: '#1d1d1f', cursor: '#7f7f7f', cursorAccent: '#ffffff', selectionBackground: '#b4d7ff',
|
||||
black: '#000000', red: '#c23621', green: '#25bc24', yellow: '#adad27', blue: '#492ee1', magenta: '#d338d3', cyan: '#33bbc8', white: '#cbcccd',
|
||||
brightBlack: '#818383', brightRed: '#fc391f', brightGreen: '#31e722', brightYellow: '#eaec23', brightBlue: '#5833ff', brightMagenta: '#f935f8', brightCyan: '#14f0f0', brightWhite: '#e9ebeb' };
|
||||
TERM_THEMES.cyberpunk = { name: 'Cyberpunk 2077', background: '#0a0a0f', foreground: '#eafcff', cursor: '#fcee0a', cursorAccent: '#0a0a0f', selectionBackground: '#00f0ff44',
|
||||
black: '#15151f', red: '#ff003c', green: '#00ff9f', yellow: '#fcee0a', blue: '#00b8ff', magenta: '#ff2bd6', cyan: '#00f0ff', white: '#d7e6ee',
|
||||
brightBlack: '#5a7680', brightRed: '#ff4f70', brightGreen: '#5cffc0', brightYellow: '#fff45c', brightBlue: '#5cd3ff', brightMagenta: '#ff6be4', brightCyan: '#6bf7ff', brightWhite: '#ffffff' };
|
||||
|
||||
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
|
||||
window.APP_THEMES = {
|
||||
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
|
||||
@@ -37,5 +51,9 @@ window.APP_THEMES = {
|
||||
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
|
||||
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
|
||||
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
|
||||
light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' },
|
||||
light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light', light: true },
|
||||
xp: { name: 'Windows XP', bg: '#ece9d8', side: '#0a5fdc', card: '#ffffff', accent: '#316ac5', text: '#000000', term: 'xp', light: true },
|
||||
win11: { name: 'Windows 11', bg: '#f3f3f3', side: '#ebebeb', card: '#ffffff', accent: '#0067c0', text: '#1b1b1b', term: 'campbell', light: true },
|
||||
macos: { name: 'macOS', bg: '#ffffff', side: '#e8e8ed', card: '#f5f5f7', accent: '#007aff', text: '#1d1d1f', term: 'macos', light: true },
|
||||
cyberpunk: { name: 'Cyberpunk 2077', bg: '#0a0a0f', side: '#07070b', card: '#15151f', accent: '#fcee0a', text: '#eafcff', term: 'cyberpunk' },
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,36 @@
|
||||
// Anmeldung bzw. Ersteinrichtung (Admin-Konto) der Web-Version
|
||||
(async () => {
|
||||
'use strict';
|
||||
I18N.setLanguage('auto', navigator.language);
|
||||
const T = I18N.t;
|
||||
const $ = (s) => document.querySelector(s);
|
||||
document.querySelectorAll('[data-t]').forEach((e) => { e.textContent = T(e.dataset.t); });
|
||||
|
||||
const st = await fetch('/api/state').then((r) => r.json());
|
||||
if (st.user) { location.href = '/app/src/renderer/index.html'; return; }
|
||||
const setup = st.setup;
|
||||
$('#ver').textContent = `MrTerm Web ${st.version}`;
|
||||
$('#intro').textContent = setup ? T('Welcome! Create the administrator account for this MrTerm server.') : T('Sign in to your MrTerm server.');
|
||||
$('#submit').textContent = setup ? T('Create account') : T('Sign in');
|
||||
$('#repeatRow').hidden = !setup;
|
||||
if (setup) $('[name=password]').autocomplete = 'new-password';
|
||||
|
||||
$('#form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const f = Object.fromEntries(new FormData(e.target));
|
||||
const err = $('#err');
|
||||
err.hidden = true;
|
||||
if (setup && f.password !== f.repeat) { err.textContent = T('The passwords do not match.'); err.hidden = false; return; }
|
||||
$('#submit').disabled = true;
|
||||
try {
|
||||
const r = await fetch(setup ? '/api/setup' : '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: JSON.stringify({ name: f.name, password: f.password }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
location.href = '/app/src/renderer/index.html';
|
||||
} catch (ex) {
|
||||
err.textContent = T(ex.message);
|
||||
err.hidden = false;
|
||||
$('#submit').disabled = false;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,109 @@
|
||||
// window.api für die Web-Version: gleiche Schnittstelle wie src/preload.js (Electron),
|
||||
// aber über einen WebSocket zum MrTerm-Web-Server. Dateidialoge, Zwischenablage und Links laufen im Browser.
|
||||
(() => {
|
||||
'use strict';
|
||||
const listeners = new Map();
|
||||
const waits = new Map();
|
||||
let ws, seq = 0, queue = [], retry = 0, banner;
|
||||
const emit = (ch, args) => listeners.get(ch)?.forEach((fn) => { try { fn(...args); } catch (e) { console.error(e); } });
|
||||
|
||||
function showBanner(text) {
|
||||
if (!document.body) return;
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.style.cssText = 'position:fixed;left:50%;top:10px;transform:translateX(-50%);z-index:9999;background:var(--card,#222);color:var(--text,#eee);border:1px solid var(--border,#444);border-radius:10px;padding:8px 14px;font:13px system-ui;box-shadow:0 6px 24px #0006';
|
||||
document.body.append(banner);
|
||||
}
|
||||
banner.textContent = text;
|
||||
banner.hidden = !text;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
|
||||
ws.onopen = () => { retry = 0; showBanner(''); for (const m of queue) ws.send(m); queue = []; };
|
||||
ws.onmessage = (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.t === 'reply') {
|
||||
const w = waits.get(m.id);
|
||||
if (!w) return;
|
||||
waits.delete(m.id);
|
||||
m.ok ? w.resolve(m.v) : w.reject(new Error(m.v));
|
||||
} else if (m.t === 'evt') emit(m.ch, m.args || []);
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
for (const w of waits.values()) w.reject(new Error('Connection to the MrTerm server lost.'));
|
||||
waits.clear();
|
||||
if (e.code === 4001) { location.href = '/'; return; }
|
||||
// Sitzung abgelaufen? Dann zur Anmeldung, sonst neu verbinden
|
||||
fetch('/api/me', { headers: { 'X-MrTerm': '1' } }).then((r) => {
|
||||
if (r.status === 401) { location.href = '/'; return; }
|
||||
showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …');
|
||||
setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000));
|
||||
}).catch(() => { showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …'); setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)); });
|
||||
};
|
||||
}
|
||||
const post = (m) => { const s = JSON.stringify(m); if (ws.readyState === 1) ws.send(s); else queue.push(s); };
|
||||
const remote = (ch, args) => new Promise((resolve, reject) => { const id = ++seq; waits.set(id, { resolve, reject }); post({ t: 'call', id, ch, args }); });
|
||||
|
||||
// Datei im Browser auswählen und als Text lesen
|
||||
function pickFile(accept) {
|
||||
return new Promise((resolve) => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file';
|
||||
if (accept) inp.accept = accept;
|
||||
inp.style.display = 'none';
|
||||
inp.onchange = async () => { const f = inp.files[0]; inp.remove(); resolve(f ? { name: f.name, content: await f.text() } : null); };
|
||||
inp.addEventListener('cancel', () => { inp.remove(); resolve(null); });
|
||||
document.body.append(inp);
|
||||
inp.click();
|
||||
});
|
||||
}
|
||||
function downloadText(name, text) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const a = Object.assign(document.createElement('a'), { href: url, download: name });
|
||||
document.body.append(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
// Kanäle, die im Browser selbst erledigt werden
|
||||
const local = {
|
||||
// Ohne HTTPS gibt es navigator.clipboard nicht: Kopieren per execCommand, Einfügen dann nur per Strg+V
|
||||
'clipboard:write': (t) => {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(String(t));
|
||||
const ta = Object.assign(document.createElement('textarea'), { value: String(t) });
|
||||
ta.style.cssText = 'position:fixed;opacity:0';
|
||||
document.body.append(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
||||
},
|
||||
'clipboard:read': () => {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.readText();
|
||||
throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.');
|
||||
},
|
||||
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
|
||||
'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; },
|
||||
'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); },
|
||||
'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; },
|
||||
'import:pickFile': async () => pickFile(),
|
||||
'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); },
|
||||
};
|
||||
|
||||
window.api = {
|
||||
platform: 'web',
|
||||
call: (ch, ...args) => (local[ch] ? Promise.resolve().then(() => local[ch](...args)) : remote(ch, args)),
|
||||
on(ch, fn) {
|
||||
if (!listeners.has(ch)) listeners.set(ch, new Set());
|
||||
listeners.get(ch).add(fn);
|
||||
return () => listeners.get(ch).delete(fn);
|
||||
},
|
||||
win: { min() {}, max() {}, close() {} },
|
||||
ssh: {
|
||||
write: (id, d) => post({ t: 'send', ch: 'ssh:write', args: [id, d] }),
|
||||
resize: (id, c, r) => post({ t: 'send', ch: 'ssh:resize', args: [id, c, r] }),
|
||||
close: (id) => post({ t: 'send', ch: 'ssh:close', args: [id] }),
|
||||
},
|
||||
rdp: { bounds() {}, show() {}, hide() {}, focus() {}, close() {} },
|
||||
replySecret: (reqId, v) => post({ t: 'send', ch: 'secret:reply', args: [reqId, v] }),
|
||||
pairReply: (reqId, ok) => post({ t: 'send', ch: 'sync:pairReply', args: [reqId, ok] }),
|
||||
};
|
||||
document.documentElement.classList.add('web');
|
||||
connect();
|
||||
})();
|
||||
@@ -0,0 +1,376 @@
|
||||
// MrTerm Web: die Desktop-Oberfläche im Browser, selbst gehostet (Docker).
|
||||
// - Benutzerkonten; jeder Tresor ist mit einem eigenen Datenschlüssel (DEK) verschlüsselt,
|
||||
// den nur das Login-Passwort des Benutzers entpackt (scrypt → AES-256-GCM).
|
||||
// - Oberfläche ↔ Server über WebSocket (/ws), gleiche Kanäle wie Electron/Android (src/core/backend.js).
|
||||
// - SFTP-Up-/Downloads über HTTP (/web/upload, /web/download).
|
||||
//
|
||||
// Umgebung: PORT (8080), DATA_DIR (/data), TRUST_PROXY=1 (X-Forwarded-Proto/-For auswerten, z. B. hinter Traefik)
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { Store } = require('../main/store');
|
||||
const { createBackend } = require('../core/backend');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
const ROOT = process.env.MRTERM_ROOT || path.resolve(__dirname, '..', '..');
|
||||
const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'web-data'));
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
const TRUST_PROXY = process.env.TRUST_PROXY === '1';
|
||||
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { return '0.0.0'; } })();
|
||||
const SESSION_IDLE = 12 * 3600e3;
|
||||
const SESSION_MAX = 7 * 24 * 3600e3;
|
||||
const COOKIE = 'mrterm_sid';
|
||||
|
||||
process.on('uncaughtException', (e) => console.error('Uncaught exception:', e));
|
||||
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||
fs.mkdirSync(path.join(DATA, 'users'), { recursive: true, mode: 0o700 });
|
||||
|
||||
// ============================================================ Krypto-Helfer
|
||||
const scrypt = (pw, salt, N = 2 ** 15) => new Promise((resolve, reject) =>
|
||||
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||||
function box(key, plain) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const ct = Buffer.concat([c.update(plain), c.final()]);
|
||||
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
||||
}
|
||||
function unbox(key, b) {
|
||||
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64'));
|
||||
d.setAuthTag(Buffer.from(b.tag, 'base64'));
|
||||
return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]);
|
||||
}
|
||||
// Verschlüsselung des Tresors mit dem DEK (Schnittstelle wie Electrons safeStorage)
|
||||
const vaultCrypto = (dek) => ({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString(s) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', dek, iv);
|
||||
const ct = Buffer.concat([c.update(String(s), 'utf8'), c.final()]);
|
||||
return Buffer.concat([iv, c.getAuthTag(), ct]);
|
||||
},
|
||||
decryptString(buf) {
|
||||
const d = crypto.createDecipheriv('aes-256-gcm', dek, buf.subarray(0, 12));
|
||||
d.setAuthTag(buf.subarray(12, 28));
|
||||
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================ Benutzer
|
||||
const USERS_FILE = path.join(DATA, 'users.json');
|
||||
let users = (() => { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')).users || []; } catch { return []; } })();
|
||||
function saveUsers() {
|
||||
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify({ users }, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
|
||||
}
|
||||
const validName = (n) => /^[A-Za-z0-9._@-]{1,64}$/.test(String(n || ''));
|
||||
const validPw = (p) => typeof p === 'string' && p.length >= 8 && p.length <= 1024;
|
||||
const publicUser = (u) => ({ id: u.id, name: u.name, admin: !!u.admin });
|
||||
|
||||
async function createUser(name, password, admin) {
|
||||
if (!validName(name)) throw new Error('Usernames may contain letters, digits and . _ @ - (max. 64).');
|
||||
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
|
||||
if (users.some((u) => u.name.toLowerCase() === name.toLowerCase())) throw new Error('This username is already taken.');
|
||||
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
|
||||
const dek = crypto.randomBytes(32);
|
||||
const u = {
|
||||
id: crypto.randomUUID(), name, admin: !!admin, createdAt: Date.now(),
|
||||
auth: { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') },
|
||||
kek: { salt: kekSalt.toString('base64') },
|
||||
dek: box(await scrypt(password, kekSalt), dek),
|
||||
};
|
||||
users.push(u);
|
||||
saveUsers();
|
||||
return u;
|
||||
}
|
||||
// Prüft das Passwort und liefert den entpackten DEK (oder null)
|
||||
async function verify(u, password) {
|
||||
const hash = await scrypt(password, Buffer.from(u.auth.salt, 'base64'));
|
||||
if (!crypto.timingSafeEqual(hash, Buffer.from(u.auth.hash, 'base64'))) return null;
|
||||
try { return unbox(await scrypt(password, Buffer.from(u.kek.salt, 'base64')), u.dek); } catch { return null; }
|
||||
}
|
||||
async function setPassword(u, dek, password) {
|
||||
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
|
||||
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
|
||||
u.auth = { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') };
|
||||
u.kek = { salt: kekSalt.toString('base64') };
|
||||
u.dek = box(await scrypt(password, kekSalt), dek);
|
||||
saveUsers();
|
||||
}
|
||||
|
||||
// ============================================================ Sitzungen
|
||||
const sessions = new Map(); // token -> { userId, dek, created, last }
|
||||
function newSession(u, dek) {
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
sessions.set(token, { userId: u.id, dek, created: Date.now(), last: Date.now() });
|
||||
return token;
|
||||
}
|
||||
function sessionOf(req) {
|
||||
const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`));
|
||||
const s = m && sessions.get(m[1]);
|
||||
if (!s) return null;
|
||||
const now = Date.now();
|
||||
if (now - s.last > SESSION_IDLE || now - s.created > SESSION_MAX || !users.some((u) => u.id === s.userId)) { sessions.delete(m[1]); return null; }
|
||||
s.last = now;
|
||||
return { token: m[1], ...s, user: users.find((u) => u.id === s.userId) };
|
||||
}
|
||||
setInterval(() => { for (const [t, s] of sessions) if (Date.now() - s.last > SESSION_IDLE || Date.now() - s.created > SESSION_MAX) sessions.delete(t); }, 600e3).unref();
|
||||
|
||||
const secure = (req) => req.socket.encrypted || (TRUST_PROXY && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
|
||||
const cookie = (req, token, maxAge) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? '; Secure' : ''}`;
|
||||
const clientIp = (req) => (TRUST_PROXY && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || '';
|
||||
|
||||
// Anmeldeversuche begrenzen: 10 Fehlversuche je IP in 15 Minuten
|
||||
const failures = new Map();
|
||||
const blocked = (ip) => { const f = failures.get(ip); return f && f.n >= 10 && Date.now() - f.first < 900e3; };
|
||||
const fail = (ip) => { const f = failures.get(ip); if (!f || Date.now() - f.first > 900e3) failures.set(ip, { n: 1, first: Date.now() }); else f.n++; };
|
||||
|
||||
// ============================================================ Laufzeit je Benutzer (Tresor + Backend)
|
||||
const runtimes = new Map(); // userId -> { store, be, sockets, timer }
|
||||
function runtimeFor(sess) {
|
||||
let rt = runtimes.get(sess.userId);
|
||||
if (rt) { clearTimeout(rt.timer); return rt; }
|
||||
const dir = path.join(DATA, 'users', sess.userId);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const store = new Store({ dir, crypto: vaultCrypto(sess.dek) });
|
||||
store.load();
|
||||
const sockets = new Set();
|
||||
const send = (ch, ...args) => { const msg = JSON.stringify({ t: 'evt', ch, args }); for (const ws of sockets) if (ws.readyState === 1) ws.send(msg); };
|
||||
const be = createBackend({ store, send, platform: 'web', version: VERSION });
|
||||
// Desktop-Funktionen, die es im Browser nicht gibt: neutrale Antworten, damit die Oberfläche nicht stolpert
|
||||
be.handle('vpn:status', () => ({}));
|
||||
be.handle('fw:active', () => []);
|
||||
be.handle('rdp:detect', () => ({}));
|
||||
be.handle('rdp:embedSupported', () => ({ ok: false, reason: 'web' }));
|
||||
be.handle('update:check', () => ({ available: false }));
|
||||
be.handle('sync:status', () => ({ enabled: false, running: false, peers: [], nearby: [], share: { keys: [], hosts: [], vpns: [] } }));
|
||||
rt = { store, be, sockets, timer: null };
|
||||
runtimes.set(sess.userId, rt);
|
||||
return rt;
|
||||
}
|
||||
// Letzter Browser-Tab zu: SSH-Verbindungen nach kurzer Zeit schließen und den Tresor aus dem Speicher nehmen
|
||||
function release(userId) {
|
||||
const rt = runtimes.get(userId);
|
||||
if (!rt || rt.sockets.size) return;
|
||||
rt.timer = setTimeout(() => { if (!rt.sockets.size) { rt.be.close(); runtimes.delete(userId); } }, 60e3);
|
||||
}
|
||||
function dropUser(userId) {
|
||||
for (const [t, s] of sessions) if (s.userId === userId) sessions.delete(t);
|
||||
const rt = runtimes.get(userId);
|
||||
if (rt) { for (const ws of rt.sockets) ws.close(4001, 'signed out'); rt.be.close(); runtimes.delete(userId); }
|
||||
}
|
||||
|
||||
// ============================================================ HTTP
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2' };
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
|
||||
function headers(extra = {}) {
|
||||
return { 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store', ...extra };
|
||||
}
|
||||
const json = (res, code, obj, extra = {}) => { res.writeHead(code, headers({ 'Content-Type': 'application/json', ...extra })); res.end(JSON.stringify(obj)); };
|
||||
const redirect = (res, to) => { res.writeHead(302, headers({ Location: to })); res.end(); };
|
||||
|
||||
// Freigegebene statische Dateien: URL-Präfix → Verzeichnis
|
||||
const STATIC = [
|
||||
['/app/src/renderer/', path.join(ROOT, 'src', 'renderer')],
|
||||
['/app/src/i18n.js', path.join(ROOT, 'src', 'i18n.js')],
|
||||
['/app/node_modules/@xterm/', path.join(ROOT, 'node_modules', '@xterm')],
|
||||
['/web/', path.join(ROOT, 'src', 'web', 'public')],
|
||||
];
|
||||
function staticFile(urlPath) {
|
||||
for (const [prefix, dir] of STATIC) {
|
||||
if (urlPath === prefix && !prefix.endsWith('/')) return dir;
|
||||
if (!prefix.endsWith('/') || !urlPath.startsWith(prefix)) continue;
|
||||
const file = path.resolve(dir, '.' + path.posix.normalize('/' + decodeURIComponent(urlPath.slice(prefix.length))));
|
||||
if (!file.startsWith(dir + path.sep)) return null;
|
||||
if (dir.endsWith('@xterm') && !/[\\/](lib|css)[\\/][^\\/]+$/.test(file)) return null;
|
||||
if (dir.endsWith('renderer') && /\.(rdm|md)$/.test(file)) return null;
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function serveFile(res, file, transform) {
|
||||
fs.stat(file, (err, st) => {
|
||||
if (err || !st.isFile()) return json(res, 404, { error: 'Not found' });
|
||||
const type = MIME[path.extname(file)] || 'application/octet-stream';
|
||||
const cache = type.startsWith('text/html') ? 'no-store' : 'private, max-age=300';
|
||||
if (transform) {
|
||||
const body = transform(fs.readFileSync(file, 'utf8'));
|
||||
res.writeHead(200, headers({ 'Content-Type': type, 'Cache-Control': cache }));
|
||||
return res.end(body);
|
||||
}
|
||||
res.writeHead(200, headers({ 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': cache }));
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
}
|
||||
function readJson(req, limit = 64 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0; const parts = [];
|
||||
req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('Request too large')); req.destroy(); } else parts.push(c); });
|
||||
req.on('end', () => { try { resolve(parts.length ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); } catch { reject(new Error('Invalid JSON')); } });
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const APP_URL = '/app/src/renderer/index.html';
|
||||
|
||||
async function api(req, res, url, sess) {
|
||||
const p = url.pathname;
|
||||
// Schreibende Anfragen nur mit eigenem Header (Browser senden ihn nicht seitenübergreifend ohne CORS-Freigabe)
|
||||
if (req.method === 'POST' && req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
|
||||
if (p === '/api/state' && req.method === 'GET') return json(res, 200, { setup: !users.length, user: sess ? publicUser(sess.user) : null, version: VERSION });
|
||||
if (p === '/api/setup' && req.method === 'POST') {
|
||||
if (users.length) return json(res, 409, { error: 'Setup is already complete.' });
|
||||
const b = await readJson(req);
|
||||
const u = await createUser(b.name, b.password, true);
|
||||
const dek = await verify(u, b.password);
|
||||
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
|
||||
}
|
||||
if (p === '/api/login' && req.method === 'POST') {
|
||||
const ip = clientIp(req);
|
||||
if (blocked(ip)) return json(res, 429, { error: 'Too many failed attempts. Try again in 15 minutes.' });
|
||||
const b = await readJson(req);
|
||||
const u = users.find((x) => x.name.toLowerCase() === String(b.name || '').toLowerCase());
|
||||
const dek = u ? await verify(u, String(b.password || '')) : (await scrypt('x', crypto.randomBytes(16)), null);
|
||||
if (!dek) { fail(ip); return json(res, 401, { error: 'Wrong username or password.' }); }
|
||||
failures.delete(ip);
|
||||
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
|
||||
}
|
||||
if (!sess) return json(res, 401, { error: 'Not signed in' });
|
||||
if (p === '/api/me') return json(res, 200, publicUser(sess.user));
|
||||
if (p === '/api/logout' && req.method === 'POST') {
|
||||
sessions.delete(sess.token);
|
||||
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, '', 0) });
|
||||
}
|
||||
if (p === '/api/password' && req.method === 'POST') {
|
||||
const b = await readJson(req);
|
||||
if (!(await verify(sess.user, String(b.old || '')))) return json(res, 400, { error: 'The current password is wrong.' });
|
||||
await setPassword(sess.user, sess.dek, b.password);
|
||||
for (const [t, s] of sessions) if (s.userId === sess.userId && t !== sess.token) sessions.delete(t);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p.startsWith('/api/users')) {
|
||||
if (!sess.user.admin) return json(res, 403, { error: 'Administrators only.' });
|
||||
if (p === '/api/users' && req.method === 'GET') return json(res, 200, { users: users.map(publicUser) });
|
||||
if (p === '/api/users' && req.method === 'POST') {
|
||||
const b = await readJson(req);
|
||||
return json(res, 200, publicUser(await createUser(b.name, b.password, !!b.admin)));
|
||||
}
|
||||
if (p === '/api/users/delete' && req.method === 'POST') {
|
||||
const b = await readJson(req);
|
||||
const u = users.find((x) => x.id === b.id);
|
||||
if (!u) return json(res, 404, { error: 'User not found.' });
|
||||
if (u.id === sess.userId) return json(res, 400, { error: 'You cannot delete yourself.' });
|
||||
if (u.admin && users.filter((x) => x.admin).length === 1) return json(res, 400, { error: 'The last administrator cannot be deleted.' });
|
||||
dropUser(u.id);
|
||||
users = users.filter((x) => x.id !== u.id);
|
||||
saveUsers();
|
||||
fs.rmSync(path.join(DATA, 'users', u.id), { recursive: true, force: true });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
// SFTP-Dateien des angemeldeten Benutzers streamen
|
||||
function sftpFor(sess, url) {
|
||||
const rt = runtimes.get(sess.userId);
|
||||
const sid = url.searchParams.get('sid') || '';
|
||||
const file = url.searchParams.get('path') || '';
|
||||
if (!rt || !file.startsWith('/')) throw new Error('Invalid request');
|
||||
return { sftp: rt.be.ssh.sftpOf(sid), file };
|
||||
}
|
||||
function download(req, res, sess, url) {
|
||||
let t;
|
||||
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
|
||||
t.sftp.stat(t.file, (err, st) => {
|
||||
if (err) return json(res, 404, { error: err.message });
|
||||
const name = path.posix.basename(t.file);
|
||||
res.writeHead(200, headers({
|
||||
'Content-Type': 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': 'no-store',
|
||||
'Content-Disposition': `attachment; filename="${name.replace(/[^\x20-\x7e]|["\\]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
}));
|
||||
const rs = t.sftp.createReadStream(t.file);
|
||||
rs.on('error', () => res.destroy());
|
||||
rs.pipe(res);
|
||||
});
|
||||
}
|
||||
function upload(req, res, sess, url) {
|
||||
let t;
|
||||
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
|
||||
const ws = t.sftp.createWriteStream(t.file);
|
||||
let done = false;
|
||||
const finish = (err) => { if (done) return; done = true; err ? json(res, 500, { error: err.message }) : json(res, 200, { ok: true }); };
|
||||
ws.on('error', finish);
|
||||
ws.on('close', () => finish());
|
||||
req.on('error', finish);
|
||||
req.pipe(ws);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const p = url.pathname;
|
||||
const sess = sessionOf(req);
|
||||
if (p === '/healthz') return json(res, 200, { ok: true });
|
||||
if (p.startsWith('/api/')) return await api(req, res, url, sess);
|
||||
if (p === '/web/download' && req.method === 'GET') return sess ? download(req, res, sess, url) : json(res, 401, { error: 'Not signed in' });
|
||||
if (p === '/web/upload' && req.method === 'POST') {
|
||||
if (!sess) return json(res, 401, { error: 'Not signed in' });
|
||||
if (req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
|
||||
return upload(req, res, sess, url);
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed' });
|
||||
if (p === '/' || p === '/login') return sess && p === '/' ? redirect(res, APP_URL) : serveFile(res, path.join(ROOT, 'src', 'web', 'public', 'login.html'));
|
||||
if (p === '/favicon.ico' || p === '/favicon.png') return serveFile(res, path.join(ROOT, 'src', 'renderer', 'logo.png'));
|
||||
if (p === APP_URL) {
|
||||
if (!sess) return redirect(res, '/');
|
||||
// Browser-Anbindung (window.api) vor den übrigen Skripten laden
|
||||
return serveFile(res, staticFile(p), (html) => html.replace('<script ', '<script src="/web/web-api.js"></script>\n <script '));
|
||||
}
|
||||
const file = staticFile(p);
|
||||
if (!file) return json(res, 404, { error: 'Not found' });
|
||||
// Nur die Anmeldeseite und ihre Dateien sind ohne Sitzung erreichbar
|
||||
const PUBLIC = ['/web/login.js', '/web/login.css', '/app/src/i18n.js', '/app/src/renderer/app-themes.css', '/app/src/renderer/logo.png'];
|
||||
if (!sess && !PUBLIC.includes(p)) return json(res, 401, { error: 'Not signed in' });
|
||||
return serveFile(res, file);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!res.headersSent) json(res, 500, { error: e.message || 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================ WebSocket
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const sess = sessionOf(req);
|
||||
// Nur Verbindungen von der eigenen Seite (Schutz vor Cross-Site-WebSocket-Hijacking)
|
||||
const origin = req.headers.origin ? new URL(req.headers.origin).host : '';
|
||||
const host = (TRUST_PROXY && req.headers['x-forwarded-host']) || req.headers.host;
|
||||
if (url.pathname !== '/ws' || !sess || origin !== host) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); return socket.destroy(); }
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const rt = runtimeFor(sess);
|
||||
rt.sockets.add(ws);
|
||||
const lang = String(req.headers['accept-language'] || 'en').split(',')[0];
|
||||
ws.on('message', async (raw) => {
|
||||
let m;
|
||||
try { m = JSON.parse(raw.toString('utf8')); } catch { return; }
|
||||
if (!sessions.has(sess.token)) return ws.close(4001, 'signed out');
|
||||
if (m.t === 'send') return rt.be.notify(String(m.ch), Array.isArray(m.args) ? m.args : []);
|
||||
if (m.t !== 'call') return;
|
||||
try {
|
||||
i18n.setLanguage(rt.store.get().settings.language, lang);
|
||||
const v = await rt.be.call(String(m.ch), Array.isArray(m.args) ? m.args : []);
|
||||
ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: true, v: v === undefined ? null : v }));
|
||||
} catch (e) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: false, v: e?.message || String(e) }));
|
||||
}
|
||||
});
|
||||
ws.on('close', () => { rt.sockets.delete(ws); release(sess.userId); });
|
||||
ws.send(JSON.stringify({ t: 'hello', user: publicUser(sess.user), version: VERSION }));
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (e) => { console.error(`Cannot listen on port ${PORT}: ${e.message}`); process.exit(1); });
|
||||
server.listen(PORT, () => console.log(`MrTerm Web ${VERSION} on port ${PORT} (data: ${DATA})${users.length ? '' : ' – open it in the browser to create the admin account'}`));
|
||||
Reference in New Issue
Block a user