CH-J Toolsupravljanje strežnikov prek SSH
Meni
Objavljen vir

CH-J Tools

Brskajte po imenikih in datotekah za določeno izdajo programa.

Prenos vir ZIP
CH-J / Server Manager 1704 datoteke
0.1.2-beta Dodano: · Zgrajena:
app.js Dodano: Datum, ki ni zabeležen 146,7 KB · 2234 vrstice
Oznaka Prenesi datoteko
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, kexPolicy:target.kexPolicy || "prefer-hybrid",
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,kexPolicy:jump.kexPolicy || "prefer-hybrid", ...(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 updateViewChannelSelect: $("#updateViewChannelSelect"), pluginViewChannelSelect: $("#pluginViewChannelSelect"),
67 settingsForm: $("#settingsForm"), languageSelect: $("#languageSelect"), channelSelect: $("#channelSelect"), pluginChannelSelect: $("#pluginChannelSelect"), autoCheckInput: $("#autoCheckInput"),
68 baseUrlsText: $("#baseUrlsText"), settingsStatus: $("#settingsStatus"), lockVaultButton: $("#lockVaultButton"),
69 vaultOverlay: $("#vaultOverlay"), vaultForm: $("#vaultForm"), vaultLanguageSelect: $("#vaultLanguageSelect"), vaultModeText: $("#vaultModeText"), vaultTitle: $("#vaultTitle"),
70 vaultDescription: $("#vaultDescription"), vaultPassword: $("#vaultPassword"), vaultConfirmLabel: $("#vaultConfirmLabel"),
71 vaultPasswordConfirm: $("#vaultPasswordConfirm"), vaultMessage: $("#vaultMessage"), vaultSubmit: $("#vaultSubmit"),
72 showVaultResetButton: $("#showVaultResetButton"), vaultResetPanel: $("#vaultResetPanel"),
73 vaultResetConfirmation: $("#vaultResetConfirmation"), cancelVaultResetButton: $("#cancelVaultResetButton"),
74 confirmVaultResetButton: $("#confirmVaultResetButton"),
75 profileRows: $("#profileRows"), profileForm: $("#profileForm"), profileId: $("#profileId"), profileLabel: $("#profileLabel"),
76 profileHost: $("#profileHost"), profilePort: $("#profilePort"), profileUsername: $("#profileUsername"),
77 profileAuthMethod: $("#profileAuthMethod"), privateKeyField: $("#privateKeyField"), passwordStorageField: $("#passwordStorageField"),
78 profileStorePassword: $("#profileStorePassword"), profileStoredPassword: $("#profileStoredPassword"),
79 profilePrivateKeyPath: $("#profilePrivateKeyPath"), selectPrivateKeyButton: $("#selectPrivateKeyButton"),
80 profileMessage: $("#profileMessage"), deleteProfileButton: $("#deleteProfileButton"), newProfileButton: $("#newProfileButton")
81};
83const titles = {
84 keychain: "nav.keychain",
85 overview: "nav.overview", terminal: "terminal.title", profiles: "nav.servers",
86 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 (elements.vaultOverlay.hidden || !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); }
140let moduleAreaSuspended = false;
141// A module is a separate WebView above this page, so it may only be visible while its area is on screen.
142function reportModuleArea() {
143 const panel = document.querySelector('[data-view-panel="module"]');
144 if (!api?.setModuleArea || !panel) return;
145 if (!panel.classList.contains("active") || moduleAreaSuspended) { void api.setModuleArea(null).catch(() => {}); return; }
146 const rect = $("#moduleHostArea").getBoundingClientRect();
147 void api.setModuleArea({ x: rect.left, y: rect.top, width: rect.width, height: Math.max(1, rect.height), pageHeight: window.innerHeight }).catch(() => {});
150let protectedEpoch = 0;
151let keychainEntries = [], keychainTab = "openpgp", keychainSelected = "", keychainDetailTab = "key", keychainSync = null;
152async function loadKeychain() {
153 const epoch = protectedEpoch;
154 if (!state.vault?.unlocked) return;
155 try {
156 const entries = await api.keychain("list");
157 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
158 keychainEntries = entries;
159 try { keychainSync = await api.keychain("syncStatus"); } catch { keychainSync = null; }
160 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
161 renderKeychain();
162 } catch (error) { $("#keychainMessage").textContent = errorText(error); }
164async function keychainAction(action, options = {}) {
165 const epoch = protectedEpoch;
166 const controls = [...$("#keychainForm").querySelectorAll("button"), ...$("#keychainRows").querySelectorAll("button"), ...($("#keychainDetailActions")?.querySelectorAll("button") || [])];
167 controls.forEach(button => { button.disabled = true; });
168 $("#keychainMessage").textContent = "";
169 try {
170 const result = await api.keychain(action, options);
171 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
172 if (result.publicationPending) $("#keychainMessage").textContent = t("keychain.revokedPendingMessage");
173 else if (result.published) $("#keychainMessage").textContent = t(result.revoked || result.keyserver?.revoked ? "keychain.revocationPublished" : "keychain.published");
174 else if (result.verified) $("#keychainMessage").textContent = t("keychain.verified");
175 else if (result.signed) $("#keychainMessage").textContent = t("keychain.signed");
176 else if (result.completed) $("#keychainMessage").textContent=t("keychain.completed");
177 else if (result.stored) $("#keychainMessage").textContent=t("keychain.edited");
178 else if (result.queued) $("#keychainMessage").textContent=t(result.revoked ? "keysync.revocationQueued" : "keysync.queued");
179 else if (result.exported) $("#keychainMessage").textContent=t("keychain.exported");
180 $("#keychainPassphrase").value = ""; if ($("#keychainDetailPassphrase")) $("#keychainDetailPassphrase").value = "";
181 if (state.vault?.unlocked) await loadKeychain();
182 } catch (error) { if (state.vault?.unlocked && epoch === protectedEpoch) { $("#keychainMessage").textContent = errorText(error); await loadKeychain(); } }
183 finally { controls.forEach(button => { button.disabled = false; }); }
185$("#keychainCategory").addEventListener("change",()=>renderKeychain());
186$("#keychainForm").addEventListener("submit", event => {
187 event.preventDefault();
188 if ($("#keychainImportDialog")?.open) $("#keychainImportDialog").close();
189 void keychainAction("import", { label: $("#keychainLabel").value, passphrase: $("#keychainPassphrase").value });
190});
191$("#keychainImportOpen").addEventListener("click", () => { $("#keychainForm").reset(); $("#keychainImportDialog").showModal(); $("#keychainLabel").focus(); });
192$("#keychainImportCancel").addEventListener("click", () => { $("#keychainForm").reset(); $("#keychainImportDialog").close(); });
193// Keychain view modelled on GPG Keychain: tabs PGP / SSH / passwords, a toolbar, one table row per key,
194// and a symmetric detail panel (key, user IDs, subkeys) with editing and grouped actions below the table.
195const groupedFingerprint = value => { const text = String(value || ""); return text.includes(":") ? text : text.replace(/(.{4})/g, "$1 ").trim(); };
196const keyDate = seconds => seconds ? new Date(seconds * 1000).toLocaleDateString() : "—";
197let keychainAlgorithms = null, keychainSubkeySelected = "", keychainEditing = "";
198function splitUserId(uid) { const match = /^(.*?)\s*(?:\(([^)]*)\))?\s*(?:<([^>]*)>)?$/.exec(uid || ""); return { name: (match?.[1] || uid || "").trim(), comment: match?.[2] || "", email: match?.[3] || "" }; }
199function keyValidity(entry) {
200 if (entry.revoked) return { text: t("keychain.revoked"), level: 0 };
201 if (entry.expired) return { text: t("keychain.expired"), level: 1 };
202 return { text: entry.expiresAt ? `${t("keychain.validUntil")} ${keyDate(entry.expiresAt)}` : t("keychain.noExpiry"), level: entry.kind === "openpgp" && entry.bindingValid === false ? 2 : 4 };
204// Server state is separate from local validity: a locally revoked key may still wait for its published revocation.
205function keyServerState(entry) {
206 if (entry.kind !== "openpgp" || entry.keyserverSupported === false) return { text: "—", level: "none" };
207 const sync = keychainSync?.keys?.[entry.fingerprint];
208 const job = sync?.job;
209 if (job && !["success"].includes(job.state)) return { text: `${t(`keysync.kind.${job.kind}`)} · ${t(`keysync.state.${job.state}`)}`, level: job.state === "failed" ? "revoked" : "pending" };
210 const verified = sync?.server?.verifiedAt ? { checkedAt: sync.server.verifiedAt, found: sync.server.found, revoked: sync.server.revoked } : null;
211 const server = verified || entry.keyserver || {};
212 if (entry.revoked && server.revoked !== true) return { text: t("keychain.server.revocationPending"), level: "pending" };
213 if (!server.checkedAt) return { text: t("keychain.server.unknown"), level: "none" };
214 if (!server.found) return { text: t("keychain.server.notPublished"), level: "none" };
215 return server.revoked ? { text: t("keychain.server.revoked"), level: "revoked" } : { text: t("keychain.server.published"), level: "ok" };
217const ACTION_GROUPS = [
218 ["keychain.group.use", ["sign", "verify", "signInline", "verifyInline", "encryptFile", "decryptFile"]],
219 ["keychain.group.show", ["showPublic", "showPrivate"]],
220 ["keychain.group.server", ["publish", "checkServer", "revokeAndPublish"]],
221];
222function keychainActionList(entry) {
223 return [["showPublic", "keychain.showPublic"], ...(entry.hasPrivate ? [["showPrivate", "keychain.showPrivate"]] : []), ...(entry.kind === "openpgp" ? [["verify", "keychain.verify"]] : []), ["exportPublic", "keychain.exportPublic"], ...(entry.kind==="openpgp" && [4,6].includes(entry.version)?[["verifyInline","keychain.verifyInline"],...(entry.encryptSupported?[["encryptFile","keychain.encryptFile"]]:[]),...(entry.hasPrivate && entry.decryptSupported?[["decryptFile","keychain.decryptFile"]]:[]),...(entry.inlineSignSupported?[["signInline","keychain.signInline"]]:[])]:[]), ...(entry.kind === "openpgp" && entry.keyserverSupported !== false ? [["publish", entry.revoked ? "keychain.publishRevocation" : "keychain.publish"], ["checkServer", "keychain.checkServer"]] : []), ...(entry.hasPrivate ? [// Owner's decision 2026-11-05: no file signing/verification with SSH keys in the UI (Core keeps SSHSIG).
224...(!entry.revoked && entry.kind === "openpgp" && entry.signSupported !== false ? [["sign", "keychain.sign"]] : []), ...(!entry.revoked && entry.kind === "openpgp" && entry.revokeSupported !== false ? [["revoke", "keychain.revoke"], ...(entry.keyserverSupported !== false ? [["revokeAndPublish", "keychain.revokeAndPublish"]] : [])] : []), ["exportPrivate", "keychain.exportPrivate"], ...(entry.kind === "openpgp" && [4,6].includes(entry.version) && !entry.revoked ? [["exportRevocation", "keychain.exportRevocation"]] : [])] : []), ["remove", "keychain.remove"]];
226const keyPassphrase = () => $("#keychainDetailPassphrase")?.value || "";
227function runKeychainAction(entry, action) {
228 if (action === "showPublic" || action === "showPrivate") {
229 const priv = action === "showPrivate";
230 void showSecret(`${entry.label || entry.kind} · ${t(priv ? "keychain.privateKey" : "keychain.publicKey")}`,
231 async vaultPassword => ({ text: (await api.keychain(action, { id: entry.id, vaultPassword })).text }), priv);
232 return;
233 }
234 if (["remove", "exportPrivate", "exportRevocation", "revoke", "publish", "revokeAndPublish"].includes(action) && !window.confirm(t(`keychain.${action}Confirm`))) return;
235 void keychainAction(action, { id: entry.id, passphrase: keyPassphrase(), confirmed: ["revoke", "publish", "revokeAndPublish"].includes(action) });
237function editKeychainKey(entry, payload, confirmKey) {
238 if (confirmKey && !window.confirm(t(confirmKey))) return;
239 keychainEditing = "";
240 void keychainAction("edit", { id: entry.id, passphrase: keyPassphrase(), ...payload });
242function showKeychainTab(tab) {
243 keychainTab = tab;
244 for (const button of document.querySelectorAll("[data-keychain-tab]")) { const active = button.dataset.keychainTab === tab; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); }
245 $("#keychainKeysPane").hidden = tab === "passwords";
246 $("#keychainPasswordsPane").hidden = tab !== "passwords";
247 for (const node of document.querySelectorAll("#keychainKeysPane [data-keychain-only]")) node.hidden = node.dataset.keychainOnly !== tab;
248 if (tab === "passwords") void loadPasswords(); else renderKeychain();
250function cell(row, text, className) { const td = document.createElement("td"); td.textContent = text || ""; if (className) td.className = className; row.append(td); return td; }
251function renderKeychain() {
252 const rows = $("#keychainRows"); rows.replaceChildren();
253 const filter = $("#keychainFilter").value.trim().toLowerCase(), secretOnly = $("#keychainSecretOnly").checked, category = $("#keychainCategory").value || "all";
254 const ofKind = keychainEntries.filter(entry => entry.kind === keychainTab);
255 const shown = ofKind.filter(entry => (!secretOnly || entry.hasPrivate)
256 && (category === "all" || (category === "post-quantum") === Boolean(entry.postQuantum || (entry.subkeys || []).some(k => k.postQuantum)))
257 && (!filter || [entry.label, entry.fingerprint, entry.keyId, ...(entry.userIds || []), ...(entry.subkeys || []).flatMap(k => [k.fingerprint, k.keyId])].some(value => String(value || "").toLowerCase().replace(/\s/g, "").includes(filter.replace(/\s/g, "")))));
258 if (!shown.some(entry => entry.id === keychainSelected)) keychainSelected = shown[0]?.id || "";
259 for (const entry of shown) {
260 const uid = splitUserId(entry.userIds?.[0] || entry.label);
261 const validity = keyValidity(entry);
262 const row = document.createElement("tr"); row.tabIndex = 0; row.className = entry.id === keychainSelected ? "selected" : ""; if (entry.hasPrivate) row.classList.add("secret");
263 cell(row, entry.hasPrivate ? "sec/pub" : "pub");
264 cell(row, keychainTab === "openpgp" ? uid.name || entry.label : entry.label);
265 if (keychainTab === "openpgp") cell(row, uid.email);
266 cell(row, keyDate(entry.keyCreatedAt || (entry.createdAt ? Date.parse(entry.createdAt) / 1000 : 0)));
267 cell(row, groupedFingerprint(entry.fingerprint), "mono");
268 const validityCell = cell(row, ""); validityCell.title = validity.text;
269 const bar = document.createElement("span"); bar.className = `validity-bar level-${validity.level}`; bar.setAttribute("aria-label", validity.text);
270 for (let i = 0; i < 4; i++) bar.append(document.createElement("i"));
271 validityCell.append(bar);
272 if (keychainTab === "openpgp") { const server = keyServerState(entry); const badge = cell(row, ""); const span = document.createElement("span"); span.className = `server-badge ${server.level}`; span.textContent = server.text; badge.append(span); }
273 const select = () => { if (keychainSelected !== entry.id) { keychainSelected = entry.id; keychainSubkeySelected = ""; keychainEditing = ""; } renderKeychain(); };
274 row.addEventListener("click", select);
275 row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } });
276 rows.append(row);
277 }
278 $("#keychainCount").textContent = t("keychain.count", { shown: shown.length, total: ofKind.length });
279 renderKeySync();
280 renderKeychainDetail(shown.find(entry => entry.id === keychainSelected));
281 renderExportMenu(shown.find(entry => entry.id === keychainSelected));
283function detailLine(host, label, value, extra) {
284 if ((value === undefined || value === null || value === "") && !extra) return;
285 const row = document.createElement("div"); row.className = "detail-line";
286 const name = document.createElement("span"); name.textContent = label;
287 const text = document.createElement("strong"); text.textContent = value || "";
288 row.append(name, text); if (extra) row.append(extra); host.append(row);
290function smallButton(label, handler, danger) { const b = document.createElement("button"); b.type = "button"; b.className = `button small${danger ? " danger" : ""}`; b.textContent = label; b.addEventListener("click", handler); return b; }
291function expiryEditor(entry, fingerprint, current) {
292 const box = document.createElement("span"); box.className = "inline-editor";
293 const input = document.createElement("input"); input.type = "datetime-local";
294 if (current) { const d = new Date(current * 1000); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); input.value = d.toISOString().slice(0, 16); }
295 box.append(input, smallButton(t("keychain.save"), () => {
296 const seconds = input.value ? Math.floor(new Date(input.value).getTime() / 1000) : null;
297 if (seconds !== null && seconds <= Date.now() / 1000) { $("#keychainMessage").textContent = t("keychain.futureDate"); return; }
298 editKeychainKey(entry, { operation: "setExpiration", ...(fingerprint ? { fingerprint } : {}), expiresAt: seconds });
299 }), smallButton(t("vault.cancel"), () => { keychainEditing = ""; renderKeychain(); }));
300 return box;
302function editable(entry) { return entry.kind === "openpgp" && entry.hasPrivate && !entry.revoked && [4, 6].includes(entry.version) && entry.editSupported !== false; }
303function renderKeychainDetail(entry) {
304 const detail = $("#keychainDetail"), body = $("#keychainDetailBody"), actions = $("#keychainDetailActions");
305 detail.hidden = !entry; body.replaceChildren(); actions.replaceChildren();
306 if (!entry) return;
307 if (entry.kind !== "openpgp" && keychainDetailTab !== "key") keychainDetailTab = "key";
308 for (const button of detail.querySelectorAll("[data-keychain-detail]")) { button.classList.toggle("active", button.dataset.keychainDetail === keychainDetailTab); button.hidden = button.dataset.keychainOnly ? entry.kind !== button.dataset.keychainOnly : false; }
309 const uid = splitUserId(entry.userIds?.[0] || entry.label);
310 const title = entry.kind === "openpgp" ? uid.name || entry.label : entry.label;
311 $("#keychainAvatar").textContent = String(title || "?").split(/\s+/).map(part => part[0]).join("").slice(0, 2).toUpperCase();
312 $("#keychainDetailName").textContent = title;
313 $("#keychainDetailSub").textContent = [uid.email, t(entry.hasPrivate ? "keychain.publicAndPrivate" : "keychain.publicOnly")].filter(Boolean).join(" · ");
314 $("#keychainDetailPassphraseField").hidden = !entry.hasPrivate;
315 const canEdit = editable(entry);
316 if (keychainDetailTab === "key") {
317 const grid = document.createElement("div"); grid.className = "detail-grid"; body.append(grid);
318 const left = document.createElement("div"), right = document.createElement("div"); grid.append(left, right);
319 detailLine(left, t("keychain.col.name"), title);
320 if (entry.kind === "openpgp") { detailLine(left, t("keychain.col.email"), uid.email || "—"); detailLine(left, t("keychain.comment"), uid.comment || "—"); }
321 detailLine(left, t("keychain.col.created"), entry.keyCreatedAt ? new Date(entry.keyCreatedAt * 1000).toLocaleString() : entry.createdAt ? new Date(entry.createdAt).toLocaleString() : "—");
322 if (entry.kind === "openpgp") {
323 const editing = keychainEditing === "primary";
324 detailLine(left, t("keychain.expires"), editing ? "" : entry.expiresAt ? new Date(entry.expiresAt * 1000).toLocaleString() : t("keychain.noExpiry"),
325 editing ? expiryEditor(entry, null, entry.expiresAt) : canEdit ? smallButton(t("keychain.change"), () => { keychainEditing = "primary"; renderKeychain(); }) : null);
326 }
327 detailLine(left, t("keychain.col.validity"), keyValidity(entry).text);
328 if (entry.kind === "openpgp") detailLine(left, t("keychain.col.server"), keyServerState(entry).text);
329 detailLine(right, t("keychain.col.type"), t(entry.hasPrivate ? "keychain.publicAndPrivate" : "keychain.publicOnly"));
330 if (entry.keyId) detailLine(right, t("keychain.keyId"), entry.keyId.slice(-8));
331 detailLine(right, t("keychain.length"), entry.bits ? String(entry.bits) : "—");
332 detailLine(right, t("keychain.algorithm"), `${entry.postQuantum ? t("keychain.pqc") + " · " : ""}${entry.algorithm || entry.type || "SSH"}${entry.qBits ? " · q" + entry.qBits + " · " + entry.signatureHash : ""}`);
333 detailLine(right, t("keychain.col.fingerprint"), groupedFingerprint(entry.fingerprint));
334 if (entry.kind === "openpgp") {
335 detailLine(right, t("keychain.capabilities.effective"), `${entry.effectiveCapabilities || "—"} · ${t("keychain.capabilities.certificate")} [${entry.certificateCapabilities || "—"}]`);
336 detailLine(right, t("keychain.format"), `${entry.serializationProfile || "OpenPGP"} · V${entry.version || "?"}`);
337 }
338 } else if (keychainDetailTab === "uids") {
339 const table = document.createElement("table"); table.className = "keychain-table compact";
340 const head = document.createElement("tr"); for (const key of ["keychain.col.name", "keychain.col.email", "keychain.comment", "keychain.col.validity", ""]) { const th = document.createElement("th"); th.textContent = key ? t(key) : ""; head.append(th); }
341 table.append(head);
342 const details = entry.userIdentityDetails || (entry.userIds || []).map(id => ({ id }));
343 for (const item of details) {
344 const value = item.id || item.userId || item.value || ""; const parts = splitUserId(value); const row = document.createElement("tr");
345 for (const text of [parts.name, parts.email, parts.comment, item.revoked ? t("keychain.revoked") : t("keychain.valid")]) cell(row, text);
346 const actionCell = cell(row, "");
347 if (canEdit && !item.revoked && details.length > 1) actionCell.append(smallButton(t("keychain.revokeUid"), () => editKeychainKey(entry, { operation: "revokeUserId", userId: value, confirmed: true }, "keychain.revokeUidConfirm"), true));
348 table.append(row);
349 }
350 body.append(table);
351 } else {
352 const subkeys = entry.subkeys || [];
353 if (!subkeys.some(k => k.fingerprint === keychainSubkeySelected)) keychainSubkeySelected = subkeys[0]?.fingerprint || "";
354 const table = document.createElement("table"); table.className = "keychain-table compact";
355 const head = document.createElement("tr"); for (const key of ["keychain.subkey", "keychain.length", "keychain.algorithm", "keychain.col.created", "keychain.expires", "keychain.capabilities.effective"]) { const th = document.createElement("th"); th.textContent = t(key); head.append(th); }
356 table.append(head);
357 for (const subkey of subkeys) {
358 const row = document.createElement("tr"); row.tabIndex = 0; if (subkey.fingerprint === keychainSubkeySelected) row.className = "selected";
359 const state = subkey.revoked ? t("keychain.revoked") : subkey.expired ? t("keychain.expired") : subkey.expiresAt ? keyDate(subkey.expiresAt) : t("keychain.noExpiry");
360 for (const text of [subkey.keyId ? subkey.keyId.slice(-8) : String(subkey.fingerprint || "").slice(-8), subkey.bits ? String(subkey.bits) : "—", `${subkey.postQuantum ? t("keychain.pqc") + " · " : ""}${subkey.algorithm || ""}`, keyDate(subkey.keyCreatedAt), state, subkey.effectiveCapabilities || "—"]) cell(row, text);
361 row.addEventListener("click", () => { keychainSubkeySelected = subkey.fingerprint; keychainEditing = ""; renderKeychain(); });
362 table.append(row);
363 }
364 if (subkeys.length) body.append(table); else { const empty = document.createElement("p"); empty.className = "hint"; empty.textContent = t("keychain.noSubkeys"); body.append(empty); }
365 const selected = subkeys.find(k => k.fingerprint === keychainSubkeySelected);
366 if (selected) {
367 const grid = document.createElement("div"); grid.className = "detail-grid"; body.append(grid);
368 const left = document.createElement("div"), right = document.createElement("div"); grid.append(left, right);
369 const editing = keychainEditing === `sub:${selected.fingerprint}`;
370 detailLine(left, t("keychain.col.created"), selected.keyCreatedAt ? new Date(selected.keyCreatedAt * 1000).toLocaleString() : "—");
371 detailLine(left, t("keychain.expires"), editing ? "" : selected.expiresAt ? new Date(selected.expiresAt * 1000).toLocaleString() : t("keychain.noExpiry"),
372 editing ? expiryEditor(entry, selected.fingerprint, selected.expiresAt) : canEdit && !selected.revoked ? smallButton(t("keychain.change"), () => { keychainEditing = `sub:${selected.fingerprint}`; renderKeychain(); }) : null);
373 detailLine(left, t("keychain.length"), selected.bits ? String(selected.bits) : "—");
374 detailLine(right, t("keychain.algorithm"), selected.algorithm || "—");
375 detailLine(right, t("keychain.col.fingerprint"), groupedFingerprint(selected.fingerprint));
376 detailLine(right, t("keychain.capabilities.effective"), selected.effectiveCapabilities || "—");
377 }
378 if (canEdit) {
379 const tools = document.createElement("div"); tools.className = "keychain-actions";
380 if (selected && !selected.revoked) tools.append(smallButton(t("keychain.revokeSubkey"), () => editKeychainKey(entry, { operation: "revokeSubkey", fingerprint: selected.fingerprint, confirmed: true }, "keychain.revokeSubkeyConfirm"), true));
381 tools.append(smallButton(keychainEditing === "addSubkey" ? t("vault.cancel") : t("keychain.addSubkey"), () => { keychainEditing = keychainEditing === "addSubkey" ? "" : "addSubkey"; renderKeychain(); }));
382 body.append(tools);
383 if (keychainEditing === "addSubkey") body.append(addSubkeyForm(entry));
384 }
385 }
386 // Grouped actions: frequent use, show, server; export lives in the toolbar menu; destructive actions apart.
387 const available = new Map(keychainActionList(entry));
388 for (const [groupKey, ids] of ACTION_GROUPS) {
389 const present = ids.filter(id => available.has(id)); if (!present.length) continue;
390 const group = document.createElement("div"); group.className = "action-group";
391 const label = document.createElement("span"); label.textContent = t(groupKey); group.append(label);
392 for (const id of present) { const b = smallButton(t(available.get(id)), () => runKeychainAction(entry, id)); b.dataset.keychainAction = id; group.append(b); }
393 actions.append(group);
394 }
395 const danger = document.createElement("div"); danger.className = "action-group danger-group";
396 for (const id of ["revoke", "remove"].filter(id => available.has(id))) { const b = smallButton(t(available.get(id)), () => runKeychainAction(entry, id), true); b.dataset.keychainAction = id; danger.append(b); }
397 if (danger.children.length) actions.append(danger);
399// Collapsible KeySync bar: one summary line, details on demand.
400function renderKeySync() {
401 const bar = $("#keySyncBar"); if (!bar) return;
402 const sync = keychainSync; const counts = sync?.counts || {};
403 const parts = ["pending", "uploading", "verifying", "retry", "failed", "success"].filter(key => counts[key]).map(key => `${counts[key]} ${t(`keysync.state.${key}`)}`);
404 $("#keySyncSummary").textContent = `KeySync · ${parts.length ? parts.join(" · ") : t("keysync.idle")}`;
405 $("#keySyncLast").textContent = sync?.lastRunAt ? `${t("keysync.lastRun")} ${new Date(sync.lastRunAt * 1000).toLocaleTimeString()}` : "";
406 $("#keySyncInterval").textContent = t("keysync.interval", { seconds: sync?.intervalSecs || 90 });
407 const host = $("#keySyncJobs"); host.replaceChildren();
408 const label = fingerprint => { const entry = keychainEntries.find(e => e.fingerprint === fingerprint); const uid = splitUserId(entry?.userIds?.[0] || entry?.label || ""); return `${uid.name || entry?.label || ""} · ${String(fingerprint).slice(0, 4)}…${String(fingerprint).slice(-4)}`; };
409 for (const job of [...(sync?.jobs || [])].reverse()) {
410 const row = document.createElement("div"); row.className = "keysync-job";
411 const text = document.createElement("span"); text.textContent = `${t(`keysync.kind.${job.kind}`)} · ${label(job.fingerprint)}${job.lastError ? " · " + job.lastError : ""}`;
412 const badge = document.createElement("span"); badge.className = `server-badge ${job.state === "success" ? "ok" : job.state === "failed" ? "revoked" : "pending"}`; badge.textContent = t(`keysync.state.${job.state}`);
413 row.append(text, badge);
414 if (job.state === "failed") row.append(smallButton(t("keysync.retry"), () => void keychainAction("syncRetry", { jobId: job.id })));
415 if (["failed", "success"].includes(job.state)) row.append(smallButton(t("keysync.dismiss"), () => void keychainAction("syncDismiss", { jobId: job.id })));
416 host.append(row);
417 }
418 if (!host.children.length) { const empty = document.createElement("p"); empty.className = "hint"; empty.textContent = t("keysync.empty"); host.append(empty); }
420function renderExportMenu(entry) {
421 const list = $("#keychainExportList"); list.replaceChildren();
422 const available = entry ? new Map(keychainActionList(entry)) : new Map();
423 for (const id of ["exportPublic", "exportPrivate", "exportRevocation"].filter(id => available.has(id))) {
424 const b = document.createElement("button"); b.type = "button"; b.textContent = t(available.get(id)); b.dataset.keychainAction = id;
425 b.addEventListener("click", () => { $("#keychainExportMenu").open = false; runKeychainAction(entry, id); }); list.append(b);
426 }
427 $("#keychainExportMenu").classList.toggle("disabled", !list.children.length);
429function addSubkeyForm(entry) {
430 const form = document.createElement("div"); form.className = "inline-form";
431 const role = document.createElement("select");
432 for (const [value, key, cap] of [["encrypt", "keychain.role.encrypt", "E"], ["sign", "keychain.role.sign", "S"], ["authenticate", "keychain.role.authenticate", "A"]]) { const o = document.createElement("option"); o.value = value; o.dataset.cap = cap; o.textContent = t(key); role.append(o); }
433 const algorithm = document.createElement("select");
434 const expiry = document.createElement("input"); expiry.type = "datetime-local";
435 const five = new Date(); five.setFullYear(five.getFullYear() + 5); five.setMinutes(five.getMinutes() - five.getTimezoneOffset()); expiry.value = five.toISOString().slice(0, 16);
436 const fill = () => {
437 const cap = role.selectedOptions[0].dataset.cap; algorithm.replaceChildren();
438 for (const spec of (keychainAlgorithms || []).filter(spec => (spec.versions || []).includes(entry.version) && String(spec.capabilities || "").includes(cap) && !String(spec.label || "").includes("legacy"))) { const o = document.createElement("option"); o.value = spec.id; o.textContent = spec.label; algorithm.append(o); }
439 };
440 role.addEventListener("change", fill);
441 if (!keychainAlgorithms) void api.keychain("algorithms").then(list => { keychainAlgorithms = Array.isArray(list) ? list : []; fill(); }).catch(() => { keychainAlgorithms = []; fill(); }); else fill();
442 const labelled = (key, control) => { const l = document.createElement("label"); const s = document.createElement("span"); s.textContent = t(key); l.append(s, control); return l; };
443 form.append(labelled("keychain.role", role), labelled("keychain.algorithm", algorithm), labelled("keychain.expires", expiry), smallButton(t("keychain.addSubkey"), () => {
444 const seconds = expiry.value ? Math.floor(new Date(expiry.value).getTime() / 1000) : null;
445 editKeychainKey(entry, { operation: "addSubkey", subkey: { role: role.value, algorithm: algorithm.value, expiresAt: seconds } });
446 }));
447 return form;
449document.querySelectorAll("[data-keychain-tab]").forEach(button => button.addEventListener("click", () => showKeychainTab(button.dataset.keychainTab)));
450document.querySelectorAll("[data-keychain-detail]").forEach(button => button.addEventListener("click", () => { keychainDetailTab = button.dataset.keychainDetail; renderKeychain(); }));
451$("#keychainFilter").addEventListener("input", () => renderKeychain());
452$("#keySyncRun").addEventListener("click", () => void keychainAction("syncRun"));
453$("#keychainSecretOnly").addEventListener("change", () => renderKeychain());
454// Secret text (key material, stored passwords): the Core asks for biometrics or the vault password
455// every time; the text stays in the dialog only and is cleared on close, lock or after 60 seconds.
456let secretTimer = null, secretRun = null;
457function resetSecret() {
458 clearTimeout(secretTimer); secretTimer = null; secretRun = null;
459 $("#secretText").value = ""; $("#secretText").hidden = true; $("#secretCopy").hidden = true;
460 $("#secretVaultPassword").value = ""; $("#secretAuth").hidden = true; $("#secretMessage").textContent = "";
462function closeSecret() {
463 resetSecret();
464 if ($("#secretDialog").open) $("#secretDialog").close();
466async function showSecret(title, request, sensitive) {
467 const epoch = protectedEpoch; const dialog = $("#secretDialog");
468 resetSecret();
469 $("#secretTitle").textContent = title;
470 if (!dialog.open) dialog.showModal();
471 const run = async vaultPassword => {
472 $("#secretAuthConfirm").disabled = true;
473 try {
474 const result = await request(vaultPassword);
475 if (epoch !== protectedEpoch || !dialog.open || secretRun !== run) return;
476 $("#secretAuth").hidden = true; $("#secretVaultPassword").value = "";
477 if (typeof result.text === "string") { $("#secretText").value = result.text; $("#secretText").hidden = false; $("#secretCopy").hidden = false; }
478 $("#secretMessage").textContent = result.message || (sensitive ? t("secret.autoHide") : "");
479 if (sensitive) secretTimer = setTimeout(closeSecret, 60000);
480 } catch (error) {
481 if (epoch !== protectedEpoch || !dialog.open || secretRun !== run) return;
482 if (["REVEAL_PASSWORD_REQUIRED", "REVEAL_PASSWORD_INVALID"].includes(error?.code)) {
483 $("#secretAuth").hidden = false; $("#secretMessage").textContent = t(`secret.${error.code}`); $("#secretVaultPassword").value = ""; $("#secretVaultPassword").focus();
484 } else $("#secretMessage").textContent = errorText(error);
485 } finally { $("#secretAuthConfirm").disabled = false; }
486 };
487 secretRun = run;
488 await run("");
490$("#secretAuthConfirm").addEventListener("click", () => void secretRun?.($("#secretVaultPassword").value));
491$("#secretVaultPassword").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); void secretRun?.(event.target.value); } });
492$("#secretClose").addEventListener("click", closeSecret);
493$("#secretDialog").addEventListener("close", () => { if (!$("#secretDialog").open) resetSecret(); });
494$("#secretCopy").addEventListener("click", async () => {
495 const area = $("#secretText");
496 try { await navigator.clipboard.writeText(area.value); } catch { area.focus(); area.select(); document.execCommand("copy"); }
497 $("#secretMessage").textContent = t("secret.copied");
498});
500function resetPasswordForm() { $("#passwordForm").reset(); $("#passwordId").value = ""; $("#passwordValue").placeholder = ""; }
501async function loadPasswords() {
502 const epoch = protectedEpoch;
503 if (!state.vault?.unlocked) return;
504 try {
505 const items = await api.keychain("passwordList");
506 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
507 const rows = $("#passwordRows"); rows.replaceChildren();
508 if (!items.length) { const empty = document.createElement("p"); empty.className = "hint"; empty.textContent = t("passwords.empty"); rows.append(empty); }
509 for (const item of items) {
510 const row = document.createElement("div"); row.className = "password-row";
511 const name = document.createElement("strong"); name.textContent = item.label; row.append(name);
512 const details = document.createElement("div"); details.className = "keychain-details";
513 const info = document.createElement("p"); info.textContent = [item.username, item.url].filter(Boolean).join(" · ") || "—"; details.append(info);
514 if (item.note) { const note = document.createElement("p"); note.textContent = item.note; details.append(note); }
515 row.append(details);
516 const button = (key, handler) => { const b = document.createElement("button"); b.type = "button"; b.className = "button"; b.textContent = t(key); b.addEventListener("click", handler); row.append(b); };
517 button("passwords.show", () => void showSecret(item.label, async vaultPassword => ({ text: (await api.keychain("passwordReveal", { id: item.id, vaultPassword })).password }), true));
518 button("passwords.copy", () => void showSecret(item.label, async vaultPassword => { await api.keychain("passwordCopy", { id: item.id, vaultPassword }); return { message: t("passwords.copied") }; }, false));
519 button("passwords.edit", () => {
520 $("#passwordId").value = item.id; $("#passwordLabel").value = item.label || ""; $("#passwordUsername").value = item.username || "";
521 $("#passwordUrl").value = item.url || ""; $("#passwordNote").value = item.note || ""; $("#passwordValue").value = "";
522 $("#passwordValue").placeholder = t("passwords.keepPlaceholder"); $("#passwordMessage").textContent = ""; $("#passwordLabel").focus();
523 });
524 button("passwords.remove", async () => {
525 if (!window.confirm(t("passwords.removeConfirm"))) return;
526 try { await api.keychain("passwordRemove", { id: item.id }); if ($("#passwordId").value === item.id) resetPasswordForm(); await loadPasswords(); }
527 catch (error) { $("#passwordMessage").textContent = errorText(error); }
528 });
529 rows.append(row);
530 }
531 } catch (error) { if (epoch === protectedEpoch) $("#passwordMessage").textContent = errorText(error); }
533$("#passwordForm").addEventListener("submit", async event => {
534 event.preventDefault();
535 const epoch = protectedEpoch; $("#passwordMessage").className = "form-message"; $("#passwordMessage").textContent = "";
536 try {
537 await api.keychain("passwordSave", { id: $("#passwordId").value || undefined, label: $("#passwordLabel").value, username: $("#passwordUsername").value,
538 url: $("#passwordUrl").value, note: $("#passwordNote").value, password: $("#passwordValue").value });
539 if (epoch !== protectedEpoch) return;
540 resetPasswordForm(); $("#passwordMessage").className = "form-message success"; $("#passwordMessage").textContent = t("passwords.saved");
541 await loadPasswords();
542 } catch (error) { if (epoch === protectedEpoch) $("#passwordMessage").textContent = errorText(error); }
543});
544$("#passwordNew").addEventListener("click", () => { resetPasswordForm(); $("#passwordMessage").textContent = ""; $("#passwordLabel").focus(); });
545document.querySelectorAll("[data-generator-mode]").forEach(button => button.addEventListener("click", async () => {
546 try { await api.keychain("openGenerator", { kind: button.dataset.generatorMode }); }
547 catch (error) { $("#keychainMessage").textContent = errorText(error); }
548}));
549api?.onGeneratorUnlockRequested(() => { showView("overview"); requestVault("generator"); });
550api?.onKeychainChanged(() => { if (state.vault?.unlocked) void loadKeychain(); });
551let protectedDestination = null;
552function requestVault(destination = null) {
553 protectedDestination = destination;
554 configureVault(state.vault);
555 elements.vaultOverlay.hidden = false;
556 setTimeout(() => elements.vaultPassword.focus(), 20);
557 void maybeAutoUnlock();
559function showView(name) {
560 if (["profiles", "keychain"].includes(name) && !state.vault?.unlocked) { requestVault(name); return; }
561 if (name === "keychain") { showKeychainTab(keychainTab); void loadKeychain(); }
562 document.querySelectorAll("[data-view]").forEach((button) => button.classList.toggle("active", button.dataset.view === name));
563 document.querySelectorAll("[data-view-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.viewPanel === name));
564 elements.viewTitle.textContent = titles[name] ? t(titles[name]) : "CH-J Tools";
565 const terminal = name === "terminal";
566 $("main").classList.toggle("module-active", name === "module");
567 reportModuleArea();
568 renderOpenModules();
569 $("main").classList.toggle("terminal-active", terminal); $("#terminalStrip").hidden = !terminal;
570 if (terminal) requestAnimationFrame(() => { fitTerminal(); activePane.terminal?.focus(); });
573function pluginDisplay(id) {
574 return state.config?.plugins.display?.[id] || (id === HASH_TOOL_ID ? "navigation" : "visible");
577let preferenceSave = Promise.resolve();
578function saveUiPreferences(patch) {
579 preferenceSave = preferenceSave.then(async () => {
580 state.config = await api.updateConfig(patch());
581 renderPlugins();
582 }).catch((error) => { window.alert(errorText(error)); renderPlugins(); });
583 return preferenceSave;
586function moveNavigation(key, targetKey, after = false) {
587 if (!$("#navigationEditToggle").checked) return;
588 const keys = [...$("#mainNavigation").children].map((row) => row.dataset.navKey);
589 const from = keys.indexOf(key), to = keys.indexOf(targetKey);
590 if (from < 0 || to < 0 || from === to) return;
591 keys.splice(from, 1); keys.splice(keys.indexOf(targetKey) + Number(after), 0, key);
592 saveUiPreferences(() => ({ ui: { navigationOrder: [...keys, ...state.config.ui.navigationOrder.filter((id) => !keys.includes(id))] } }))
593 .then(() => [...$("#mainNavigation").children].find((row) => row.dataset.navKey === key)?.querySelector(".nav-button").focus());
596// Replaces the leading glyph of a navigation button with the shared SVG icon for its stable key.
597function setNavIcon(button, key) {
598 const current = button.querySelector(".nav-icon");
599 if (current?.dataset.key === key) return;
600 const icon = window.chjNavIcons.createIcon(key);
601 icon.dataset.key = key;
602 const first = button.firstElementChild;
603 if (current) current.replaceWith(icon);
604 else if (first && !first.classList.contains("nav-label")) first.replaceWith(icon);
605 else button.prepend(icon);
608function renderNavigation() {
609 const navigation = $("#mainNavigation");
610 const buttons = new Map(coreNavigationButtons.map((button) => [button.dataset.view, button]));
611 if (pluginDisplay(HASH_TOOL_ID) === "navigation") buttons.set(`plugin:${HASH_TOOL_ID}`, hashToolButton);
612 for (const plugin of state.plugins) {
613 if (plugin.id === HASH_TOOL_ID || pluginDisplay(plugin.id) !== "navigation") continue;
614 const button = document.createElement("button"); button.type = "button"; button.className = "nav-button";
615 const label = document.createElement("span"); label.className = "nav-label"; label.textContent = plugin.name;
616 button.append(label);
617 button.addEventListener("click", async () => { try { await api.openPlugin(plugin.id); } catch (error) { window.alert(errorText(error)); } });
618 buttons.set(`plugin:${plugin.id}`, button);
619 }
620 hashToolButton.querySelector(".nav-label").textContent = t("nav.hash");
621 const defaults = [...coreNavigationButtons.map((button) => button.dataset.view)];
622 defaults.splice(defaults.indexOf("settings"), 0, `plugin:${HASH_TOOL_ID}`);
623 const order = [...new Set([...(state.config?.ui.navigationOrder || []), ...defaults, ...buttons.keys()])].filter((key) => buttons.has(key));
624 for (const [key, button] of buttons) setNavIcon(button, key);
625 navigation.replaceChildren();
626 const editing = $("#navigationEditToggle").checked;
627 navigation.classList.toggle("navigation-editing", editing);
628 order.forEach((key) => {
629 const row = document.createElement("div"); row.className = "navigation-row"; row.dataset.navKey = key;
630 const button = buttons.get(key);
631 button.title = editing ? t("nav.drag") : "";
632 row.draggable = editing;
633 const handle = document.createElement("span"); handle.className = "navigation-drag"; handle.textContent = "⠿"; handle.setAttribute("aria-hidden", "true");
634 row.addEventListener("dragstart", (event) => {
635 if (!$("#navigationEditToggle").checked) { event.preventDefault(); return; }
636 event.dataTransfer.setData("text/chj-navigation", key); event.dataTransfer.effectAllowed = "move";
637 row.classList.add("navigation-dragging");
638 });
639 row.addEventListener("dragend", () => navigation.querySelectorAll(".navigation-row").forEach((item) => item.classList.remove("navigation-dragging", "navigation-drop-before", "navigation-drop-after")));
640 row.addEventListener("dragover", (event) => {
641 if (!$("#navigationEditToggle").checked || ![...event.dataTransfer.types].includes("text/chj-navigation")) return;
642 event.preventDefault(); event.dataTransfer.dropEffect = "move";
643 const after = event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2;
644 row.classList.toggle("navigation-drop-before", !after); row.classList.toggle("navigation-drop-after", after);
645 });
646 row.addEventListener("dragleave", () => row.classList.remove("navigation-drop-before", "navigation-drop-after"));
647 row.addEventListener("drop", (event) => {
648 if (!$("#navigationEditToggle").checked) return;
649 event.preventDefault(); row.classList.remove("navigation-drop-before", "navigation-drop-after");
650 moveNavigation(event.dataTransfer.getData("text/chj-navigation"), key, event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2);
651 });
652 row.append(button, handle); navigation.append(row);
653 });
654 renderOpenModules();
657$("#navigationEditToggle").addEventListener("change", renderNavigation);
658$("#mainNavigation").addEventListener("click", (event) => {
659 if ($("#navigationEditToggle").checked) { event.preventDefault(); event.stopPropagation(); }
660}, true);
662// ---------- sidebar layout: modes, width, hide/show, tooltips (persisted in config.ui.sidebar by Main) ----------
663const SIDEBAR_LIMITS = Object.freeze({ min: 220, max: 420, default: 248 });
664const appShell = $(".app-shell");
665const sidebarTooltip = $("#sidebarTooltip");
666const sidebarShortcut = navigator.platform.startsWith("Mac") ? "⌘⇧B" : "Ctrl+Shift+B";
667let sidebarSave = Promise.resolve();
668const clampSidebarWidth = (value) => Math.round(Math.min(SIDEBAR_LIMITS.max, Math.max(SIDEBAR_LIMITS.min, value)));
669function sidebarState() { return { mode: "full", hidden: false, width: SIDEBAR_LIMITS.default, ...(state.config?.ui.sidebar || {}) }; }
670function applySidebar() {
671 const sidebar = sidebarState();
672 if (appShell.dataset.sidebarMode && (appShell.dataset.sidebarMode !== sidebar.mode || appShell.classList.contains("sidebar-hidden") !== sidebar.hidden)) {
673 appShell.classList.remove("sidebar-switching"); void appShell.offsetWidth; appShell.classList.add("sidebar-switching");
674 }
675 appShell.dataset.sidebarMode = sidebar.mode;
676 appShell.classList.toggle("sidebar-hidden", sidebar.hidden);
677 appShell.style.setProperty("--sidebar-full-width", `${sidebar.width}px`);
678 document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.setAttribute("aria-checked", String(button.dataset.sidebarMode === sidebar.mode)));
679 const toggle = $("#sidebarToggle");
680 const label = `${t(sidebar.hidden ? "nav.showSidebar" : "nav.hideSidebar")} (${sidebarShortcut})`;
681 toggle.setAttribute("aria-expanded", String(!sidebar.hidden)); toggle.setAttribute("aria-label", label); toggle.title = label;
682 $("#sidebarResizer").setAttribute("aria-valuenow", String(sidebar.width));
683 // Reordering needs the labels; it is available in the full mode only.
684 if (sidebar.mode !== "full" && $("#navigationEditToggle").checked) { $("#navigationEditToggle").checked = false; renderNavigation(); }
685 hideSidebarTooltip();
687// The view changes immediately; Main stores the normalized value (width clamped to 220-420 px).
688function saveSidebar(patch) {
689 state.config = { ...state.config, ui: { ...state.config.ui, sidebar: { ...sidebarState(), ...patch } } };
690 applySidebar();
691 sidebarSave = sidebarSave.then(async () => { state.config = await api.updateConfig({ ui: { sidebar: patch } }); applySidebar(); })
692 .catch((error) => window.alert(errorText(error)));
693 return sidebarSave;
695function toggleSidebar() {
696 const hidden = !sidebarState().hidden;
697 saveSidebar({ hidden });
698 if (!hidden) requestAnimationFrame(() => document.querySelector("#mainNavigation .nav-button.active, #mainNavigation .nav-button")?.focus());
700function showSidebarTooltip(target) {
701 const sidebar = sidebarState();
702 const navButton = target.classList.contains("nav-button");
703 if (sidebar.hidden || (navButton && sidebar.mode === "full")) return;
704 const text = (navButton ? target.querySelector(".nav-label")?.textContent : target.getAttribute("aria-label"))?.trim();
705 if (!text) return;
706 sidebarTooltip.textContent = text;
707 sidebarTooltip.hidden = false;
708 const box = target.getBoundingClientRect();
709 const top = Math.min(window.innerHeight - 18, Math.max(18, box.top + box.height / 2));
710 sidebarTooltip.style.left = `${Math.round(Math.max(box.right, $("#sidebar").getBoundingClientRect().right) + 8)}px`;
711 sidebarTooltip.style.top = `${Math.round(top)}px`;
713function hideSidebarTooltip() { clearTimeout(tooltipTimer); sidebarTooltip.hidden = true; }
714const tooltipTarget = (event) => event.target.closest?.("#mainNavigation .nav-button, .sidebar-mode");
715// Like native tooltips: after a click the tooltip stays hidden until the pointer leaves that item.
716let tooltipSuppressed = null;
717// Hover shows the tooltip after a short delay (300 ms); keyboard focus shows it immediately.
718let tooltipTimer = null;
719$("#sidebar").addEventListener("mouseover", (event) => {
720 const target = tooltipTarget(event);
721 if (!target || target === tooltipSuppressed || target.contains(event.relatedTarget)) return;
722 clearTimeout(tooltipTimer);
723 tooltipTimer = setTimeout(() => { if (target.matches(":hover")) showSidebarTooltip(target); }, 300);
724});
725$("#sidebar").addEventListener("mouseout", (event) => { const target = tooltipTarget(event); if (target && !target.contains(event.relatedTarget)) { clearTimeout(tooltipTimer); hideSidebarTooltip(); if (target === tooltipSuppressed) tooltipSuppressed = null; } });
726// Keyboard focus shows the tooltip at once. The last input method is tracked explicitly instead of relying only on
727// :focus-visible, whose heuristic differs between platforms and window states.
728let keyboardInput = false;
729document.addEventListener("keydown", () => { keyboardInput = true; }, true);
730document.addEventListener("pointerdown", () => { keyboardInput = false; }, true);
731$("#sidebar").addEventListener("focusin", (event) => { const target = tooltipTarget(event); if (target && (keyboardInput || target.matches(":focus-visible"))) showSidebarTooltip(target); });
732$("#sidebar").addEventListener("focusout", hideSidebarTooltip);
733$("#sidebar").addEventListener("click", (event) => { tooltipSuppressed = tooltipTarget(event) || null; hideSidebarTooltip(); });
734$("#mainNavigation").addEventListener("scroll", hideSidebarTooltip);
735window.addEventListener("resize", hideSidebarTooltip);
736document.querySelectorAll(".sidebar-mode[data-sidebar-mode]").forEach((button) => button.addEventListener("click", () => saveSidebar({ mode: button.dataset.sidebarMode, hidden: false })));
737$("#sidebarToggle").addEventListener("click", toggleSidebar);
738document.addEventListener("keydown", (event) => {
739 if (event.key === "Escape") hideSidebarTooltip();
740 // Captured before the terminal so the shortcut works everywhere; plain Ctrl+B still reaches the SSH shell.
741 if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.code === "KeyB") { event.preventDefault(); event.stopPropagation(); toggleSidebar(); }
742}, true);
744 const resizer = $("#sidebarResizer");
745 resizer.addEventListener("pointerdown", (event) => {
746 if (event.button !== 0 || sidebarState().mode !== "full") return;
747 event.preventDefault();
748 resizer.setPointerCapture(event.pointerId);
749 const left = $("#sidebar").getBoundingClientRect().left;
750 let width = sidebarState().width;
751 appShell.classList.add("sidebar-resizing");
752 const move = (moveEvent) => {
753 width = clampSidebarWidth(moveEvent.clientX - left);
754 appShell.style.setProperty("--sidebar-full-width", `${width}px`);
755 resizer.setAttribute("aria-valuenow", String(width));
756 };
757 // Window-level listeners keep the drag alive even if pointer capture is lost; any end event finishes it once.
758 let done = false;
759 const end = () => {
760 if (done) return; done = true;
761 window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", end);
762 resizer.removeEventListener("lostpointercapture", end);
763 appShell.classList.remove("sidebar-resizing");
764 if (width !== sidebarState().width) saveSidebar({ width });
765 };
766 window.addEventListener("pointermove", move); window.addEventListener("pointerup", end); window.addEventListener("pointercancel", end);
767 resizer.addEventListener("lostpointercapture", end);
768 });
769 resizer.addEventListener("dblclick", () => saveSidebar({ width: SIDEBAR_LIMITS.default }));
770 resizer.addEventListener("keydown", (event) => {
771 const width = sidebarState().width; const step = event.shiftKey ? 48 : 16;
772 const next = { ArrowLeft: width - step, ArrowRight: width + step, Home: SIDEBAR_LIMITS.min, End: SIDEBAR_LIMITS.max }[event.key];
773 if (next === undefined) return;
774 event.preventDefault();
775 if (clampSidebarWidth(next) !== width) saveSidebar({ width: clampSidebarWidth(next) });
776 });
779function cancelProfileImport() {
780 importPreview = null;
781 $("#profileImportDialog").close();
782 $("#profileImportRows").replaceChildren();
783 void api.cancelProfileImport().catch(() => {});
786$("#importProfilesButton").addEventListener("click", async (event) => {
787 const epoch = protectedEpoch;
788 const button = event.currentTarget; button.disabled = true;
789 $("#profileTransferMessage").textContent = "";
790 try {
791 const preview = await api.previewProfileImport();
792 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
793 if (preview.canceled) return;
794 importPreview = preview;
795 $("#profileImportRows").replaceChildren();
796 for (const server of preview.servers) {
797 const row = document.createElement("tr");
798 for (const value of [server.name, server.host, server.username, server.port, server.duplicate ? t("transfer.yes") : "—"]) {
799 const cell = document.createElement("td"); cell.textContent = value; row.append(cell);
800 }
801 $("#profileImportRows").append(row);
802 }
803 $("#profileImportSummary").textContent = t("transfer.summary", { count: preview.servers.length, duplicates: preview.servers.filter((server) => server.duplicate).length });
804 $("#profileDuplicatePolicy").value = "skip";
805 $("#profileImportMessage").textContent = "";
806 $("#profileImportDialog").showModal();
807 } catch (error) { $("#profileTransferMessage").textContent = errorText(error); }
808 finally { button.disabled = false; }
809});
810$("#cancelProfileImport").addEventListener("click", cancelProfileImport);
811$("#profileImportDialog").addEventListener("cancel", (event) => { event.preventDefault(); cancelProfileImport(); });
812$("#confirmProfileImport").addEventListener("click", async (event) => {
813 if (!importPreview) return;
814 const button = event.currentTarget; button.disabled = true;
815 try {
816 const result = await api.commitProfileImport(importPreview.token, $("#profileDuplicatePolicy").value);
817 cancelProfileImport();
818 await loadProfiles();
819 $("#profileTransferMessage").textContent = t("transfer.imported", result);
820 } catch (error) { $("#profileImportMessage").textContent = errorText(error); }
821 finally { button.disabled = false; }
822});
823$("#exportProfilesButton").addEventListener("click", async (event) => {
824 const button = event.currentTarget; button.disabled = true;
825 try {
826 const result = await api.exportProfiles($("#profileExportFormat").value, $("#exportSelectedProfiles").checked ? [...selectedExportIds] : undefined);
827 if (!result.canceled) $("#profileTransferMessage").textContent = t("transfer.exported", result);
828 } catch (error) { $("#profileTransferMessage").textContent = errorText(error); }
829 finally { button.disabled = false; }
830});
832function setBusy(button, busy, label) {
833 button.disabled = busy;
834 if (label) button.textContent = label;
837function formatBytes(bytes) {
838 const units = ["B", "KB", "MB", "GB"];
839 let value = Number(bytes || 0);
840 let index = 0;
841 while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; }
842 return `${i18n.formatNumber(value, { maximumFractionDigits: 1 })} ${units[index]}`;
845// The main window starts with the catalogs of its language only; another language is fetched from the Core first.
846async function ensureLanguage(language) {
847 const lib = window.CHJ_I18N;
848 if (!lib?.hasLanguage || lib.hasLanguage(language) || !api?.getLanguageCatalogs) return;
849 try {
850 const bundle = await api.getLanguageCatalogs(lib.normalizeLanguage(language));
851 lib.addCatalogs(bundle.core);
852 window.CHJ_DIAGNOSTICS_I18N?.add?.(bundle.diagnostics);
853 const chrome = globalThis.__CHJ_I18N__?.catalogs?.chrome;
854 if (chrome && bundle.chrome) for (const [code, catalog] of Object.entries(bundle.chrome)) if (!chrome[code]) chrome[code] = catalog;
855 } catch { /* the lookup chain falls back to English and Czech */ }
858function applyLanguage(language) {
859 const selected = i18n.setLanguage(language);
860 document.documentElement.lang = selected;
861 document.documentElement.dir = i18n.getDirection();
862 i18n.apply(document);
863 elements.languageSelect.value = selected;
864 elements.vaultLanguageSelect.value = selected;
865 const activeView = document.querySelector("[data-view].active")?.dataset.view || "overview";
866 showView(activeView);
867 if (state.update) renderUpdate(state.update);
868 if (state.config) elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
869 renderPlugins();
870 renderProfiles();
871 if (state.config) applySidebar();
872 diagnosticsView?.refreshLanguage();
873 renderTerminalProfileSelect();
874 if (state.vault) {
875 const unlockOpen = !elements.vaultOverlay.hidden;
876 configureVault(state.vault, false);
877 if (unlockOpen && !state.vault.unlocked) elements.vaultOverlay.hidden = false;
878 }
879 renderBiometrics();
880 const terminalStateKeys = { disconnected: "terminal.disconnected", connecting: "terminal.connecting", connected: "terminal.connected", error: "terminal.connectionError" };
881 for (const pane of panes) setTerminalState(pane, pane.kind, t(terminalStateKeys[pane.kind] || "terminal.disconnected"));
882 renderTerminalStrip();
883 renderMonitoringSettings();
884 renderAvailability();
885 return selected;
888function renderUpdate(updateState) {
889 state.update = updateState;
890 queueMicrotask(() => offerUpdate());
891 elements.currentVersion.textContent = state.info?.version ? `v${state.info.version}` : "—";
892 const release = updateState.release;
893 const currentReleaseSelected = updateState.releaseRelation === "sameVersionCurrentRelease";
894 elements.downloadUpdateButton.hidden = currentReleaseSelected;
895 elements.downloadUpdateButton.disabled = !release || currentReleaseSelected;
896 renderReleaseCatalog(updateState);
897 if (updateState.downloaded) {
898 elements.updateHeadline.textContent = t("updates.downloaded", { version: release?.version || "?" });
899 elements.updateMessage.textContent = `${updateState.downloaded.filename} · ${formatBytes(updateState.downloaded.size)} · ${t("updates.signatureVerified")}`;
900 elements.updateHash.hidden = false;
901 elements.updateHash.textContent = `SHA-512 ${updateState.downloaded.sha512}`;
902 elements.revealUpdateButton.hidden = false;
903 elements.installUpdateButton.hidden = false;
904 } else if (release) {
905 const relationKey = {
906 newerVersion: "updates.newerVersion",
907 sameVersionNewerRelease: "updates.sameVersionNewerRelease",
908 sameVersionCurrentRelease: "updates.sameVersionCurrentRelease",
909 sameVersionOlderRelease: "updates.sameVersionOlderRelease",
910 olderVersion: "updates.olderVersion"
911 }[updateState.releaseRelation] || "updates.selected";
912 elements.updateHeadline.textContent = t(relationKey, { version: release.version });
913 const published = t("updates.publishedAt", { date: formatReleaseDate(release.publishedAt) });
914 elements.updateMessage.textContent = release.notes ? `${published} · ${release.notes}` : published;
915 elements.updateHash.hidden = false;
916 elements.updateHash.textContent = `SHA-512 ${release.sha512}`;
917 elements.revealUpdateButton.hidden = true;
918 elements.installUpdateButton.hidden = true;
919 } else {
920 elements.updateHeadline.textContent = t("updates.noneAvailable");
921 elements.updateMessage.textContent = t("updates.noCompatible");
922 elements.updateHash.hidden = true;
923 elements.revealUpdateButton.hidden = true;
924 elements.installUpdateButton.hidden = true;
925 }
928function formatReleaseDate(value) {
929 const date = new Date(value);
930 if (Number.isNaN(date.getTime())) return String(value || "—");
931 return new Intl.DateTimeFormat(i18n.getLocale(), { dateStyle: "medium", timeStyle: "medium" }).format(date);
934function renderReleaseCatalog(updateState) {
935 const releases = Array.isArray(updateState.releases) ? updateState.releases : [];
936 elements.updateReleaseCount.textContent = String(releases.length);
937 elements.updateReleaseRows.replaceChildren();
938 if (!releases.length) {
939 const empty = document.createElement("div");
940 empty.className = "update-release-empty";
941 empty.textContent = t(updateState.checked ? "updates.catalogEmpty" : "updates.catalogBeforeCheck");
942 elements.updateReleaseRows.append(empty);
943 return;
944 }
945 for (const release of releases) {
946 const row = document.createElement("button");
947 row.type = "button";
948 row.className = `update-release-row${release.id === updateState.selectedReleaseId ? " selected" : ""}`;
949 const version = document.createElement("strong"); version.textContent = `v${release.version}`;
950 const published = document.createElement("span"); published.textContent = formatReleaseDate(release.publishedAt);
951 const file = document.createElement("span"); file.textContent = `${release.filename} · ${formatBytes(release.size)}`;
952 const action = document.createElement("span"); action.className = "release-select-label"; action.textContent = t(release.id === updateState.selectedReleaseId ? "updates.selectedLabel" : "updates.selectLabel");
953 row.append(version, published, file, action);
954 row.addEventListener("click", async () => {
955 try { renderUpdate(await api.selectUpdateRelease(release.id)); }
956 catch (error) { elements.updateMessage.textContent = errorText(error); }
957 });
958 elements.updateReleaseRows.append(row);
959 }
962function renderPlugins() {
963 const plugins = state.plugins;
964 renderNavigation();
965 elements.pluginCountValue.textContent = String(plugins.length);
966 elements.pluginRows.replaceChildren();
967 if (plugins.length === 0) {
968 const empty = document.createElement("div");
969 empty.className = "empty-state";
970 const strong = document.createElement("strong"); strong.textContent = t("plugins.none");
971 const span = document.createElement("span"); span.textContent = t("plugins.first");
972 empty.append(strong, span); elements.pluginRows.append(empty); renderPluginCatalog(); renderOpenModules(); return;
973 }
974 for (const plugin of plugins) {
975 const row = document.createElement("div"); row.className = "plugin-row installed-plugin-row"; row.dataset.pluginId = plugin.id;
976 const name = document.createElement("div"); name.className = "installed-plugin-name";
977 const title = document.createElement("strong"); title.textContent = plugin.name; name.append(title);
978 const details = document.createElement("details"); details.className = "installed-plugin-details";
979 const summary = document.createElement("summary"); summary.textContent = t("plugins.details");
980 const description = document.createElement("p"); // First-party modules have translated descriptions; any other module shows its manifest text.
981 const descriptionKey = `module.description.${plugin.id}`, translatedDescription = t(descriptionKey);
982 description.textContent = translatedDescription === descriptionKey ? plugin.description : translatedDescription;
983 const permissions = document.createElement("p"); permissions.textContent = `${t("plugins.permissions")}: ${plugin.permissions.join(", ") || t("plugins.noPermissions")}`;
984 details.append(summary, description, permissions); name.append(details);
985 const version = document.createElement("span"); version.textContent = plugin.version;
986 const actions = document.createElement("div"); actions.className = "plugin-actions";
987 const open = document.createElement("button"); open.type = "button"; open.className = "button"; open.textContent = t("plugins.open");
988 open.addEventListener("click", async () => { try { await api.openPlugin(plugin.id); } catch (error) { window.alert(errorText(error)); } });
989 const mode = pluginDisplay(plugin.id);
990 row.classList.toggle("plugin-hidden", mode === "hidden");
991 open.disabled = mode === "hidden";
992 const visible = document.createElement("input"); visible.type = "checkbox"; visible.className = "plugin-visibility"; visible.checked = mode !== "hidden";
993 visible.setAttribute("aria-label", `${plugin.name}: ${t("plugins.show")}`);
994 const pinned = document.createElement("input"); pinned.type = "checkbox"; pinned.className = "plugin-pin"; pinned.checked = mode === "navigation"; pinned.disabled = mode === "hidden";
995 pinned.setAttribute("aria-label", `${plugin.name}: ${t("plugins.inMenu")}`);
996 const saveDisplay = (value) => {
997 visible.disabled = true; pinned.disabled = true;
998 saveUiPreferences(() => ({ plugins: { display: { ...state.config.plugins.display, [plugin.id]: value } } }));
999 };
1000 visible.addEventListener("change", () => saveDisplay(visible.checked ? "visible" : "hidden"));
1001 pinned.addEventListener("change", () => saveDisplay(pinned.checked ? "navigation" : "visible"));
1002 actions.append(open);
1003 if (plugin.bundled) {
1004 version.title = t("plugins.bundled");
1005 } else {
1006 const remove = document.createElement("button"); remove.type = "button"; remove.className = "button danger"; remove.textContent = t("plugins.remove");
1007 remove.addEventListener("click", async () => {
1008 if (!window.confirm(t("plugins.removeConfirm", { name: plugin.name }))) return;
1009 remove.disabled = true;
1010 try {
1011 state.pluginState = await api.uninstallPlugin(plugin.id);
1012 state.plugins = state.pluginState.installed;
1013 renderPlugins();
1014 } catch (error) { window.alert(errorText(error)); remove.disabled = false; }
1015 });
1016 actions.append(remove);
1018 row.append(name, version, visible, pinned, actions); elements.pluginRows.append(row);
1020 renderPluginCatalog();
1021 renderOpenModules();
1024function renderModuleBar(open, moduleView) {
1025 const tabs = $("#moduleTabs");
1026 tabs.replaceChildren();
1027 for (const plugin of open) {
1028 const tab = document.createElement("button");
1029 tab.type = "button"; tab.className = "module-tab"; tab.setAttribute("role", "tab");
1030 const selected = Boolean(plugin.active && moduleView);
1031 tab.classList.toggle("active", selected); tab.setAttribute("aria-selected", String(selected));
1032 tab.title = plugin.detached ? `${plugin.name} · ${t("plugins.inWindow")}` : plugin.name;
1033 tab.append(window.chjNavIcons.createIcon(`plugin:${plugin.pluginId}`, "nav-icon module-tab-icon"));
1034 const name = document.createElement("span"); name.textContent = plugin.name; tab.append(name);
1035 if (plugin.detached) { const mark = document.createElement("small"); mark.textContent = "↗"; tab.append(mark); }
1036 tab.addEventListener("click", () => { void api.openPlugin(plugin.pluginId).catch((error) => window.alert(errorText(error))); });
1037 tabs.append(tab);
1041function renderOpenModules(windows = state.pluginWindows) {
1042 state.pluginWindows = Array.isArray(windows) ? windows : [];
1043 const open = state.pluginWindows;
1044 const moduleView = document.querySelector('[data-view-panel="module"]')?.classList.contains("active");
1045 renderModuleBar(open, moduleView);
1046 const pinned = (id) => document.querySelector(`#mainNavigation [data-nav-key="plugin:${id}"]`);
1047 document.querySelectorAll("#mainNavigation [data-nav-key^='plugin:']").forEach((row) => {
1048 const plugin = open.find((item) => `plugin:${item.pluginId}` === row.dataset.navKey);
1049 row.classList.toggle("module-open", Boolean(plugin));
1050 row.querySelector(".nav-button")?.classList.toggle("active", Boolean(plugin?.active && moduleView));
1051 });
1052 const unpinned = open.filter((plugin) => !pinned(plugin.pluginId));
1053 $("#openModulesNav").hidden = unpinned.length === 0;
1054 const items = $("#openModulesItems");
1055 items.replaceChildren();
1056 for (const plugin of unpinned) {
1057 const button = document.createElement("button");
1058 button.type = "button";
1059 button.className = "nav-button";
1060 button.classList.toggle("active", Boolean(plugin.active && moduleView));
1061 button.title = plugin.detached ? `${plugin.name} · ${t("plugins.inWindow")}` : plugin.name;
1062 button.append(window.chjNavIcons.createIcon(`plugin:${plugin.pluginId}`, "nav-icon"));
1063 const name = document.createElement("span");
1064 name.className = "nav-label";
1065 name.textContent = plugin.name;
1066 button.append(name);
1067 button.addEventListener("click", () => { void api.openPlugin(plugin.pluginId).catch((error) => window.alert(errorText(error))); });
1068 items.append(button);
1072function renderPluginCatalog() {
1073 if (!elements.pluginCatalogRows) return;
1074 const catalog = (state.pluginState?.catalog || []).filter((plugin) => plugin.id !== HASH_TOOL_ID);
1075 elements.pluginCatalogSource.textContent = state.pluginState?.sourceBaseUrl || "";
1076 elements.pluginCatalogRows.replaceChildren();
1077 if (!catalog.length) {
1078 const empty = document.createElement("div"); empty.className = "plugin-catalog-empty";
1079 empty.textContent = t(state.pluginState?.sourceBaseUrl ? "plugins.catalogEmpty" : "plugins.catalogBeforeCheck");
1080 elements.pluginCatalogRows.append(empty); return;
1082 for (const plugin of catalog) {
1083 const row = document.createElement("div"); row.className = "plugin-row plugin-table-row";
1084 const name = document.createElement("div");
1085 const strong = document.createElement("strong"); strong.textContent = plugin.name;
1086 const notes = document.createElement("small"); notes.textContent = plugin.notes || plugin.id;
1087 name.append(strong, notes);
1088 const version = document.createElement("span"); version.textContent = `v${plugin.version} · ${plugin.channel}`;
1089 const permissions = document.createElement("span"); permissions.textContent = plugin.permissions.join(", ") || t("plugins.noPermissions");
1090 const install = document.createElement("button"); install.type = "button"; install.className = `button${plugin.installed ? "" : " primary"}`;
1091 install.disabled = plugin.installed;
1092 install.textContent = t(plugin.installed ? "plugins.installed" : plugin.updateAvailable ? "plugins.update" : "plugins.install");
1093 install.addEventListener("click", async () => {
1094 if (!window.confirm(t("plugins.installConfirm", { name: plugin.name, version: plugin.version, permissions: plugin.permissions.join(", ") || t("plugins.noPermissions") }))) return;
1095 install.disabled = true; install.textContent = t("plugins.installing");
1096 try {
1097 state.pluginState = await api.installPlugin(plugin.releaseId);
1098 state.plugins = state.pluginState.installed;
1099 renderPlugins();
1100 } catch (error) { window.alert(errorText(error)); install.disabled = false; install.textContent = t("plugins.install"); }
1101 });
1102 row.append(name, version, permissions, install); elements.pluginCatalogRows.append(row);
1106function configureVault(status, invalidate = true) {
1107 if (invalidate) protectedEpoch++;
1108 state.vault = status;
1109 renderBiometrics();
1110 $("#securityMasterPassword").value = "";
1111 elements.vaultOverlay.hidden = true;
1112 for (const button of document.querySelectorAll('[data-view="profiles"], [data-view="keychain"]')) {
1113 button.classList.toggle("vault-locked", !status.unlocked);
1114 button.title = status.unlocked ? "" : t("vault.lockedMode");
1116 elements.lockVaultButton.disabled = !status.unlocked;
1117 elements.vaultMessage.className = "form-message";
1118 elements.vaultMessage.textContent = "";
1119 elements.vaultPassword.value = "";
1120 elements.vaultPasswordConfirm.value = "";
1121 elements.vaultResetConfirmation.value = "";
1122 elements.vaultResetPanel.hidden = true;
1123 if (status.unlocked) {
1124 const destination = protectedDestination; protectedDestination = null;
1125 if (destination === "generator") { void api.openPlugin("chj.key-generator").catch(error => { elements.vaultMessage.textContent = errorText(error); }); }
1126 else if (destination) showView(destination);
1127 return;
1129 if (["profiles", "keychain"].some(name => document.querySelector(`[data-view-panel="${name}"]`)?.classList.contains("active"))) showView("overview");
1130 if (status.damaged) {
1131 elements.vaultForm.dataset.mode = "damaged";
1132 elements.vaultModeText.textContent = t("vault.damagedMode");
1133 elements.vaultTitle.textContent = t("vault.damagedTitle");
1134 elements.vaultDescription.textContent = t("vault.damagedDescription");
1135 elements.vaultSubmit.disabled = true;
1136 elements.vaultConfirmLabel.hidden = true;
1137 elements.showVaultResetButton.hidden = false;
1138 return;
1140 const setup = status.needsSetup;
1141 elements.vaultForm.dataset.mode = setup ? "setup" : "unlock";
1142 elements.vaultModeText.textContent = t(setup ? "vault.setupMode" : "vault.lockedMode");
1143 elements.vaultSubmit.disabled = false;
1144 elements.vaultConfirmLabel.hidden = !setup;
1145 elements.showVaultResetButton.hidden = setup;
1146 elements.vaultPassword.autocomplete = setup ? "new-password" : "current-password";
1147 elements.vaultTitle.textContent = t(setup ? "vault.setupTitle" : "vault.unlockTitle");
1148 elements.vaultDescription.textContent = t(setup ? "vault.setupDescription" : "vault.lockedDescription");
1149 elements.vaultSubmit.textContent = t(setup ? "vault.create" : "vault.unlock");
1150 setTimeout(() => elements.vaultPassword.focus(), 20);
1153function clearProtectedUi() {
1154 protectedEpoch++;
1155 availabilityState = new Map();
1156 $("#keychainRows").replaceChildren();
1157 $("#keychainForm").reset();
1158 keychainEntries = []; keychainSelected = ""; $("#keychainDetail").hidden = true; $("#keychainCount").textContent = "";
1159 if ($("#keychainDetailPassphrase")) $("#keychainDetailPassphrase").value = ""; if ($("#keychainImportDialog")?.open) $("#keychainImportDialog").close();
1160 closeSecret();
1161 $("#passwordRows").replaceChildren(); resetPasswordForm(); $("#passwordMessage").textContent = "";
1162 resetProfileForm();
1163 $("#suggestCustom").value = ""; $("#suggestHidden").value = "";
1164 $("#navigationEditToggle").checked = false;
1165 renderNavigation();
1166 cancelProfileImport();
1167 selectedExportIds.clear();
1168 state.profiles = [];
1169 state.selectedProfileId = "";
1170 renderProfiles();
1171 diagnosticsView?.refreshLanguage();
1172 renderTerminalProfileSelect();
1173 for (const pane of panes) {
1174 pane.sessionId = null; pane.pending = false; pane.latency = null;
1175 pane.root.querySelectorAll("input").forEach((input) => { input.value = input.type === "number" ? "22" : ""; });
1176 pane.root.querySelectorAll(".manual-host select").forEach((select) => { select.value = "password"; });
1177 setTerminalState(pane, "disconnected", t("terminal.disconnected"));
1178 updateTerminalProfile(pane); pane.terminal?.reset();
1182async function loadProfiles(selectId = state.selectedProfileId) {
1183 const epoch = protectedEpoch;
1184 if (!state.vault?.unlocked) return;
1185 const profiles = await api.listProfiles();
1186 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
1187 await loadAvailability();
1188 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
1189 state.profiles = profiles;
1190 if (!state.profiles.some((profile) => profile.id === selectId)) selectId = state.profiles[0]?.id || "";
1191 state.selectedProfileId = selectId;
1192 renderProfiles();
1193 diagnosticsView?.refreshLanguage();
1194 renderTerminalProfileSelect();
1195 renderAvailability();
1196 if (selectId) fillProfileForm(state.profiles.find((profile) => profile.id === selectId));
1197 else resetProfileForm();
1200function renderProfiles() {
1201 for (const id of selectedExportIds) if (!state.profiles.some((profile) => profile.id === id)) selectedExportIds.delete(id);
1202 elements.profileRows.replaceChildren();
1203 if (!state.profiles.length) {
1204 const empty = document.createElement("div"); empty.className = "profile-empty";
1205 empty.textContent = t("profiles.none"); elements.profileRows.append(empty); return;
1207 for (const profile of state.profiles) {
1208 const row = document.createElement("button"); row.type = "button";
1209 row.className = `profile-row${profile.id === state.selectedProfileId ? " active" : ""}`;
1210 const name = document.createElement("strong"); name.dataset.availabilityId = profile.id;
1211 const dot = document.createElement("i"); dot.className = "availability-dot";
1212 const availabilityLabel = document.createElement("small"); availabilityLabel.className = "availability-text";
1213 name.append(dot, profile.label, availabilityLabel);
1214 const address = document.createElement("span"); address.textContent = `${profile.username}@${profile.host}:${profile.port}`;
1215 const auth = document.createElement("span"); auth.className = "profile-auth"; auth.textContent = t(profile.authMethod === "privateKey" ? "profiles.key" : "profiles.password");
1216 row.append(name, address, auth);
1217 row.addEventListener("click", () => { state.selectedProfileId = profile.id; renderProfiles(); fillProfileForm(profile); });
1218 const wrapper = document.createElement("div"); wrapper.className = "profile-select-row";
1219 const select = document.createElement("input"); select.type = "checkbox"; select.checked = selectedExportIds.has(profile.id);
1220 select.setAttribute("aria-label", t("transfer.select", { name: profile.label }));
1221 select.addEventListener("change", () => { if (select.checked) selectedExportIds.add(profile.id); else selectedExportIds.delete(profile.id); });
1222 wrapper.append(select, row); elements.profileRows.append(wrapper);
1226function availabilitySummary(profile) {
1227 const entry = availabilityState.get(profile.id), status = entry?.status || "unknown";
1228 const ms = entry?.latencyMs;
1229 const text = status === "online" ? (typeof ms === "number" ? `${ms < 1 ? "<1" : Math.round(ms)} ms` : t("availability.online"))
1230 : status === "offline" ? t("availability.offline") : status === "checking" ? t("availability.checking")
1231 : status === "unverified" ? t("availability.unverified") : "";
1232 return { status, text, entry };
1234function serverOptionText(profile) {
1235 const { status, text } = availabilitySummary(profile);
1236 return `${{ online: "🟢", offline: "🔴" }[status] || "⚪"} ${profile.label}${text ? ` — ${text}` : ""}`;
1238function availabilityDetails(profile) {
1239 const { status, entry } = availabilitySummary(profile);
1240 const when = (value) => new Date(value).toLocaleString(i18n.getLanguage());
1241 return [t(`availability.${status}`), entry?.via === "jump" && status === "online" ? t("availability.viaJump") : "", entry?.reason ? t(`availability.reason.${entry.reason}`) : "",
1242 entry?.lastSuccessAt ? t("availability.lastSuccess", { time: when(entry.lastSuccessAt) }) : "", entry?.lastAttemptAt ? t("availability.lastAttempt", { time: when(entry.lastAttemptAt) }) : ""].filter(Boolean).join("\n");
1244// Updates in place: an open menu or a selected row must survive every 12 s.
1245function renderAvailability() {
1246 const known = new Map(state.profiles.map((profile) => [profile.id, profile]));
1247 for (const select of [$("#profileJumpProfileId"), ...panes.flatMap((pane) => [pane.select, pane.jumpSelect])]) {
1248 for (const option of select.options) {
1249 const profile = known.get(option.value);
1250 if (profile && option.textContent !== serverOptionText(profile)) option.textContent = serverOptionText(profile);
1253 document.querySelectorAll("[data-availability-id]").forEach((row) => {
1254 const profile = known.get(row.dataset.availabilityId);
1255 if (!profile) return;
1256 const { status, text } = availabilitySummary(profile);
1257 const dot = row.querySelector(".availability-dot"), label = row.querySelector(".availability-text");
1258 dot.dataset.status = status; label.dataset.status = status; label.textContent = text;
1259 row.title = availabilityDetails(profile);
1260 });
1262async function loadAvailability() {
1263 const epoch = protectedEpoch;
1264 if (!state.vault?.unlocked) return;
1265 try {
1266 const entries = await api.listAvailability();
1267 if (!state.vault?.unlocked || epoch !== protectedEpoch) return;
1268 availabilityState = new Map(entries.map((entry) => [entry.id, entry])); renderAvailability();
1269 } catch {}
1271function renderMonitoringSettings() {
1272 const select = $("#monitoringInterval"), config = state.config?.monitoring;
1273 select.replaceChildren(...[5, 10, 12, 15, 30, 60].map((seconds) => new Option(t("availability.settingsSeconds", { seconds }), String(seconds))));
1274 select.value = String(config?.intervalSeconds ?? 12);
1275 $("#monitoringEnabled").checked = config?.enabled !== false;
1276 $("#refreshAvailabilityButton").disabled = config?.enabled === false;
1278function profileOptions(select, exclude, firstKey, manual = false) {
1279 const previous = select.value;
1280 select.replaceChildren(...(firstKey ? [new Option(t(firstKey), "")] : []));
1281 for (const profile of state.profiles) if (profile.id !== exclude) select.add(new Option(serverOptionText(profile), profile.id));
1282 if (manual) select.add(new Option(t("terminal.manualHost"), "@manual"));
1283 select.value = [...select.options].some((option) => option.value === previous) ? previous : "";
1285function renderTerminalProfileSelect() {
1286 profileOptions($("#profileJumpProfileId"), elements.profileId.value, "terminal.noJump");
1287 for (const pane of panes) {
1288 const previous = pane.select.value;
1289 profileOptions(pane.select, null, null, true);
1290 if (!pane.select.value) pane.select.value = state.profiles[0]?.id || "@manual";
1291 profileOptions(pane.jumpSelect, pane.select.value, "terminal.noJump", true);
1292 if (pane.select.value !== previous) selectTarget(pane);
1293 else updateTerminalProfile(pane);
1297function resetProfileForm() {
1298 elements.profileForm.reset();
1299 elements.profileId.value = "";
1300 elements.profilePort.value = "22";
1301 elements.profileAuthMethod.value = "password";
1302 elements.profilePrivateKeyPath.value = "";
1303 void loadProfileKeychainKeys("");
1304 elements.profileStorePassword.checked = false;
1305 elements.profileStoredPassword.value = "";
1306 elements.profileStoredPassword.placeholder = t("profiles.storePasswordPlaceholder");
1307 elements.deleteProfileButton.hidden = true;
1308 elements.profileMessage.textContent = "";
1309 profileOptions($("#profileJumpProfileId"), "", "terminal.noJump");
1310 $("#profileJumpProfileId").value = "";
1311 updatePrivateKeyField();
1314function fillProfileForm(profile) {
1315 if (!profile) { resetProfileForm(); return; }
1316 elements.profileId.value = profile.id;
1317 elements.profileLabel.value = profile.label;
1318 elements.profileHost.value = profile.host;
1319 elements.profilePort.value = String(profile.port);
1320 elements.profileUsername.value = profile.username;
1321 profileOptions($("#profileJumpProfileId"), profile.id, "terminal.noJump");
1322 $("#profileJumpProfileId").value = profile.jumpProfileId || "";
1323 elements.profileAuthMethod.value = profile.authMethod === "privateKey" && profile.keychainKeyId ? "keychain" : profile.authMethod;
1324 void loadProfileKeychainKeys(profile.keychainKeyId || "");
1325 $("#profileKexPolicy").value=profile.kexPolicy || "prefer-hybrid";
1326 elements.profilePrivateKeyPath.value = profile.privateKeyPath || "";
1327 elements.profileStorePassword.checked = Boolean(profile.hasStoredPassword);
1328 elements.profileStoredPassword.value = "";
1329 elements.profileStoredPassword.placeholder = t(profile.hasStoredPassword ? "profiles.passwordStoredPlaceholder" : "profiles.storePasswordPlaceholder");
1330 elements.deleteProfileButton.hidden = false;
1331 elements.profileMessage.textContent = "";
1332 updatePrivateKeyField();
1335function updatePrivateKeyField() {
1336 const method = elements.profileAuthMethod.value;
1337 elements.privateKeyField.hidden = method !== "privateKey";
1338 $("#keychainKeyField").hidden = method !== "keychain";
1339 // Password login stores the SSH password; key login stores the key passphrase in the same vault slot.
1340 const keyLogin = method !== "password";
1341 for (const [id, password, passphrase] of [["#profileStorePasswordText", "profiles.storePassword", "profiles.storePassphrase"], ["#profileStoredPasswordText", "profiles.storedPassword", "profiles.storedPassphrase"]]) {
1342 const node = $(id); node.dataset.i18n = keyLogin ? passphrase : password; node.textContent = t(node.dataset.i18n);
1344 elements.profileStoredPassword.disabled = !elements.profileStorePassword.checked;
1346// SSH keys with a private part from the Keychain, for key login without a key file.
1347async function loadProfileKeychainKeys(selected) {
1348 const epoch = protectedEpoch; const select = $("#profileKeychainKeyId");
1349 const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = t("profiles.keychainKeySelect");
1350 select.replaceChildren(placeholder); select.value = "";
1351 if (!state.vault?.unlocked) return;
1352 let keys = [];
1353 try { keys = (await api.keychain("list")).filter(entry => entry.kind === "ssh" && entry.hasPrivate); } catch { keys = []; }
1354 if (epoch !== protectedEpoch) return;
1355 for (const key of keys) { const option = document.createElement("option"); option.value = key.id; option.textContent = `${key.label || "SSH"} · ${key.fingerprint || ""}`; select.append(option); }
1356 if (selected && !keys.some(key => key.id === selected)) { const missing = document.createElement("option"); missing.value = selected; missing.textContent = t("profiles.keychainKeyMissing"); select.append(missing); }
1357 select.value = selected || "";
1360function createManualHost(pane, name) {
1361 const group = document.createElement("fieldset"); group.className = "manual-host"; group.hidden = true;
1362 group.innerHTML = `<legend data-i18n="${name === "target" ? "terminal.server" : "terminal.jumpHost"}"></legend>
1363 <label><span data-i18n="profiles.host"></span><input data-field="host" maxlength="253" autocomplete="off" spellcheck="false"></label>
1364 <label><span data-i18n="profiles.port"></span><input data-field="port" type="number" min="1" max="65535" value="22"></label>
1365 <label><span data-i18n="profiles.user"></span><input data-field="username" maxlength="128" autocomplete="off" spellcheck="false"></label>
1366 <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>
1367 <label><span data-i18n="ssh.kexPolicy"></span><select data-field="kexPolicy"><option value="prefer-hybrid" data-i18n="ssh.kexPrefer"></option><option value="hybrid-only" data-i18n="ssh.kexStrict"></option></select></label>
1368 <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>`;
1369 group.querySelectorAll("[data-field]").forEach((field) => { field.id = `pane-${pane.slot}-${name}-${field.dataset.field}`; });
1370 group.querySelector("button").addEventListener("click", async () => {
1371 try {
1372 const result = await api.selectPrivateKey();
1373 if (!result.canceled && !pane.pending && pane.kind !== "connected") group.querySelector('[data-field="privateKeyPath"]').value = result.path;
1374 } catch (error) { writeTerminalError(pane, error); }
1375 });
1376 group.addEventListener("input", () => updateTerminalProfile(pane));
1377 group.addEventListener("change", () => updateTerminalProfile(pane));
1378 return group;
1380function manualHost(group) {
1381 return Object.fromEntries([...group.querySelectorAll("[data-field]")].map((field) => [field.dataset.field, field.dataset.field === "port" ? Number(field.value) : field.value.trim()]));
1383function selectedTarget(pane) {
1384 return pane.select.value === "@manual" ? manualHost(pane.targetFields) : state.profiles.find((profile) => profile.id === pane.select.value);
1386function selectedJump(pane) {
1387 return pane.jumpSelect.value === "@manual" ? manualHost(pane.jumpFields) : state.profiles.find((profile) => profile.id === pane.jumpSelect.value) || null;
1389function createTerminalPane(slot) {
1390 let root = $("#terminalPane");
1391 if (slot === 2) {
1392 root = root.cloneNode(true); root.id = "terminalPane2";
1393 root.querySelectorAll("[id]").forEach((element) => { element.id += "2"; });
1394 // The first pane has already received its generated connection fields.
1395 root.querySelectorAll(".pane-extra").forEach((element) => element.remove());
1396 root.querySelector(".terminal-pane-heading strong").textContent = "T2";
1397 root.hidden = true; $("#terminalPanes").append(root);
1399 const find = (id) => root.querySelector(`#${id}${slot === 2 ? "2" : ""}`);
1400 const pane = { slot, root, sessionId: null, kind: "disconnected", pending: false, latency: null, terminal: null, fit: null, jumpOpen: false, sentSize: "",
1401 select: find("terminalProfileSelect"), credential: find("terminalCredential"), credentialText: find("credentialLabelText"),
1402 connect: find("connectButton"), disconnect: find("disconnectButton"), badge: find("terminalState"),
1403 latencyElement: find("terminalLatency"), host: find("terminalHost"), screen: find("terminal"),
1404 jumpToggle: find("jumpToggle"), jumpToggleText: find("jumpToggleText") };
1405 const extra = document.createElement("div"); extra.className = "pane-extra terminal-options";
1406 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>`;
1407 pane.jumpSelect = extra.querySelector("select"); pane.jumpCredential = extra.querySelector("input");
1408 pane.jumpCredential.id = `terminalJumpCredential${slot === 2 ? "2" : ""}`;
1409 pane.options = extra; pane.jumpRow = extra.querySelector(".jump-toolbar");
1410 pane.targetFields = createManualHost(pane, "target"); pane.jumpFields = createManualHost(pane, "jump");
1411 extra.prepend(pane.targetFields); extra.append(pane.jumpFields);
1412 pane.screen.before(extra);
1413 pane.tab = document.createElement("button"); pane.tab.type = "button"; pane.tab.className = "terminal-tab"; pane.tab.setAttribute("role", "tab");
1414 pane.tab.innerHTML = `<span class="terminal-dot"></span><strong>T${slot}</strong><span class="terminal-tab-name"></span>`;
1415 pane.tab.dataset.kind = pane.kind; pane.tabName = pane.tab.querySelector(".terminal-tab-name");
1416 pane.tab.addEventListener("click", () => setActivePane(pane, true));
1417 $("#terminalTabs").append(pane.tab);
1418 root.addEventListener("focusin", () => setActivePane(pane));
1419 root.addEventListener("pointerdown", () => setActivePane(pane));
1420 pane.select.addEventListener("change", () => selectTarget(pane));
1421 pane.jumpSelect.addEventListener("change", () => { pane.jumpCredential.value = ""; if (pane.jumpSelect.value) pane.jumpOpen = true; updateTerminalProfile(pane); });
1422 pane.jumpToggle.addEventListener("click", () => { pane.jumpOpen = !pane.jumpOpen; updateTerminalProfile(pane); requestAnimationFrame(() => fitPane(pane)); });
1423 pane.connect.addEventListener("click", () => void connectTerminal(pane));
1424 pane.disconnect.addEventListener("click", () => void disconnectTerminal(pane).catch((error) => writeTerminalError(pane, error)));
1425 return pane;
1427function selectTarget(pane) {
1428 pane.credential.value = ""; pane.jumpCredential.value = "";
1429 profileOptions(pane.jumpSelect, pane.select.value, "terminal.noJump", true);
1430 const target = selectedTarget(pane);
1431 pane.jumpSelect.value = target?.jumpProfileId || "";
1432 pane.jumpOpen = Boolean(pane.jumpSelect.value);
1433 updateTerminalProfile(pane);
1435function updateTerminalProfile(pane) {
1436 const target = selectedTarget(pane), jump = selectedJump(pane);
1437 pane.targetFields.hidden = pane.select.value !== "@manual";
1438 pane.jumpFields.hidden = pane.jumpSelect.value !== "@manual";
1439 for (const group of [pane.targetFields, pane.jumpFields]) group.querySelector(".key-field").hidden = manualHost(group).authMethod !== "privateKey";
1440 pane.credentialText.textContent = t(target?.authMethod === "privateKey" ? "terminal.passphrase" : "terminal.password");
1441 pane.credential.placeholder = t(target?.hasStoredPassword ? "terminal.savedPasswordHint" : target?.authMethod === "privateKey" ? "terminal.keyPassphrase" : "terminal.sshPassword");
1442 updateCredentialField(pane);
1443 pane.jumpCredential.closest("label").hidden = !jump || Boolean(jump.hasStoredPassword);
1444 pane.jumpCredential.placeholder = t(jump?.authMethod === "privateKey" ? "terminal.keyPassphrase" : "terminal.sshPassword");
1445 pane.options.hidden = !pane.jumpOpen && pane.targetFields.hidden;
1446 pane.jumpRow.hidden = !pane.jumpOpen;
1447 pane.jumpFields.hidden = pane.jumpFields.hidden || !pane.jumpOpen;
1448 pane.jumpToggle.classList.toggle("active", Boolean(jump)); pane.jumpToggle.setAttribute("aria-expanded", String(pane.jumpOpen));
1449 pane.jumpToggleText.textContent = jump?.label || jump?.host || t("terminal.jumpHost"); pane.jumpToggle.title = t("terminal.jumpHost");
1450 // Keep the host line tied to the current session while its controls are frozen.
1451 pane.host.textContent = target ? `${jump ? `${jump.host} ⇢ ` : ""}${target.username}@${target.host}:${target.port}` : t("terminal.noProfile");
1452 pane.host.title = pane.host.textContent;
1453 renderTerminalTab(pane);
1455// A saved password needs no input; the field returns after a failed attempt, for a one-time replacement.
1456function updateCredentialField(pane) {
1457 const target = selectedTarget(pane);
1458 pane.credential.closest("label").hidden = Boolean(target?.hasStoredPassword && pane.kind !== "error");
1460function renderTerminalTab(pane) {
1461 const live = pane.kind === "connected" || pane.kind === "connecting", target = live ? selectedTarget(pane) : null;
1462 pane.tab.dataset.kind = pane.kind;
1463 pane.tabName.textContent = target ? target.label || target.host : "";
1464 pane.tab.title = `${pane.badge.textContent}${target ? ` — ${pane.host.textContent}` : ""} (${terminalShortcut(pane.slot)})`;
1466function terminalShortcut(key) { return `${state.info?.platform === "darwin" ? "⌘" : "Ctrl+Shift+"}${key}`; }
1467function setActivePane(pane, focus = false) {
1468 activePane = pane;
1469 const single = (state.config?.ui.terminalLayout || "single") === "single";
1470 for (const item of panes) {
1471 const active = item === pane;
1472 item.root.classList.toggle("active", active); item.tab.classList.toggle("active", active); item.tab.setAttribute("aria-selected", String(active));
1473 if (single) item.root.hidden = !active;
1475 if (focus) requestAnimationFrame(() => { fitPane(pane); pane.terminal?.focus(); });
1477async function loadSuggestDictionary() {
1478 const epoch = protectedEpoch;
1479 if (!state.vault?.unlocked) { $("#suggestCustom").value = ""; $("#suggestHidden").value = ""; return null; }
1480 const result = await api.getTerminalDictionary();
1481 if (!state.vault?.unlocked || epoch !== protectedEpoch) return null;
1482 if (result.ok) { $("#suggestCustom").value = result.value.custom.join("\n"); $("#suggestHidden").value = result.value.hidden.join("\n"); }
1483 return result.ok ? result.value : null;
1485const dictionaryLines = (element) => element.value.split("\n").map((line) => line.trim()).filter(Boolean);
1486async function hideSuggestion(command) {
1487 const current = await loadSuggestDictionary();
1488 if (current && !current.hidden.includes(command)) { await api.saveTerminalDictionary({ custom: current.custom, hidden: [...current.hidden, command] }); await loadSuggestDictionary(); }
1490function applyTerminalAppearance(appearance) {
1491 const options = window.CHJ_TERMINAL_APPEARANCE.terminalOptions(appearance);
1492 for (const pane of panes) {
1493 if (!pane.terminal) continue;
1494 pane.terminal.options.fontFamily = options.fontFamily; pane.terminal.options.fontSize = options.fontSize; pane.terminal.options.fontWeight = options.fontWeight;
1495 pane.terminal.options.lineHeight = options.lineHeight; pane.terminal.options.letterSpacing = options.letterSpacing; pane.terminal.options.cursorStyle = options.cursorStyle;
1496 pane.terminal.options.theme = options.theme;
1497 fitPane(pane);
1500const APPEARANCE_CUSTOM = "@custom", APPEARANCE_EDITED = "@edited";
1501const appearanceApi = () => window.CHJ_TERMINAL_APPEARANCE;
1502const userProfiles = () => state.config?.ui.terminalProfiles || [];
1503function profileById(id) {
1504 return appearanceApi().PROFILES.find((profile) => profile.id === id) || userProfiles().find((profile) => profile.id === id);
1506function appearanceFromForm() {
1507 const { DEFAULTS, ANSI_NAMES, ANSI_DEFAULTS } = appearanceApi();
1508 const chosen = $("#appearanceFont").value;
1509 const color = (id, standard) => { const value = $(id).value.toLowerCase(); return value === standard ? "" : value; };
1510 const ansi = {};
1511 ANSI_NAMES.forEach((name, index) => { const value = document.querySelector(`[data-ansi="${name}"]`).value.toLowerCase(); if (value !== ANSI_DEFAULTS[index]) ansi[name] = value; });
1512 const profile = $("#appearanceProfile").value;
1513 return { profile: profile === APPEARANCE_EDITED ? "" : profile, fontFamily: chosen === APPEARANCE_CUSTOM ? $("#appearanceCustomFont").value.trim() : chosen, fontSize: Number($("#appearanceSize").value) || DEFAULTS.fontSize, fontWeight: $("#appearanceWeight").value,
1514 lineHeight: Number($("#appearanceLineHeight").value) || DEFAULTS.lineHeight, letterSpacing: Number($("#appearanceLetterSpacing").value) || 0, cursorStyle: $("#appearanceCursorStyle").value,
1515 foreground: color("#appearanceForeground", DEFAULTS.foreground), background: color("#appearanceBackground", DEFAULTS.background), cursor: color("#appearanceCursor", DEFAULTS.cursor), selection: color("#appearanceSelection", DEFAULTS.selection), ansi };
1517function previewTerminalAppearance() {
1518 const appearance = appearanceFromForm();
1519 const { terminalOptions, isProportional, ANSI_NAMES, ANSI_DEFAULTS } = appearanceApi();
1520 const options = terminalOptions(appearance), preview = $("#appearancePreview");
1521 $("#appearanceCustomRow").hidden = $("#appearanceFont").value !== APPEARANCE_CUSTOM;
1522 $("#appearanceFontWarning").hidden = !isProportional(appearance.fontFamily);
1523 $("#appearanceProfileDelete").disabled = !userProfiles().some((profile) => profile.id === $("#appearanceProfile").value);
1524 Object.assign(preview.style, { fontFamily: options.fontFamily, fontSize: `${options.fontSize}px`, fontWeight: options.fontWeight, lineHeight: String(options.lineHeight * 1.2), letterSpacing: `${options.letterSpacing}px`, color: options.theme.foreground, background: options.theme.background });
1525 $("#appearanceSwatches").replaceChildren(...ANSI_NAMES.map((name, index) => { const swatch = document.createElement("i"); swatch.style.background = options.theme[name] || ANSI_DEFAULTS[index]; swatch.title = name; return swatch; }));
1527function renderAppearanceProfiles(selected) {
1528 const select = $("#appearanceProfile");
1529 const option = (value, text) => new Option(text, value);
1530 const groups = [...appearanceApi().PROFILES.map((profile) => option(profile.id, profile.key ? t(profile.key) : profile.name))];
1531 if (userProfiles().length) {
1532 const own = document.createElement("optgroup"); own.label = t("terminal.appearance.ownProfiles");
1533 own.append(...userProfiles().map((profile) => option(profile.id, profile.name)));
1534 groups.push(own);
1536 groups.push(option(APPEARANCE_EDITED, t("terminal.appearance.edited")));
1537 select.replaceChildren(...groups);
1538 select.value = selected && [...select.querySelectorAll("option")].some((entry) => entry.value === selected) ? selected : APPEARANCE_EDITED;
1540function fillAppearanceForm(appearance = {}) {
1541 const { DEFAULTS, FONTS, ANSI_NAMES, ANSI_DEFAULTS } = appearanceApi(), select = $("#appearanceFont");
1542 if (!select.options.length) select.append(new Option(t("terminal.appearance.defaultFont"), ""), ...FONTS.map((font) => new Option(font, font)), new Option(t("terminal.appearance.customFont"), APPEARANCE_CUSTOM));
1543 const font = appearance.fontFamily || "";
1544 select.value = !font || FONTS.includes(font) ? font : APPEARANCE_CUSTOM;
1545 $("#appearanceCustomFont").value = select.value === APPEARANCE_CUSTOM ? font : "";
1546 $("#appearanceSize").value = appearance.fontSize || DEFAULTS.fontSize; $("#appearanceWeight").value = appearance.fontWeight || DEFAULTS.fontWeight;
1547 $("#appearanceLineHeight").value = appearance.lineHeight || DEFAULTS.lineHeight; $("#appearanceLetterSpacing").value = appearance.letterSpacing || 0;
1548 $("#appearanceCursorStyle").value = appearance.cursorStyle || DEFAULTS.cursorStyle;
1549 $("#appearanceForeground").value = appearance.foreground || DEFAULTS.foreground; $("#appearanceBackground").value = appearance.background || DEFAULTS.background;
1550 $("#appearanceCursor").value = appearance.cursor || DEFAULTS.cursor; $("#appearanceSelection").value = appearance.selection || DEFAULTS.selection;
1551 ANSI_NAMES.forEach((name, index) => { document.querySelector(`[data-ansi="${name}"]`).value = appearance.ansi?.[name] || ANSI_DEFAULTS[index]; });
1553function renderTerminalAppearance(appearance = {}) {
1554 fillAppearanceForm(appearance);
1555 renderAppearanceProfiles(appearance.profile || (isDefaultAppearance(appearance) ? "chj" : APPEARANCE_EDITED));
1556 previewTerminalAppearance();
1558function isDefaultAppearance(a) {
1559 const { DEFAULTS } = appearanceApi();
1560 return !a.fontFamily && (!a.fontSize || a.fontSize === DEFAULTS.fontSize) && (!a.fontWeight || a.fontWeight === DEFAULTS.fontWeight) && (!a.lineHeight || a.lineHeight === DEFAULTS.lineHeight) && !a.letterSpacing
1561 && (!a.cursorStyle || a.cursorStyle === DEFAULTS.cursorStyle) && !a.foreground && !a.background && !a.cursor && !a.selection && !Object.keys(a.ansi || {}).length;
1563function appearanceMessage(key) { $("#appearanceStatus").textContent = key ? t(key) : ""; }
1564async function saveUserProfile(name, appearance) {
1565 const id = `user-${crypto.randomUUID()}`;
1566 state.config = await api.updateConfig({ ui: { terminalProfiles: [...userProfiles(), { id, name, appearance: { ...appearance, profile: "" } }] } });
1567 return id;
1569function showSettingsTab(name) {
1570 document.querySelectorAll("[data-settings-tab]").forEach((tab) => {
1571 const active = tab.dataset.settingsTab === name;
1572 tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1;
1573 });
1574 document.querySelectorAll("[data-settings-page]").forEach((page) => { page.hidden = page.dataset.settingsPage !== name; });
1575 $("#settingsForm").hidden = !$("#settingsForm").querySelector(`[data-settings-page="${name}"]`);
1576 if (name === "standby") void loadStandby();
1578// Settings → Standby Helper: the separate helper applies every change itself (menu entries, login item, background).
1579let standbyBusy = false;
1580function renderStandby(result) {
1581 const settings = result?.settings || { enabled: false, startAtLogin: true, menu: { verify: true, sign: true } };
1582 const status = result?.status || {};
1583 $("#standbyEnabled").checked = settings.enabled;
1584 $("#standbyMenuVerify").checked = settings.menu.verify;
1585 $("#standbyMenuSign").checked = settings.menu.sign;
1586 $("#standbyLogin").checked = settings.startAtLogin;
1587 const monitor = settings.monitor || { enabled: false, intervalSecs: 60, check: "ssh", customPort: 8443, showAddresses: true };
1588 $("#standbyMonitorEnabled").checked = monitor.enabled;
1589 $("#standbyMonitorCheck").value = monitor.check;
1590 $("#standbyMonitorPort").value = String(monitor.customPort);
1591 $("#standbyMonitorInterval").value = String(monitor.intervalSecs);
1592 $("#standbyMonitorAddresses").checked = monitor.showAddresses;
1593 $("#standbyMonitorPortRow").hidden = monitor.check !== "custom";
1594 renderStandbyServers(monitor.servers);
1595 const missing = status.error === "STANDBY_HELPER_MISSING";
1596 const platform = status.platform || "";
1597 $("#standbyEnabled").disabled = standbyBusy || missing;
1598 $("#standbyOptions").disabled = standbyBusy || missing || !settings.enabled;
1599 $("#standbyLogin").disabled = $("#standbyOptions").disabled;
1600 $("#standbyMonitorOptions").disabled = $("#standbyOptions").disabled || !monitor.enabled;
1601 const installed = status.installed || {};
1602 const lines = [];
1603 if (missing) lines.push(t("standby.missing"));
1604 else if (status.error) lines.push(t("standby.error", { message: status.message || status.error }));
1605 else if (status.platform) {
1606 lines.push(t(status.running ? "standby.running" : "standby.stopped"));
1607 lines.push(t("standby.entries", { verify: t(installed.verify ? "standby.yes" : "standby.no"), sign: t(installed.sign ? "standby.yes" : "standby.no") }));
1609 $("#standbyStatus").textContent = lines.join(" · ");
1610 $("#standbyPlatformNote").textContent = platform === "macos" ? t("standby.noteMac") : platform === "windows" ? t("standby.noteWindows") : platform === "linux" ? t("standby.noteLinux") : "";
1612// Per-server checks as text: "ssh, web-443, smb-445, 8080" (web = https, a bare number = tcp on that port).
1613const STANDBY_CHECK_ALIASES = { web: "https", https: "https", http: "http", ssh: "ssh", smb: "smb", ftp: "ftp", dns: "dns", rdp: "rdp", tcp: "tcp" };
1614function parseStandbyChecks(text) {
1615 const checks = [], invalid = [];
1616 for (const raw of String(text || "").split(/[,;\s]+/).map((part) => part.trim().toLowerCase()).filter(Boolean)) {
1617 const match = raw.match(/^(?:([a-z]+)(?:[-:](\d{1,5}))?|(\d{1,5}))$/);
1618 const type = match ? (match[3] ? "tcp" : STANDBY_CHECK_ALIASES[match[1]]) : null;
1619 const digits = match ? (match[2] ?? match[3]) : undefined;
1620 const port = digits === undefined ? null : Number(digits);
1621 if (!type || (port !== null && (port < 1 || port > 65535)) || (type === "tcp" && !port)) invalid.push(raw);
1622 else checks.push({ type, port });
1624 return { checks: checks.slice(0, 8), invalid };
1626function formatStandbyChecks(checks) { return (checks || []).map((check) => (check.port ? `${check.type}-${check.port}` : check.type)).join(", "); }
1627// Last saved per-server choices: rows that are not shown (locked vault, jump host) keep them on save.
1628let standbySavedChoices = {};
1629function renderStandbyServers(choices) {
1630 standbySavedChoices = { ...(choices || {}) };
1631 const host = $("#standbyMonitorServers");
1632 host.replaceChildren();
1633 for (const profile of state.profiles || []) {
1634 const choice = choices?.[profile.id] || { enabled: true, checks: [] };
1635 const row = document.createElement("div"); row.className = "standby-server-row"; row.dataset.profileId = profile.id;
1636 const label = document.createElement("label"); label.className = "check-label";
1637 const box = document.createElement("input"); box.type = "checkbox"; box.checked = choice.enabled !== false;
1638 const name = document.createElement("span"); name.textContent = profile.label || profile.host;
1639 label.append(box, name);
1640 const checks = document.createElement("input"); checks.type = "text"; checks.value = formatStandbyChecks(choice.checks); checks.placeholder = "ssh, web-443"; checks.setAttribute("aria-label", t("standby.monitorServers"));
1641 row.append(label, checks);
1642 if (profile.jumpProfileId) { box.checked = false; box.disabled = true; checks.disabled = true; const note = document.createElement("p"); note.className = "hint"; note.textContent = t("standby.monitorJump"); row.append(note); }
1643 box.addEventListener("change", () => void saveStandby());
1644 checks.addEventListener("change", () => void saveStandby());
1645 host.append(row);
1648function readStandbyServers() {
1649 const servers = { ...standbySavedChoices }, invalid = [];
1650 for (const row of document.querySelectorAll("#standbyMonitorServers .standby-server-row")) {
1651 const [box, text] = row.querySelectorAll("input");
1652 if (box.disabled) continue;
1653 const parsed = parseStandbyChecks(text.value);
1654 invalid.push(...parsed.invalid);
1655 servers[row.dataset.profileId] = { enabled: box.checked, checks: parsed.checks };
1657 return { servers, invalid };
1659function readStandbyForm() {
1660 const port = Number.parseInt($("#standbyMonitorPort").value, 10);
1661 const { servers } = readStandbyServers();
1662 const monitor = { enabled: $("#standbyMonitorEnabled").checked, intervalSecs: Number($("#standbyMonitorInterval").value), check: $("#standbyMonitorCheck").value, customPort: port >= 1 && port <= 65535 ? port : 8443, showAddresses: $("#standbyMonitorAddresses").checked, servers };
1663 return { enabled: $("#standbyEnabled").checked, startAtLogin: $("#standbyLogin").checked, menu: { verify: $("#standbyMenuVerify").checked, sign: $("#standbyMenuSign").checked }, monitor };
1665async function loadStandby() {
1666 if (standbyBusy) return;
1667 try { renderStandby(await api.getStandbyHelper()); } catch (error) { $("#standbyMessage").textContent = errorText(error); }
1669async function saveStandby() {
1670 if (standbyBusy) return;
1671 const invalid = readStandbyServers().invalid;
1672 if (invalid.length) { $("#standbyMessage").textContent = t("standby.monitorInvalid", { token: invalid.join(", ") }); return; }
1673 const wanted = readStandbyForm();
1674 standbyBusy = true; renderStandby({ settings: wanted, status: {} });
1675 $("#standbyMessage").textContent = t("standby.applying");
1676 try { const result = await api.setStandbyHelper(wanted); standbyBusy = false; renderStandby(result); $("#standbyMessage").textContent = result.status?.error ? errorText(result.status.error) : t("settings.saved"); }
1677 catch (error) { standbyBusy = false; $("#standbyMessage").textContent = errorText(error); await loadStandby(); }
1679function initTerminal() {
1680 for (const pane of panes) {
1681 if (!window.Terminal || !window.FitAddon?.FitAddon) { pane.host.textContent = t("terminal.libraryMissing"); continue; }
1682 pane.terminal = new window.Terminal({ cursorBlink: true, scrollback: 8000, ...window.CHJ_TERMINAL_APPEARANCE.terminalOptions(state.config?.ui.terminalAppearance) });
1683 pane.fit = new window.FitAddon.FitAddon(); pane.terminal.loadAddon(pane.fit); pane.terminal.open(pane.screen);
1684 pane.terminal.writeln(`\x1b[2m${t("terminal.welcome")}\x1b[0m\r\n`);
1685 pane.suggest = window.CHJ_TERMINAL_SUGGEST?.attach({ terminal: pane.terminal, host: pane.screen, t, confirm: (text) => window.confirm(text), sessionId: () => (pane.kind === "connected" ? pane.sessionId : null), query: (sessionId, input) => api.suggestTerminal(sessionId, input, state.config?.ui.terminalSuggestRepair !== false), send: (sessionId, data) => api.sendTerminalInput(sessionId, data), enabled: () => state.config?.ui.terminalSuggest !== false, hideCommand: hideSuggestion });
1686 pane.terminal.onData((data) => { pane.suggest?.onInput(data); if (pane.kind === "connected") api.sendTerminalInput(pane.sessionId, data); });
1687 new ResizeObserver(() => fitPane(pane)).observe(pane.screen);
1689 fitTerminal();
1691function fitPane(pane) {
1692 if (!pane.terminal || !pane.fit || pane.screen.offsetParent === null) return;
1693 try {
1694 pane.fit.fit();
1695 // Dragging the divider fits every frame; the server hears only real size changes.
1696 const size = `${pane.sessionId}:${pane.terminal.cols}x${pane.terminal.rows}`;
1697 if (pane.kind === "connected" && size !== pane.sentSize) { pane.sentSize = size; void api.resizeTerminal(pane.sessionId, pane.terminal.cols, pane.terminal.rows).catch(() => {}); }
1698 } catch {}
1700function fitTerminal() { for (const pane of panes) fitPane(pane); }
1701function setTerminalState(pane, kind, text = t(`terminal.${kind === "error" ? "connectionError" : kind}`)) {
1702 if (kind !== "connected") pane.suggest?.reset();
1703 pane.kind = kind; pane.badge.className = `connection-badge ${kind}`; pane.badge.textContent = text; pane.badge.title = text;
1704 pane.root.dataset.kind = kind;
1705 if (kind !== "connected") pane.latency = null;
1706 renderTerminalLatency(pane);
1707 const busy = pane.pending || kind === "connecting" || kind === "connected";
1708 pane.root.querySelectorAll("select, input, .manual-host button").forEach((field) => { field.disabled = busy; });
1709 pane.connect.disabled = busy;
1710 pane.disconnect.disabled = !pane.sessionId || (!pane.pending && kind !== "connected" && kind !== "connecting");
1711 updateCredentialField(pane); renderTerminalTab(pane);
1712 requestAnimationFrame(() => fitPane(pane));
1714function writeTerminalError(pane, error) { pane.terminal?.writeln(`\r\n\x1b[31m${errorText(error)}\x1b[0m`); }
1715async function connectTerminal(pane) {
1716 if (pane.pending || pane.kind === "connected") return;
1717 const target = selectedTarget(pane), jump = selectedJump(pane);
1718 if (!target) return;
1719 const valid = (host) => host.host && host.username && Number.isInteger(host.port) && host.port >= 1 && host.port <= 65535 && (host.authMethod !== "privateKey" || host.privateKeyPath || host.keychainKeyId);
1720 if (!valid(target) || (jump && !valid(jump))) { writeTerminalError(pane, { code: "SSH_TARGET_INVALID" }); return; }
1721 const sessionId = terminalSessionId(pane.slot);
1722 pane.sessionId = sessionId; pane.pending = true;
1723 fitPane(pane); pane.terminal?.clear();
1724 pane.terminal?.writeln(`\x1b[33m${t("terminal.connectingTo", { target: `${target.username}@${target.host}:${target.port}` })}\x1b[0m`);
1725 setTerminalState(pane, "connecting");
1726 try {
1727 while (pane.sessionId === sessionId) {
1728 const result = await api.connectSsh(buildTerminalPayload({ sessionId, slot: pane.slot, target, jump,
1729 credential: pane.credential.value, jumpCredential: pane.jumpCredential.value,
1730 cols: pane.terminal?.cols || 100, rows: pane.terminal?.rows || 30 }));
1731 if (pane.sessionId !== sessionId) {
1732 if (result.ok) await api.disconnectSsh(sessionId);
1733 return;
1735 if (result.ok) {
1736 pane.credential.value = ""; pane.jumpCredential.value = ""; pane.pending = false;
1737 setTerminalState(pane, "connected"); fitPane(pane); pane.terminal?.focus(); return;
1739 const issue = result.error || { message: t("ssh.failed") };
1740 const changed = issue.code === "HOST_KEY_MISMATCH";
1741 if (!changed && issue.code !== "HOST_KEY_UNKNOWN") throw issue;
1742 const prompt = `${t(issue.hop === "jump" ? "ssh.hopJump" : "ssh.hopTarget")}\n\n${t(changed ? "ssh.changedHostConfirm" : "ssh.unknownHost", issue)}`;
1743 if (!window.confirm(prompt)) break;
1744 const trusted = await api.trustSshHostKey({ sessionId, profileId: target.id || "", fingerprint: issue.fingerprint,
1745 ...(changed ? { knownFingerprint: issue.knownFingerprint, replaceKnown: true } : {}) });
1746 if (pane.sessionId !== sessionId) return;
1747 if (!trusted.ok) throw trusted.error || { message: t("ssh.trustFailed") };
1749 } catch (error) { if (pane.sessionId === sessionId) writeTerminalError(pane, error); }
1750 finally {
1751 if (pane.sessionId === sessionId) {
1752 pane.pending = false;
1753 if (pane.kind !== "connected") { pane.sessionId = null; setTerminalState(pane, "error"); }
1757async function disconnectTerminal(pane) {
1758 const sessionId = pane.sessionId;
1759 if (!sessionId) return;
1760 // Invalidate before awaiting IPC, so late replies and events cannot revive the pane.
1761 pane.sessionId = null; pane.pending = false; setTerminalState(pane, "disconnected");
1762 await api.disconnectSsh(sessionId);
1764function applyTerminalLayout(layout) {
1765 const split = layout !== "single", box = $("#terminalPanes");
1766 if (split) lastSplitLayout = layout;
1767 box.classList.toggle("split", split); box.classList.toggle("stacked", layout === "stacked");
1768 $("#terminalDivider").hidden = !split; $("#terminalDivider").setAttribute("aria-orientation", layout === "stacked" ? "horizontal" : "vertical");
1769 $("#terminalSwap").hidden = !split;
1770 for (const pane of panes) pane.root.hidden = !split && pane !== activePane;
1771 setActivePane(activePane);
1772 renderTerminalStrip();
1773 applyTerminalSplit();
1774 requestAnimationFrame(fitTerminal);
1776function renderTerminalStrip() {
1777 const layout = state.config?.ui.terminalLayout || "single";
1778 document.querySelectorAll("#terminalLayout button").forEach((button) => {
1779 const active = button.dataset.layout === layout;
1780 button.classList.toggle("active", active); button.setAttribute("aria-checked", String(active));
1781 button.title = t(button.dataset.label); button.setAttribute("aria-label", button.title);
1782 });
1783 document.querySelector('#terminalLayout [data-layout="single"]').title += ` (${terminalShortcut(state.info?.platform === "darwin" ? "⇧↩" : "Enter")})`;
1784 $("#terminalSwap").title = t("terminal.swap"); $("#terminalSwap").setAttribute("aria-label", t("terminal.swap"));
1785 for (const pane of panes) renderTerminalTab(pane);
1787function applyTerminalSplit(ratio = state.config?.ui.terminalSplit ?? 0.5) {
1788 const swapped = state.config?.ui.terminalSwapped === true, [first, second] = swapped ? [panes[1], panes[0]] : panes;
1789 $("#terminalPanes").classList.toggle("swapped", swapped);
1790 first.root.style.flexGrow = String(Math.round(ratio * 1000)); second.root.style.flexGrow = String(Math.round((1 - ratio) * 1000));
1791 $("#terminalDivider").setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
1793async function saveTerminalUi(patch) {
1794 try { state.config = await api.updateConfig({ ui: patch }); }
1795 catch (error) { window.alert(errorText(error)); }
1796 applyTerminalLayout(state.config?.ui.terminalLayout || "single");
1798// Hiding a pane never ends its session: the tab keeps its status and brings it back.
1799$("#terminalLayout").addEventListener("click", (event) => {
1800 const layout = event.target.closest("[data-layout]")?.dataset.layout;
1801 if (layout) void saveTerminalUi({ terminalLayout: layout });
1802});
1803$("#terminalSwap").addEventListener("click", () => void saveTerminalUi({ terminalSwapped: state.config?.ui.terminalSwapped !== true }));
1805 const divider = $("#terminalDivider"), box = $("#terminalPanes"), clamp = (value) => Math.min(0.8, Math.max(0.2, value));
1806 let dragging = false, ratio = 0.5;
1807 divider.addEventListener("pointerdown", (event) => {
1808 event.preventDefault(); divider.setPointerCapture(event.pointerId);
1809 dragging = true; ratio = state.config?.ui.terminalSplit ?? 0.5; box.classList.add("resizing");
1810 });
1811 divider.addEventListener("pointermove", (event) => {
1812 if (!dragging) return;
1813 const rect = box.getBoundingClientRect(), stacked = box.classList.contains("stacked");
1814 const position = stacked ? (event.clientY - rect.top) / rect.height : (event.clientX - rect.left) / rect.width;
1815 ratio = clamp(!stacked && document.documentElement.dir === "rtl" ? 1 - position : position);
1816 applyTerminalSplit(ratio);
1817 });
1818 const release = () => {
1819 if (!dragging) return;
1820 dragging = false; box.classList.remove("resizing");
1821 void saveTerminalUi({ terminalSplit: ratio });
1822 };
1823 divider.addEventListener("pointerup", release); divider.addEventListener("pointercancel", release);
1824 divider.addEventListener("dblclick", () => void saveTerminalUi({ terminalSplit: 0.5 }));
1825 divider.addEventListener("keydown", (event) => {
1826 const step = { ArrowLeft: -0.05, ArrowUp: -0.05, ArrowRight: 0.05, ArrowDown: 0.05 }[event.key];
1827 if (!step) return;
1828 event.preventDefault();
1829 void saveTerminalUi({ terminalSplit: clamp((state.config?.ui.terminalSplit ?? 0.5) + step) });
1830 });
1832// Capture phase: the focused terminal must not receive these as input.
1833window.addEventListener("keydown", (event) => {
1834 if (!$("main").classList.contains("terminal-active") || event.altKey) return;
1835 const mac = state.info?.platform === "darwin";
1836 if (!(mac ? event.metaKey : event.ctrlKey && event.shiftKey)) return;
1837 if (event.code === "Digit1" || event.code === "Digit2") setActivePane(panes[event.code === "Digit1" ? 0 : 1], true);
1838 else if (event.code === "Enter" && event.shiftKey) void saveTerminalUi({ terminalLayout: state.config?.ui.terminalLayout === "single" ? lastSplitLayout : "single" });
1839 else return;
1840 event.preventDefault(); event.stopPropagation();
1841}, true);
1843function renderTerminalLatency(pane) {
1844 const element = pane.latencyElement;
1845 element.hidden = pane.kind !== "connected";
1846 const latency = pane.latency;
1847 const ms = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? (value < 0.1 ? "<0.1 ms" : `${value.toFixed(1)} ms`) : null;
1848 const pingReasons = { jump: "terminal.pingJump", timeout: "terminal.pingTimeout", denied: "terminal.pingDenied", unreachable: "terminal.pingUnreachable", unsupported: "terminal.pingUnsupported", error: "terminal.pingError" };
1849 let ping = `— (${t("terminal.measuring")})`;
1850 let ssh = ping;
1851 if (latency?.measuredAt || latency?.pingStatus === "jump") {
1852 const family = latency.pingFamily === 6 ? "IPv6" : latency.pingFamily === 4 ? "IPv4" : "";
1853 const pingValue = latency.pingStatus === "jump" ? null : ms(latency.pingMs);
1854 const loss = typeof latency.pingLoss === "number" && latency.pingLoss > 0 ? `, ${t("terminal.pingLoss", { loss: Math.round(latency.pingLoss * 100) })}` : "";
1855 ping = pingValue ? `${latency.pingLessThan ? "<" : ""}${pingValue}${family ? ` ${family}` : ""}${loss}` : `— (${t(pingReasons[latency.pingStatus] || "terminal.latencyUnavailable")})`;
1856 const sshValue = ms(latency.sshRttMs);
1857 ssh = sshValue || `— (${t("terminal.latencyUnavailable")})`;
1859 element.textContent = `${t("terminal.ping")}: ${ping} · ${t("terminal.sshRtt")}: ${ssh}`;
1860 const method = latency?.sshRttMethod === "transport" ? t("terminal.sshRttTransport") : latency?.sshRttMethod === "command" ? t("terminal.sshRttCommand") : "";
1861 element.title = [element.textContent, t("terminal.latencyMeaning"), method, latency?.pingReason, latency?.sshRttError].filter(Boolean).join("\n");
1864async function checkUpdates() {
1865 setBusy(elements.checkUpdateButton, true, t("updates.checking"));
1866 elements.updateHeadline.textContent = t("updates.checkingServer");
1867 elements.updateMessage.textContent = t("updates.tryingEndpoints");
1868 try { renderUpdate(await api.checkForUpdates()); }
1869 catch (error) { elements.updateHeadline.textContent = t("updates.checkFailed"); elements.updateMessage.textContent = errorText(error); }
1870 finally { setBusy(elements.checkUpdateButton, false, t("updates.check")); }
1873async function initialize() {
1874 if (!api || !i18n) throw new Error("Core preload or i18n API is unavailable.");
1875 [state.info, state.config, state.pluginState, state.update, state.vault] = await Promise.all([
1876 api.getInfo(), api.getConfig(), api.getPluginState(), api.getUpdateState(), api.getVaultStatus()
1877 ]);
1878 state.plugins = state.pluginState.installed;
1879 state.pluginWindows = state.pluginState.windows || [];
1880 await ensureLanguage(state.config.ui.language);
1881 i18n.setLanguage(state.config.ui.language);
1882 document.documentElement.lang = i18n.getLanguage();
1883 document.documentElement.dir = i18n.getDirection();
1884 i18n.apply(document);
1885 applySidebar();
1886 elements.runtimeText.textContent = `${state.info.platform} / ${state.info.arch}`;
1887 elements.sidebarVersion.textContent = `v${state.info.version}`;
1888 $("#heroVersion").textContent = `CORE v${state.info.version}`;
1889 applyTerminalLayout(state.config.ui.terminalLayout);
1890 elements.coreApiValue.textContent = state.info.coreApi;
1891 elements.dataSchemaValue.textContent = String(state.info.dataSchema);
1892 elements.currentVersion.textContent = `v${state.info.version}`;
1893 elements.languageSelect.value = state.config.ui.language;
1894 $("#suggestEnabled").checked = state.config.ui.terminalSuggest !== false;
1895 renderTerminalAppearance(state.config.ui.terminalAppearance);
1896 $("#suggestRepair").checked = state.config.ui.terminalSuggestRepair !== false;
1897 void loadSuggestDictionary();
1898 elements.vaultLanguageSelect.value = state.config.ui.language;
1899 elements.vaultLanguageSelect.disabled = false;
1900 elements.channelSelect.value = state.config.updates.channel;
1901 elements.updateViewChannelSelect.value = state.config.updates.channel;
1902 elements.pluginChannelSelect.value = state.config.plugins.channel;
1903 elements.pluginViewChannelSelect.value = state.config.plugins.channel;
1904 elements.autoCheckInput.checked = state.config.updates.autoCheck;
1905 renderMonitoringSettings();
1906 elements.baseUrlsText.textContent = state.config.updates.baseUrls.join("\n");
1907 elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
1908 const security = state.config.security;
1909 $("#autoLockEnabled").checked = security.autoLockMinutes > 0;
1910 $("#autoLockMinutes").value = String(security.autoLockMinutes || 15);
1911 $("#autoLockMinutes").disabled = !security.autoLockMinutes;
1912 $("#lockOnBlur").checked = security.lockOnBlur;
1913 $("#requireSystemAuthentication").checked = security.requireSystemAuthentication;
1914 diagnosticsView = window.CHJ_DIAGNOSTICS.mount($("#diagnosticsView"), {
1915 // Diagnostics follows the application theme. The application shell is dark-only, so
1916 // the module must not fall back to the OS colour scheme (light on many Windows/Linux setups).
1917 api: api.diagnostics, language: () => i18n.getLanguage(), getTheme: () => APP_THEME
1918 });
1919 renderPlugins(); renderUpdate(state.update); initTerminal(); configureVault(state.vault);
1920 renderTerminalProfileSelect();
1921 void refreshBiometrics().then(maybeAutoUnlock);
1922 window.addEventListener("focus", () => void maybeAutoUnlock());
1923 document.addEventListener("visibilitychange", () => void maybeAutoUnlock());
1924 if (state.vault.unlocked) await loadProfiles();
1927$("#vaultDismiss").addEventListener("click", () => {
1928 protectedDestination = null; elements.vaultOverlay.hidden = true;
1929 elements.vaultPassword.value = ""; elements.vaultPasswordConfirm.value = "";
1930});
1931document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
1932hashToolButton.addEventListener("click", async () => {
1933 try { await api.openPlugin(HASH_TOOL_ID); }
1934 catch (error) { window.alert(errorText(error)); }
1935});
1936elements.vaultForm.addEventListener("submit", async (event) => {
1937 event.preventDefault();
1938 const password = elements.vaultPassword.value;
1939 const passwordLength = Array.from(password).length;
1940 if (passwordLength < 4 || passwordLength > 64) { elements.vaultMessage.textContent = t("vault.passwordLength"); return; }
1941 if (state.vault.needsSetup && password !== elements.vaultPasswordConfirm.value) { elements.vaultMessage.textContent = t("vault.passwordMismatch"); return; }
1942 setBusy(elements.vaultSubmit, true, t(state.vault.needsSetup ? "vault.creating" : "vault.unlocking"));
1943 try {
1944 const status = state.vault.needsSetup ? await api.createVault(password) : await api.unlockVault(password);
1945 configureVault(status);
1946 await loadProfiles();
1947 } catch (error) { elements.vaultMessage.className = "form-message"; elements.vaultMessage.textContent = errorText(error); }
1948 finally { if (!state.vault.unlocked) setBusy(elements.vaultSubmit, false, t(state.vault.needsSetup ? "vault.create" : "vault.unlock")); }
1949});
1950elements.lockVaultButton.addEventListener("click", async () => {
1951 configureVault(await api.lockVault());
1952 clearProtectedUi();
1953 for (const pane of panes) pane.terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);
1954});
1955$("#biometricUnlockButton").addEventListener("click", async event => {
1956 event.currentTarget.disabled = true;
1957 try { configureVault(await api.unlockVaultWithBiometrics()); await loadProfiles(); }
1958 catch (error) { elements.vaultMessage.textContent = errorText(error); }
1959 finally { $("#biometricUnlockButton").disabled = false; void refreshBiometrics(); }
1960});
1961for (const [id, action, message] of [
1962 ["enableBiometricsButton", () => api.enableBiometrics(), "security.enabled"],
1963 ["disableBiometricsButton", () => api.disableBiometrics(), "security.disabled"],
1964 ["testBiometricsButton", () => api.authenticateSensitiveAction(), "security.testPassed"]
1965]) {
1966 $("#" + id).addEventListener("click", async event => {
1967 event.currentTarget.disabled = true;
1968 try { await action(); $("#securityMessage").textContent = t(message); }
1969 catch (error) { $("#securityMessage").textContent = errorText(error); }
1970 finally { await refreshBiometrics(); }
1971 });
1973$("#autoLockEnabled").addEventListener("change", event => {
1974 $("#autoLockMinutes").disabled = !event.target.checked;
1975 if (event.target.checked && $("#autoLockMinutes").value === "0") $("#autoLockMinutes").value = "15";
1976});
1977for (const id of ["#standbyEnabled", "#standbyMenuVerify", "#standbyMenuSign", "#standbyLogin", "#standbyMonitorEnabled", "#standbyMonitorCheck", "#standbyMonitorPort", "#standbyMonitorInterval", "#standbyMonitorAddresses"]) $(id).addEventListener("change", () => void saveStandby());
1978$("#standbyRefresh").addEventListener("click", () => { $("#standbyMessage").textContent = ""; void loadStandby(); });
1979$("#securityForm").addEventListener("submit", async event => {
1980 event.preventDefault();
1981 const password = $("#securityMasterPassword").value; $("#securityMasterPassword").value = "";
1982 try {
1983 state.config = await api.updateConfig({ masterPassword: password, security: {
1984 autoLockMinutes: $("#autoLockEnabled").checked ? Number($("#autoLockMinutes").value) : 0,
1985 lockOnBlur: $("#lockOnBlur").checked, requireSystemAuthentication: $("#requireSystemAuthentication").checked
1986 } });
1987 $("#securityMessage").textContent = t("settings.saved");
1988 } catch (error) { $("#securityMessage").textContent = errorText(error); }
1989});
1990elements.showVaultResetButton.addEventListener("click", () => {
1991 elements.vaultResetPanel.hidden = false;
1992 elements.showVaultResetButton.hidden = true;
1993 elements.vaultResetConfirmation.focus();
1994});
1995elements.cancelVaultResetButton.addEventListener("click", () => {
1996 elements.vaultResetPanel.hidden = true;
1997 elements.showVaultResetButton.hidden = false;
1998 elements.vaultResetConfirmation.value = "";
1999});
2000elements.confirmVaultResetButton.addEventListener("click", async () => {
2001 if (elements.vaultResetConfirmation.value !== "SMAZAT") {
2002 elements.vaultMessage.textContent = t("vault.typeDelete");
2003 return;
2005 if (!window.confirm(t("vault.finalResetConfirm"))) return;
2006 setBusy(elements.confirmVaultResetButton, true, t("vault.deleting"));
2007 try {
2008 const status = await api.resetVault("SMAZAT");
2009 clearProtectedUi();
2010 configureVault(status);
2011 elements.vaultMessage.className = "form-message success";
2012 elements.vaultMessage.textContent = t("vault.resetDone");
2013 } catch (error) {
2014 elements.vaultMessage.className = "form-message";
2015 elements.vaultMessage.textContent = errorText(error);
2016 } finally {
2017 setBusy(elements.confirmVaultResetButton, false, t("vault.resetAction"));
2019});
2020elements.newProfileButton.addEventListener("click", () => { state.selectedProfileId = ""; renderProfiles(); resetProfileForm(); elements.profileLabel.focus(); });
2021elements.profileAuthMethod.addEventListener("change", updatePrivateKeyField);
2022elements.profileStorePassword.addEventListener("change", updatePrivateKeyField);
2023elements.selectPrivateKeyButton.addEventListener("click", async () => {
2024 const epoch = protectedEpoch;
2025 const result = await api.selectPrivateKey();
2026 if (state.vault?.unlocked && epoch === protectedEpoch && !result.canceled) elements.profilePrivateKeyPath.value = result.path;
2027});
2028elements.profileForm.addEventListener("submit", async (event) => {
2029 event.preventDefault(); elements.profileMessage.className = "form-message"; elements.profileMessage.textContent = "";
2030 try {
2031 const saved = await api.saveProfile({
2032 id: elements.profileId.value || undefined, label: elements.profileLabel.value, host: elements.profileHost.value,
2033 port: Number(elements.profilePort.value), username: elements.profileUsername.value,
2034 jumpProfileId: $("#profileJumpProfileId").value,
2035 authMethod: elements.profileAuthMethod.value === "keychain" ? "privateKey" : elements.profileAuthMethod.value,kexPolicy:$("#profileKexPolicy").value,
2036 privateKeyPath: elements.profileAuthMethod.value === "privateKey" ? elements.profilePrivateKeyPath.value : "",
2037 keychainKeyId: elements.profileAuthMethod.value === "keychain" ? $("#profileKeychainKeyId").value : "",
2038 storePassword: elements.profileStorePassword.checked,
2039 password: elements.profileStoredPassword.value
2040 });
2041 await loadProfiles(saved.id); elements.profileMessage.className = "form-message success"; elements.profileMessage.textContent = t("profiles.saved");
2042 } catch (error) { elements.profileMessage.textContent = errorText(error); }
2043});
2044elements.deleteProfileButton.addEventListener("click", async () => {
2045 const id = elements.profileId.value;
2046 if (!id || !window.confirm(t("profiles.deleteConfirm"))) return;
2047 await api.deleteProfile(id); state.selectedProfileId = ""; await loadProfiles("");
2048});
2049elements.checkUpdateButton.addEventListener("click", checkUpdates);
2050elements.downloadUpdateButton.addEventListener("click", async () => {
2051 setBusy(elements.downloadUpdateButton, true, t("updates.downloadingAndVerifying"));
2052 elements.updateHeadline.textContent = t("updates.verifyingSignature");
2053 try { renderUpdate(await api.downloadUpdate()); }
2054 catch (_error) {
2055 elements.updateHeadline.textContent = t("updates.downloadRejected");
2056 elements.updateMessage.textContent = t("updates.signatureFailure");
2057 elements.installUpdateButton.hidden = true;
2058 elements.revealUpdateButton.hidden = true;
2060 finally { setBusy(elements.downloadUpdateButton, false, t("updates.download")); }
2061});
2062elements.installUpdateButton.addEventListener("click", async () => {
2063 const release = state.update?.release;
2064 if (!release || !window.confirm(t("updates.installConfirm", { version: release.version, date: formatReleaseDate(release.publishedAt) }))) return;
2065 setBusy(elements.installUpdateButton, true, t("updates.verifyingSignature"));
2066 try { await api.installUpdate(); }
2067 catch (_error) {
2068 try { renderUpdate(await api.getUpdateState()); } catch {}
2069 elements.updateHeadline.textContent = t("updates.installFailed");
2070 elements.updateMessage.textContent = t("updates.installFailure");
2071 elements.installUpdateButton.hidden = true;
2072 } finally { setBusy(elements.installUpdateButton, false, t("updates.install")); }
2073});
2074elements.revealUpdateButton.addEventListener("click", () => api.revealUpdate());
2075elements.updateViewChannelSelect.addEventListener("change", async () => {
2076 state.config = await api.updateConfig({ updates: { channel: elements.updateViewChannelSelect.value } });
2077 elements.channelSelect.value = state.config.updates.channel;
2078 elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
2079 await checkUpdates();
2080});
2081elements.pluginViewChannelSelect.addEventListener("change", async () => {
2082 state.config = await api.updateConfig({ plugins: { channel: elements.pluginViewChannelSelect.value } });
2083 elements.pluginChannelSelect.value = state.config.plugins.channel;
2084 elements.checkPluginCatalogButton.click();
2085});
2086elements.checkPluginCatalogButton.addEventListener("click", async () => {
2087 setBusy(elements.checkPluginCatalogButton, true, t("plugins.checking"));
2088 try { state.pluginState = await api.checkPluginCatalog(); state.plugins = state.pluginState.installed; renderPlugins(); }
2089 catch (error) { window.alert(errorText(error)); }
2090 finally { setBusy(elements.checkPluginCatalogButton, false, t("plugins.checkCatalog")); }
2091});
2092$("#settingsTabs").addEventListener("click", (event) => { const tab = event.target.closest("[data-settings-tab]"); if (tab) showSettingsTab(tab.dataset.settingsTab); });
2093$("#settingsTabs").addEventListener("keydown", (event) => {
2094 const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
2095 if (!step) return;
2096 const tabs = [...document.querySelectorAll("[data-settings-tab]")], next = tabs[(tabs.indexOf(document.activeElement) + step + tabs.length) % tabs.length];
2097 showSettingsTab(next.dataset.settingsTab); next.focus(); event.preventDefault();
2098});
2099showSettingsTab("general");
2100for (const element of document.querySelectorAll('[data-settings-page="appearance"] details input, [data-settings-page="appearance"] details select')) {
2101 element.addEventListener("input", () => { $("#appearanceProfile").value = APPEARANCE_EDITED; appearanceMessage(); previewTerminalAppearance(); });
2103$("#appearanceProfile").addEventListener("change", () => {
2104 const id = $("#appearanceProfile").value, profile = profileById(id);
2105 if (profile) fillAppearanceForm(profile.appearance);
2106 appearanceMessage(); previewTerminalAppearance();
2107});
2108$("#appearanceReset").addEventListener("click", () => { renderTerminalAppearance({}); appearanceMessage(); });
2109$("#appearanceProfileSave").addEventListener("click", async () => {
2110 const name = $("#appearanceProfileName").value.trim();
2111 if (!name) { appearanceMessage("terminal.appearance.nameRequired"); return; }
2112 const id = await saveUserProfile(name, appearanceFromForm());
2113 $("#appearanceProfileName").value = ""; renderAppearanceProfiles(id); appearanceMessage("terminal.appearance.profileSaved"); previewTerminalAppearance();
2114});
2115$("#appearanceProfileDelete").addEventListener("click", async () => {
2116 const id = $("#appearanceProfile").value;
2117 state.config = await api.updateConfig({ ui: { terminalProfiles: userProfiles().filter((profile) => profile.id !== id) } });
2118 renderAppearanceProfiles(APPEARANCE_EDITED); appearanceMessage(); previewTerminalAppearance();
2119});
2120$("#appearanceExport").addEventListener("click", () => {
2121 const selected = profileById($("#appearanceProfile").value), option = $("#appearanceProfile").selectedOptions[0];
2122 $("#appearanceShare").value = appearanceApi().exportProfile(selected ? option.textContent : "CH-J", { ...appearanceFromForm(), profile: "" });
2123 appearanceMessage("terminal.appearance.exported");
2124});
2125$("#appearanceImport").addEventListener("click", async () => {
2126 let imported;
2127 try { imported = appearanceApi().parseProfile($("#appearanceShare").value); }
2128 catch { appearanceMessage("terminal.appearance.importInvalid"); return; }
2129 const id = await saveUserProfile(imported.name, imported.appearance);
2130 // Core has validated and cleaned the values; what it kept is what the form shows.
2131 renderAppearanceProfiles(id); fillAppearanceForm(profileById(id).appearance); appearanceMessage("terminal.appearance.imported"); previewTerminalAppearance();
2132});
2133elements.settingsForm.addEventListener("submit", async (event) => {
2134 event.preventDefault();
2135 state.config = await api.updateConfig({
2136 ui: { language: elements.languageSelect.value, terminalSuggest: $("#suggestEnabled").checked, terminalSuggestRepair: $("#suggestRepair").checked, terminalAppearance: appearanceFromForm() },
2137 updates: { channel: elements.channelSelect.value, autoCheck: elements.autoCheckInput.checked },
2138 plugins: { channel: elements.pluginChannelSelect.value },
2139 monitoring: { enabled: $("#monitoringEnabled").checked, intervalSeconds: Number($("#monitoringInterval").value) }
2140 });
2141 applyTerminalAppearance(state.config.ui.terminalAppearance);
2142 elements.updateViewChannelSelect.value = state.config.updates.channel;
2143 elements.pluginViewChannelSelect.value = state.config.plugins.channel;
2144 await ensureLanguage(state.config.ui.language);
2145 applyLanguage(state.config.ui.language);
2146 elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
2147 renderMonitoringSettings();
2148 if (state.vault?.unlocked) {
2149 const saved = await api.saveTerminalDictionary({ custom: dictionaryLines($("#suggestCustom")), hidden: dictionaryLines($("#suggestHidden")) });
2150 if (!saved.ok) { elements.settingsStatus.textContent = errorText(saved.error); return; }
2151 await loadSuggestDictionary();
2153 elements.settingsStatus.textContent = t("settings.saved"); setTimeout(() => { elements.settingsStatus.textContent = ""; }, 1800);
2154});
2155elements.languageSelect.addEventListener("change", async () => { await ensureLanguage(elements.languageSelect.value); applyLanguage(elements.languageSelect.value); });
2156$("#refreshAvailabilityButton").addEventListener("click", () => void api.refreshAvailability().catch(() => {}));
2157document.querySelectorAll("[data-legal-document]").forEach((button) => {
2158 button.addEventListener("click", async () => {
2159 try { await api.openLegalDocument(button.dataset.legalDocument); }
2160 catch (error) { window.alert(errorText(error)); }
2161 });
2162});
2163elements.vaultLanguageSelect.addEventListener("change", async () => {
2164 await ensureLanguage(elements.vaultLanguageSelect.value);
2165 const language = applyLanguage(elements.vaultLanguageSelect.value);
2166 state.config = await api.updateConfig({ ui: { language } });
2167});
2169api?.onUpdateState((payload) => renderUpdate(payload));
2170api?.onAvailabilityChanged((event) => {
2171 if (!state.vault?.unlocked) { availabilityState = new Map(); return; }
2172 if (event.reset) availabilityState = new Map();
2173 for (const id of event.removed || []) availabilityState.delete(id);
2174 for (const entry of event.servers || []) availabilityState.set(entry.id, entry);
2175 renderAvailability();
2176});
2177api?.onPluginWindowState((windows) => {
2178 renderOpenModules(windows);
2179 // The shown module was collapsed, detached or closed: leave the empty area.
2180 const onModuleView = document.querySelector('[data-view-panel="module"]')?.classList.contains("active");
2181 if (onModuleView && !state.pluginWindows.some((item) => item.active)) showView("overview");
2182 const active = state.pluginWindows.find((item) => item.active);
2183 if (onModuleView && active) elements.viewTitle.textContent = active.name;
2184});
2185api?.onModuleActivate?.(() => {
2186 showView("module");
2187 const active = state.pluginWindows.find((item) => item.active);
2188 if (active) elements.viewTitle.textContent = active.name;
2189});
2190const shownModule = () => state.pluginWindows.find((item) => item.active)?.pluginId;
2191for (const [id, command] of [["#moduleDetach", "detachModule"], ["#moduleCollapse", "collapseModule"], ["#moduleClose", "closeModule"]]) {
2192 $(id).addEventListener("click", () => { const module = shownModule(); if (module) void api[command](module).catch((error) => window.alert(errorText(error))); });
2194new ResizeObserver(reportModuleArea).observe($("#moduleHostArea"));
2195window.addEventListener("resize", reportModuleArea);
2197const updateOffered = new Set();
2198function offerUpdate() {
2199 const update = state.update;
2200 const version = update?.release?.version;
2201 const newer = ["newerVersion", "sameVersionNewerRelease"].includes(update?.releaseRelation);
2202 const offerId = `${version}:${update?.release?.sha512 || ""}`;
2203 const dialog = $("#updateAvailableDialog");
2204 if (!version || !newer || updateOffered.has(offerId) || dialog.open || !$("#vaultOverlay").hidden) return;
2205 updateOffered.add(offerId);
2206 $("#updateAvailableMessage").textContent = t("updates.availableMessage", { current: state.info?.version || "?", version });
2207 moduleAreaSuspended = true; reportModuleArea();
2208 dialog.showModal();
2210$("#updateAvailableDialog").addEventListener("close", () => { moduleAreaSuspended = false; reportModuleArea(); });
2211$("#updateAvailableLater").addEventListener("click", () => $("#updateAvailableDialog").close());
2212$("#updateAvailableOpen").addEventListener("click", () => { $("#updateAvailableDialog").close(); showView("settings"); showSettingsTab("updates"); });
2213new MutationObserver(offerUpdate).observe($("#vaultOverlay"), { attributes: true, attributeFilter: ["hidden"] });
2214api?.onSshData((event) => paneForEvent(panes, event)?.terminal?.write(event.data));
2215api?.onSshState((event) => {
2216 const pane = paneForEvent(panes, event);
2217 if (!pane) return;
2218 pane.latency = event.latency;
2219 // A host-key retry may emit disconnected before the next connect. Keep controls frozen.
2220 if (pane.pending && event.state !== "connected") return;
2221 const kind = ["connected", "connecting"].includes(event.state) ? event.state : "disconnected";
2222 if (kind === "disconnected") pane.sessionId = null;
2223 setTerminalState(pane, kind, t(`terminal.${kind}`));
2224});
2225api?.onSshError((event) => { const pane = paneForEvent(panes, event); if (pane) writeTerminalError(pane, event); });
2226api?.onVaultLocked(status => {
2227 configureVault(status); clearProtectedUi(); void refreshBiometrics().then(maybeAutoUnlock);
2228 for (const pane of panes) pane.terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);
2229});
2231initialize().catch((error) => {
2232 elements.runtimeText.textContent = t("errors.initialization");
2233 elements.updateMessage.textContent = errorText(error);
2234});
SHA-256ce50adf16f079a7cbd667aa72636aee7f11781bb059fc8dd5bc47296cf794415

CH-J Proprietary Software License 1.14 · Vir je na voljo pod CH-J Proprietary Software License 1.14. Njena razpoložljivost ne spreminja pogojev licence ali dodeljuje dodatnih pravic.

Arhiv SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Datoteke