CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 406 Dateien
core-20261024T135205Z-7c487d719de2 Hinzugefügt: · Erstellt:
app.js Hinzugefügt: Datum nicht erfasst 62,9 KB · 1119 Zeilen
Code Datei herunterladen
1"use strict";
2const APP_THEME = "dark";
4const api = window.chjCore;
5const i18n = window.chjI18n;
6const t = (key, values) => i18n.t(key, values);
7const TERMINAL_SESSION_ID = "terminal-main";
8const HASH_TOOL_ID = "chj.hash-checksum";
9if (api?.platform) document.body.classList.add(`platform-${api.platform}`);
10const 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};
24const $ = (selector) => document.querySelector(selector);
25const hashToolButton = $("#hashToolButton");
26const coreNavigationButtons = [...$("#mainNavigation").querySelectorAll("[data-view]")];
27const selectedExportIds = new Set();
28let importPreview = null;
30const 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};
59const 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};
64let diagnosticsView = null;
65let terminal = null;
66let fitAddon = null;
68function 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"));
75let biometricStatus = null;
76function 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;
86// Automatic Touch ID / Windows Hello prompt. Main decides whether it is armed and keeps it
87// single-flight; the renderer only reports that the window is visible and focused.
88let autoUnlockRunning = false;
89async 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(); }
107async function refreshBiometrics() {
108 try { biometricStatus = await api.getBiometricStatus(); renderBiometrics(); }
109 catch (error) { $("#securityMessage").textContent = errorText(error); }
112function 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);
119function pluginDisplay(id) {
120 return state.config?.plugins.display?.[id] || (id === HASH_TOOL_ID ? "navigation" : "visible");
123let preferenceSave = Promise.resolve();
124function 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;
132function 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());
142// Replaces the leading glyph of a navigation button with the shared SVG icon for its stable key.
143function 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);
154function 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 });
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) ----------
208const SIDEBAR_LIMITS = Object.freeze({ min: 220, max: 420, default: 248 });
209const appShell = $(".app-shell");
210const sidebarTooltip = $("#sidebarTooltip");
211const sidebarShortcut = navigator.platform.startsWith("Mac") ? "⌘⇧B" : "Ctrl+Shift+B";
212let sidebarSave = Promise.resolve();
213const clampSidebarWidth = (value) => Math.round(Math.min(SIDEBAR_LIMITS.max, Math.max(SIDEBAR_LIMITS.min, value)));
214function sidebarState() { return { mode: "full", hidden: false, width: SIDEBAR_LIMITS.default, ...(state.config?.ui.sidebar || {}) }; }
215function 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();
232// The view changes immediately; Main stores the normalized value (width clamped to 220-420 px).
233function 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;
240function toggleSidebar() {
241 const hidden = !sidebarState().hidden;
242 saveSidebar({ hidden });
243 if (!hidden) requestAnimationFrame(() => document.querySelector("#mainNavigation .nav-button.active, #mainNavigation .nav-button")?.focus());
245function 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`;
258function hideSidebarTooltip() { clearTimeout(tooltipTimer); sidebarTooltip.hidden = true; }
259const 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.
261let tooltipSuppressed = null;
262// Hover shows the tooltip after a short delay (300 ms); keyboard focus shows it immediately.
263let 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 on
272// :focus-visible, whose heuristic differs between platforms and window states.
273let keyboardInput = false;
274document.addEventListener("keydown", () => { keyboardInput = true; }, true);
275document.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);
280window.addEventListener("resize", hideSidebarTooltip);
281document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.addEventListener("click", () => saveSidebar({ mode: button.dataset.sidebarMode, hidden: false })));
282$("#sidebarToggle").addEventListener("click", toggleSidebar);
283document.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);
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 });
324function cancelProfileImport() {
325 importPreview = null;
326 $("#profileImportDialog").close();
327 $("#profileImportRows").replaceChildren();
328 void api.cancelProfileImport().catch(() => {});
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});
375function setBusy(button, busy, label) {
376 button.disabled = busy;
377 if (label) button.textContent = label;
380function 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]}`;
388function 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;
410function 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 }
449function 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);
455function 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 }
483function 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();
543function 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 }
569function 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 }
603function 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);
638function 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();
653async 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();
664function 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 }
687function 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();
695function 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();
709function 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();
726function 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;
733function 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");
746function 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();
768function 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 {}
776function 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";
787function 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");
808async 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 || 30
825 });
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: true
852 });
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"));
863async 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")); }
872async 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, so
906 // 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_THEME
908 });
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();
916document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
917hashToolButton.addEventListener("click", async () => {
918 try { await api.openPlugin(HASH_TOOL_ID); }
919 catch (error) { window.alert(errorText(error)); }
920});
921elements.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});
935elements.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});
946for (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 });
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").checked
969 } });
970 $("#securityMessage").textContent = t("settings.saved");
971 } catch (error) { $("#securityMessage").textContent = errorText(error); }
972});
973elements.showVaultResetButton.addEventListener("click", () => {
974 elements.vaultResetPanel.hidden = false;
975 elements.showVaultResetButton.hidden = true;
976 elements.vaultResetConfirmation.focus();
977});
978elements.cancelVaultResetButton.addEventListener("click", () => {
979 elements.vaultResetPanel.hidden = true;
980 elements.showVaultResetButton.hidden = false;
981 elements.vaultResetConfirmation.value = "";
982});
983elements.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"));
1002});
1003elements.newProfileButton.addEventListener("click", () => { state.selectedProfileId = ""; renderProfiles(); resetProfileForm(); elements.profileLabel.focus(); });
1004elements.profileAuthMethod.addEventListener("change", updatePrivateKeyField);
1005elements.profileStorePassword.addEventListener("change", updatePrivateKeyField);
1006elements.selectPrivateKeyButton.addEventListener("click", async () => {
1007 const result = await api.selectPrivateKey();
1008 if (!result.canceled) elements.profilePrivateKeyPath.value = result.path;
1009});
1010elements.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.value
1019 });
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});
1023elements.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});
1028elements.terminalProfileSelect.addEventListener("change", () => { elements.terminalCredential.value = ""; updateTerminalProfile(); });
1029elements.connectButton.addEventListener("click", () => connectTerminal().catch((error) => { setTerminalState("error", t("terminal.error")); terminal?.writeln(`\r\n\x1b[31m${errorText(error)}\x1b[0m`); }));
1030elements.disconnectButton.addEventListener("click", async () => { await api.disconnectSsh(TERMINAL_SESSION_ID); });
1031elements.checkUpdateButton.addEventListener("click", checkUpdates);
1032elements.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;
1042 finally { setBusy(elements.downloadUpdateButton, false, t("updates.download")); }
1043});
1044elements.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});
1056elements.revealUpdateButton.addEventListener("click", () => api.revealUpdate());
1057elements.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});
1063elements.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});
1068elements.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});
1074elements.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});
1087elements.languageSelect.addEventListener("change", () => applyLanguage(elements.languageSelect.value));
1088document.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});
1094elements.vaultLanguageSelect.addEventListener("change", async () => {
1095 const language = applyLanguage(elements.vaultLanguageSelect.value);
1096 state.config = await api.updateConfig({ ui: { language } });
1097});
1099api?.onUpdateState((payload) => renderUpdate(payload));
1100api?.onPluginWindowState((windows) => renderPluginTaskbar(windows));
1101api?.onSshData((payload) => { if (payload.sessionId === TERMINAL_SESSION_ID) terminal?.write(payload.data); });
1102api?.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});
1110api?.onSshError((payload) => { if (payload.sessionId === TERMINAL_SESSION_ID) terminal?.writeln(`\r\n\x1b[31m${errorText(payload)}\x1b[0m`); });
1111api?.onVaultLocked(status => {
1112 configureVault(status); clearProtectedUi(); void refreshBiometrics().then(maybeAutoUnlock);
1113 terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);
1114});
1116initialize().catch((error) => {
1117 elements.runtimeText.textContent = t("errors.initialization");
1118 elements.updateMessage.textContent = errorText(error);
1119});
SHA-256d88bc35149cf28f4951f4fb70533b26c4230fd35ccda803c3e6f64e01e06e2ac

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6
Dateien