Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8d73dd60 | ||
|
|
d87feb63af |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"productName": "MrTerm",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.0",
|
||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||
"main": "src/main/main.js",
|
||||
"author": "MrBlake",
|
||||
|
||||
@@ -156,6 +156,8 @@
|
||||
'sudo password': 'sudo-Passwort',
|
||||
'sudo password for {user}': 'sudo-Passwort für {user}',
|
||||
'Wrong password. sudo password for {user}': 'Falsches Passwort. sudo-Passwort für {user}',
|
||||
'Open in new window': 'In neuem Fenster öffnen',
|
||||
'Back to main window': 'Zurück ins Hauptfenster',
|
||||
// Support-Assistent
|
||||
'Delete chat': 'Chat löschen',
|
||||
'Delete chat?': 'Chat löschen?',
|
||||
|
||||
+63
-11
@@ -43,8 +43,19 @@ process.on('uncaughtException', (e) => {
|
||||
});
|
||||
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||
|
||||
// Abgelöste Tabs laufen in eigenen Fenstern. Ereignisse gehen an alle Fenster (die Oberfläche filtert nach Sitzungs-ID),
|
||||
// Passwortabfragen nur an das Fenster, das die Sitzung zeigt; Kopplung/Updates nur an das Hauptfenster.
|
||||
const tabWins = new Set();
|
||||
const sessionOwner = new Map(); // Sitzungs-ID -> BrowserWindow (nur für abgelöste Fenster)
|
||||
const MAIN_ONLY = new Set(['sync:pairPrompt', 'update:available']);
|
||||
const alive = (w) => w && !w.isDestroyed();
|
||||
function send(channel, ...args) {
|
||||
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args);
|
||||
if (channel === 'secret:request') {
|
||||
const w = sessionOwner.get(args[0]?.sessionId);
|
||||
if (alive(w)) return w.webContents.send(channel, ...args);
|
||||
}
|
||||
if (alive(win)) win.webContents.send(channel, ...args);
|
||||
if (!MAIN_ONLY.has(channel)) for (const w of tabWins) if (alive(w)) w.webContents.send(channel, ...args);
|
||||
}
|
||||
|
||||
async function confirmHostKey(target, fingerprint, known) {
|
||||
@@ -91,12 +102,13 @@ const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
|
||||
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
|
||||
store.onChange = () => sync.schedule();
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
function newWindow(opts = {}) {
|
||||
const w = new BrowserWindow({
|
||||
width: 1360,
|
||||
height: 860,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
...opts,
|
||||
backgroundColor: '#14161d',
|
||||
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
|
||||
frame: false,
|
||||
@@ -108,18 +120,57 @@ function createWindow() {
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
w.on('maximize', () => w.webContents.send('win:state', true));
|
||||
w.on('unmaximize', () => w.webContents.send('win:state', false));
|
||||
w.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||
return w;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = newWindow({ minWidth: 900, minHeight: 560 });
|
||||
Menu.setApplicationMenu(null);
|
||||
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
||||
win.on('maximize', () => send('win:state', true));
|
||||
// Hauptfenster zu = MrTerm beenden, auch abgelöste Tab-Fenster schließen
|
||||
win.on('closed', () => { for (const w of tabWins) if (alive(w)) w.close(); });
|
||||
// Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen
|
||||
const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } };
|
||||
win.on('move', followRdp);
|
||||
win.on('resize', followRdp);
|
||||
win.on('restore', followRdp);
|
||||
win.on('unmaximize', () => send('win:state', false));
|
||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||
}
|
||||
|
||||
// Tab in ein eigenes Fenster lösen: data = { kind, host, id, title, … } wird vom neuen Fenster per win:handoff abgeholt
|
||||
const handoffs = new Map();
|
||||
function openTabWindow(data, pos) {
|
||||
const token = crypto.randomUUID();
|
||||
handoffs.set(token, data);
|
||||
const w = newWindow({ width: 1000, height: 650, ...(pos ? { x: Math.round(pos.x - 120), y: Math.round(pos.y - 20) } : {}) });
|
||||
tabWins.add(w);
|
||||
w.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'), { query: { detached: token } });
|
||||
// Fenster zu: dort noch offene Sitzungen beenden
|
||||
w.on('closed', () => {
|
||||
tabWins.delete(w);
|
||||
for (const [id, o] of sessionOwner) {
|
||||
if (o !== w) continue;
|
||||
sessionOwner.delete(id);
|
||||
for (const m of [ssh, docker, firewall, network]) { try { m.close(id); } catch {} }
|
||||
}
|
||||
});
|
||||
}
|
||||
ipcMain.handle('win:detach', (_e, data, pos) => { openTabWindow(data, pos); return true; });
|
||||
ipcMain.handle('win:handoff', (_e, token) => { const d = handoffs.get(token); handoffs.delete(token); return d || null; });
|
||||
// Sitzung gehört (jetzt) dem aufrufenden Fenster; im Hauptfenster gilt keine Zuordnung
|
||||
ipcMain.on('win:claim', (e, id) => {
|
||||
const w = BrowserWindow.fromWebContents(e.sender);
|
||||
if (w === win) sessionOwner.delete(id); else sessionOwner.set(id, w);
|
||||
});
|
||||
// Tab ins Hauptfenster zurück
|
||||
ipcMain.handle('win:attach', (_e, data) => { if (alive(win)) { win.webContents.send('win:adopt', data); win.focus(); } return true; });
|
||||
// Ein anderes Fenster hat den Tab übernommen (Drag & Drop): beim bisherigen Fenster ohne Trennen entfernen
|
||||
ipcMain.on('win:released', (e, id) => {
|
||||
for (const w of [win, ...tabWins]) if (alive(w) && w.webContents !== e.sender) w.webContents.send('win:released', id);
|
||||
});
|
||||
|
||||
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
|
||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||
function handle(channel, fn) {
|
||||
@@ -133,9 +184,10 @@ function handle(channel, fn) {
|
||||
}
|
||||
|
||||
// ---------- Fenster ----------
|
||||
ipcMain.on('win:min', () => win.minimize());
|
||||
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
||||
ipcMain.on('win:close', () => win.close());
|
||||
const senderWin = (e) => BrowserWindow.fromWebContents(e.sender);
|
||||
ipcMain.on('win:min', (e) => senderWin(e)?.minimize());
|
||||
ipcMain.on('win:max', (e) => { const w = senderWin(e); if (w) w.isMaximized() ? w.unmaximize() : w.maximize(); });
|
||||
ipcMain.on('win:close', (e) => senderWin(e)?.close());
|
||||
|
||||
// ---------- App-Sperre (Passwort / FIDO2) ----------
|
||||
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||
|
||||
@@ -20,6 +20,12 @@ contextBridge.exposeInMainWorld('api', {
|
||||
min: () => ipcRenderer.send('win:min'),
|
||||
max: () => ipcRenderer.send('win:max'),
|
||||
close: () => ipcRenderer.send('win:close'),
|
||||
// Tabs lösen / zurückholen
|
||||
detach: (data, pos) => ipcRenderer.invoke('win:detach', data, pos),
|
||||
attach: (data) => ipcRenderer.invoke('win:attach', data),
|
||||
handoff: (token) => ipcRenderer.invoke('win:handoff', token),
|
||||
claim: (id) => ipcRenderer.send('win:claim', id),
|
||||
released: (id) => ipcRenderer.send('win:released', id),
|
||||
},
|
||||
ssh: {
|
||||
write: (id, d) => ipcRenderer.send('ssh:write', id, d),
|
||||
|
||||
+115
-5
@@ -159,6 +159,12 @@ async function reload() {
|
||||
S.activeForwards = new Set(await call('fw:active'));
|
||||
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||||
render();
|
||||
// Abgelöstes Fenster: übergebenen Tab erst nach dem Entsperren übernehmen (vorher fehlen Tresor und Einstellungen)
|
||||
if (DETACHED && !S.handoffDone) {
|
||||
S.handoffDone = true;
|
||||
const d = await api.win.handoff(DETACHED);
|
||||
if (d) adoptTab(d); else api.win.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title)
|
||||
@@ -1547,10 +1553,14 @@ function addTab(session) {
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []),
|
||||
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
|
||||
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
|
||||
...(DETACHABLE.has(session.kind) ? ['-', { label: T('Open in new window'), icon: 'dup', run: () => detachTab(session) },
|
||||
...(DETACHED ? [{ label: T('Back to main window'), icon: 'home', run: () => attachTab(session) }] : [])] : []),
|
||||
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||||
{ label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) },
|
||||
]);
|
||||
session.tabEl = tab;
|
||||
tabDrag(tab, session);
|
||||
if (!WEB) api.win.claim(session.id);
|
||||
$('#tabs').append(tab);
|
||||
$('#sessions').append(session.el);
|
||||
activateTab(session.id);
|
||||
@@ -1578,17 +1588,101 @@ function closeTab(id) {
|
||||
t.dispose();
|
||||
t.el.remove(); t.tabEl.remove();
|
||||
S.tabs.splice(i, 1);
|
||||
if (DETACHED && !S.tabs.length) return api.win.close();
|
||||
if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home');
|
||||
}
|
||||
$('.tab.home').onclick = () => activateTab('home');
|
||||
|
||||
// ============================================================ Tabs verschieben, lösen und zurückholen (Desktop)
|
||||
// Ziehen in der Tableiste = Reihenfolge ändern; aus dem Fenster ziehen = eigenes Fenster;
|
||||
// in die Tableiste eines anderen MrTerm-Fensters ziehen = dorthin verschieben. Terminals behalten dabei ihre SSH-Verbindung.
|
||||
const DETACHED = !WEB && new URLSearchParams(location.search).get('detached');
|
||||
const DETACHABLE = new Set(WEB ? [] : ['ssh', 'sftp', 'docker', 'firewall', 'network']);
|
||||
const TAB_MIME = 'application/x-mrterm-tab';
|
||||
|
||||
function tabHandoff(t) {
|
||||
const d = { kind: t.kind, host: t.host, id: t.id, title: t.title };
|
||||
if (t.kind === 'ssh') Object.assign(d, { text: t.bufferText(), alive: t.alive });
|
||||
return d;
|
||||
}
|
||||
// Tab hier entfernen, ohne die SSH-Verbindung zu trennen (andere Tabarten verbinden im neuen Fenster neu)
|
||||
function releaseTab(t) { t.keepAlive = true; closeTab(t.id); }
|
||||
function adoptTab(d) {
|
||||
if (d.kind === 'ssh') return new TerminalSession(d.host, d);
|
||||
return ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[d.kind])?.(d.host);
|
||||
}
|
||||
async function detachTab(t, pos) { await api.win.detach(tabHandoff(t), pos); releaseTab(t); }
|
||||
async function attachTab(t) { await api.win.attach(tabHandoff(t)); releaseTab(t); }
|
||||
|
||||
function tabDrag(tab, session) {
|
||||
tab.draggable = true;
|
||||
tab.addEventListener('dragstart', (e) => {
|
||||
S.dragTab = session;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
if (DETACHABLE.has(session.kind)) e.dataTransfer.setData(TAB_MIME, JSON.stringify(tabHandoff(session)));
|
||||
else e.dataTransfer.setData('text/plain', session.title);
|
||||
});
|
||||
tab.addEventListener('dragend', (e) => {
|
||||
const t = S.dragTab; S.dragTab = null;
|
||||
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
|
||||
if (!t || !DETACHABLE.has(t.kind) || e.dataTransfer.dropEffect !== 'none') return;
|
||||
// Außerhalb des Fensters losgelassen → eigenes Fenster an dieser Stelle
|
||||
const out = e.screenX < window.screenX || e.screenX > window.screenX + window.outerWidth || e.screenY < window.screenY || e.screenY > window.screenY + window.outerHeight;
|
||||
if (out) detachTab(t, { x: e.screenX, y: e.screenY });
|
||||
});
|
||||
}
|
||||
function tabDropTarget(e) {
|
||||
const tabs = $$('#tabs .tab:not(.home)');
|
||||
const over = tabs.find((x) => { const r = x.getBoundingClientRect(); return e.clientX >= r.left && e.clientX <= r.right; });
|
||||
if (!over) return null;
|
||||
const r = over.getBoundingClientRect();
|
||||
return { el: over, after: e.clientX > r.left + r.width / 2 };
|
||||
}
|
||||
const tabsBar = $('#tabs');
|
||||
tabsBar.addEventListener('dragover', (e) => {
|
||||
const local = !!S.dragTab, foreign = !local && [...e.dataTransfer.types].includes(TAB_MIME);
|
||||
if (!local && !foreign) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
|
||||
const t = tabDropTarget(e);
|
||||
if (t && t.el !== S.dragTab?.tabEl) t.el.classList.add(t.after ? 'drop-after' : 'drop-before');
|
||||
});
|
||||
tabsBar.addEventListener('dragleave', (e) => { if (!tabsBar.contains(e.relatedTarget)) $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); });
|
||||
tabsBar.addEventListener('drop', (e) => {
|
||||
const target = tabDropTarget(e);
|
||||
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
|
||||
let moved = S.dragTab;
|
||||
if (!moved) {
|
||||
// Tab aus einem anderen Fenster
|
||||
const raw = e.dataTransfer.getData(TAB_MIME);
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
const d = JSON.parse(raw);
|
||||
api.win.released(d.id);
|
||||
moved = adoptTab(d);
|
||||
if (!moved) return;
|
||||
} else e.preventDefault();
|
||||
if (target && target.el !== moved.tabEl) target.el.insertAdjacentElement(target.after ? 'afterend' : 'beforebegin', moved.tabEl);
|
||||
else if (!target) tabsBar.append(moved.tabEl);
|
||||
// Reihenfolge für Strg+Tab / Strg+1..9 übernehmen
|
||||
const order = $$('#tabs .tab[data-tab]').map((x) => x.dataset.tab);
|
||||
S.tabs.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
||||
activateTab(moved.id);
|
||||
});
|
||||
if (!WEB) {
|
||||
api.on('win:adopt', (d) => { adoptTab(d); });
|
||||
api.on('win:released', (id) => { const t = S.tabs.find((x) => x.id === id); if (t) releaseTab(t); });
|
||||
}
|
||||
|
||||
// ============================================================ Terminal-Session
|
||||
class TerminalSession {
|
||||
constructor(host) {
|
||||
// attach: { id, title, text, alive } – bestehende SSH-Sitzung aus einem anderen Fenster übernehmen
|
||||
constructor(host, attach) {
|
||||
this.kind = 'ssh';
|
||||
this.id = uid();
|
||||
this.id = attach?.id || uid();
|
||||
this.host = host;
|
||||
this.title = host.label || host.address;
|
||||
this.title = attach?.title || host.label || host.address;
|
||||
this.el = h(`<div class="session">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
|
||||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||||
@@ -1607,7 +1701,21 @@ class TerminalSession {
|
||||
this.bindUi();
|
||||
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
|
||||
addTab(this);
|
||||
this.connect();
|
||||
if (attach) this.adopt(attach); else this.connect();
|
||||
}
|
||||
|
||||
adopt({ text, alive }) {
|
||||
if (text) this.term.write(text.replace(/\r?\n/g, '\r\n'));
|
||||
this.alive = !!alive; this.ended = !alive;
|
||||
setTabState(this, alive ? 'on' : '');
|
||||
requestAnimationFrame(() => { this.doFit(); if (this.alive) api.ssh.resize(this.id, this.term.cols, this.term.rows); });
|
||||
}
|
||||
// Gesamter Puffer als Text (beim Lösen in ein anderes Fenster; Farben gehen dabei verloren)
|
||||
bufferText() {
|
||||
const b = this.term.buffer.active, out = [];
|
||||
for (let i = 0; i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');
|
||||
while (out.length && !out[out.length - 1]) out.pop();
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
initTerm() {
|
||||
@@ -1760,7 +1868,7 @@ class TerminalSession {
|
||||
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
|
||||
}
|
||||
}
|
||||
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
|
||||
dispose() { this.unsub(); this.ro.disconnect(); if (!this.keepAlive) api.ssh.close(this.id); this.term.dispose(); }
|
||||
}
|
||||
// Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
|
||||
function aiMarkdown(md) {
|
||||
@@ -3177,5 +3285,7 @@ api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch
|
||||
api.on('toast', (m, t) => toast(m, t));
|
||||
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
|
||||
|
||||
// Abgelöstes Fenster: nur Tabs, ohne Seitenleiste und Tresor-Tab; den übergebenen Tab übernehmen
|
||||
if (DETACHED) document.body.classList.add('detached');
|
||||
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
|
||||
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
|
||||
|
||||
@@ -28,6 +28,11 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
|
||||
#tabs::-webkit-scrollbar { height: 0; }
|
||||
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px 0 12px; border: none; background: transparent; border-radius: 8px; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
|
||||
.tab.home { min-width: 0; }
|
||||
/* Tabs verschieben / lösen */
|
||||
.tab.drop-before { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.tab.drop-after { box-shadow: inset -3px 0 0 var(--accent); }
|
||||
body.detached #sidebar, body.detached .tab.home, body.detached #homeView { display: none !important; }
|
||||
body.detached .brand { width: auto; padding-right: 18px; }
|
||||
.tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
|
||||
.tab:hover { background: var(--hover); color: var(--text); }
|
||||
.tab.active { background: var(--bg); color: var(--text); box-shadow: 0 0 0 1px var(--chrome-line); }
|
||||
|
||||
Reference in New Issue
Block a user