Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
872 Dateien
core-20261030T220507Z-61e4558d889f
Hinzugefügt: · Erstellt:
1
"use strict";2
const APP_THEME = "dark";4
const api = window.chjCore;5
const i18n = window.chjI18n;6
const t = (key, values) => i18n.t(key, values);7
// Pure terminal helpers are also exercised by tests/ui without a browser.8
function terminalSessionId(slot, cryptoSource = globalThis.crypto) {9
const bytes = cryptoSource.getRandomValues(new Uint8Array(4));10
return `terminal-${slot}-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;11
}12
function paneForEvent(panes, event) {13
return event?.sessionId ? panes.find((pane) => pane.sessionId === event.sessionId) : undefined;14
}15
function buildTerminalPayload({ sessionId, slot, target, jump, credential, jumpCredential, cols, rows }) {16
const payload = { sessionId, slot, cols, rows };17
if (target.id) payload.profileId = target.id;18
else payload.adhoc = { host: target.host, port: target.port, username: target.username, authMethod: target.authMethod,19
...(target.authMethod === "privateKey" ? { privateKeyPath: target.privateKeyPath } : {}) };20
payload[target.authMethod === "privateKey" ? "passphrase" : "password"] = credential;21
if (jump !== undefined) {22
payload.jump = jump ? (jump.id ? { profileId: jump.id } : { host: jump.host, port: jump.port, username: jump.username,23
authMethod: jump.authMethod, ...(jump.authMethod === "privateKey" ? { privateKeyPath: jump.privateKeyPath } : {}) }) : null;24
if (jump) payload[jump.authMethod === "privateKey" ? "jumpPassphrase" : "jumpPassword"] = jumpCredential;25
}26
return payload;27
}28
// End pure terminal helpers.29
const HASH_TOOL_ID = "chj.hash-checksum";30
if (api?.platform) document.body.classList.add(`platform-${api.platform}`);31
// Language choices come from i18n/languages.json (native names), so adding a language needs no code change.32
for (const select of [document.querySelector("#vaultLanguageSelect"), document.querySelector("#languageSelect")]) {33
for (const language of window.CHJ_I18N?.languages || []) {34
const option = document.createElement("option");35
option.value = language.code;36
option.textContent = language.name;37
select?.append(option);38
}39
}40
const state = {41
info: null,42
config: null,43
plugins: [],44
pluginState: null,45
pluginWindows: [],46
update: null,47
vault: null,48
profiles: [],49
selectedProfileId: ""50
};52
const $ = (selector) => document.querySelector(selector);53
const hashToolButton = $("#hashToolButton");54
const coreNavigationButtons = [...$("#mainNavigation").querySelectorAll("[data-view]")];55
const selectedExportIds = new Set();56
let importPreview = null;58
const elements = {59
viewTitle: $("#viewTitle"), runtimeText: $("#runtimeText"), sidebarVersion: $("#sidebarVersion"),60
coreApiValue: $("#coreApiValue"), dataSchemaValue: $("#dataSchemaValue"), pluginCountValue: $("#pluginCountValue"),61
currentVersion: $("#currentVersion"), updateChannel: $("#updateChannel"), updateHeadline: $("#updateHeadline"),62
updateMessage: $("#updateMessage"), updateHash: $("#updateHash"), checkUpdateButton: $("#checkUpdateButton"),63
downloadUpdateButton: $("#downloadUpdateButton"), installUpdateButton: $("#installUpdateButton"), revealUpdateButton: $("#revealUpdateButton"),64
updateReleaseRows: $("#updateReleaseRows"), updateReleaseCount: $("#updateReleaseCount"), pluginRows: $("#pluginRows"),65
pluginCatalogRows: $("#pluginCatalogRows"), pluginCatalogSource: $("#pluginCatalogSource"), checkPluginCatalogButton: $("#checkPluginCatalogButton"),66
pluginTaskbar: $("#pluginTaskbar"), pluginTaskbarItems: $("#pluginTaskbarItems"),67
updateViewChannelSelect: $("#updateViewChannelSelect"), pluginViewChannelSelect: $("#pluginViewChannelSelect"),68
settingsForm: $("#settingsForm"), languageSelect: $("#languageSelect"), channelSelect: $("#channelSelect"), pluginChannelSelect: $("#pluginChannelSelect"), autoCheckInput: $("#autoCheckInput"),69
baseUrlsText: $("#baseUrlsText"), settingsStatus: $("#settingsStatus"), lockVaultButton: $("#lockVaultButton"),70
vaultOverlay: $("#vaultOverlay"), vaultForm: $("#vaultForm"), vaultLanguageSelect: $("#vaultLanguageSelect"), vaultModeText: $("#vaultModeText"), vaultTitle: $("#vaultTitle"),71
vaultDescription: $("#vaultDescription"), vaultPassword: $("#vaultPassword"), vaultConfirmLabel: $("#vaultConfirmLabel"),72
vaultPasswordConfirm: $("#vaultPasswordConfirm"), vaultMessage: $("#vaultMessage"), vaultSubmit: $("#vaultSubmit"),73
showVaultResetButton: $("#showVaultResetButton"), vaultResetPanel: $("#vaultResetPanel"),74
vaultResetConfirmation: $("#vaultResetConfirmation"), cancelVaultResetButton: $("#cancelVaultResetButton"),75
confirmVaultResetButton: $("#confirmVaultResetButton"),76
profileRows: $("#profileRows"), profileForm: $("#profileForm"), profileId: $("#profileId"), profileLabel: $("#profileLabel"),77
profileHost: $("#profileHost"), profilePort: $("#profilePort"), profileUsername: $("#profileUsername"),78
profileAuthMethod: $("#profileAuthMethod"), privateKeyField: $("#privateKeyField"), passwordStorageField: $("#passwordStorageField"),79
profileStorePassword: $("#profileStorePassword"), profileStoredPassword: $("#profileStoredPassword"),80
profilePrivateKeyPath: $("#profilePrivateKeyPath"), selectPrivateKeyButton: $("#selectPrivateKeyButton"),81
profileMessage: $("#profileMessage"), deleteProfileButton: $("#deleteProfileButton"), newProfileButton: $("#newProfileButton")82
};84
const titles = {85
overview: "nav.overview", terminal: "terminal.title", profiles: "nav.servers",86
updates: "nav.updates", plugins: "nav.plugins", diagnostics: "nav.diagnostics", settings: "nav.settings"87
};89
let diagnosticsView = null;90
const panes = [createTerminalPane(1), createTerminalPane(2)];91
let activePane = panes[0], lastSplitLayout = "split";92
let availabilityState = new Map();94
function errorText(error) {95
const code = error?.code || String(error?.message || "").match(/BIOMETRIC_[A-Z_]+/)?.[0];96
if (code?.startsWith("BIOMETRIC_")) return t(`biometrics.${code}`);97
const message = ["SSH_LOCAL_NETWORK_DENIED", "SSH_MAC_NETWORK_UNREACHABLE"].includes(code)98
? `${t(`ssh.${code}`)}\n${error.message || ""}` : ["JUMP_CHANNEL_FAILED", "JUMP_LOOP", "JUMP_TOO_DEEP", "JUMP_PROFILE_NOT_FOUND", "SSH_TARGET_INVALID"].includes(code)99
? t(`ssh.${code}`) + (error.nextHost ? ` (${error.nextHost}:${error.nextPort})` : "") : error?.message || String(error || t("errors.unknown"));100
return error?.hop === "jump" ? `${t("ssh.jumpError", { host: error.hopHost || error.host || "?" })}: ${message}` : message;101
}103
let biometricStatus = null;104
function renderBiometrics() {105
const status = biometricStatus;106
$("#biometricUnlockButton").hidden = !status?.enabled || !status.available || state.vault?.unlocked || !state.vault?.initialized;107
$("#biometricUnlockButton").textContent = t(`biometrics.${status?.provider || "touch-id"}`);108
$("#biometricStatus").textContent = status ? `${status.provider}: ${t(`biometrics.${status.code}`)} · ${t(status.enabled ? "security.enabled" : "security.disabled")}` : "";109
$("#biometricProtection").textContent = status ? t(status.protection === "biometry-current-set" ? "security.strong" : "security.convenience") : "";110
$("#enableBiometricsButton").disabled = !state.vault?.unlocked || !status?.available || status.enabled;111
$("#disableBiometricsButton").disabled = !state.vault?.unlocked || !status?.enrolled;112
$("#testBiometricsButton").disabled = !state.vault?.unlocked || !status?.available;113
}114
// Automatic Touch ID / Windows Hello prompt. Main decides whether it is armed and keeps it115
// single-flight; the renderer only reports that the window is visible and focused.116
let autoUnlockRunning = false;117
async function maybeAutoUnlock() {118
if (autoUnlockRunning || !api?.autoUnlockVaultWithBiometrics) return;119
if (!state.vault || state.vault.unlocked || !state.vault.initialized || state.vault.needsSetup) return;120
if (!biometricStatus?.enabled || !biometricStatus.available) return;121
if (document.visibilityState !== "visible" || !document.hasFocus() || elements.vaultPassword.value) return;122
autoUnlockRunning = true;123
$("#biometricUnlockButton").disabled = true;124
elements.vaultMessage.textContent = t("biometrics.autoPrompt");125
try {126
const result = await api.autoUnlockVaultWithBiometrics();127
if (result.status?.unlocked) { configureVault(result.status); await loadProfiles(); }128
else if (result.error) {129
elements.vaultMessage.textContent = `${errorText(result.error)} ${t("biometrics.usePassword")}`;130
elements.vaultPassword.focus();131
} else elements.vaultMessage.textContent = "";132
} catch (error) { elements.vaultMessage.textContent = errorText(error); }133
finally { autoUnlockRunning = false; $("#biometricUnlockButton").disabled = false; void refreshBiometrics(); }134
}135
async function refreshBiometrics() {136
try { biometricStatus = await api.getBiometricStatus(); renderBiometrics(); }137
catch (error) { $("#securityMessage").textContent = errorText(error); }138
}140
function showView(name) {141
document.querySelectorAll("[data-view]").forEach((button) => button.classList.toggle("active", button.dataset.view === name));142
document.querySelectorAll("[data-view-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.viewPanel === name));143
elements.viewTitle.textContent = titles[name] ? t(titles[name]) : "CH-J Server Manager";144
const terminal = name === "terminal";145
$("main").classList.toggle("terminal-active", terminal); $("#terminalStrip").hidden = !terminal;146
if (terminal) requestAnimationFrame(() => { fitTerminal(); activePane.terminal?.focus(); });147
}149
function pluginDisplay(id) {150
return state.config?.plugins.display?.[id] || (id === HASH_TOOL_ID ? "navigation" : "visible");151
}153
let preferenceSave = Promise.resolve();154
function saveUiPreferences(patch) {155
preferenceSave = preferenceSave.then(async () => {156
state.config = await api.updateConfig(patch());157
renderPlugins();158
}).catch((error) => { window.alert(errorText(error)); renderPlugins(); });159
return preferenceSave;160
}162
function moveNavigation(key, targetKey, after = false) {163
if (!$("#navigationEditToggle").checked) return;164
const keys = [...$("#mainNavigation").children].map((row) => row.dataset.navKey);165
const from = keys.indexOf(key), to = keys.indexOf(targetKey);166
if (from < 0 || to < 0 || from === to) return;167
keys.splice(from, 1); keys.splice(keys.indexOf(targetKey) + Number(after), 0, key);168
saveUiPreferences(() => ({ ui: { navigationOrder: [...keys, ...state.config.ui.navigationOrder.filter((id) => !keys.includes(id))] } }))169
.then(() => [...$("#mainNavigation").children].find((row) => row.dataset.navKey === key)?.querySelector(".nav-button").focus());170
}172
// Replaces the leading glyph of a navigation button with the shared SVG icon for its stable key.173
function setNavIcon(button, key) {174
const current = button.querySelector(".nav-icon");175
if (current?.dataset.key === key) return;176
const icon = window.chjNavIcons.createIcon(key);177
icon.dataset.key = key;178
const first = button.firstElementChild;179
if (current) current.replaceWith(icon);180
else if (first && !first.classList.contains("nav-label")) first.replaceWith(icon);181
else button.prepend(icon);182
}184
function renderNavigation() {185
const navigation = $("#mainNavigation");186
const buttons = new Map(coreNavigationButtons.map((button) => [button.dataset.view, button]));187
if (pluginDisplay(HASH_TOOL_ID) === "navigation") buttons.set(`plugin:${HASH_TOOL_ID}`, hashToolButton);188
for (const plugin of state.plugins) {189
if (plugin.id === HASH_TOOL_ID || pluginDisplay(plugin.id) !== "navigation") continue;190
const button = document.createElement("button"); button.type = "button"; button.className = "nav-button";191
const label = document.createElement("span"); label.className = "nav-label"; label.textContent = plugin.name;192
button.append(label);193
button.addEventListener("click", async () => { try { await api.openPlugin(plugin.id); } catch (error) { window.alert(errorText(error)); } });194
buttons.set(`plugin:${plugin.id}`, button);195
}196
hashToolButton.querySelector(".nav-label").textContent = t("nav.hash");197
const defaults = [...coreNavigationButtons.map((button) => button.dataset.view)];198
defaults.splice(defaults.indexOf("settings"), 0, `plugin:${HASH_TOOL_ID}`);199
const order = [...new Set([...(state.config?.ui.navigationOrder || []), ...defaults, ...buttons.keys()])].filter((key) => buttons.has(key));200
for (const [key, button] of buttons) setNavIcon(button, key);201
navigation.replaceChildren();202
const editing = $("#navigationEditToggle").checked;203
navigation.classList.toggle("navigation-editing", editing);204
order.forEach((key) => {205
const row = document.createElement("div"); row.className = "navigation-row"; row.dataset.navKey = key;206
const button = buttons.get(key);207
button.title = editing ? t("nav.drag") : "";208
row.draggable = editing;209
const handle = document.createElement("span"); handle.className = "navigation-drag"; handle.textContent = "⠿"; handle.setAttribute("aria-hidden", "true");210
row.addEventListener("dragstart", (event) => {211
if (!$("#navigationEditToggle").checked) { event.preventDefault(); return; }212
event.dataTransfer.setData("text/chj-navigation", key); event.dataTransfer.effectAllowed = "move";213
row.classList.add("navigation-dragging");214
});215
row.addEventListener("dragend", () => navigation.querySelectorAll(".navigation-row").forEach((item) => item.classList.remove("navigation-dragging", "navigation-drop-before", "navigation-drop-after")));216
row.addEventListener("dragover", (event) => {217
if (!$("#navigationEditToggle").checked || ![...event.dataTransfer.types].includes("text/chj-navigation")) return;218
event.preventDefault(); event.dataTransfer.dropEffect = "move";219
const after = event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2;220
row.classList.toggle("navigation-drop-before", !after); row.classList.toggle("navigation-drop-after", after);221
});222
row.addEventListener("dragleave", () => row.classList.remove("navigation-drop-before", "navigation-drop-after"));223
row.addEventListener("drop", (event) => {224
if (!$("#navigationEditToggle").checked) return;225
event.preventDefault(); row.classList.remove("navigation-drop-before", "navigation-drop-after");226
moveNavigation(event.dataTransfer.getData("text/chj-navigation"), key, event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2);227
});228
row.append(button, handle); navigation.append(row);229
});230
}232
$("#navigationEditToggle").addEventListener("change", renderNavigation);233
$("#mainNavigation").addEventListener("click", (event) => {234
if ($("#navigationEditToggle").checked) { event.preventDefault(); event.stopPropagation(); }235
}, true);237
// ---------- sidebar layout: modes, width, hide/show, tooltips (persisted in config.ui.sidebar by Main) ----------238
const SIDEBAR_LIMITS = Object.freeze({ min: 220, max: 420, default: 248 });239
const appShell = $(".app-shell");240
const sidebarTooltip = $("#sidebarTooltip");241
const sidebarShortcut = navigator.platform.startsWith("Mac") ? "⌘⇧B" : "Ctrl+Shift+B";242
let sidebarSave = Promise.resolve();243
const clampSidebarWidth = (value) => Math.round(Math.min(SIDEBAR_LIMITS.max, Math.max(SIDEBAR_LIMITS.min, value)));244
function sidebarState() { return { mode: "full", hidden: false, width: SIDEBAR_LIMITS.default, ...(state.config?.ui.sidebar || {}) }; }245
function applySidebar() {246
const sidebar = sidebarState();247
if (appShell.dataset.sidebarMode && (appShell.dataset.sidebarMode !== sidebar.mode || appShell.classList.contains("sidebar-hidden") !== sidebar.hidden)) {248
appShell.classList.remove("sidebar-switching"); void appShell.offsetWidth; appShell.classList.add("sidebar-switching");249
}250
appShell.dataset.sidebarMode = sidebar.mode;251
appShell.classList.toggle("sidebar-hidden", sidebar.hidden);252
appShell.style.setProperty("--sidebar-full-width", `${sidebar.width}px`);253
document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.setAttribute("aria-checked", String(button.dataset.sidebarMode === sidebar.mode)));254
const toggle = $("#sidebarToggle");255
const label = `${t(sidebar.hidden ? "nav.showSidebar" : "nav.hideSidebar")} (${sidebarShortcut})`;256
toggle.setAttribute("aria-expanded", String(!sidebar.hidden)); toggle.setAttribute("aria-label", label); toggle.title = label;257
$("#sidebarResizer").setAttribute("aria-valuenow", String(sidebar.width));258
// Reordering needs the labels; it is available in the full mode only.259
if (sidebar.mode !== "full" && $("#navigationEditToggle").checked) { $("#navigationEditToggle").checked = false; renderNavigation(); }260
hideSidebarTooltip();261
}262
// The view changes immediately; Main stores the normalized value (width clamped to 220-420 px).263
function saveSidebar(patch) {264
state.config = { ...state.config, ui: { ...state.config.ui, sidebar: { ...sidebarState(), ...patch } } };265
applySidebar();266
sidebarSave = sidebarSave.then(async () => { state.config = await api.updateConfig({ ui: { sidebar: patch } }); applySidebar(); })267
.catch((error) => window.alert(errorText(error)));268
return sidebarSave;269
}270
function toggleSidebar() {271
const hidden = !sidebarState().hidden;272
saveSidebar({ hidden });273
if (!hidden) requestAnimationFrame(() => document.querySelector("#mainNavigation .nav-button.active, #mainNavigation .nav-button")?.focus());274
}275
function showSidebarTooltip(target) {276
const sidebar = sidebarState();277
const navButton = target.classList.contains("nav-button");278
if (sidebar.hidden || (navButton && sidebar.mode === "full")) return;279
const text = (navButton ? target.querySelector(".nav-label")?.textContent : target.getAttribute("aria-label"))?.trim();280
if (!text) return;281
sidebarTooltip.textContent = text;282
sidebarTooltip.hidden = false;283
const box = target.getBoundingClientRect();284
const top = Math.min(window.innerHeight - 18, Math.max(18, box.top + box.height / 2));285
sidebarTooltip.style.left = `${Math.round(Math.max(box.right, $("#sidebar").getBoundingClientRect().right) + 8)}px`;286
sidebarTooltip.style.top = `${Math.round(top)}px`;287
}288
function hideSidebarTooltip() { clearTimeout(tooltipTimer); sidebarTooltip.hidden = true; }289
const tooltipTarget = (event) => event.target.closest?.("#mainNavigation .nav-button, .sidebar-mode");290
// Like native tooltips: after a click the tooltip stays hidden until the pointer leaves that item.291
let tooltipSuppressed = null;292
// Hover shows the tooltip after a short delay (300 ms); keyboard focus shows it immediately.293
let tooltipTimer = null;294
$("#sidebar").addEventListener("mouseover", (event) => {295
const target = tooltipTarget(event);296
if (!target || target === tooltipSuppressed || target.contains(event.relatedTarget)) return;297
clearTimeout(tooltipTimer);298
tooltipTimer = setTimeout(() => { if (target.matches(":hover")) showSidebarTooltip(target); }, 300);299
});300
$("#sidebar").addEventListener("mouseout", (event) => { const target = tooltipTarget(event); if (target && !target.contains(event.relatedTarget)) { clearTimeout(tooltipTimer); hideSidebarTooltip(); if (target === tooltipSuppressed) tooltipSuppressed = null; } });301
// Keyboard focus shows the tooltip at once. The last input method is tracked explicitly instead of relying only on302
// :focus-visible, whose heuristic differs between platforms and window states.303
let keyboardInput = false;304
document.addEventListener("keydown", () => { keyboardInput = true; }, true);305
document.addEventListener("pointerdown", () => { keyboardInput = false; }, true);306
$("#sidebar").addEventListener("focusin", (event) => { const target = tooltipTarget(event); if (target && (keyboardInput || target.matches(":focus-visible"))) showSidebarTooltip(target); });307
$("#sidebar").addEventListener("focusout", hideSidebarTooltip);308
$("#sidebar").addEventListener("click", (event) => { tooltipSuppressed = tooltipTarget(event) || null; hideSidebarTooltip(); });309
$("#mainNavigation").addEventListener("scroll", hideSidebarTooltip);310
window.addEventListener("resize", hideSidebarTooltip);311
document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.addEventListener("click", () => saveSidebar({ mode: button.dataset.sidebarMode, hidden: false })));312
$("#sidebarToggle").addEventListener("click", toggleSidebar);313
document.addEventListener("keydown", (event) => {314
if (event.key === "Escape") hideSidebarTooltip();315
// Captured before the terminal so the shortcut works everywhere; plain Ctrl+B still reaches the SSH shell.316
if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.code === "KeyB") { event.preventDefault(); event.stopPropagation(); toggleSidebar(); }317
}, true);318
{319
const resizer = $("#sidebarResizer");320
resizer.addEventListener("pointerdown", (event) => {321
if (event.button !== 0 || sidebarState().mode !== "full") return;322
event.preventDefault();323
resizer.setPointerCapture(event.pointerId);324
const left = $("#sidebar").getBoundingClientRect().left;325
let width = sidebarState().width;326
appShell.classList.add("sidebar-resizing");327
const move = (moveEvent) => {328
width = clampSidebarWidth(moveEvent.clientX - left);329
appShell.style.setProperty("--sidebar-full-width", `${width}px`);330
resizer.setAttribute("aria-valuenow", String(width));331
};332
// Window-level listeners keep the drag alive even if pointer capture is lost; any end event finishes it once.333
let done = false;334
const end = () => {335
if (done) return; done = true;336
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", end);337
resizer.removeEventListener("lostpointercapture", end);338
appShell.classList.remove("sidebar-resizing");339
if (width !== sidebarState().width) saveSidebar({ width });340
};341
window.addEventListener("pointermove", move); window.addEventListener("pointerup", end); window.addEventListener("pointercancel", end);342
resizer.addEventListener("lostpointercapture", end);343
});344
resizer.addEventListener("dblclick", () => saveSidebar({ width: SIDEBAR_LIMITS.default }));345
resizer.addEventListener("keydown", (event) => {346
const width = sidebarState().width; const step = event.shiftKey ? 48 : 16;347
const next = { ArrowLeft: width - step, ArrowRight: width + step, Home: SIDEBAR_LIMITS.min, End: SIDEBAR_LIMITS.max }[event.key];348
if (next === undefined) return;349
event.preventDefault();350
if (clampSidebarWidth(next) !== width) saveSidebar({ width: clampSidebarWidth(next) });351
});352
}354
function cancelProfileImport() {355
importPreview = null;356
$("#profileImportDialog").close();357
$("#profileImportRows").replaceChildren();358
void api.cancelProfileImport().catch(() => {});359
}361
$("#importProfilesButton").addEventListener("click", async (event) => {362
const button = event.currentTarget; button.disabled = true;363
$("#profileTransferMessage").textContent = "";364
try {365
const preview = await api.previewProfileImport();366
if (preview.canceled) return;367
importPreview = preview;368
$("#profileImportRows").replaceChildren();369
for (const server of preview.servers) {370
const row = document.createElement("tr");371
for (const value of [server.name, server.host, server.username, server.port, server.duplicate ? t("transfer.yes") : "—"]) {372
const cell = document.createElement("td"); cell.textContent = value; row.append(cell);373
}374
$("#profileImportRows").append(row);375
}376
$("#profileImportSummary").textContent = t("transfer.summary", { count: preview.servers.length, duplicates: preview.servers.filter((server) => server.duplicate).length });377
$("#profileDuplicatePolicy").value = "skip";378
$("#profileImportMessage").textContent = "";379
$("#profileImportDialog").showModal();380
} catch (error) { $("#profileTransferMessage").textContent = errorText(error); }381
finally { button.disabled = false; }382
});383
$("#cancelProfileImport").addEventListener("click", cancelProfileImport);384
$("#profileImportDialog").addEventListener("cancel", (event) => { event.preventDefault(); cancelProfileImport(); });385
$("#confirmProfileImport").addEventListener("click", async (event) => {386
if (!importPreview) return;387
const button = event.currentTarget; button.disabled = true;388
try {389
const result = await api.commitProfileImport(importPreview.token, $("#profileDuplicatePolicy").value);390
cancelProfileImport();391
await loadProfiles();392
$("#profileTransferMessage").textContent = t("transfer.imported", result);393
} catch (error) { $("#profileImportMessage").textContent = errorText(error); }394
finally { button.disabled = false; }395
});396
$("#exportProfilesButton").addEventListener("click", async (event) => {397
const button = event.currentTarget; button.disabled = true;398
try {399
const result = await api.exportProfiles($("#profileExportFormat").value, $("#exportSelectedProfiles").checked ? [...selectedExportIds] : undefined);400
if (!result.canceled) $("#profileTransferMessage").textContent = t("transfer.exported", result);401
} catch (error) { $("#profileTransferMessage").textContent = errorText(error); }402
finally { button.disabled = false; }403
});405
function setBusy(button, busy, label) {406
button.disabled = busy;407
if (label) button.textContent = label;408
}410
function formatBytes(bytes) {411
const units = ["B", "KB", "MB", "GB"];412
let value = Number(bytes || 0);413
let index = 0;414
while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; }415
return `${i18n.formatNumber(value, { maximumFractionDigits: 1 })} ${units[index]}`;416
}418
function applyLanguage(language) {419
const selected = i18n.setLanguage(language);420
document.documentElement.lang = selected;421
document.documentElement.dir = i18n.getDirection();422
i18n.apply(document);423
elements.languageSelect.value = selected;424
elements.vaultLanguageSelect.value = selected;425
const activeView = document.querySelector("[data-view].active")?.dataset.view || "overview";426
showView(activeView);427
if (state.update) renderUpdate(state.update);428
if (state.config) elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });429
renderPlugins();430
renderProfiles();431
if (state.config) applySidebar();432
diagnosticsView?.refreshLanguage();433
renderTerminalProfileSelect();434
if (state.vault) configureVault(state.vault);435
renderBiometrics();436
const terminalStateKeys = { disconnected: "terminal.disconnected", connecting: "terminal.connecting", connected: "terminal.connected", error: "terminal.connectionError" };437
for (const pane of panes) setTerminalState(pane, pane.kind, t(terminalStateKeys[pane.kind] || "terminal.disconnected"));438
renderTerminalStrip();439
renderMonitoringSettings();440
renderAvailability();441
return selected;442
}444
function renderUpdate(updateState) {445
state.update = updateState;446
elements.currentVersion.textContent = state.info?.version ? `v${state.info.version}` : "—";447
const release = updateState.release;448
const currentReleaseSelected = updateState.releaseRelation === "sameVersionCurrentRelease";449
elements.downloadUpdateButton.hidden = currentReleaseSelected;450
elements.downloadUpdateButton.disabled = !release || currentReleaseSelected;451
renderReleaseCatalog(updateState);452
if (updateState.downloaded) {453
elements.updateHeadline.textContent = t("updates.downloaded", { version: release?.version || "?" });454
elements.updateMessage.textContent = `${updateState.downloaded.filename} · ${formatBytes(updateState.downloaded.size)} · ${t("updates.signatureVerified")}`;455
elements.updateHash.hidden = false;456
elements.updateHash.textContent = `SHA-512 ${updateState.downloaded.sha512}`;457
elements.revealUpdateButton.hidden = false;458
elements.installUpdateButton.hidden = false;459
} else if (release) {460
const relationKey = {461
newerVersion: "updates.newerVersion",462
sameVersionNewerRelease: "updates.sameVersionNewerRelease",463
sameVersionCurrentRelease: "updates.sameVersionCurrentRelease",464
sameVersionOlderRelease: "updates.sameVersionOlderRelease",465
olderVersion: "updates.olderVersion"466
}[updateState.releaseRelation] || "updates.selected";467
elements.updateHeadline.textContent = t(relationKey, { version: release.version });468
const published = t("updates.publishedAt", { date: formatReleaseDate(release.publishedAt) });469
elements.updateMessage.textContent = release.notes ? `${published} · ${release.notes}` : published;470
elements.updateHash.hidden = false;471
elements.updateHash.textContent = `SHA-512 ${release.sha512}`;472
elements.revealUpdateButton.hidden = true;473
elements.installUpdateButton.hidden = true;474
} else {475
elements.updateHeadline.textContent = t("updates.noneAvailable");476
elements.updateMessage.textContent = t("updates.noCompatible");477
elements.updateHash.hidden = true;478
elements.revealUpdateButton.hidden = true;479
elements.installUpdateButton.hidden = true;480
}481
}483
function formatReleaseDate(value) {484
const date = new Date(value);485
if (Number.isNaN(date.getTime())) return String(value || "—");486
return new Intl.DateTimeFormat(i18n.getLocale(), { dateStyle: "medium", timeStyle: "medium" }).format(date);487
}489
function renderReleaseCatalog(updateState) {490
const releases = Array.isArray(updateState.releases) ? updateState.releases : [];491
elements.updateReleaseCount.textContent = String(releases.length);492
elements.updateReleaseRows.replaceChildren();493
if (!releases.length) {494
const empty = document.createElement("div");495
empty.className = "update-release-empty";496
empty.textContent = t(updateState.checked ? "updates.catalogEmpty" : "updates.catalogBeforeCheck");497
elements.updateReleaseRows.append(empty);498
return;499
}500
for (const release of releases) {501
const row = document.createElement("button");502
row.type = "button";503
row.className = `update-release-row${release.id === updateState.selectedReleaseId ? " selected" : ""}`;504
const version = document.createElement("strong"); version.textContent = `v${release.version}`;505
const published = document.createElement("span"); published.textContent = formatReleaseDate(release.publishedAt);506
const file = document.createElement("span"); file.textContent = `${release.filename} · ${formatBytes(release.size)}`;507
const action = document.createElement("span"); action.className = "release-select-label"; action.textContent = t(release.id === updateState.selectedReleaseId ? "updates.selectedLabel" : "updates.selectLabel");508
row.append(version, published, file, action);509
row.addEventListener("click", async () => {510
try { renderUpdate(await api.selectUpdateRelease(release.id)); }511
catch (error) { elements.updateMessage.textContent = errorText(error); }512
});513
elements.updateReleaseRows.append(row);514
}515
}517
function renderPlugins() {518
const plugins = state.plugins;519
renderNavigation();520
elements.pluginCountValue.textContent = String(plugins.length);521
elements.pluginRows.replaceChildren();522
if (plugins.length === 0) {523
const empty = document.createElement("div");524
empty.className = "empty-state";525
const strong = document.createElement("strong"); strong.textContent = t("plugins.none");526
const span = document.createElement("span"); span.textContent = t("plugins.first");527
empty.append(strong, span); elements.pluginRows.append(empty); renderPluginCatalog(); renderPluginTaskbar(); return;528
}529
for (const plugin of plugins) {530
const row = document.createElement("div"); row.className = "plugin-row installed-plugin-row"; row.dataset.pluginId = plugin.id;531
const name = document.createElement("div"); name.className = "installed-plugin-name";532
const title = document.createElement("strong"); title.textContent = plugin.name; name.append(title);533
const details = document.createElement("details"); details.className = "installed-plugin-details";534
const summary = document.createElement("summary"); summary.textContent = t("plugins.details");535
const description = document.createElement("p"); // First-party modules have translated descriptions; any other module shows its manifest text.536
const descriptionKey = `module.description.${plugin.id}`, translatedDescription = t(descriptionKey);537
description.textContent = translatedDescription === descriptionKey ? plugin.description : translatedDescription;538
const permissions = document.createElement("p"); permissions.textContent = `${t("plugins.permissions")}: ${plugin.permissions.join(", ") || t("plugins.noPermissions")}`;539
details.append(summary, description, permissions); name.append(details);540
const version = document.createElement("span"); version.textContent = plugin.version;541
const actions = document.createElement("div"); actions.className = "plugin-actions";542
const open = document.createElement("button"); open.type = "button"; open.className = "button"; open.textContent = t("plugins.open");543
open.addEventListener("click", async () => { try { await api.openPlugin(plugin.id); } catch (error) { window.alert(errorText(error)); } });544
const mode = pluginDisplay(plugin.id);545
row.classList.toggle("plugin-hidden", mode === "hidden");546
open.disabled = mode === "hidden";547
const visible = document.createElement("input"); visible.type = "checkbox"; visible.className = "plugin-visibility"; visible.checked = mode !== "hidden";548
visible.setAttribute("aria-label", `${plugin.name}: ${t("plugins.show")}`);549
const pinned = document.createElement("input"); pinned.type = "checkbox"; pinned.className = "plugin-pin"; pinned.checked = mode === "navigation"; pinned.disabled = mode === "hidden";550
pinned.setAttribute("aria-label", `${plugin.name}: ${t("plugins.inMenu")}`);551
const saveDisplay = (value) => {552
visible.disabled = true; pinned.disabled = true;553
saveUiPreferences(() => ({ plugins: { display: { ...state.config.plugins.display, [plugin.id]: value } } }));554
};555
visible.addEventListener("change", () => saveDisplay(visible.checked ? "visible" : "hidden"));556
pinned.addEventListener("change", () => saveDisplay(pinned.checked ? "navigation" : "visible"));557
actions.append(open);558
if (plugin.bundled) {559
version.title = t("plugins.bundled");560
} else {561
const remove = document.createElement("button"); remove.type = "button"; remove.className = "button danger"; remove.textContent = t("plugins.remove");562
remove.addEventListener("click", async () => {563
if (!window.confirm(t("plugins.removeConfirm", { name: plugin.name }))) return;564
remove.disabled = true;565
try {566
state.pluginState = await api.uninstallPlugin(plugin.id);567
state.plugins = state.pluginState.installed;568
renderPlugins();569
} catch (error) { window.alert(errorText(error)); remove.disabled = false; }570
});571
actions.append(remove);572
}573
row.append(name, version, visible, pinned, actions); elements.pluginRows.append(row);574
}575
renderPluginCatalog();576
renderPluginTaskbar();577
}579
function renderPluginTaskbar(windows = state.pluginWindows) {580
state.pluginWindows = Array.isArray(windows) ? windows : [];581
const minimized = state.pluginWindows.filter((item) => item.minimized);582
elements.pluginTaskbar.hidden = minimized.length === 0;583
elements.pluginTaskbarItems.replaceChildren();584
for (const plugin of minimized) {585
const button = document.createElement("button");586
button.type = "button";587
button.className = "plugin-taskbar-item";588
button.title = t("plugins.restore", { name: plugin.name });589
const icon = document.createElement("span");590
icon.className = "plugin-taskbar-icon";591
icon.append(window.chjNavIcons.createIcon(`plugin:${plugin.pluginId}`, "nav-icon taskbar-icon"));592
const name = document.createElement("strong");593
name.textContent = plugin.name;594
const action = document.createElement("small");595
action.textContent = t("plugins.restoreAction");596
button.append(icon, name, action);597
button.addEventListener("click", async () => {598
try { await api.openPlugin(plugin.pluginId); }599
catch (error) { window.alert(errorText(error)); }600
});601
elements.pluginTaskbarItems.append(button);602
}603
}605
function renderPluginCatalog() {606
if (!elements.pluginCatalogRows) return;607
const catalog = (state.pluginState?.catalog || []).filter((plugin) => plugin.id !== HASH_TOOL_ID);608
elements.pluginCatalogSource.textContent = state.pluginState?.sourceBaseUrl || "";609
elements.pluginCatalogRows.replaceChildren();610
if (!catalog.length) {611
const empty = document.createElement("div"); empty.className = "plugin-catalog-empty";612
empty.textContent = t(state.pluginState?.sourceBaseUrl ? "plugins.catalogEmpty" : "plugins.catalogBeforeCheck");613
elements.pluginCatalogRows.append(empty); return;614
}615
for (const plugin of catalog) {616
const row = document.createElement("div"); row.className = "plugin-row plugin-table-row";617
const name = document.createElement("div");618
const strong = document.createElement("strong"); strong.textContent = plugin.name;619
const notes = document.createElement("small"); notes.textContent = plugin.notes || plugin.id;620
name.append(strong, notes);621
const version = document.createElement("span"); version.textContent = `v${plugin.version} · ${plugin.channel}`;622
const permissions = document.createElement("span"); permissions.textContent = plugin.permissions.join(", ") || t("plugins.noPermissions");623
const install = document.createElement("button"); install.type = "button"; install.className = `button${plugin.installed ? "" : " primary"}`;624
install.disabled = plugin.installed;625
install.textContent = t(plugin.installed ? "plugins.installed" : plugin.updateAvailable ? "plugins.update" : "plugins.install");626
install.addEventListener("click", async () => {627
if (!window.confirm(t("plugins.installConfirm", { name: plugin.name, version: plugin.version, permissions: plugin.permissions.join(", ") || t("plugins.noPermissions") }))) return;628
install.disabled = true; install.textContent = t("plugins.installing");629
try {630
state.pluginState = await api.installPlugin(plugin.releaseId);631
state.plugins = state.pluginState.installed;632
renderPlugins();633
} catch (error) { window.alert(errorText(error)); install.disabled = false; install.textContent = t("plugins.install"); }634
});635
row.append(name, version, permissions, install); elements.pluginCatalogRows.append(row);636
}637
}639
function configureVault(status) {640
state.vault = status;641
renderBiometrics();642
$("#securityMasterPassword").value = "";643
elements.vaultOverlay.hidden = Boolean(status.unlocked);644
elements.vaultMessage.className = "form-message";645
elements.vaultMessage.textContent = "";646
elements.vaultPassword.value = "";647
elements.vaultPasswordConfirm.value = "";648
elements.vaultResetConfirmation.value = "";649
elements.vaultResetPanel.hidden = true;650
if (status.unlocked) return;651
if (status.damaged) {652
elements.vaultForm.dataset.mode = "damaged";653
elements.vaultModeText.textContent = t("vault.damagedMode");654
elements.vaultTitle.textContent = t("vault.damagedTitle");655
elements.vaultDescription.textContent = t("vault.damagedDescription");656
elements.vaultSubmit.disabled = true;657
elements.vaultConfirmLabel.hidden = true;658
elements.showVaultResetButton.hidden = false;659
return;660
}661
const setup = status.needsSetup;662
elements.vaultForm.dataset.mode = setup ? "setup" : "unlock";663
elements.vaultModeText.textContent = t(setup ? "vault.setupMode" : "vault.lockedMode");664
elements.vaultSubmit.disabled = false;665
elements.vaultConfirmLabel.hidden = !setup;666
elements.showVaultResetButton.hidden = setup;667
elements.vaultPassword.autocomplete = setup ? "new-password" : "current-password";668
elements.vaultTitle.textContent = t(setup ? "vault.setupTitle" : "vault.unlockTitle");669
elements.vaultDescription.textContent = t(setup ? "vault.setupDescription" : "vault.lockedDescription");670
elements.vaultSubmit.textContent = t(setup ? "vault.create" : "vault.unlock");671
setTimeout(() => elements.vaultPassword.focus(), 20);672
}674
function clearProtectedUi() {675
$("#navigationEditToggle").checked = false;676
renderNavigation();677
cancelProfileImport();678
selectedExportIds.clear();679
state.profiles = [];680
state.selectedProfileId = "";681
renderProfiles();682
diagnosticsView?.refreshLanguage();683
renderTerminalProfileSelect();684
for (const pane of panes) {685
pane.sessionId = null; pane.pending = false; pane.latency = null;686
pane.root.querySelectorAll("input").forEach((input) => { input.value = input.type === "number" ? "22" : ""; });687
pane.root.querySelectorAll(".manual-host select").forEach((select) => { select.value = "password"; });688
setTerminalState(pane, "disconnected", t("terminal.disconnected"));689
updateTerminalProfile(pane); pane.terminal?.reset();690
}691
}693
async function loadProfiles(selectId = state.selectedProfileId) {694
state.profiles = await api.listProfiles();695
await loadAvailability();696
if (!state.profiles.some((profile) => profile.id === selectId)) selectId = state.profiles[0]?.id || "";697
state.selectedProfileId = selectId;698
renderProfiles();699
diagnosticsView?.refreshLanguage();700
renderTerminalProfileSelect();701
renderAvailability();702
if (selectId) fillProfileForm(state.profiles.find((profile) => profile.id === selectId));703
else resetProfileForm();704
}706
function renderProfiles() {707
for (const id of selectedExportIds) if (!state.profiles.some((profile) => profile.id === id)) selectedExportIds.delete(id);708
elements.profileRows.replaceChildren();709
if (!state.profiles.length) {710
const empty = document.createElement("div"); empty.className = "profile-empty";711
empty.textContent = t("profiles.none"); elements.profileRows.append(empty); return;712
}713
for (const profile of state.profiles) {714
const row = document.createElement("button"); row.type = "button";715
row.className = `profile-row${profile.id === state.selectedProfileId ? " active" : ""}`;716
const name = document.createElement("strong"); name.dataset.availabilityId = profile.id;717
const dot = document.createElement("i"); dot.className = "availability-dot";718
const availabilityLabel = document.createElement("small"); availabilityLabel.className = "availability-text";719
name.append(dot, profile.label, availabilityLabel);720
const address = document.createElement("span"); address.textContent = `${profile.username}@${profile.host}:${profile.port}`;721
const auth = document.createElement("span"); auth.className = "profile-auth"; auth.textContent = t(profile.authMethod === "privateKey" ? "profiles.key" : "profiles.password");722
row.append(name, address, auth);723
row.addEventListener("click", () => { state.selectedProfileId = profile.id; renderProfiles(); fillProfileForm(profile); });724
const wrapper = document.createElement("div"); wrapper.className = "profile-select-row";725
const select = document.createElement("input"); select.type = "checkbox"; select.checked = selectedExportIds.has(profile.id);726
select.setAttribute("aria-label", t("transfer.select", { name: profile.label }));727
select.addEventListener("change", () => { if (select.checked) selectedExportIds.add(profile.id); else selectedExportIds.delete(profile.id); });728
wrapper.append(select, row); elements.profileRows.append(wrapper);729
}730
}732
function availabilitySummary(profile) {733
const entry = availabilityState.get(profile.id), status = entry?.status || "unknown";734
const ms = entry?.latencyMs;735
const text = status === "online" ? (typeof ms === "number" ? `${ms < 1 ? "<1" : Math.round(ms)} ms` : t("availability.online"))736
: status === "offline" ? t("availability.offline") : status === "checking" ? t("availability.checking")737
: status === "unverified" ? t("availability.unverified") : "";738
return { status, text, entry };739
}740
function serverOptionText(profile) {741
const { status, text } = availabilitySummary(profile);742
return `${{ online: "🟢", offline: "🔴" }[status] || "⚪"} ${profile.label}${text ? ` — ${text}` : ""}`;743
}744
function availabilityDetails(profile) {745
const { status, entry } = availabilitySummary(profile);746
const when = (value) => new Date(value).toLocaleString(i18n.getLanguage());747
return [t(`availability.${status}`), entry?.via === "jump" && status === "online" ? t("availability.viaJump") : "", entry?.reason ? t(`availability.reason.${entry.reason}`) : "",748
entry?.lastSuccessAt ? t("availability.lastSuccess", { time: when(entry.lastSuccessAt) }) : "", entry?.lastAttemptAt ? t("availability.lastAttempt", { time: when(entry.lastAttemptAt) }) : ""].filter(Boolean).join("\n");749
}750
// Updates in place: an open menu or a selected row must survive every 12 s.751
function renderAvailability() {752
const known = new Map(state.profiles.map((profile) => [profile.id, profile]));753
for (const select of [$("#profileJumpProfileId"), ...panes.flatMap((pane) => [pane.select, pane.jumpSelect])]) {754
for (const option of select.options) {755
const profile = known.get(option.value);756
if (profile && option.textContent !== serverOptionText(profile)) option.textContent = serverOptionText(profile);757
}758
}759
document.querySelectorAll("[data-availability-id]").forEach((row) => {760
const profile = known.get(row.dataset.availabilityId);761
if (!profile) return;762
const { status, text } = availabilitySummary(profile);763
const dot = row.querySelector(".availability-dot"), label = row.querySelector(".availability-text");764
dot.dataset.status = status; label.dataset.status = status; label.textContent = text;765
row.title = availabilityDetails(profile);766
});767
}768
async function loadAvailability() {769
try { availabilityState = new Map((await api.listAvailability()).map((entry) => [entry.id, entry])); renderAvailability(); } catch {}770
}771
function renderMonitoringSettings() {772
const select = $("#monitoringInterval"), config = state.config?.monitoring;773
select.replaceChildren(...[5, 10, 12, 15, 30, 60].map((seconds) => new Option(t("availability.settingsSeconds", { seconds }), String(seconds))));774
select.value = String(config?.intervalSeconds ?? 12);775
$("#monitoringEnabled").checked = config?.enabled !== false;776
$("#refreshAvailabilityButton").disabled = config?.enabled === false;777
}778
function profileOptions(select, exclude, firstKey, manual = false) {779
const previous = select.value;780
select.replaceChildren(...(firstKey ? [new Option(t(firstKey), "")] : []));781
for (const profile of state.profiles) if (profile.id !== exclude) select.add(new Option(serverOptionText(profile), profile.id));782
if (manual) select.add(new Option(t("terminal.manualHost"), "@manual"));783
select.value = [...select.options].some((option) => option.value === previous) ? previous : "";784
}785
function renderTerminalProfileSelect() {786
profileOptions($("#profileJumpProfileId"), elements.profileId.value, "terminal.noJump");787
for (const pane of panes) {788
const previous = pane.select.value;789
profileOptions(pane.select, null, null, true);790
if (!pane.select.value) pane.select.value = state.profiles[0]?.id || "@manual";791
profileOptions(pane.jumpSelect, pane.select.value, "terminal.noJump", true);792
if (pane.select.value !== previous) selectTarget(pane);793
else updateTerminalProfile(pane);794
}795
}797
function resetProfileForm() {798
elements.profileForm.reset();799
elements.profileId.value = "";800
elements.profilePort.value = "22";801
elements.profileAuthMethod.value = "password";802
elements.profilePrivateKeyPath.value = "";803
elements.profileStorePassword.checked = false;804
elements.profileStoredPassword.value = "";805
elements.profileStoredPassword.placeholder = t("profiles.storePasswordPlaceholder");806
elements.deleteProfileButton.hidden = true;807
elements.profileMessage.textContent = "";808
profileOptions($("#profileJumpProfileId"), "", "terminal.noJump");809
$("#profileJumpProfileId").value = "";810
updatePrivateKeyField();811
}813
function fillProfileForm(profile) {814
if (!profile) { resetProfileForm(); return; }815
elements.profileId.value = profile.id;816
elements.profileLabel.value = profile.label;817
elements.profileHost.value = profile.host;818
elements.profilePort.value = String(profile.port);819
elements.profileUsername.value = profile.username;820
profileOptions($("#profileJumpProfileId"), profile.id, "terminal.noJump");821
$("#profileJumpProfileId").value = profile.jumpProfileId || "";822
elements.profileAuthMethod.value = profile.authMethod;823
elements.profilePrivateKeyPath.value = profile.privateKeyPath || "";824
elements.profileStorePassword.checked = Boolean(profile.hasStoredPassword);825
elements.profileStoredPassword.value = "";826
elements.profileStoredPassword.placeholder = t(profile.hasStoredPassword ? "profiles.passwordStoredPlaceholder" : "profiles.storePasswordPlaceholder");827
elements.deleteProfileButton.hidden = false;828
elements.profileMessage.textContent = "";829
updatePrivateKeyField();830
}832
function updatePrivateKeyField() {833
const privateKey = elements.profileAuthMethod.value === "privateKey";834
elements.privateKeyField.hidden = !privateKey;835
elements.passwordStorageField.hidden = privateKey;836
elements.profileStoredPassword.disabled = privateKey || !elements.profileStorePassword.checked;837
}839
function createManualHost(pane, name) {840
const group = document.createElement("fieldset"); group.className = "manual-host"; group.hidden = true;841
group.innerHTML = `<legend data-i18n="${name === "target" ? "terminal.server" : "terminal.jumpHost"}"></legend>842
<label><span data-i18n="profiles.host"></span><input data-field="host" maxlength="253" autocomplete="off" spellcheck="false"></label>843
<label><span data-i18n="profiles.port"></span><input data-field="port" type="number" min="1" max="65535" value="22"></label>844
<label><span data-i18n="profiles.user"></span><input data-field="username" maxlength="128" autocomplete="off" spellcheck="false"></label>845
<label><span data-i18n="profiles.auth"></span><select data-field="authMethod"><option value="password" data-i18n="profiles.authPassword"></option><option value="privateKey" data-i18n="profiles.authKey"></option></select></label>846
<div class="key-field" hidden><label><span data-i18n="profiles.privateKey"></span><input data-field="privateKeyPath" readonly></label><button class="button" type="button" data-i18n="profiles.select"></button></div>`;847
group.querySelectorAll("[data-field]").forEach((field) => { field.id = `pane-${pane.slot}-${name}-${field.dataset.field}`; });848
group.querySelector("button").addEventListener("click", async () => {849
try {850
const result = await api.selectPrivateKey();851
if (!result.canceled && state.vault?.unlocked && !pane.pending && pane.kind !== "connected") group.querySelector('[data-field="privateKeyPath"]').value = result.path;852
} catch (error) { writeTerminalError(pane, error); }853
});854
group.addEventListener("input", () => updateTerminalProfile(pane));855
group.addEventListener("change", () => updateTerminalProfile(pane));856
return group;857
}858
function manualHost(group) {859
return Object.fromEntries([...group.querySelectorAll("[data-field]")].map((field) => [field.dataset.field, field.dataset.field === "port" ? Number(field.value) : field.value.trim()]));860
}861
function selectedTarget(pane) {862
return pane.select.value === "@manual" ? manualHost(pane.targetFields) : state.profiles.find((profile) => profile.id === pane.select.value);863
}864
function selectedJump(pane) {865
return pane.jumpSelect.value === "@manual" ? manualHost(pane.jumpFields) : state.profiles.find((profile) => profile.id === pane.jumpSelect.value) || null;866
}867
function createTerminalPane(slot) {868
let root = $("#terminalPane");869
if (slot === 2) {870
root = root.cloneNode(true); root.id = "terminalPane2";871
root.querySelectorAll("[id]").forEach((element) => { element.id += "2"; });872
// The first pane has already received its generated connection fields.873
root.querySelectorAll(".pane-extra").forEach((element) => element.remove());874
root.querySelector(".terminal-pane-heading strong").textContent = "T2";875
root.hidden = true; $("#terminalPanes").append(root);876
}877
const find = (id) => root.querySelector(`#${id}${slot === 2 ? "2" : ""}`);878
const pane = { slot, root, sessionId: null, kind: "disconnected", pending: false, latency: null, terminal: null, fit: null, jumpOpen: false, sentSize: "",879
select: find("terminalProfileSelect"), credential: find("terminalCredential"), credentialText: find("credentialLabelText"),880
connect: find("connectButton"), disconnect: find("disconnectButton"), badge: find("terminalState"),881
latencyElement: find("terminalLatency"), host: find("terminalHost"), screen: find("terminal"),882
jumpToggle: find("jumpToggle"), jumpToggleText: find("jumpToggleText") };883
const extra = document.createElement("div"); extra.className = "pane-extra terminal-options";884
extra.innerHTML = `<div class="jump-toolbar"><label><span class="sr-only" data-i18n="terminal.jumpHost"></span><select id="terminalJumpSelect${slot === 2 ? "2" : ""}"></select></label><label class="jump-credential"><span class="sr-only" data-i18n="terminal.jumpHost"></span><input type="password" autocomplete="off"></label></div>`;885
pane.jumpSelect = extra.querySelector("select"); pane.jumpCredential = extra.querySelector("input");886
pane.jumpCredential.id = `terminalJumpCredential${slot === 2 ? "2" : ""}`;887
pane.options = extra; pane.jumpRow = extra.querySelector(".jump-toolbar");888
pane.targetFields = createManualHost(pane, "target"); pane.jumpFields = createManualHost(pane, "jump");889
extra.prepend(pane.targetFields); extra.append(pane.jumpFields);890
pane.screen.before(extra);891
pane.tab = document.createElement("button"); pane.tab.type = "button"; pane.tab.className = "terminal-tab"; pane.tab.setAttribute("role", "tab");892
pane.tab.innerHTML = `<span class="terminal-dot"></span><strong>T${slot}</strong><span class="terminal-tab-name"></span>`;893
pane.tab.dataset.kind = pane.kind; pane.tabName = pane.tab.querySelector(".terminal-tab-name");894
pane.tab.addEventListener("click", () => setActivePane(pane, true));895
$("#terminalTabs").append(pane.tab);896
root.addEventListener("focusin", () => setActivePane(pane));897
root.addEventListener("pointerdown", () => setActivePane(pane));898
pane.select.addEventListener("change", () => selectTarget(pane));899
pane.jumpSelect.addEventListener("change", () => { pane.jumpCredential.value = ""; if (pane.jumpSelect.value) pane.jumpOpen = true; updateTerminalProfile(pane); });900
pane.jumpToggle.addEventListener("click", () => { pane.jumpOpen = !pane.jumpOpen; updateTerminalProfile(pane); requestAnimationFrame(() => fitPane(pane)); });901
pane.connect.addEventListener("click", () => void connectTerminal(pane));902
pane.disconnect.addEventListener("click", () => void disconnectTerminal(pane).catch((error) => writeTerminalError(pane, error)));903
return pane;904
}905
function selectTarget(pane) {906
pane.credential.value = ""; pane.jumpCredential.value = "";907
profileOptions(pane.jumpSelect, pane.select.value, "terminal.noJump", true);908
const target = selectedTarget(pane);909
pane.jumpSelect.value = target?.jumpProfileId || "";910
pane.jumpOpen = Boolean(pane.jumpSelect.value);911
updateTerminalProfile(pane);912
}913
function updateTerminalProfile(pane) {914
const target = selectedTarget(pane), jump = selectedJump(pane);915
pane.targetFields.hidden = pane.select.value !== "@manual";916
pane.jumpFields.hidden = pane.jumpSelect.value !== "@manual";917
for (const group of [pane.targetFields, pane.jumpFields]) group.querySelector(".key-field").hidden = manualHost(group).authMethod !== "privateKey";918
pane.credentialText.textContent = t(target?.authMethod === "privateKey" ? "terminal.passphrase" : "terminal.password");919
pane.credential.placeholder = t(target?.authMethod === "privateKey" ? "terminal.keyPassphrase" : target?.hasStoredPassword ? "terminal.savedPasswordHint" : "terminal.sshPassword");920
updateCredentialField(pane);921
pane.jumpCredential.closest("label").hidden = !jump || (jump.authMethod === "password" && jump.hasStoredPassword);922
pane.jumpCredential.placeholder = t(jump?.authMethod === "privateKey" ? "terminal.keyPassphrase" : "terminal.sshPassword");923
pane.options.hidden = !pane.jumpOpen && pane.targetFields.hidden;924
pane.jumpRow.hidden = !pane.jumpOpen;925
pane.jumpFields.hidden = pane.jumpFields.hidden || !pane.jumpOpen;926
pane.jumpToggle.classList.toggle("active", Boolean(jump)); pane.jumpToggle.setAttribute("aria-expanded", String(pane.jumpOpen));927
pane.jumpToggleText.textContent = jump?.label || jump?.host || t("terminal.jumpHost"); pane.jumpToggle.title = t("terminal.jumpHost");928
// Keep the host line tied to the current session while its controls are frozen.929
pane.host.textContent = target ? `${jump ? `${jump.host} ⇢ ` : ""}${target.username}@${target.host}:${target.port}` : t("terminal.noProfile");930
pane.host.title = pane.host.textContent;931
renderTerminalTab(pane);932
}933
// A saved password needs no input; the field returns after a failed attempt, for a one-time replacement.934
function updateCredentialField(pane) {935
const target = selectedTarget(pane);936
pane.credential.closest("label").hidden = Boolean(target?.hasStoredPassword && target.authMethod === "password" && pane.kind !== "error");937
}938
function renderTerminalTab(pane) {939
const live = pane.kind === "connected" || pane.kind === "connecting", target = live ? selectedTarget(pane) : null;940
pane.tab.dataset.kind = pane.kind;941
pane.tabName.textContent = target ? target.label || target.host : "";942
pane.tab.title = `${pane.badge.textContent}${target ? ` — ${pane.host.textContent}` : ""} (${terminalShortcut(pane.slot)})`;943
}944
function terminalShortcut(key) { return `${state.info?.platform === "darwin" ? "⌘" : "Ctrl+Shift+"}${key}`; }945
function setActivePane(pane, focus = false) {946
activePane = pane;947
const single = (state.config?.ui.terminalLayout || "single") === "single";948
for (const item of panes) {949
const active = item === pane;950
item.root.classList.toggle("active", active); item.tab.classList.toggle("active", active); item.tab.setAttribute("aria-selected", String(active));951
if (single) item.root.hidden = !active;952
}953
if (focus) requestAnimationFrame(() => { fitPane(pane); pane.terminal?.focus(); });954
}955
function initTerminal() {956
for (const pane of panes) {957
if (!window.Terminal || !window.FitAddon?.FitAddon) { pane.host.textContent = t("terminal.libraryMissing"); continue; }958
pane.terminal = new window.Terminal({ cursorBlink: true,959
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: 13, scrollback: 8000,960
theme: { background: "#050b12", foreground: "#d8e7f5", cursor: "#43d6a0", selectionBackground: "#264c6d" } });961
pane.fit = new window.FitAddon.FitAddon(); pane.terminal.loadAddon(pane.fit); pane.terminal.open(pane.screen);962
pane.terminal.writeln(`\x1b[2m${t("terminal.welcome")}\x1b[0m\r\n`);963
pane.terminal.onData((data) => { if (pane.kind === "connected") api.sendTerminalInput(pane.sessionId, data); });964
new ResizeObserver(() => fitPane(pane)).observe(pane.screen);965
}966
fitTerminal();967
}968
function fitPane(pane) {969
if (!pane.terminal || !pane.fit || pane.screen.offsetParent === null) return;970
try {971
pane.fit.fit();972
// Dragging the divider fits every frame; the server hears only real size changes.973
const size = `${pane.sessionId}:${pane.terminal.cols}x${pane.terminal.rows}`;974
if (pane.kind === "connected" && size !== pane.sentSize) { pane.sentSize = size; void api.resizeTerminal(pane.sessionId, pane.terminal.cols, pane.terminal.rows).catch(() => {}); }975
} catch {}976
}977
function fitTerminal() { for (const pane of panes) fitPane(pane); }978
function setTerminalState(pane, kind, text = t(`terminal.${kind === "error" ? "connectionError" : kind}`)) {979
pane.kind = kind; pane.badge.className = `connection-badge ${kind}`; pane.badge.textContent = text; pane.badge.title = text;980
pane.root.dataset.kind = kind;981
if (kind !== "connected") pane.latency = null;982
renderTerminalLatency(pane);983
const busy = pane.pending || kind === "connecting" || kind === "connected";984
pane.root.querySelectorAll("select, input, .manual-host button").forEach((field) => { field.disabled = busy; });985
pane.connect.disabled = busy;986
pane.disconnect.disabled = !pane.sessionId || (!pane.pending && kind !== "connected" && kind !== "connecting");987
updateCredentialField(pane); renderTerminalTab(pane);988
requestAnimationFrame(() => fitPane(pane));989
}990
function writeTerminalError(pane, error) { pane.terminal?.writeln(`\r\n\x1b[31m${errorText(error)}\x1b[0m`); }991
async function connectTerminal(pane) {992
if (pane.pending || pane.kind === "connected" || !state.vault?.unlocked) return;993
const target = selectedTarget(pane), jump = selectedJump(pane);994
if (!target) return;995
const valid = (host) => host.host && host.username && Number.isInteger(host.port) && host.port >= 1 && host.port <= 65535 && (host.authMethod !== "privateKey" || host.privateKeyPath);996
if (!valid(target) || (jump && !valid(jump))) { writeTerminalError(pane, { code: "SSH_TARGET_INVALID" }); return; }997
const sessionId = terminalSessionId(pane.slot);998
pane.sessionId = sessionId; pane.pending = true;999
fitPane(pane); pane.terminal?.clear();1000
pane.terminal?.writeln(`\x1b[33m${t("terminal.connectingTo", { target: `${target.username}@${target.host}:${target.port}` })}\x1b[0m`);1001
setTerminalState(pane, "connecting");1002
try {1003
while (pane.sessionId === sessionId && state.vault?.unlocked) {1004
const result = await api.connectSsh(buildTerminalPayload({ sessionId, slot: pane.slot, target, jump,1005
credential: pane.credential.value, jumpCredential: pane.jumpCredential.value,1006
cols: pane.terminal?.cols || 100, rows: pane.terminal?.rows || 30 }));1007
if (pane.sessionId !== sessionId || !state.vault?.unlocked) {1008
if (result.ok) await api.disconnectSsh(sessionId);1009
return;1010
}1011
if (result.ok) {1012
pane.credential.value = ""; pane.jumpCredential.value = ""; pane.pending = false;1013
setTerminalState(pane, "connected"); fitPane(pane); pane.terminal?.focus(); return;1014
}1015
const issue = result.error || { message: t("ssh.failed") };1016
const changed = issue.code === "HOST_KEY_MISMATCH";1017
if (!changed && issue.code !== "HOST_KEY_UNKNOWN") throw issue;1018
const prompt = `${t(issue.hop === "jump" ? "ssh.hopJump" : "ssh.hopTarget")}\n\n${t(changed ? "ssh.changedHostConfirm" : "ssh.unknownHost", issue)}`;1019
if (!window.confirm(prompt)) break;1020
const trusted = await api.trustSshHostKey({ sessionId, profileId: target.id || "", fingerprint: issue.fingerprint,1021
...(changed ? { knownFingerprint: issue.knownFingerprint, replaceKnown: true } : {}) });1022
if (pane.sessionId !== sessionId || !state.vault?.unlocked) return;1023
if (!trusted.ok) throw trusted.error || { message: t("ssh.trustFailed") };1024
}1025
} catch (error) { if (pane.sessionId === sessionId) writeTerminalError(pane, error); }1026
finally {1027
if (pane.sessionId === sessionId) {1028
pane.pending = false;1029
if (pane.kind !== "connected") { pane.sessionId = null; setTerminalState(pane, "error"); }1030
}1031
}1032
}1033
async function disconnectTerminal(pane) {1034
const sessionId = pane.sessionId;1035
if (!sessionId) return;1036
// Invalidate before awaiting IPC, so late replies and events cannot revive the pane.1037
pane.sessionId = null; pane.pending = false; setTerminalState(pane, "disconnected");1038
await api.disconnectSsh(sessionId);1039
}1040
function applyTerminalLayout(layout) {1041
const split = layout !== "single", box = $("#terminalPanes");1042
if (split) lastSplitLayout = layout;1043
box.classList.toggle("split", split); box.classList.toggle("stacked", layout === "stacked");1044
$("#terminalDivider").hidden = !split; $("#terminalDivider").setAttribute("aria-orientation", layout === "stacked" ? "horizontal" : "vertical");1045
$("#terminalSwap").hidden = !split;1046
for (const pane of panes) pane.root.hidden = !split && pane !== activePane;1047
setActivePane(activePane);1048
renderTerminalStrip();1049
applyTerminalSplit();1050
requestAnimationFrame(fitTerminal);1051
}1052
function renderTerminalStrip() {1053
const layout = state.config?.ui.terminalLayout || "single";1054
document.querySelectorAll("#terminalLayout button").forEach((button) => {1055
const active = button.dataset.layout === layout;1056
button.classList.toggle("active", active); button.setAttribute("aria-checked", String(active));1057
button.title = t(button.dataset.label); button.setAttribute("aria-label", button.title);1058
});1059
document.querySelector('#terminalLayout [data-layout="single"]').title += ` (${terminalShortcut(state.info?.platform === "darwin" ? "⇧↩" : "Enter")})`;1060
$("#terminalSwap").title = t("terminal.swap"); $("#terminalSwap").setAttribute("aria-label", t("terminal.swap"));1061
for (const pane of panes) renderTerminalTab(pane);1062
}1063
function applyTerminalSplit(ratio = state.config?.ui.terminalSplit ?? 0.5) {1064
const swapped = state.config?.ui.terminalSwapped === true, [first, second] = swapped ? [panes[1], panes[0]] : panes;1065
$("#terminalPanes").classList.toggle("swapped", swapped);1066
first.root.style.flexGrow = String(Math.round(ratio * 1000)); second.root.style.flexGrow = String(Math.round((1 - ratio) * 1000));1067
$("#terminalDivider").setAttribute("aria-valuenow", String(Math.round(ratio * 100)));1068
}1069
async function saveTerminalUi(patch) {1070
try { state.config = await api.updateConfig({ ui: patch }); }1071
catch (error) { window.alert(errorText(error)); }1072
applyTerminalLayout(state.config?.ui.terminalLayout || "single");1073
}1074
// Hiding a pane never ends its session: the tab keeps its status and brings it back.1075
$("#terminalLayout").addEventListener("click", (event) => {1076
const layout = event.target.closest("[data-layout]")?.dataset.layout;1077
if (layout) void saveTerminalUi({ terminalLayout: layout });1078
});1079
$("#terminalSwap").addEventListener("click", () => void saveTerminalUi({ terminalSwapped: state.config?.ui.terminalSwapped !== true }));1080
{1081
const divider = $("#terminalDivider"), box = $("#terminalPanes"), clamp = (value) => Math.min(0.8, Math.max(0.2, value));1082
let dragging = false, ratio = 0.5;1083
divider.addEventListener("pointerdown", (event) => {1084
event.preventDefault(); divider.setPointerCapture(event.pointerId);1085
dragging = true; ratio = state.config?.ui.terminalSplit ?? 0.5; box.classList.add("resizing");1086
});1087
divider.addEventListener("pointermove", (event) => {1088
if (!dragging) return;1089
const rect = box.getBoundingClientRect(), stacked = box.classList.contains("stacked");1090
const position = stacked ? (event.clientY - rect.top) / rect.height : (event.clientX - rect.left) / rect.width;1091
ratio = clamp(!stacked && document.documentElement.dir === "rtl" ? 1 - position : position);1092
applyTerminalSplit(ratio);1093
});1094
const release = () => {1095
if (!dragging) return;1096
dragging = false; box.classList.remove("resizing");1097
void saveTerminalUi({ terminalSplit: ratio });1098
};1099
divider.addEventListener("pointerup", release); divider.addEventListener("pointercancel", release);1100
divider.addEventListener("dblclick", () => void saveTerminalUi({ terminalSplit: 0.5 }));1101
divider.addEventListener("keydown", (event) => {1102
const step = { ArrowLeft: -0.05, ArrowUp: -0.05, ArrowRight: 0.05, ArrowDown: 0.05 }[event.key];1103
if (!step) return;1104
event.preventDefault();1105
void saveTerminalUi({ terminalSplit: clamp((state.config?.ui.terminalSplit ?? 0.5) + step) });1106
});1107
}1108
// Capture phase: the focused terminal must not receive these as input.1109
window.addEventListener("keydown", (event) => {1110
if (!$("main").classList.contains("terminal-active") || !state.vault?.unlocked || event.altKey) return;1111
const mac = state.info?.platform === "darwin";1112
if (!(mac ? event.metaKey : event.ctrlKey && event.shiftKey)) return;1113
if (event.code === "Digit1" || event.code === "Digit2") setActivePane(panes[event.code === "Digit1" ? 0 : 1], true);1114
else if (event.code === "Enter" && event.shiftKey) void saveTerminalUi({ terminalLayout: state.config?.ui.terminalLayout === "single" ? lastSplitLayout : "single" });1115
else return;1116
event.preventDefault(); event.stopPropagation();1117
}, true);1119
function renderTerminalLatency(pane) {1120
const element = pane.latencyElement;1121
element.hidden = pane.kind !== "connected";1122
const latency = pane.latency;1123
const ms = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? (value < 0.1 ? "<0.1 ms" : `${value.toFixed(1)} ms`) : null;1124
const pingReasons = { jump: "terminal.pingJump", timeout: "terminal.pingTimeout", denied: "terminal.pingDenied", unreachable: "terminal.pingUnreachable", unsupported: "terminal.pingUnsupported", error: "terminal.pingError" };1125
let ping = `— (${t("terminal.measuring")})`;1126
let ssh = ping;1127
if (latency?.measuredAt || latency?.pingStatus === "jump") {1128
const family = latency.pingFamily === 6 ? "IPv6" : latency.pingFamily === 4 ? "IPv4" : "";1129
const pingValue = latency.pingStatus === "jump" ? null : ms(latency.pingMs);1130
const loss = typeof latency.pingLoss === "number" && latency.pingLoss > 0 ? `, ${t("terminal.pingLoss", { loss: Math.round(latency.pingLoss * 100) })}` : "";1131
ping = pingValue ? `${latency.pingLessThan ? "<" : ""}${pingValue}${family ? ` ${family}` : ""}${loss}` : `— (${t(pingReasons[latency.pingStatus] || "terminal.latencyUnavailable")})`;1132
const sshValue = ms(latency.sshRttMs);1133
ssh = sshValue || `— (${t("terminal.latencyUnavailable")})`;1134
}1135
element.textContent = `${t("terminal.ping")}: ${ping} · ${t("terminal.sshRtt")}: ${ssh}`;1136
const method = latency?.sshRttMethod === "transport" ? t("terminal.sshRttTransport") : latency?.sshRttMethod === "command" ? t("terminal.sshRttCommand") : "";1137
element.title = [element.textContent, t("terminal.latencyMeaning"), method, latency?.pingReason, latency?.sshRttError].filter(Boolean).join("\n");1138
}1140
async function checkUpdates() {1141
setBusy(elements.checkUpdateButton, true, t("updates.checking"));1142
elements.updateHeadline.textContent = t("updates.checkingServer");1143
elements.updateMessage.textContent = t("updates.tryingEndpoints");1144
try { renderUpdate(await api.checkForUpdates()); }1145
catch (error) { elements.updateHeadline.textContent = t("updates.checkFailed"); elements.updateMessage.textContent = errorText(error); }1146
finally { setBusy(elements.checkUpdateButton, false, t("updates.check")); }1147
}1149
async function initialize() {1150
if (!api || !i18n) throw new Error("Core preload or i18n API is unavailable.");1151
[state.info, state.config, state.pluginState, state.update, state.vault] = await Promise.all([1152
api.getInfo(), api.getConfig(), api.getPluginState(), api.getUpdateState(), api.getVaultStatus()1153
]);1154
state.plugins = state.pluginState.installed;1155
state.pluginWindows = state.pluginState.windows || [];1156
i18n.setLanguage(state.config.ui.language);1157
document.documentElement.lang = i18n.getLanguage();1158
document.documentElement.dir = i18n.getDirection();1159
i18n.apply(document);1160
applySidebar();1161
elements.runtimeText.textContent = `${state.info.platform} / ${state.info.arch}`;1162
elements.sidebarVersion.textContent = `v${state.info.version}`;1163
$("#heroVersion").textContent = `CORE v${state.info.version}`;1164
applyTerminalLayout(state.config.ui.terminalLayout);1165
elements.coreApiValue.textContent = state.info.coreApi;1166
elements.dataSchemaValue.textContent = String(state.info.dataSchema);1167
elements.currentVersion.textContent = `v${state.info.version}`;1168
elements.languageSelect.value = state.config.ui.language;1169
elements.vaultLanguageSelect.value = state.config.ui.language;1170
elements.vaultLanguageSelect.disabled = false;1171
elements.channelSelect.value = state.config.updates.channel;1172
elements.updateViewChannelSelect.value = state.config.updates.channel;1173
elements.pluginChannelSelect.value = state.config.plugins.channel;1174
elements.pluginViewChannelSelect.value = state.config.plugins.channel;1175
elements.autoCheckInput.checked = state.config.updates.autoCheck;1176
renderMonitoringSettings();1177
elements.baseUrlsText.textContent = state.config.updates.baseUrls.join("\n");1178
elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });1179
const security = state.config.security;1180
$("#autoLockEnabled").checked = security.autoLockMinutes > 0;1181
$("#autoLockMinutes").value = String(security.autoLockMinutes || 15);1182
$("#autoLockMinutes").disabled = !security.autoLockMinutes;1183
$("#lockOnBlur").checked = security.lockOnBlur;1184
$("#requireSystemAuthentication").checked = security.requireSystemAuthentication;1185
diagnosticsView = window.CHJ_DIAGNOSTICS.mount($("#diagnosticsView"), {1186
// Diagnostics follows the application theme. The application shell is dark-only, so1187
// the module must not fall back to the OS colour scheme (light on many Windows/Linux setups).1188
api: api.diagnostics, language: () => i18n.getLanguage(), getTheme: () => APP_THEME1189
});1190
renderPlugins(); renderUpdate(state.update); initTerminal(); configureVault(state.vault);1191
void refreshBiometrics().then(maybeAutoUnlock);1192
window.addEventListener("focus", () => void maybeAutoUnlock());1193
document.addEventListener("visibilitychange", () => void maybeAutoUnlock());1194
if (state.vault.unlocked) await loadProfiles();1195
}1197
document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));1198
hashToolButton.addEventListener("click", async () => {1199
try { await api.openPlugin(HASH_TOOL_ID); }1200
catch (error) { window.alert(errorText(error)); }1201
});1202
elements.vaultForm.addEventListener("submit", async (event) => {1203
event.preventDefault();1204
const password = elements.vaultPassword.value;1205
const passwordLength = Array.from(password).length;1206
if (passwordLength < 4 || passwordLength > 64) { elements.vaultMessage.textContent = t("vault.passwordLength"); return; }1207
if (state.vault.needsSetup && password !== elements.vaultPasswordConfirm.value) { elements.vaultMessage.textContent = t("vault.passwordMismatch"); return; }1208
setBusy(elements.vaultSubmit, true, t(state.vault.needsSetup ? "vault.creating" : "vault.unlocking"));1209
try {1210
const status = state.vault.needsSetup ? await api.createVault(password) : await api.unlockVault(password);1211
configureVault(status);1212
await loadProfiles();1213
} catch (error) { elements.vaultMessage.className = "form-message"; elements.vaultMessage.textContent = errorText(error); }1214
finally { if (!state.vault.unlocked) setBusy(elements.vaultSubmit, false, t(state.vault.needsSetup ? "vault.create" : "vault.unlock")); }1215
});1216
elements.lockVaultButton.addEventListener("click", async () => {1217
configureVault(await api.lockVault());1218
clearProtectedUi();1219
for (const pane of panes) pane.terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);1220
});1221
$("#biometricUnlockButton").addEventListener("click", async event => {1222
event.currentTarget.disabled = true;1223
try { configureVault(await api.unlockVaultWithBiometrics()); await loadProfiles(); }1224
catch (error) { elements.vaultMessage.textContent = errorText(error); }1225
finally { $("#biometricUnlockButton").disabled = false; void refreshBiometrics(); }1226
});1227
for (const [id, action, message] of [1228
["enableBiometricsButton", () => api.enableBiometrics(), "security.enabled"],1229
["disableBiometricsButton", () => api.disableBiometrics(), "security.disabled"],1230
["testBiometricsButton", () => api.authenticateSensitiveAction(), "security.testPassed"]1231
]) {1232
$("#" + id).addEventListener("click", async event => {1233
event.currentTarget.disabled = true;1234
try { await action(); $("#securityMessage").textContent = t(message); }1235
catch (error) { $("#securityMessage").textContent = errorText(error); }1236
finally { await refreshBiometrics(); }1237
});1238
}1239
$("#autoLockEnabled").addEventListener("change", event => {1240
$("#autoLockMinutes").disabled = !event.target.checked;1241
if (event.target.checked && $("#autoLockMinutes").value === "0") $("#autoLockMinutes").value = "15";1242
});1243
$("#securityForm").addEventListener("submit", async event => {1244
event.preventDefault();1245
const password = $("#securityMasterPassword").value; $("#securityMasterPassword").value = "";1246
try {1247
state.config = await api.updateConfig({ masterPassword: password, security: {1248
autoLockMinutes: $("#autoLockEnabled").checked ? Number($("#autoLockMinutes").value) : 0,1249
lockOnBlur: $("#lockOnBlur").checked, requireSystemAuthentication: $("#requireSystemAuthentication").checked1250
} });1251
$("#securityMessage").textContent = t("settings.saved");1252
} catch (error) { $("#securityMessage").textContent = errorText(error); }1253
});1254
elements.showVaultResetButton.addEventListener("click", () => {1255
elements.vaultResetPanel.hidden = false;1256
elements.showVaultResetButton.hidden = true;1257
elements.vaultResetConfirmation.focus();1258
});1259
elements.cancelVaultResetButton.addEventListener("click", () => {1260
elements.vaultResetPanel.hidden = true;1261
elements.showVaultResetButton.hidden = false;1262
elements.vaultResetConfirmation.value = "";1263
});1264
elements.confirmVaultResetButton.addEventListener("click", async () => {1265
if (elements.vaultResetConfirmation.value !== "SMAZAT") {1266
elements.vaultMessage.textContent = t("vault.typeDelete");1267
return;1268
}1269
if (!window.confirm(t("vault.finalResetConfirm"))) return;1270
setBusy(elements.confirmVaultResetButton, true, t("vault.deleting"));1271
try {1272
const status = await api.resetVault("SMAZAT");1273
clearProtectedUi();1274
configureVault(status);1275
elements.vaultMessage.className = "form-message success";1276
elements.vaultMessage.textContent = t("vault.resetDone");1277
} catch (error) {1278
elements.vaultMessage.className = "form-message";1279
elements.vaultMessage.textContent = errorText(error);1280
} finally {1281
setBusy(elements.confirmVaultResetButton, false, t("vault.resetAction"));1282
}1283
});1284
elements.newProfileButton.addEventListener("click", () => { state.selectedProfileId = ""; renderProfiles(); resetProfileForm(); elements.profileLabel.focus(); });1285
elements.profileAuthMethod.addEventListener("change", updatePrivateKeyField);1286
elements.profileStorePassword.addEventListener("change", updatePrivateKeyField);1287
elements.selectPrivateKeyButton.addEventListener("click", async () => {1288
const result = await api.selectPrivateKey();1289
if (!result.canceled) elements.profilePrivateKeyPath.value = result.path;1290
});1291
elements.profileForm.addEventListener("submit", async (event) => {1292
event.preventDefault(); elements.profileMessage.className = "form-message"; elements.profileMessage.textContent = "";1293
try {1294
const saved = await api.saveProfile({1295
id: elements.profileId.value || undefined, label: elements.profileLabel.value, host: elements.profileHost.value,1296
port: Number(elements.profilePort.value), username: elements.profileUsername.value,1297
jumpProfileId: $("#profileJumpProfileId").value,1298
authMethod: elements.profileAuthMethod.value, privateKeyPath: elements.profilePrivateKeyPath.value,1299
storePassword: elements.profileAuthMethod.value === "password" && elements.profileStorePassword.checked,1300
password: elements.profileStoredPassword.value1301
});1302
await loadProfiles(saved.id); elements.profileMessage.className = "form-message success"; elements.profileMessage.textContent = t("profiles.saved");1303
} catch (error) { elements.profileMessage.textContent = errorText(error); }1304
});1305
elements.deleteProfileButton.addEventListener("click", async () => {1306
const id = elements.profileId.value;1307
if (!id || !window.confirm(t("profiles.deleteConfirm"))) return;1308
await api.deleteProfile(id); state.selectedProfileId = ""; await loadProfiles("");1309
});1310
elements.checkUpdateButton.addEventListener("click", checkUpdates);1311
elements.downloadUpdateButton.addEventListener("click", async () => {1312
setBusy(elements.downloadUpdateButton, true, t("updates.downloadingAndVerifying"));1313
elements.updateHeadline.textContent = t("updates.verifyingSignature");1314
try { renderUpdate(await api.downloadUpdate()); }1315
catch (_error) {1316
elements.updateHeadline.textContent = t("updates.downloadRejected");1317
elements.updateMessage.textContent = t("updates.signatureFailure");1318
elements.installUpdateButton.hidden = true;1319
elements.revealUpdateButton.hidden = true;1320
}1321
finally { setBusy(elements.downloadUpdateButton, false, t("updates.download")); }1322
});1323
elements.installUpdateButton.addEventListener("click", async () => {1324
const release = state.update?.release;1325
if (!release || !window.confirm(t("updates.installConfirm", { version: release.version, date: formatReleaseDate(release.publishedAt) }))) return;1326
setBusy(elements.installUpdateButton, true, t("updates.verifyingSignature"));1327
try { await api.installUpdate(); }1328
catch (_error) {1329
try { renderUpdate(await api.getUpdateState()); } catch {}1330
elements.updateHeadline.textContent = t("updates.installFailed");1331
elements.updateMessage.textContent = t("updates.installFailure");1332
elements.installUpdateButton.hidden = true;1333
} finally { setBusy(elements.installUpdateButton, false, t("updates.install")); }1334
});1335
elements.revealUpdateButton.addEventListener("click", () => api.revealUpdate());1336
elements.updateViewChannelSelect.addEventListener("change", async () => {1337
state.config = await api.updateConfig({ updates: { channel: elements.updateViewChannelSelect.value } });1338
elements.channelSelect.value = state.config.updates.channel;1339
elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });1340
await checkUpdates();1341
});1342
elements.pluginViewChannelSelect.addEventListener("change", async () => {1343
state.config = await api.updateConfig({ plugins: { channel: elements.pluginViewChannelSelect.value } });1344
elements.pluginChannelSelect.value = state.config.plugins.channel;1345
elements.checkPluginCatalogButton.click();1346
});1347
elements.checkPluginCatalogButton.addEventListener("click", async () => {1348
setBusy(elements.checkPluginCatalogButton, true, t("plugins.checking"));1349
try { state.pluginState = await api.checkPluginCatalog(); state.plugins = state.pluginState.installed; renderPlugins(); }1350
catch (error) { window.alert(errorText(error)); }1351
finally { setBusy(elements.checkPluginCatalogButton, false, t("plugins.checkCatalog")); }1352
});1353
elements.settingsForm.addEventListener("submit", async (event) => {1354
event.preventDefault();1355
state.config = await api.updateConfig({1356
ui: { language: elements.languageSelect.value },1357
updates: { channel: elements.channelSelect.value, autoCheck: elements.autoCheckInput.checked },1358
plugins: { channel: elements.pluginChannelSelect.value },1359
monitoring: { enabled: $("#monitoringEnabled").checked, intervalSeconds: Number($("#monitoringInterval").value) }1360
});1361
elements.updateViewChannelSelect.value = state.config.updates.channel;1362
elements.pluginViewChannelSelect.value = state.config.plugins.channel;1363
applyLanguage(state.config.ui.language);1364
elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });1365
renderMonitoringSettings();1366
elements.settingsStatus.textContent = t("settings.saved"); setTimeout(() => { elements.settingsStatus.textContent = ""; }, 1800);1367
});1368
elements.languageSelect.addEventListener("change", () => applyLanguage(elements.languageSelect.value));1369
$("#refreshAvailabilityButton").addEventListener("click", () => void api.refreshAvailability().catch(() => {}));1370
document.querySelectorAll("[data-legal-document]").forEach((button) => {1371
button.addEventListener("click", async () => {1372
try { await api.openLegalDocument(button.dataset.legalDocument); }1373
catch (error) { window.alert(errorText(error)); }1374
});1375
});1376
elements.vaultLanguageSelect.addEventListener("change", async () => {1377
const language = applyLanguage(elements.vaultLanguageSelect.value);1378
state.config = await api.updateConfig({ ui: { language } });1379
});1381
api?.onUpdateState((payload) => renderUpdate(payload));1382
api?.onAvailabilityChanged((event) => {1383
if (event.reset) availabilityState = new Map();1384
for (const id of event.removed || []) availabilityState.delete(id);1385
for (const entry of event.servers || []) availabilityState.set(entry.id, entry);1386
renderAvailability();1387
});1388
api?.onPluginWindowState((windows) => renderPluginTaskbar(windows));1389
api?.onSshData((event) => paneForEvent(panes, event)?.terminal?.write(event.data));1390
api?.onSshState((event) => {1391
const pane = paneForEvent(panes, event);1392
if (!pane) return;1393
pane.latency = event.latency;1394
// A host-key retry may emit disconnected before the next connect. Keep controls frozen.1395
if (pane.pending && event.state !== "connected") return;1396
const kind = ["connected", "connecting"].includes(event.state) ? event.state : "disconnected";1397
if (kind === "disconnected") pane.sessionId = null;1398
setTerminalState(pane, kind, t(`terminal.${kind}`));1399
});1400
api?.onSshError((event) => { const pane = paneForEvent(panes, event); if (pane) writeTerminalError(pane, event); });1401
api?.onVaultLocked(status => {1402
configureVault(status); clearProtectedUi(); void refreshBiometrics().then(maybeAutoUnlock);1403
for (const pane of panes) pane.terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);1404
});1406
initialize().catch((error) => {1407
elements.runtimeText.textContent = t("errors.initialization");1408
elements.updateMessage.textContent = errorText(error);1409
});SHA-256
f0caafc4cbe027808a83ef7cc97bc5a74c989b0d8430076b2ba1001cf7020fa0CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df