CH-J Toolsspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Tools

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / Server Manager 872 souborů
core-20261030T220507Z-61e4558d889f Přidáno: · Sestaveno:
app.js Přidáno: Datum není evidováno 83,4 KB · 1409 řádků
Kód Stáhnout soubor
1"use strict";
2const APP_THEME = "dark";
4const api = window.chjCore;
5const i18n = window.chjI18n;
6const t = (key, values) => i18n.t(key, values);
7// Pure terminal helpers are also exercised by tests/ui without a browser.
8function 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("")}`;
12function paneForEvent(panes, event) {
13 return event?.sessionId ? panes.find((pane) => pane.sessionId === event.sessionId) : undefined;
15function 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;
28// End pure terminal helpers.
29const HASH_TOOL_ID = "chj.hash-checksum";
30if (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.
32for (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 }
40const 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};
52const $ = (selector) => document.querySelector(selector);
53const hashToolButton = $("#hashToolButton");
54const coreNavigationButtons = [...$("#mainNavigation").querySelectorAll("[data-view]")];
55const selectedExportIds = new Set();
56let importPreview = null;
58const 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};
84const 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};
89let diagnosticsView = null;
90const panes = [createTerminalPane(1), createTerminalPane(2)];
91let activePane = panes[0], lastSplitLayout = "split";
92let availabilityState = new Map();
94function 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;
103let biometricStatus = null;
104function 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;
114// Automatic Touch ID / Windows Hello prompt. Main decides whether it is armed and keeps it
115// single-flight; the renderer only reports that the window is visible and focused.
116let autoUnlockRunning = false;
117async 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(); }
135async function refreshBiometrics() {
136 try { biometricStatus = await api.getBiometricStatus(); renderBiometrics(); }
137 catch (error) { $("#securityMessage").textContent = errorText(error); }
140function 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(); });
149function pluginDisplay(id) {
150 return state.config?.plugins.display?.[id] || (id === HASH_TOOL_ID ? "navigation" : "visible");
153let preferenceSave = Promise.resolve();
154function 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;
162function 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());
172// Replaces the leading glyph of a navigation button with the shared SVG icon for its stable key.
173function 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);
184function 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 });
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) ----------
238const SIDEBAR_LIMITS = Object.freeze({ min: 220, max: 420, default: 248 });
239const appShell = $(".app-shell");
240const sidebarTooltip = $("#sidebarTooltip");
241const sidebarShortcut = navigator.platform.startsWith("Mac") ? "⌘⇧B" : "Ctrl+Shift+B";
242let sidebarSave = Promise.resolve();
243const clampSidebarWidth = (value) => Math.round(Math.min(SIDEBAR_LIMITS.max, Math.max(SIDEBAR_LIMITS.min, value)));
244function sidebarState() { return { mode: "full", hidden: false, width: SIDEBAR_LIMITS.default, ...(state.config?.ui.sidebar || {}) }; }
245function 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();
262// The view changes immediately; Main stores the normalized value (width clamped to 220-420 px).
263function 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;
270function toggleSidebar() {
271 const hidden = !sidebarState().hidden;
272 saveSidebar({ hidden });
273 if (!hidden) requestAnimationFrame(() => document.querySelector("#mainNavigation .nav-button.active, #mainNavigation .nav-button")?.focus());
275function 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`;
288function hideSidebarTooltip() { clearTimeout(tooltipTimer); sidebarTooltip.hidden = true; }
289const 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.
291let tooltipSuppressed = null;
292// Hover shows the tooltip after a short delay (300 ms); keyboard focus shows it immediately.
293let 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 on
302// :focus-visible, whose heuristic differs between platforms and window states.
303let keyboardInput = false;
304document.addEventListener("keydown", () => { keyboardInput = true; }, true);
305document.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);
310window.addEventListener("resize", hideSidebarTooltip);
311document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.addEventListener("click", () => saveSidebar({ mode: button.dataset.sidebarMode, hidden: false })));
312$("#sidebarToggle").addEventListener("click", toggleSidebar);
313document.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);
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 });
354function cancelProfileImport() {
355 importPreview = null;
356 $("#profileImportDialog").close();
357 $("#profileImportRows").replaceChildren();
358 void api.cancelProfileImport().catch(() => {});
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});
405function setBusy(button, busy, label) {
406 button.disabled = busy;
407 if (label) button.textContent = label;
410function 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]}`;
418function 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;
444function 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 }
483function 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);
489function 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 }
517function 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();
579function 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 }
605function 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 }
639function 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);
674function 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 }
693async 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();
706function 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 }
732function 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 };
740function serverOptionText(profile) {
741 const { status, text } = availabilitySummary(profile);
742 return `${{ online: "🟢", offline: "🔴" }[status] || "⚪"} ${profile.label}${text ? ` — ${text}` : ""}`;
744function 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");
750// Updates in place: an open menu or a selected row must survive every 12 s.
751function 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 });
768async function loadAvailability() {
769 try { availabilityState = new Map((await api.listAvailability()).map((entry) => [entry.id, entry])); renderAvailability(); } catch {}
771function 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;
778function 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 : "";
785function 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 }
797function 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();
813function 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();
832function 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;
839function 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;
858function 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()]));
861function selectedTarget(pane) {
862 return pane.select.value === "@manual" ? manualHost(pane.targetFields) : state.profiles.find((profile) => profile.id === pane.select.value);
864function selectedJump(pane) {
865 return pane.jumpSelect.value === "@manual" ? manualHost(pane.jumpFields) : state.profiles.find((profile) => profile.id === pane.jumpSelect.value) || null;
867function 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;
905function 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);
913function 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);
933// A saved password needs no input; the field returns after a failed attempt, for a one-time replacement.
934function updateCredentialField(pane) {
935 const target = selectedTarget(pane);
936 pane.credential.closest("label").hidden = Boolean(target?.hasStoredPassword && target.authMethod === "password" && pane.kind !== "error");
938function 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)})`;
944function terminalShortcut(key) { return `${state.info?.platform === "darwin" ? "⌘" : "Ctrl+Shift+"}${key}`; }
945function 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(); });
955function 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();
968function 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 {}
977function fitTerminal() { for (const pane of panes) fitPane(pane); }
978function 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));
990function writeTerminalError(pane, error) { pane.terminal?.writeln(`\r\n\x1b[31m${errorText(error)}\x1b[0m`); }
991async 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;
1011 if (result.ok) {
1012 pane.credential.value = ""; pane.jumpCredential.value = ""; pane.pending = false;
1013 setTerminalState(pane, "connected"); fitPane(pane); pane.terminal?.focus(); return;
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") };
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"); }
1033async 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);
1040function 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);
1052function 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);
1063function 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)));
1069async 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");
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 }));
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 });
1108// Capture phase: the focused terminal must not receive these as input.
1109window.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);
1119function 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")})`;
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");
1140async 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")); }
1149async 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, so
1187 // 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_THEME
1189 });
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();
1197document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
1198hashToolButton.addEventListener("click", async () => {
1199 try { await api.openPlugin(HASH_TOOL_ID); }
1200 catch (error) { window.alert(errorText(error)); }
1201});
1202elements.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});
1216elements.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});
1227for (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 });
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").checked
1250 } });
1251 $("#securityMessage").textContent = t("settings.saved");
1252 } catch (error) { $("#securityMessage").textContent = errorText(error); }
1253});
1254elements.showVaultResetButton.addEventListener("click", () => {
1255 elements.vaultResetPanel.hidden = false;
1256 elements.showVaultResetButton.hidden = true;
1257 elements.vaultResetConfirmation.focus();
1258});
1259elements.cancelVaultResetButton.addEventListener("click", () => {
1260 elements.vaultResetPanel.hidden = true;
1261 elements.showVaultResetButton.hidden = false;
1262 elements.vaultResetConfirmation.value = "";
1263});
1264elements.confirmVaultResetButton.addEventListener("click", async () => {
1265 if (elements.vaultResetConfirmation.value !== "SMAZAT") {
1266 elements.vaultMessage.textContent = t("vault.typeDelete");
1267 return;
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"));
1283});
1284elements.newProfileButton.addEventListener("click", () => { state.selectedProfileId = ""; renderProfiles(); resetProfileForm(); elements.profileLabel.focus(); });
1285elements.profileAuthMethod.addEventListener("change", updatePrivateKeyField);
1286elements.profileStorePassword.addEventListener("change", updatePrivateKeyField);
1287elements.selectPrivateKeyButton.addEventListener("click", async () => {
1288 const result = await api.selectPrivateKey();
1289 if (!result.canceled) elements.profilePrivateKeyPath.value = result.path;
1290});
1291elements.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.value
1301 });
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});
1305elements.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});
1310elements.checkUpdateButton.addEventListener("click", checkUpdates);
1311elements.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;
1321 finally { setBusy(elements.downloadUpdateButton, false, t("updates.download")); }
1322});
1323elements.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});
1335elements.revealUpdateButton.addEventListener("click", () => api.revealUpdate());
1336elements.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});
1342elements.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});
1347elements.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});
1353elements.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});
1368elements.languageSelect.addEventListener("change", () => applyLanguage(elements.languageSelect.value));
1369$("#refreshAvailabilityButton").addEventListener("click", () => void api.refreshAvailability().catch(() => {}));
1370document.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});
1376elements.vaultLanguageSelect.addEventListener("change", async () => {
1377 const language = applyLanguage(elements.vaultLanguageSelect.value);
1378 state.config = await api.updateConfig({ ui: { language } });
1379});
1381api?.onUpdateState((payload) => renderUpdate(payload));
1382api?.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});
1388api?.onPluginWindowState((windows) => renderPluginTaskbar(windows));
1389api?.onSshData((event) => paneForEvent(panes, event)?.terminal?.write(event.data));
1390api?.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});
1400api?.onSshError((event) => { const pane = paneForEvent(panes, event); if (pane) writeTerminalError(pane, event); });
1401api?.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});
1406initialize().catch((error) => {
1407 elements.runtimeText.textContent = t("errors.initialization");
1408 elements.updateMessage.textContent = errorText(error);
1409});
SHA-256f0caafc4cbe027808a83ef7cc97bc5a74c989b0d8430076b2ba1001cf7020fa0

CH-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 archivu77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Soubory