Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
406 souborů
core-20261024T135205Z-7c487d719de2
Přidáno: · Sestaveno:
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
const TERMINAL_SESSION_ID = "terminal-main";8
const HASH_TOOL_ID = "chj.hash-checksum";9
if (api?.platform) document.body.classList.add(`platform-${api.platform}`);10
const state = {11
info: null,12
config: null,13
plugins: [],14
pluginState: null,15
pluginWindows: [],16
update: null,17
vault: null,18
profiles: [],19
selectedProfileId: "",20
terminalConnected: false,21
terminalStateKind: "disconnected"22
};24
const $ = (selector) => document.querySelector(selector);25
const hashToolButton = $("#hashToolButton");26
const coreNavigationButtons = [...$("#mainNavigation").querySelectorAll("[data-view]")];27
const selectedExportIds = new Set();28
let importPreview = null;30
const elements = {31
viewTitle: $("#viewTitle"), runtimeText: $("#runtimeText"), sidebarVersion: $("#sidebarVersion"),32
coreApiValue: $("#coreApiValue"), dataSchemaValue: $("#dataSchemaValue"), pluginCountValue: $("#pluginCountValue"),33
currentVersion: $("#currentVersion"), updateChannel: $("#updateChannel"), updateHeadline: $("#updateHeadline"),34
updateMessage: $("#updateMessage"), updateHash: $("#updateHash"), checkUpdateButton: $("#checkUpdateButton"),35
downloadUpdateButton: $("#downloadUpdateButton"), installUpdateButton: $("#installUpdateButton"), revealUpdateButton: $("#revealUpdateButton"),36
updateReleaseRows: $("#updateReleaseRows"), updateReleaseCount: $("#updateReleaseCount"), pluginRows: $("#pluginRows"),37
pluginCatalogRows: $("#pluginCatalogRows"), pluginCatalogSource: $("#pluginCatalogSource"), checkPluginCatalogButton: $("#checkPluginCatalogButton"),38
pluginTaskbar: $("#pluginTaskbar"), pluginTaskbarItems: $("#pluginTaskbarItems"),39
updateViewChannelSelect: $("#updateViewChannelSelect"), pluginViewChannelSelect: $("#pluginViewChannelSelect"),40
settingsForm: $("#settingsForm"), languageSelect: $("#languageSelect"), channelSelect: $("#channelSelect"), pluginChannelSelect: $("#pluginChannelSelect"), autoCheckInput: $("#autoCheckInput"),41
baseUrlsText: $("#baseUrlsText"), settingsStatus: $("#settingsStatus"), lockVaultButton: $("#lockVaultButton"),42
vaultOverlay: $("#vaultOverlay"), vaultForm: $("#vaultForm"), vaultLanguageSelect: $("#vaultLanguageSelect"), vaultModeText: $("#vaultModeText"), vaultTitle: $("#vaultTitle"),43
vaultDescription: $("#vaultDescription"), vaultPassword: $("#vaultPassword"), vaultConfirmLabel: $("#vaultConfirmLabel"),44
vaultPasswordConfirm: $("#vaultPasswordConfirm"), vaultMessage: $("#vaultMessage"), vaultSubmit: $("#vaultSubmit"),45
showVaultResetButton: $("#showVaultResetButton"), vaultResetPanel: $("#vaultResetPanel"),46
vaultResetConfirmation: $("#vaultResetConfirmation"), cancelVaultResetButton: $("#cancelVaultResetButton"),47
confirmVaultResetButton: $("#confirmVaultResetButton"),48
profileRows: $("#profileRows"), profileForm: $("#profileForm"), profileId: $("#profileId"), profileLabel: $("#profileLabel"),49
profileHost: $("#profileHost"), profilePort: $("#profilePort"), profileUsername: $("#profileUsername"),50
profileAuthMethod: $("#profileAuthMethod"), privateKeyField: $("#privateKeyField"), passwordStorageField: $("#passwordStorageField"),51
profileStorePassword: $("#profileStorePassword"), profileStoredPassword: $("#profileStoredPassword"),52
profilePrivateKeyPath: $("#profilePrivateKeyPath"), selectPrivateKeyButton: $("#selectPrivateKeyButton"),53
profileMessage: $("#profileMessage"), deleteProfileButton: $("#deleteProfileButton"), newProfileButton: $("#newProfileButton"),54
terminalProfileSelect: $("#terminalProfileSelect"), credentialLabelText: $("#credentialLabelText"), terminalCredential: $("#terminalCredential"),55
connectButton: $("#connectButton"), disconnectButton: $("#disconnectButton"), terminalState: $("#terminalState"),56
terminalHost: $("#terminalHost"), terminalElement: $("#terminal")57
};59
const titles = {60
overview: "nav.overview", terminal: "terminal.title", profiles: "nav.servers",61
updates: "nav.updates", plugins: "nav.plugins", diagnostics: "nav.diagnostics", settings: "nav.settings"62
};64
let diagnosticsView = null;65
let terminal = null;66
let fitAddon = null;68
function errorText(error) {69
const code = error?.code || String(error?.message || "").match(/BIOMETRIC_[A-Z_]+/)?.[0];70
if (code?.startsWith("BIOMETRIC_")) return t(`biometrics.${code}`);71
if (["SSH_LOCAL_NETWORK_DENIED", "SSH_MAC_NETWORK_UNREACHABLE"].includes(code)) return `${t(`ssh.${code}`)}\n${error.message || ""}`;72
return error?.message || String(error || t("errors.unknown"));73
}75
let biometricStatus = null;76
function renderBiometrics() {77
const status = biometricStatus;78
$("#biometricUnlockButton").hidden = !status?.enabled || !status.available || state.vault?.unlocked || !state.vault?.initialized;79
$("#biometricUnlockButton").textContent = t(`biometrics.${status?.provider || "touch-id"}`);80
$("#biometricStatus").textContent = status ? `${status.provider}: ${t(`biometrics.${status.code}`)} · ${t(status.enabled ? "security.enabled" : "security.disabled")}` : "";81
$("#biometricProtection").textContent = status ? t(status.protection === "biometry-current-set" ? "security.strong" : "security.convenience") : "";82
$("#enableBiometricsButton").disabled = !state.vault?.unlocked || !status?.available || status.enabled;83
$("#disableBiometricsButton").disabled = !state.vault?.unlocked || !status?.enrolled;84
$("#testBiometricsButton").disabled = !state.vault?.unlocked || !status?.available;85
}86
// Automatic Touch ID / Windows Hello prompt. Main decides whether it is armed and keeps it87
// single-flight; the renderer only reports that the window is visible and focused.88
let autoUnlockRunning = false;89
async function maybeAutoUnlock() {90
if (autoUnlockRunning || !api?.autoUnlockVaultWithBiometrics) return;91
if (!state.vault || state.vault.unlocked || !state.vault.initialized || state.vault.needsSetup) return;92
if (!biometricStatus?.enabled || !biometricStatus.available) return;93
if (document.visibilityState !== "visible" || !document.hasFocus() || elements.vaultPassword.value) return;94
autoUnlockRunning = true;95
$("#biometricUnlockButton").disabled = true;96
elements.vaultMessage.textContent = t("biometrics.autoPrompt");97
try {98
const result = await api.autoUnlockVaultWithBiometrics();99
if (result.status?.unlocked) { configureVault(result.status); await loadProfiles(); }100
else if (result.error) {101
elements.vaultMessage.textContent = `${errorText(result.error)} ${t("biometrics.usePassword")}`;102
elements.vaultPassword.focus();103
} else elements.vaultMessage.textContent = "";104
} catch (error) { elements.vaultMessage.textContent = errorText(error); }105
finally { autoUnlockRunning = false; $("#biometricUnlockButton").disabled = false; void refreshBiometrics(); }106
}107
async function refreshBiometrics() {108
try { biometricStatus = await api.getBiometricStatus(); renderBiometrics(); }109
catch (error) { $("#securityMessage").textContent = errorText(error); }110
}112
function showView(name) {113
document.querySelectorAll("[data-view]").forEach((button) => button.classList.toggle("active", button.dataset.view === name));114
document.querySelectorAll("[data-view-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.viewPanel === name));115
elements.viewTitle.textContent = titles[name] ? t(titles[name]) : "CH-J Server Manager";116
if (name === "terminal") requestAnimationFrame(fitTerminal);117
}119
function pluginDisplay(id) {120
return state.config?.plugins.display?.[id] || (id === HASH_TOOL_ID ? "navigation" : "visible");121
}123
let preferenceSave = Promise.resolve();124
function saveUiPreferences(patch) {125
preferenceSave = preferenceSave.then(async () => {126
state.config = await api.updateConfig(patch());127
renderPlugins();128
}).catch((error) => { window.alert(errorText(error)); renderPlugins(); });129
return preferenceSave;130
}132
function moveNavigation(key, targetKey, after = false) {133
if (!$("#navigationEditToggle").checked) return;134
const keys = [...$("#mainNavigation").children].map((row) => row.dataset.navKey);135
const from = keys.indexOf(key), to = keys.indexOf(targetKey);136
if (from < 0 || to < 0 || from === to) return;137
keys.splice(from, 1); keys.splice(keys.indexOf(targetKey) + Number(after), 0, key);138
saveUiPreferences(() => ({ ui: { navigationOrder: [...keys, ...state.config.ui.navigationOrder.filter((id) => !keys.includes(id))] } }))139
.then(() => [...$("#mainNavigation").children].find((row) => row.dataset.navKey === key)?.querySelector(".nav-button").focus());140
}142
// Replaces the leading glyph of a navigation button with the shared SVG icon for its stable key.143
function setNavIcon(button, key) {144
const current = button.querySelector(".nav-icon");145
if (current?.dataset.key === key) return;146
const icon = window.chjNavIcons.createIcon(key);147
icon.dataset.key = key;148
const first = button.firstElementChild;149
if (current) current.replaceWith(icon);150
else if (first && !first.classList.contains("nav-label")) first.replaceWith(icon);151
else button.prepend(icon);152
}154
function renderNavigation() {155
const navigation = $("#mainNavigation");156
const buttons = new Map(coreNavigationButtons.map((button) => [button.dataset.view, button]));157
if (pluginDisplay(HASH_TOOL_ID) === "navigation") buttons.set(`plugin:${HASH_TOOL_ID}`, hashToolButton);158
for (const plugin of state.plugins) {159
if (plugin.id === HASH_TOOL_ID || pluginDisplay(plugin.id) !== "navigation") continue;160
const button = document.createElement("button"); button.type = "button"; button.className = "nav-button";161
const label = document.createElement("span"); label.className = "nav-label"; label.textContent = plugin.name;162
button.append(label);163
button.addEventListener("click", async () => { try { await api.openPlugin(plugin.id); } catch (error) { window.alert(errorText(error)); } });164
buttons.set(`plugin:${plugin.id}`, button);165
}166
hashToolButton.querySelector(".nav-label").textContent = t("nav.hash");167
const defaults = [...coreNavigationButtons.map((button) => button.dataset.view)];168
defaults.splice(defaults.indexOf("settings"), 0, `plugin:${HASH_TOOL_ID}`);169
const order = [...new Set([...(state.config?.ui.navigationOrder || []), ...defaults, ...buttons.keys()])].filter((key) => buttons.has(key));170
for (const [key, button] of buttons) setNavIcon(button, key);171
navigation.replaceChildren();172
const editing = $("#navigationEditToggle").checked;173
navigation.classList.toggle("navigation-editing", editing);174
order.forEach((key) => {175
const row = document.createElement("div"); row.className = "navigation-row"; row.dataset.navKey = key;176
const button = buttons.get(key);177
button.title = editing ? t("nav.drag") : "";178
row.draggable = editing;179
const handle = document.createElement("span"); handle.className = "navigation-drag"; handle.textContent = "⠿"; handle.setAttribute("aria-hidden", "true");180
row.addEventListener("dragstart", (event) => {181
if (!$("#navigationEditToggle").checked) { event.preventDefault(); return; }182
event.dataTransfer.setData("text/chj-navigation", key); event.dataTransfer.effectAllowed = "move";183
row.classList.add("navigation-dragging");184
});185
row.addEventListener("dragend", () => navigation.querySelectorAll(".navigation-row").forEach((item) => item.classList.remove("navigation-dragging", "navigation-drop-before", "navigation-drop-after")));186
row.addEventListener("dragover", (event) => {187
if (!$("#navigationEditToggle").checked || ![...event.dataTransfer.types].includes("text/chj-navigation")) return;188
event.preventDefault(); event.dataTransfer.dropEffect = "move";189
const after = event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2;190
row.classList.toggle("navigation-drop-before", !after); row.classList.toggle("navigation-drop-after", after);191
});192
row.addEventListener("dragleave", () => row.classList.remove("navigation-drop-before", "navigation-drop-after"));193
row.addEventListener("drop", (event) => {194
if (!$("#navigationEditToggle").checked) return;195
event.preventDefault(); row.classList.remove("navigation-drop-before", "navigation-drop-after");196
moveNavigation(event.dataTransfer.getData("text/chj-navigation"), key, event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2);197
});198
row.append(button, handle); navigation.append(row);199
});200
}202
$("#navigationEditToggle").addEventListener("change", renderNavigation);203
$("#mainNavigation").addEventListener("click", (event) => {204
if ($("#navigationEditToggle").checked) { event.preventDefault(); event.stopPropagation(); }205
}, true);207
// ---------- sidebar layout: modes, width, hide/show, tooltips (persisted in config.ui.sidebar by Main) ----------208
const SIDEBAR_LIMITS = Object.freeze({ min: 220, max: 420, default: 248 });209
const appShell = $(".app-shell");210
const sidebarTooltip = $("#sidebarTooltip");211
const sidebarShortcut = navigator.platform.startsWith("Mac") ? "⌘⇧B" : "Ctrl+Shift+B";212
let sidebarSave = Promise.resolve();213
const clampSidebarWidth = (value) => Math.round(Math.min(SIDEBAR_LIMITS.max, Math.max(SIDEBAR_LIMITS.min, value)));214
function sidebarState() { return { mode: "full", hidden: false, width: SIDEBAR_LIMITS.default, ...(state.config?.ui.sidebar || {}) }; }215
function applySidebar() {216
const sidebar = sidebarState();217
if (appShell.dataset.sidebarMode && (appShell.dataset.sidebarMode !== sidebar.mode || appShell.classList.contains("sidebar-hidden") !== sidebar.hidden)) {218
appShell.classList.remove("sidebar-switching"); void appShell.offsetWidth; appShell.classList.add("sidebar-switching");219
}220
appShell.dataset.sidebarMode = sidebar.mode;221
appShell.classList.toggle("sidebar-hidden", sidebar.hidden);222
appShell.style.setProperty("--sidebar-full-width", `${sidebar.width}px`);223
document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.setAttribute("aria-checked", String(button.dataset.sidebarMode === sidebar.mode)));224
const toggle = $("#sidebarToggle");225
const label = `${t(sidebar.hidden ? "nav.showSidebar" : "nav.hideSidebar")} (${sidebarShortcut})`;226
toggle.setAttribute("aria-expanded", String(!sidebar.hidden)); toggle.setAttribute("aria-label", label); toggle.title = label;227
$("#sidebarResizer").setAttribute("aria-valuenow", String(sidebar.width));228
// Reordering needs the labels; it is available in the full mode only.229
if (sidebar.mode !== "full" && $("#navigationEditToggle").checked) { $("#navigationEditToggle").checked = false; renderNavigation(); }230
hideSidebarTooltip();231
}232
// The view changes immediately; Main stores the normalized value (width clamped to 220-420 px).233
function saveSidebar(patch) {234
state.config = { ...state.config, ui: { ...state.config.ui, sidebar: { ...sidebarState(), ...patch } } };235
applySidebar();236
sidebarSave = sidebarSave.then(async () => { state.config = await api.updateConfig({ ui: { sidebar: patch } }); applySidebar(); })237
.catch((error) => window.alert(errorText(error)));238
return sidebarSave;239
}240
function toggleSidebar() {241
const hidden = !sidebarState().hidden;242
saveSidebar({ hidden });243
if (!hidden) requestAnimationFrame(() => document.querySelector("#mainNavigation .nav-button.active, #mainNavigation .nav-button")?.focus());244
}245
function showSidebarTooltip(target) {246
const sidebar = sidebarState();247
const navButton = target.classList.contains("nav-button");248
if (sidebar.hidden || (navButton && sidebar.mode === "full")) return;249
const text = (navButton ? target.querySelector(".nav-label")?.textContent : target.getAttribute("aria-label"))?.trim();250
if (!text) return;251
sidebarTooltip.textContent = text;252
sidebarTooltip.hidden = false;253
const box = target.getBoundingClientRect();254
const top = Math.min(window.innerHeight - 18, Math.max(18, box.top + box.height / 2));255
sidebarTooltip.style.left = `${Math.round(Math.max(box.right, $("#sidebar").getBoundingClientRect().right) + 8)}px`;256
sidebarTooltip.style.top = `${Math.round(top)}px`;257
}258
function hideSidebarTooltip() { clearTimeout(tooltipTimer); sidebarTooltip.hidden = true; }259
const tooltipTarget = (event) => event.target.closest?.("#mainNavigation .nav-button, .sidebar-mode");260
// Like native tooltips: after a click the tooltip stays hidden until the pointer leaves that item.261
let tooltipSuppressed = null;262
// Hover shows the tooltip after a short delay (300 ms); keyboard focus shows it immediately.263
let tooltipTimer = null;264
$("#sidebar").addEventListener("mouseover", (event) => {265
const target = tooltipTarget(event);266
if (!target || target === tooltipSuppressed || target.contains(event.relatedTarget)) return;267
clearTimeout(tooltipTimer);268
tooltipTimer = setTimeout(() => { if (target.matches(":hover")) showSidebarTooltip(target); }, 300);269
});270
$("#sidebar").addEventListener("mouseout", (event) => { const target = tooltipTarget(event); if (target && !target.contains(event.relatedTarget)) { clearTimeout(tooltipTimer); hideSidebarTooltip(); if (target === tooltipSuppressed) tooltipSuppressed = null; } });271
// Keyboard focus shows the tooltip at once. The last input method is tracked explicitly instead of relying only on272
// :focus-visible, whose heuristic differs between platforms and window states.273
let keyboardInput = false;274
document.addEventListener("keydown", () => { keyboardInput = true; }, true);275
document.addEventListener("pointerdown", () => { keyboardInput = false; }, true);276
$("#sidebar").addEventListener("focusin", (event) => { const target = tooltipTarget(event); if (target && (keyboardInput || target.matches(":focus-visible"))) showSidebarTooltip(target); });277
$("#sidebar").addEventListener("focusout", hideSidebarTooltip);278
$("#sidebar").addEventListener("click", (event) => { tooltipSuppressed = tooltipTarget(event) || null; hideSidebarTooltip(); });279
$("#mainNavigation").addEventListener("scroll", hideSidebarTooltip);280
window.addEventListener("resize", hideSidebarTooltip);281
document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.addEventListener("click", () => saveSidebar({ mode: button.dataset.sidebarMode, hidden: false })));282
$("#sidebarToggle").addEventListener("click", toggleSidebar);283
document.addEventListener("keydown", (event) => {284
if (event.key === "Escape") hideSidebarTooltip();285
// Captured before the terminal so the shortcut works everywhere; plain Ctrl+B still reaches the SSH shell.286
if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.code === "KeyB") { event.preventDefault(); event.stopPropagation(); toggleSidebar(); }287
}, true);288
{289
const resizer = $("#sidebarResizer");290
resizer.addEventListener("pointerdown", (event) => {291
if (event.button !== 0 || sidebarState().mode !== "full") return;292
event.preventDefault();293
resizer.setPointerCapture(event.pointerId);294
const left = $("#sidebar").getBoundingClientRect().left;295
let width = sidebarState().width;296
appShell.classList.add("sidebar-resizing");297
const move = (moveEvent) => {298
width = clampSidebarWidth(moveEvent.clientX - left);299
appShell.style.setProperty("--sidebar-full-width", `${width}px`);300
resizer.setAttribute("aria-valuenow", String(width));301
};302
// Window-level listeners keep the drag alive even if pointer capture is lost; any end event finishes it once.303
let done = false;304
const end = () => {305
if (done) return; done = true;306
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", end);307
resizer.removeEventListener("lostpointercapture", end);308
appShell.classList.remove("sidebar-resizing");309
if (width !== sidebarState().width) saveSidebar({ width });310
};311
window.addEventListener("pointermove", move); window.addEventListener("pointerup", end); window.addEventListener("pointercancel", end);312
resizer.addEventListener("lostpointercapture", end);313
});314
resizer.addEventListener("dblclick", () => saveSidebar({ width: SIDEBAR_LIMITS.default }));315
resizer.addEventListener("keydown", (event) => {316
const width = sidebarState().width; const step = event.shiftKey ? 48 : 16;317
const next = { ArrowLeft: width - step, ArrowRight: width + step, Home: SIDEBAR_LIMITS.min, End: SIDEBAR_LIMITS.max }[event.key];318
if (next === undefined) return;319
event.preventDefault();320
if (clampSidebarWidth(next) !== width) saveSidebar({ width: clampSidebarWidth(next) });321
});322
}324
function cancelProfileImport() {325
importPreview = null;326
$("#profileImportDialog").close();327
$("#profileImportRows").replaceChildren();328
void api.cancelProfileImport().catch(() => {});329
}331
$("#importProfilesButton").addEventListener("click", async (event) => {332
const button = event.currentTarget; button.disabled = true;333
$("#profileTransferMessage").textContent = "";334
try {335
const preview = await api.previewProfileImport();336
if (preview.canceled) return;337
importPreview = preview;338
$("#profileImportRows").replaceChildren();339
for (const server of preview.servers) {340
const row = document.createElement("tr");341
for (const value of [server.name, server.host, server.username, server.port, server.duplicate ? t("transfer.yes") : "—"]) {342
const cell = document.createElement("td"); cell.textContent = value; row.append(cell);343
}344
$("#profileImportRows").append(row);345
}346
$("#profileImportSummary").textContent = t("transfer.summary", { count: preview.servers.length, duplicates: preview.servers.filter((server) => server.duplicate).length });347
$("#profileDuplicatePolicy").value = "skip";348
$("#profileImportMessage").textContent = "";349
$("#profileImportDialog").showModal();350
} catch (error) { $("#profileTransferMessage").textContent = errorText(error); }351
finally { button.disabled = false; }352
});353
$("#cancelProfileImport").addEventListener("click", cancelProfileImport);354
$("#profileImportDialog").addEventListener("cancel", (event) => { event.preventDefault(); cancelProfileImport(); });355
$("#confirmProfileImport").addEventListener("click", async (event) => {356
if (!importPreview) return;357
const button = event.currentTarget; button.disabled = true;358
try {359
const result = await api.commitProfileImport(importPreview.token, $("#profileDuplicatePolicy").value);360
cancelProfileImport();361
await loadProfiles();362
$("#profileTransferMessage").textContent = t("transfer.imported", result);363
} catch (error) { $("#profileImportMessage").textContent = errorText(error); }364
finally { button.disabled = false; }365
});366
$("#exportProfilesButton").addEventListener("click", async (event) => {367
const button = event.currentTarget; button.disabled = true;368
try {369
const result = await api.exportProfiles($("#profileExportFormat").value, $("#exportSelectedProfiles").checked ? [...selectedExportIds] : undefined);370
if (!result.canceled) $("#profileTransferMessage").textContent = t("transfer.exported", result);371
} catch (error) { $("#profileTransferMessage").textContent = errorText(error); }372
finally { button.disabled = false; }373
});375
function setBusy(button, busy, label) {376
button.disabled = busy;377
if (label) button.textContent = label;378
}380
function formatBytes(bytes) {381
const units = ["B", "KB", "MB", "GB"];382
let value = Number(bytes || 0);383
let index = 0;384
while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; }385
return `${i18n.formatNumber(value, { maximumFractionDigits: 1 })} ${units[index]}`;386
}388
function applyLanguage(language) {389
const selected = i18n.setLanguage(language);390
document.documentElement.lang = selected;391
i18n.apply(document);392
elements.languageSelect.value = selected;393
elements.vaultLanguageSelect.value = selected;394
const activeView = document.querySelector("[data-view].active")?.dataset.view || "overview";395
showView(activeView);396
if (state.update) renderUpdate(state.update);397
if (state.config) elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });398
renderPlugins();399
renderProfiles();400
if (state.config) applySidebar();401
diagnosticsView?.refreshLanguage();402
renderTerminalProfileSelect();403
if (state.vault) configureVault(state.vault);404
renderBiometrics();405
const terminalStateKeys = { disconnected: "terminal.disconnected", connecting: "terminal.connecting", connected: "terminal.connected", error: "terminal.connectionError" };406
setTerminalState(state.terminalStateKind, t(terminalStateKeys[state.terminalStateKind] || "terminal.disconnected"));407
return selected;408
}410
function renderUpdate(updateState) {411
state.update = updateState;412
elements.currentVersion.textContent = `v${updateState.currentVersion || state.info?.version || "0.1.0"}`;413
const release = updateState.release;414
const currentReleaseSelected = updateState.releaseRelation === "sameVersionCurrentRelease";415
elements.downloadUpdateButton.hidden = currentReleaseSelected;416
elements.downloadUpdateButton.disabled = !release || currentReleaseSelected;417
renderReleaseCatalog(updateState);418
if (updateState.downloaded) {419
elements.updateHeadline.textContent = t("updates.downloaded", { version: release?.version || "?" });420
elements.updateMessage.textContent = `${updateState.downloaded.filename} · ${formatBytes(updateState.downloaded.size)} · ${t("updates.signatureVerified")}`;421
elements.updateHash.hidden = false;422
elements.updateHash.textContent = `SHA-512 ${updateState.downloaded.sha512}`;423
elements.revealUpdateButton.hidden = false;424
elements.installUpdateButton.hidden = false;425
} else if (release) {426
const relationKey = {427
newerVersion: "updates.newerVersion",428
sameVersionNewerRelease: "updates.sameVersionNewerRelease",429
sameVersionCurrentRelease: "updates.sameVersionCurrentRelease",430
sameVersionOlderRelease: "updates.sameVersionOlderRelease",431
olderVersion: "updates.olderVersion"432
}[updateState.releaseRelation] || "updates.selected";433
elements.updateHeadline.textContent = t(relationKey, { version: release.version });434
const published = t("updates.publishedAt", { date: formatReleaseDate(release.publishedAt) });435
elements.updateMessage.textContent = release.notes ? `${published} · ${release.notes}` : published;436
elements.updateHash.hidden = false;437
elements.updateHash.textContent = `SHA-512 ${release.sha512}`;438
elements.revealUpdateButton.hidden = true;439
elements.installUpdateButton.hidden = true;440
} else {441
elements.updateHeadline.textContent = t("updates.noneAvailable");442
elements.updateMessage.textContent = t("updates.noCompatible");443
elements.updateHash.hidden = true;444
elements.revealUpdateButton.hidden = true;445
elements.installUpdateButton.hidden = true;446
}447
}449
function formatReleaseDate(value) {450
const date = new Date(value);451
if (Number.isNaN(date.getTime())) return String(value || "—");452
return new Intl.DateTimeFormat(i18n.getLocale(), { dateStyle: "medium", timeStyle: "medium" }).format(date);453
}455
function renderReleaseCatalog(updateState) {456
const releases = Array.isArray(updateState.releases) ? updateState.releases : [];457
elements.updateReleaseCount.textContent = String(releases.length);458
elements.updateReleaseRows.replaceChildren();459
if (!releases.length) {460
const empty = document.createElement("div");461
empty.className = "update-release-empty";462
empty.textContent = t(updateState.checked ? "updates.catalogEmpty" : "updates.catalogBeforeCheck");463
elements.updateReleaseRows.append(empty);464
return;465
}466
for (const release of releases) {467
const row = document.createElement("button");468
row.type = "button";469
row.className = `update-release-row${release.id === updateState.selectedReleaseId ? " selected" : ""}`;470
const version = document.createElement("strong"); version.textContent = `v${release.version}`;471
const published = document.createElement("span"); published.textContent = formatReleaseDate(release.publishedAt);472
const file = document.createElement("span"); file.textContent = `${release.filename} · ${formatBytes(release.size)}`;473
const action = document.createElement("span"); action.className = "release-select-label"; action.textContent = t(release.id === updateState.selectedReleaseId ? "updates.selectedLabel" : "updates.selectLabel");474
row.append(version, published, file, action);475
row.addEventListener("click", async () => {476
try { renderUpdate(await api.selectUpdateRelease(release.id)); }477
catch (error) { elements.updateMessage.textContent = errorText(error); }478
});479
elements.updateReleaseRows.append(row);480
}481
}483
function renderPlugins() {484
const plugins = state.plugins;485
renderNavigation();486
elements.pluginCountValue.textContent = String(plugins.length);487
elements.pluginRows.replaceChildren();488
if (plugins.length === 0) {489
const empty = document.createElement("div");490
empty.className = "empty-state";491
const strong = document.createElement("strong"); strong.textContent = t("plugins.none");492
const span = document.createElement("span"); span.textContent = t("plugins.first");493
empty.append(strong, span); elements.pluginRows.append(empty); renderPluginCatalog(); renderPluginTaskbar(); return;494
}495
for (const plugin of plugins) {496
const row = document.createElement("div"); row.className = "plugin-row installed-plugin-row"; row.dataset.pluginId = plugin.id;497
const name = document.createElement("div"); name.className = "installed-plugin-name";498
const title = document.createElement("strong"); title.textContent = plugin.name; name.append(title);499
const details = document.createElement("details"); details.className = "installed-plugin-details";500
const summary = document.createElement("summary"); summary.textContent = t("plugins.details");501
const description = document.createElement("p"); description.textContent = plugin.description;502
const permissions = document.createElement("p"); permissions.textContent = `${t("plugins.permissions")}: ${plugin.permissions.join(", ") || t("plugins.noPermissions")}`;503
details.append(summary, description, permissions); name.append(details);504
const version = document.createElement("span"); version.textContent = plugin.version;505
const actions = document.createElement("div"); actions.className = "plugin-actions";506
const open = document.createElement("button"); open.type = "button"; open.className = "button"; open.textContent = t("plugins.open");507
open.addEventListener("click", async () => { try { await api.openPlugin(plugin.id); } catch (error) { window.alert(errorText(error)); } });508
const mode = pluginDisplay(plugin.id);509
row.classList.toggle("plugin-hidden", mode === "hidden");510
open.disabled = mode === "hidden";511
const visible = document.createElement("input"); visible.type = "checkbox"; visible.className = "plugin-visibility"; visible.checked = mode !== "hidden";512
visible.setAttribute("aria-label", `${plugin.name}: ${t("plugins.show")}`);513
const pinned = document.createElement("input"); pinned.type = "checkbox"; pinned.className = "plugin-pin"; pinned.checked = mode === "navigation"; pinned.disabled = mode === "hidden";514
pinned.setAttribute("aria-label", `${plugin.name}: ${t("plugins.inMenu")}`);515
const saveDisplay = (value) => {516
visible.disabled = true; pinned.disabled = true;517
saveUiPreferences(() => ({ plugins: { display: { ...state.config.plugins.display, [plugin.id]: value } } }));518
};519
visible.addEventListener("change", () => saveDisplay(visible.checked ? "visible" : "hidden"));520
pinned.addEventListener("change", () => saveDisplay(pinned.checked ? "navigation" : "visible"));521
actions.append(open);522
if (plugin.bundled) {523
version.title = t("plugins.bundled");524
} else {525
const remove = document.createElement("button"); remove.type = "button"; remove.className = "button danger"; remove.textContent = t("plugins.remove");526
remove.addEventListener("click", async () => {527
if (!window.confirm(t("plugins.removeConfirm", { name: plugin.name }))) return;528
remove.disabled = true;529
try {530
state.pluginState = await api.uninstallPlugin(plugin.id);531
state.plugins = state.pluginState.installed;532
renderPlugins();533
} catch (error) { window.alert(errorText(error)); remove.disabled = false; }534
});535
actions.append(remove);536
}537
row.append(name, version, visible, pinned, actions); elements.pluginRows.append(row);538
}539
renderPluginCatalog();540
renderPluginTaskbar();541
}543
function renderPluginTaskbar(windows = state.pluginWindows) {544
state.pluginWindows = Array.isArray(windows) ? windows : [];545
const minimized = state.pluginWindows.filter((item) => item.minimized);546
elements.pluginTaskbar.hidden = minimized.length === 0;547
elements.pluginTaskbarItems.replaceChildren();548
for (const plugin of minimized) {549
const button = document.createElement("button");550
button.type = "button";551
button.className = "plugin-taskbar-item";552
button.title = t("plugins.restore", { name: plugin.name });553
const icon = document.createElement("span");554
icon.className = "plugin-taskbar-icon";555
icon.append(window.chjNavIcons.createIcon(`plugin:${plugin.pluginId}`, "nav-icon taskbar-icon"));556
const name = document.createElement("strong");557
name.textContent = plugin.name;558
const action = document.createElement("small");559
action.textContent = t("plugins.restoreAction");560
button.append(icon, name, action);561
button.addEventListener("click", async () => {562
try { await api.openPlugin(plugin.pluginId); }563
catch (error) { window.alert(errorText(error)); }564
});565
elements.pluginTaskbarItems.append(button);566
}567
}569
function renderPluginCatalog() {570
if (!elements.pluginCatalogRows) return;571
const catalog = (state.pluginState?.catalog || []).filter((plugin) => plugin.id !== HASH_TOOL_ID);572
elements.pluginCatalogSource.textContent = state.pluginState?.sourceBaseUrl || "";573
elements.pluginCatalogRows.replaceChildren();574
if (!catalog.length) {575
const empty = document.createElement("div"); empty.className = "plugin-catalog-empty";576
empty.textContent = t(state.pluginState?.sourceBaseUrl ? "plugins.catalogEmpty" : "plugins.catalogBeforeCheck");577
elements.pluginCatalogRows.append(empty); return;578
}579
for (const plugin of catalog) {580
const row = document.createElement("div"); row.className = "plugin-row plugin-table-row";581
const name = document.createElement("div");582
const strong = document.createElement("strong"); strong.textContent = plugin.name;583
const notes = document.createElement("small"); notes.textContent = plugin.notes || plugin.id;584
name.append(strong, notes);585
const version = document.createElement("span"); version.textContent = `v${plugin.version} · ${plugin.channel}`;586
const permissions = document.createElement("span"); permissions.textContent = plugin.permissions.join(", ") || t("plugins.noPermissions");587
const install = document.createElement("button"); install.type = "button"; install.className = `button${plugin.installed ? "" : " primary"}`;588
install.disabled = plugin.installed;589
install.textContent = t(plugin.installed ? "plugins.installed" : plugin.updateAvailable ? "plugins.update" : "plugins.install");590
install.addEventListener("click", async () => {591
if (!window.confirm(t("plugins.installConfirm", { name: plugin.name, version: plugin.version, permissions: plugin.permissions.join(", ") || t("plugins.noPermissions") }))) return;592
install.disabled = true; install.textContent = t("plugins.installing");593
try {594
state.pluginState = await api.installPlugin(plugin.releaseId);595
state.plugins = state.pluginState.installed;596
renderPlugins();597
} catch (error) { window.alert(errorText(error)); install.disabled = false; install.textContent = t("plugins.install"); }598
});599
row.append(name, version, permissions, install); elements.pluginCatalogRows.append(row);600
}601
}603
function configureVault(status) {604
state.vault = status;605
renderBiometrics();606
$("#securityMasterPassword").value = "";607
elements.vaultOverlay.hidden = Boolean(status.unlocked);608
elements.vaultMessage.className = "form-message";609
elements.vaultMessage.textContent = "";610
elements.vaultPassword.value = "";611
elements.vaultPasswordConfirm.value = "";612
elements.vaultResetConfirmation.value = "";613
elements.vaultResetPanel.hidden = true;614
if (status.unlocked) return;615
if (status.damaged) {616
elements.vaultForm.dataset.mode = "damaged";617
elements.vaultModeText.textContent = t("vault.damagedMode");618
elements.vaultTitle.textContent = t("vault.damagedTitle");619
elements.vaultDescription.textContent = t("vault.damagedDescription");620
elements.vaultSubmit.disabled = true;621
elements.vaultConfirmLabel.hidden = true;622
elements.showVaultResetButton.hidden = false;623
return;624
}625
const setup = status.needsSetup;626
elements.vaultForm.dataset.mode = setup ? "setup" : "unlock";627
elements.vaultModeText.textContent = t(setup ? "vault.setupMode" : "vault.lockedMode");628
elements.vaultSubmit.disabled = false;629
elements.vaultConfirmLabel.hidden = !setup;630
elements.showVaultResetButton.hidden = setup;631
elements.vaultPassword.autocomplete = setup ? "new-password" : "current-password";632
elements.vaultTitle.textContent = t(setup ? "vault.setupTitle" : "vault.unlockTitle");633
elements.vaultDescription.textContent = t(setup ? "vault.setupDescription" : "vault.lockedDescription");634
elements.vaultSubmit.textContent = t(setup ? "vault.create" : "vault.unlock");635
setTimeout(() => elements.vaultPassword.focus(), 20);636
}638
function clearProtectedUi() {639
$("#navigationEditToggle").checked = false;640
renderNavigation();641
cancelProfileImport();642
selectedExportIds.clear();643
state.profiles = [];644
state.selectedProfileId = "";645
state.terminalConnected = false;646
renderProfiles();647
diagnosticsView?.refreshLanguage();648
renderTerminalProfileSelect();649
setTerminalState("disconnected", t("terminal.disconnected"));650
terminal?.reset();651
}653
async function loadProfiles(selectId = state.selectedProfileId) {654
state.profiles = await api.listProfiles();655
if (!state.profiles.some((profile) => profile.id === selectId)) selectId = state.profiles[0]?.id || "";656
state.selectedProfileId = selectId;657
renderProfiles();658
diagnosticsView?.refreshLanguage();659
renderTerminalProfileSelect();660
if (selectId) fillProfileForm(state.profiles.find((profile) => profile.id === selectId));661
else resetProfileForm();662
}664
function renderProfiles() {665
for (const id of selectedExportIds) if (!state.profiles.some((profile) => profile.id === id)) selectedExportIds.delete(id);666
elements.profileRows.replaceChildren();667
if (!state.profiles.length) {668
const empty = document.createElement("div"); empty.className = "profile-empty";669
empty.textContent = t("profiles.none"); elements.profileRows.append(empty); return;670
}671
for (const profile of state.profiles) {672
const row = document.createElement("button"); row.type = "button";673
row.className = `profile-row${profile.id === state.selectedProfileId ? " active" : ""}`;674
const name = document.createElement("strong"); name.textContent = profile.label;675
const address = document.createElement("span"); address.textContent = `${profile.username}@${profile.host}:${profile.port}`;676
const auth = document.createElement("span"); auth.className = "profile-auth"; auth.textContent = t(profile.authMethod === "privateKey" ? "profiles.key" : "profiles.password");677
row.append(name, address, auth);678
row.addEventListener("click", () => { state.selectedProfileId = profile.id; renderProfiles(); fillProfileForm(profile); });679
const wrapper = document.createElement("div"); wrapper.className = "profile-select-row";680
const select = document.createElement("input"); select.type = "checkbox"; select.checked = selectedExportIds.has(profile.id);681
select.setAttribute("aria-label", t("transfer.select", { name: profile.label }));682
select.addEventListener("change", () => { if (select.checked) selectedExportIds.add(profile.id); else selectedExportIds.delete(profile.id); });683
wrapper.append(select, row); elements.profileRows.append(wrapper);684
}685
}687
function renderTerminalProfileSelect() {688
const previous = elements.terminalProfileSelect.value || state.selectedProfileId;689
elements.terminalProfileSelect.replaceChildren(new Option(t("terminal.selectServer"), ""));690
for (const profile of state.profiles) elements.terminalProfileSelect.add(new Option(`${profile.label} — ${profile.host}`, profile.id));691
elements.terminalProfileSelect.value = state.profiles.some((profile) => profile.id === previous) ? previous : (state.profiles[0]?.id || "");692
updateTerminalProfile();693
}695
function resetProfileForm() {696
elements.profileForm.reset();697
elements.profileId.value = "";698
elements.profilePort.value = "22";699
elements.profileAuthMethod.value = "password";700
elements.profilePrivateKeyPath.value = "";701
elements.profileStorePassword.checked = false;702
elements.profileStoredPassword.value = "";703
elements.profileStoredPassword.placeholder = t("profiles.storePasswordPlaceholder");704
elements.deleteProfileButton.hidden = true;705
elements.profileMessage.textContent = "";706
updatePrivateKeyField();707
}709
function fillProfileForm(profile) {710
if (!profile) { resetProfileForm(); return; }711
elements.profileId.value = profile.id;712
elements.profileLabel.value = profile.label;713
elements.profileHost.value = profile.host;714
elements.profilePort.value = String(profile.port);715
elements.profileUsername.value = profile.username;716
elements.profileAuthMethod.value = profile.authMethod;717
elements.profilePrivateKeyPath.value = profile.privateKeyPath || "";718
elements.profileStorePassword.checked = Boolean(profile.hasStoredPassword);719
elements.profileStoredPassword.value = "";720
elements.profileStoredPassword.placeholder = t(profile.hasStoredPassword ? "profiles.passwordStoredPlaceholder" : "profiles.storePasswordPlaceholder");721
elements.deleteProfileButton.hidden = false;722
elements.profileMessage.textContent = "";723
updatePrivateKeyField();724
}726
function updatePrivateKeyField() {727
const privateKey = elements.profileAuthMethod.value === "privateKey";728
elements.privateKeyField.hidden = !privateKey;729
elements.passwordStorageField.hidden = privateKey;730
elements.profileStoredPassword.disabled = privateKey || !elements.profileStorePassword.checked;731
}733
function updateTerminalProfile() {734
const profile = state.profiles.find((item) => item.id === elements.terminalProfileSelect.value);735
if (!profile) {736
elements.terminalHost.textContent = t("terminal.noProfile");737
elements.credentialLabelText.textContent = t("terminal.password");738
elements.terminalCredential.placeholder = t("terminal.sshPassword");739
return;740
}741
elements.terminalHost.textContent = `${profile.username}@${profile.host}:${profile.port} · ${profile.authMethod === "privateKey" ? profile.privateKeyPath : t("terminal.passwordAuth")}`;742
elements.credentialLabelText.textContent = t(profile.authMethod === "privateKey" ? "terminal.passphrase" : "terminal.password");743
elements.terminalCredential.placeholder = t(profile.authMethod === "privateKey" ? "terminal.keyPassphrase" : profile.hasStoredPassword ? "terminal.savedPasswordHint" : "terminal.sshPassword");744
}746
function initTerminal() {747
if (!window.Terminal || !window.FitAddon?.FitAddon) {748
elements.terminalHost.textContent = t("terminal.libraryMissing");749
return;750
}751
terminal = new window.Terminal({752
cursorBlink: true,753
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",754
fontSize: 13,755
scrollback: 8000,756
theme: { background: "#050b12", foreground: "#d8e7f5", cursor: "#43d6a0", selectionBackground: "#264c6d" }757
});758
fitAddon = new window.FitAddon.FitAddon();759
terminal.loadAddon(fitAddon);760
terminal.open(elements.terminalElement);761
terminal.writeln("\x1b[36mCH-J Server Manager Core\x1b[0m");762
terminal.writeln(`${t("terminal.welcome")}\r\n`);763
terminal.onData((data) => { if (state.terminalConnected) api.sendTerminalInput(TERMINAL_SESSION_ID, data); });764
new ResizeObserver(() => fitTerminal()).observe(elements.terminalElement);765
fitTerminal();766
}768
function fitTerminal() {769
if (!terminal || !fitAddon || elements.terminalElement.offsetParent === null) return;770
try {771
fitAddon.fit();772
if (state.terminalConnected) void api.resizeTerminal(TERMINAL_SESSION_ID, terminal.cols, terminal.rows);773
} catch {}774
}776
function setTerminalState(kind, text) {777
state.terminalStateKind = kind;778
elements.terminalState.className = `connection-badge ${kind}`;779
elements.terminalState.textContent = text;780
if (kind !== "connected") state.terminalLatency = null;781
renderTerminalLatency();782
elements.connectButton.disabled = kind === "connecting" || kind === "connected";783
elements.disconnectButton.disabled = kind !== "connected" && kind !== "connecting";784
elements.terminalProfileSelect.disabled = kind === "connecting" || kind === "connected";785
}787
function renderTerminalLatency() {788
const element = $("#terminalLatency");789
element.hidden = state.terminalStateKind !== "connected";790
const latency = state.terminalLatency;791
const ms = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? (value < 0.1 ? "<0.1 ms" : `${value.toFixed(1)} ms`) : null;792
const pingReasons = { timeout: "terminal.pingTimeout", denied: "terminal.pingDenied", unreachable: "terminal.pingUnreachable", unsupported: "terminal.pingUnsupported", error: "terminal.pingError" };793
let ping = `— (${t("terminal.measuring")})`;794
let ssh = ping;795
if (latency?.measuredAt) {796
const family = latency.pingFamily === 6 ? "IPv6" : latency.pingFamily === 4 ? "IPv4" : "";797
const pingValue = ms(latency.pingMs);798
const loss = typeof latency.pingLoss === "number" && latency.pingLoss > 0 ? `, ${t("terminal.pingLoss", { loss: Math.round(latency.pingLoss * 100) })}` : "";799
ping = pingValue ? `${latency.pingLessThan ? "<" : ""}${pingValue}${family ? ` ${family}` : ""}${loss}` : `— (${t(pingReasons[latency.pingStatus] || "terminal.latencyUnavailable")})`;800
const sshValue = ms(latency.sshRttMs);801
ssh = sshValue || `— (${t("terminal.latencyUnavailable")})`;802
}803
element.textContent = `${t("terminal.ping")}: ${ping} · ${t("terminal.sshRtt")}: ${ssh}`;804
const method = latency?.sshRttMethod === "transport" ? t("terminal.sshRttTransport") : latency?.sshRttMethod === "command" ? t("terminal.sshRttCommand") : "";805
element.title = [t("terminal.latencyMeaning"), method, latency?.pingReason, latency?.sshRttError].filter(Boolean).join("\n");806
}808
async function connectTerminal(retry = false) {809
const profile = state.profiles.find((item) => item.id === elements.terminalProfileSelect.value);810
if (!profile) { showView("profiles"); elements.profileMessage.textContent = t("terminal.noProfile"); return; }811
fitTerminal();812
if (!retry) {813
terminal?.clear();814
terminal?.writeln(`\x1b[33m${t("terminal.connectingTo", { target: `${profile.username}@${profile.host}:${profile.port}` })}\x1b[0m`);815
}816
setTerminalState("connecting", t("terminal.connecting"));817
const credential = elements.terminalCredential.value;818
const result = await api.connectSsh({819
sessionId: TERMINAL_SESSION_ID,820
profileId: profile.id,821
password: profile.authMethod === "password" ? credential : undefined,822
passphrase: profile.authMethod === "privateKey" ? credential : undefined,823
cols: terminal?.cols || 100,824
rows: terminal?.rows || 30825
});826
if (result.ok) {827
state.terminalConnected = true;828
elements.terminalCredential.value = "";829
setTerminalState("connected", t("terminal.connected"));830
terminal?.focus();831
return;832
}833
const issue = result.error || { code: "SSH_CONNECTION_FAILED", message: t("ssh.failed") };834
if (issue.code === "HOST_KEY_UNKNOWN") {835
const accepted = window.confirm(t("ssh.unknownHost", issue));836
if (accepted) {837
const trusted = await api.trustSshHostKey({ sessionId: TERMINAL_SESSION_ID, profileId: profile.id, fingerprint: issue.fingerprint });838
if (trusted.ok) { await connectTerminal(true); return; }839
terminal?.writeln(`\r\n\x1b[31m${trusted.error?.message || t("ssh.trustFailed")}\x1b[0m`);840
}841
} else if (issue.code === "HOST_KEY_MISMATCH") {842
terminal?.writeln(`\r\n\x1b[31m${t("ssh.keyChanged")}\x1b[0m`);843
terminal?.writeln(`${t("ssh.knownKey")}: ${issue.knownFingerprint}\r\n${t("ssh.newKey")}: ${issue.fingerprint}`);844
const accepted = window.confirm(t("ssh.changedHostConfirm", issue));845
if (accepted) {846
const trusted = await api.trustSshHostKey({847
sessionId: TERMINAL_SESSION_ID,848
profileId: profile.id,849
fingerprint: issue.fingerprint,850
knownFingerprint: issue.knownFingerprint,851
replaceKnown: true852
});853
if (trusted.ok) { await connectTerminal(true); return; }854
terminal?.writeln(`\r\n\x1b[31m${trusted.error?.message || t("ssh.trustFailed")}\x1b[0m`);855
}856
} else {857
terminal?.writeln(`\r\n\x1b[31m${errorText(issue)}\x1b[0m`);858
}859
state.terminalConnected = false;860
setTerminalState("error", t("terminal.connectionError"));861
}863
async function checkUpdates() {864
setBusy(elements.checkUpdateButton, true, t("updates.checking"));865
elements.updateHeadline.textContent = t("updates.checkingServer");866
elements.updateMessage.textContent = t("updates.tryingEndpoints");867
try { renderUpdate(await api.checkForUpdates()); }868
catch (error) { elements.updateHeadline.textContent = t("updates.checkFailed"); elements.updateMessage.textContent = errorText(error); }869
finally { setBusy(elements.checkUpdateButton, false, t("updates.check")); }870
}872
async function initialize() {873
if (!api || !i18n) throw new Error("Core preload or i18n API is unavailable.");874
[state.info, state.config, state.pluginState, state.update, state.vault] = await Promise.all([875
api.getInfo(), api.getConfig(), api.getPluginState(), api.getUpdateState(), api.getVaultStatus()876
]);877
state.plugins = state.pluginState.installed;878
state.pluginWindows = state.pluginState.windows || [];879
i18n.setLanguage(state.config.ui.language);880
document.documentElement.lang = i18n.getLanguage();881
i18n.apply(document);882
applySidebar();883
elements.runtimeText.textContent = `${state.info.platform} / ${state.info.arch}`;884
elements.sidebarVersion.textContent = `v${state.info.version}`;885
elements.coreApiValue.textContent = state.info.coreApi;886
elements.dataSchemaValue.textContent = String(state.info.dataSchema);887
elements.currentVersion.textContent = `v${state.info.version}`;888
elements.languageSelect.value = state.config.ui.language;889
elements.vaultLanguageSelect.value = state.config.ui.language;890
elements.vaultLanguageSelect.disabled = false;891
elements.channelSelect.value = state.config.updates.channel;892
elements.updateViewChannelSelect.value = state.config.updates.channel;893
elements.pluginChannelSelect.value = state.config.plugins.channel;894
elements.pluginViewChannelSelect.value = state.config.plugins.channel;895
elements.autoCheckInput.checked = state.config.updates.autoCheck;896
elements.baseUrlsText.textContent = state.config.updates.baseUrls.join("\n");897
elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });898
const security = state.config.security;899
$("#autoLockEnabled").checked = security.autoLockMinutes > 0;900
$("#autoLockMinutes").value = String(security.autoLockMinutes || 15);901
$("#autoLockMinutes").disabled = !security.autoLockMinutes;902
$("#lockOnBlur").checked = security.lockOnBlur;903
$("#requireSystemAuthentication").checked = security.requireSystemAuthentication;904
diagnosticsView = window.CHJ_DIAGNOSTICS.mount($("#diagnosticsView"), {905
// Diagnostics follows the application theme. The application shell is dark-only, so906
// the module must not fall back to the OS colour scheme (light on many Windows/Linux setups).907
api: api.diagnostics, language: () => i18n.getLanguage(), getTheme: () => APP_THEME908
});909
renderPlugins(); renderUpdate(state.update); initTerminal(); configureVault(state.vault);910
void refreshBiometrics().then(maybeAutoUnlock);911
window.addEventListener("focus", () => void maybeAutoUnlock());912
document.addEventListener("visibilitychange", () => void maybeAutoUnlock());913
if (state.vault.unlocked) await loadProfiles();914
}916
document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));917
hashToolButton.addEventListener("click", async () => {918
try { await api.openPlugin(HASH_TOOL_ID); }919
catch (error) { window.alert(errorText(error)); }920
});921
elements.vaultForm.addEventListener("submit", async (event) => {922
event.preventDefault();923
const password = elements.vaultPassword.value;924
const passwordLength = Array.from(password).length;925
if (passwordLength < 4 || passwordLength > 64) { elements.vaultMessage.textContent = t("vault.passwordLength"); return; }926
if (state.vault.needsSetup && password !== elements.vaultPasswordConfirm.value) { elements.vaultMessage.textContent = t("vault.passwordMismatch"); return; }927
setBusy(elements.vaultSubmit, true, t(state.vault.needsSetup ? "vault.creating" : "vault.unlocking"));928
try {929
const status = state.vault.needsSetup ? await api.createVault(password) : await api.unlockVault(password);930
configureVault(status);931
await loadProfiles();932
} catch (error) { elements.vaultMessage.className = "form-message"; elements.vaultMessage.textContent = errorText(error); }933
finally { if (!state.vault.unlocked) setBusy(elements.vaultSubmit, false, t(state.vault.needsSetup ? "vault.create" : "vault.unlock")); }934
});935
elements.lockVaultButton.addEventListener("click", async () => {936
configureVault(await api.lockVault());937
clearProtectedUi();938
terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);939
});940
$("#biometricUnlockButton").addEventListener("click", async event => {941
event.currentTarget.disabled = true;942
try { configureVault(await api.unlockVaultWithBiometrics()); await loadProfiles(); }943
catch (error) { elements.vaultMessage.textContent = errorText(error); }944
finally { $("#biometricUnlockButton").disabled = false; void refreshBiometrics(); }945
});946
for (const [id, action, message] of [947
["enableBiometricsButton", () => api.enableBiometrics(), "security.enabled"],948
["disableBiometricsButton", () => api.disableBiometrics(), "security.disabled"],949
["testBiometricsButton", () => api.authenticateSensitiveAction(), "security.testPassed"]950
]) {951
$("#" + id).addEventListener("click", async event => {952
event.currentTarget.disabled = true;953
try { await action(); $("#securityMessage").textContent = t(message); }954
catch (error) { $("#securityMessage").textContent = errorText(error); }955
finally { await refreshBiometrics(); }956
});957
}958
$("#autoLockEnabled").addEventListener("change", event => {959
$("#autoLockMinutes").disabled = !event.target.checked;960
if (event.target.checked && $("#autoLockMinutes").value === "0") $("#autoLockMinutes").value = "15";961
});962
$("#securityForm").addEventListener("submit", async event => {963
event.preventDefault();964
const password = $("#securityMasterPassword").value; $("#securityMasterPassword").value = "";965
try {966
state.config = await api.updateConfig({ masterPassword: password, security: {967
autoLockMinutes: $("#autoLockEnabled").checked ? Number($("#autoLockMinutes").value) : 0,968
lockOnBlur: $("#lockOnBlur").checked, requireSystemAuthentication: $("#requireSystemAuthentication").checked969
} });970
$("#securityMessage").textContent = t("settings.saved");971
} catch (error) { $("#securityMessage").textContent = errorText(error); }972
});973
elements.showVaultResetButton.addEventListener("click", () => {974
elements.vaultResetPanel.hidden = false;975
elements.showVaultResetButton.hidden = true;976
elements.vaultResetConfirmation.focus();977
});978
elements.cancelVaultResetButton.addEventListener("click", () => {979
elements.vaultResetPanel.hidden = true;980
elements.showVaultResetButton.hidden = false;981
elements.vaultResetConfirmation.value = "";982
});983
elements.confirmVaultResetButton.addEventListener("click", async () => {984
if (elements.vaultResetConfirmation.value !== "SMAZAT") {985
elements.vaultMessage.textContent = t("vault.typeDelete");986
return;987
}988
if (!window.confirm(t("vault.finalResetConfirm"))) return;989
setBusy(elements.confirmVaultResetButton, true, t("vault.deleting"));990
try {991
const status = await api.resetVault("SMAZAT");992
clearProtectedUi();993
configureVault(status);994
elements.vaultMessage.className = "form-message success";995
elements.vaultMessage.textContent = t("vault.resetDone");996
} catch (error) {997
elements.vaultMessage.className = "form-message";998
elements.vaultMessage.textContent = errorText(error);999
} finally {1000
setBusy(elements.confirmVaultResetButton, false, t("vault.resetAction"));1001
}1002
});1003
elements.newProfileButton.addEventListener("click", () => { state.selectedProfileId = ""; renderProfiles(); resetProfileForm(); elements.profileLabel.focus(); });1004
elements.profileAuthMethod.addEventListener("change", updatePrivateKeyField);1005
elements.profileStorePassword.addEventListener("change", updatePrivateKeyField);1006
elements.selectPrivateKeyButton.addEventListener("click", async () => {1007
const result = await api.selectPrivateKey();1008
if (!result.canceled) elements.profilePrivateKeyPath.value = result.path;1009
});1010
elements.profileForm.addEventListener("submit", async (event) => {1011
event.preventDefault(); elements.profileMessage.className = "form-message"; elements.profileMessage.textContent = "";1012
try {1013
const saved = await api.saveProfile({1014
id: elements.profileId.value || undefined, label: elements.profileLabel.value, host: elements.profileHost.value,1015
port: Number(elements.profilePort.value), username: elements.profileUsername.value,1016
authMethod: elements.profileAuthMethod.value, privateKeyPath: elements.profilePrivateKeyPath.value,1017
storePassword: elements.profileAuthMethod.value === "password" && elements.profileStorePassword.checked,1018
password: elements.profileStoredPassword.value1019
});1020
await loadProfiles(saved.id); elements.profileMessage.className = "form-message success"; elements.profileMessage.textContent = t("profiles.saved");1021
} catch (error) { elements.profileMessage.textContent = errorText(error); }1022
});1023
elements.deleteProfileButton.addEventListener("click", async () => {1024
const id = elements.profileId.value;1025
if (!id || !window.confirm(t("profiles.deleteConfirm"))) return;1026
await api.deleteProfile(id); state.selectedProfileId = ""; await loadProfiles("");1027
});1028
elements.terminalProfileSelect.addEventListener("change", () => { elements.terminalCredential.value = ""; updateTerminalProfile(); });1029
elements.connectButton.addEventListener("click", () => connectTerminal().catch((error) => { setTerminalState("error", t("terminal.error")); terminal?.writeln(`\r\n\x1b[31m${errorText(error)}\x1b[0m`); }));1030
elements.disconnectButton.addEventListener("click", async () => { await api.disconnectSsh(TERMINAL_SESSION_ID); });1031
elements.checkUpdateButton.addEventListener("click", checkUpdates);1032
elements.downloadUpdateButton.addEventListener("click", async () => {1033
setBusy(elements.downloadUpdateButton, true, t("updates.downloadingAndVerifying"));1034
elements.updateHeadline.textContent = t("updates.verifyingSignature");1035
try { renderUpdate(await api.downloadUpdate()); }1036
catch (_error) {1037
elements.updateHeadline.textContent = t("updates.downloadRejected");1038
elements.updateMessage.textContent = t("updates.signatureFailure");1039
elements.installUpdateButton.hidden = true;1040
elements.revealUpdateButton.hidden = true;1041
}1042
finally { setBusy(elements.downloadUpdateButton, false, t("updates.download")); }1043
});1044
elements.installUpdateButton.addEventListener("click", async () => {1045
const release = state.update?.release;1046
if (!release || !window.confirm(t("updates.installConfirm", { version: release.version, date: formatReleaseDate(release.publishedAt) }))) return;1047
setBusy(elements.installUpdateButton, true, t("updates.verifyingSignature"));1048
try { await api.installUpdate(); }1049
catch (_error) {1050
try { renderUpdate(await api.getUpdateState()); } catch {}1051
elements.updateHeadline.textContent = t("updates.installFailed");1052
elements.updateMessage.textContent = t("updates.installFailure");1053
elements.installUpdateButton.hidden = true;1054
} finally { setBusy(elements.installUpdateButton, false, t("updates.install")); }1055
});1056
elements.revealUpdateButton.addEventListener("click", () => api.revealUpdate());1057
elements.updateViewChannelSelect.addEventListener("change", async () => {1058
state.config = await api.updateConfig({ updates: { channel: elements.updateViewChannelSelect.value } });1059
elements.channelSelect.value = state.config.updates.channel;1060
elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });1061
await checkUpdates();1062
});1063
elements.pluginViewChannelSelect.addEventListener("change", async () => {1064
state.config = await api.updateConfig({ plugins: { channel: elements.pluginViewChannelSelect.value } });1065
elements.pluginChannelSelect.value = state.config.plugins.channel;1066
elements.checkPluginCatalogButton.click();1067
});1068
elements.checkPluginCatalogButton.addEventListener("click", async () => {1069
setBusy(elements.checkPluginCatalogButton, true, t("plugins.checking"));1070
try { state.pluginState = await api.checkPluginCatalog(); state.plugins = state.pluginState.installed; renderPlugins(); }1071
catch (error) { window.alert(errorText(error)); }1072
finally { setBusy(elements.checkPluginCatalogButton, false, t("plugins.checkCatalog")); }1073
});1074
elements.settingsForm.addEventListener("submit", async (event) => {1075
event.preventDefault();1076
state.config = await api.updateConfig({1077
ui: { language: elements.languageSelect.value },1078
updates: { channel: elements.channelSelect.value, autoCheck: elements.autoCheckInput.checked },1079
plugins: { channel: elements.pluginChannelSelect.value }1080
});1081
elements.updateViewChannelSelect.value = state.config.updates.channel;1082
elements.pluginViewChannelSelect.value = state.config.plugins.channel;1083
applyLanguage(state.config.ui.language);1084
elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });1085
elements.settingsStatus.textContent = t("settings.saved"); setTimeout(() => { elements.settingsStatus.textContent = ""; }, 1800);1086
});1087
elements.languageSelect.addEventListener("change", () => applyLanguage(elements.languageSelect.value));1088
document.querySelectorAll("[data-legal-document]").forEach((button) => {1089
button.addEventListener("click", async () => {1090
try { await api.openLegalDocument(button.dataset.legalDocument); }1091
catch (error) { window.alert(errorText(error)); }1092
});1093
});1094
elements.vaultLanguageSelect.addEventListener("change", async () => {1095
const language = applyLanguage(elements.vaultLanguageSelect.value);1096
state.config = await api.updateConfig({ ui: { language } });1097
});1099
api?.onUpdateState((payload) => renderUpdate(payload));1100
api?.onPluginWindowState((windows) => renderPluginTaskbar(windows));1101
api?.onSshData((payload) => { if (payload.sessionId === TERMINAL_SESSION_ID) terminal?.write(payload.data); });1102
api?.onSshState((payload) => {1103
if (payload.sessionId !== TERMINAL_SESSION_ID) return;1104
state.terminalConnected = payload.state === "connected";1105
state.terminalLatency = payload.latency;1106
if (payload.state === "connecting") setTerminalState("connecting", t("terminal.connecting"));1107
else if (payload.state === "connected") setTerminalState("connected", t("terminal.connected"));1108
else setTerminalState("disconnected", t("terminal.disconnected"));1109
});1110
api?.onSshError((payload) => { if (payload.sessionId === TERMINAL_SESSION_ID) terminal?.writeln(`\r\n\x1b[31m${errorText(payload)}\x1b[0m`); });1111
api?.onVaultLocked(status => {1112
configureVault(status); clearProtectedUi(); void refreshBiometrics().then(maybeAutoUnlock);1113
terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);1114
});1116
initialize().catch((error) => {1117
elements.runtimeText.textContent = t("errors.initialization");1118
elements.updateMessage.textContent = errorText(error);1119
});SHA-256
d88bc35149cf28f4951f4fb70533b26c4230fd35ccda803c3e6f64e01e06e2acCH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6