CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J Proprietary Software License 1.14

Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

42,6 KB · 791 linesDownload file
1"use strict";
3const api = window.chjCore;
4const i18n = window.chjI18n;
5const t = (key, values) => i18n.t(key, values);
6const TERMINAL_SESSION_ID = "terminal-main";
7const HASH_TOOL_ID = "chj.hash-checksum";
8if (api?.platform) document.body.classList.add(`platform-${api.platform}`);
9const state = {
10 info: null,
11 config: null,
12 plugins: [],
13 pluginState: null,
14 pluginWindows: [],
15 update: null,
16 vault: null,
17 profiles: [],
18 selectedProfileId: "",
19 terminalConnected: false,
20 terminalStateKind: "disconnected"
21};
23const $ = (selector) => document.querySelector(selector);
24const elements = {
25 viewTitle: $("#viewTitle"), runtimeText: $("#runtimeText"), sidebarVersion: $("#sidebarVersion"),
26 coreApiValue: $("#coreApiValue"), dataSchemaValue: $("#dataSchemaValue"), pluginCountValue: $("#pluginCountValue"),
27 currentVersion: $("#currentVersion"), updateChannel: $("#updateChannel"), updateHeadline: $("#updateHeadline"),
28 updateMessage: $("#updateMessage"), updateHash: $("#updateHash"), checkUpdateButton: $("#checkUpdateButton"),
29 downloadUpdateButton: $("#downloadUpdateButton"), installUpdateButton: $("#installUpdateButton"), revealUpdateButton: $("#revealUpdateButton"),
30 updateReleaseRows: $("#updateReleaseRows"), updateReleaseCount: $("#updateReleaseCount"), pluginRows: $("#pluginRows"),
31 pluginCatalogRows: $("#pluginCatalogRows"), pluginCatalogSource: $("#pluginCatalogSource"), checkPluginCatalogButton: $("#checkPluginCatalogButton"),
32 pluginTaskbar: $("#pluginTaskbar"), pluginTaskbarItems: $("#pluginTaskbarItems"),
33 updateViewChannelSelect: $("#updateViewChannelSelect"), pluginViewChannelSelect: $("#pluginViewChannelSelect"),
34 settingsForm: $("#settingsForm"), languageSelect: $("#languageSelect"), channelSelect: $("#channelSelect"), pluginChannelSelect: $("#pluginChannelSelect"), autoCheckInput: $("#autoCheckInput"),
35 baseUrlsText: $("#baseUrlsText"), settingsStatus: $("#settingsStatus"), lockVaultButton: $("#lockVaultButton"),
36 vaultOverlay: $("#vaultOverlay"), vaultForm: $("#vaultForm"), vaultLanguageSelect: $("#vaultLanguageSelect"), vaultModeText: $("#vaultModeText"), vaultTitle: $("#vaultTitle"),
37 vaultDescription: $("#vaultDescription"), vaultPassword: $("#vaultPassword"), vaultConfirmLabel: $("#vaultConfirmLabel"),
38 vaultPasswordConfirm: $("#vaultPasswordConfirm"), vaultMessage: $("#vaultMessage"), vaultSubmit: $("#vaultSubmit"),
39 showVaultResetButton: $("#showVaultResetButton"), vaultResetPanel: $("#vaultResetPanel"),
40 vaultResetConfirmation: $("#vaultResetConfirmation"), cancelVaultResetButton: $("#cancelVaultResetButton"),
41 confirmVaultResetButton: $("#confirmVaultResetButton"),
42 profileRows: $("#profileRows"), profileForm: $("#profileForm"), profileId: $("#profileId"), profileLabel: $("#profileLabel"),
43 profileHost: $("#profileHost"), profilePort: $("#profilePort"), profileUsername: $("#profileUsername"),
44 profileAuthMethod: $("#profileAuthMethod"), privateKeyField: $("#privateKeyField"), passwordStorageField: $("#passwordStorageField"),
45 profileStorePassword: $("#profileStorePassword"), profileStoredPassword: $("#profileStoredPassword"),
46 profilePrivateKeyPath: $("#profilePrivateKeyPath"), selectPrivateKeyButton: $("#selectPrivateKeyButton"),
47 profileMessage: $("#profileMessage"), deleteProfileButton: $("#deleteProfileButton"), newProfileButton: $("#newProfileButton"),
48 terminalProfileSelect: $("#terminalProfileSelect"), credentialLabelText: $("#credentialLabelText"), terminalCredential: $("#terminalCredential"),
49 connectButton: $("#connectButton"), disconnectButton: $("#disconnectButton"), terminalState: $("#terminalState"),
50 terminalHost: $("#terminalHost"), terminalElement: $("#terminal")
51};
53const titles = {
54 overview: "nav.overview", terminal: "terminal.title", profiles: "nav.servers",
55 updates: "nav.updates", plugins: "nav.plugins", diagnostics: "nav.diagnostics", settings: "nav.settings"
56};
58let diagnosticsView = null;
59let terminal = null;
60let fitAddon = null;
62function errorText(error) {
63 const code = error?.code || String(error?.message || "").match(/BIOMETRIC_[A-Z_]+/)?.[0];
64 if (code?.startsWith("BIOMETRIC_")) return t(`biometrics.${code}`);
65 if (["SSH_LOCAL_NETWORK_DENIED", "SSH_MAC_NETWORK_UNREACHABLE"].includes(code)) return `${t(`ssh.${code}`)}\n${error.message || ""}`;
66 return error?.message || String(error || t("errors.unknown"));
69let biometricStatus = null;
70function renderBiometrics() {
71 const status = biometricStatus;
72 $("#biometricUnlockButton").hidden = !status?.enabled || !status.available || state.vault?.unlocked || !state.vault?.initialized;
73 $("#biometricUnlockButton").textContent = t(`biometrics.${status?.provider || "touch-id"}`);
74 $("#biometricStatus").textContent = status ? `${status.provider}: ${t(`biometrics.${status.code}`)} · ${t(status.enabled ? "security.enabled" : "security.disabled")}` : "";
75 $("#biometricProtection").textContent = status ? t(status.protection === "biometry-current-set" ? "security.strong" : "security.convenience") : "";
76 $("#enableBiometricsButton").disabled = !state.vault?.unlocked || !status?.available || status.enabled;
77 $("#disableBiometricsButton").disabled = !state.vault?.unlocked || !status?.enrolled;
78 $("#testBiometricsButton").disabled = !state.vault?.unlocked || !status?.available;
80async function refreshBiometrics() {
81 try { biometricStatus = await api.getBiometricStatus(); renderBiometrics(); }
82 catch (error) { $("#securityMessage").textContent = errorText(error); }
85function showView(name) {
86 document.querySelectorAll("[data-view]").forEach((button) => button.classList.toggle("active", button.dataset.view === name));
87 document.querySelectorAll("[data-view-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.viewPanel === name));
88 elements.viewTitle.textContent = titles[name] ? t(titles[name]) : "CH-J Server Manager";
89 if (name === "terminal") requestAnimationFrame(fitTerminal);
92function setBusy(button, busy, label) {
93 button.disabled = busy;
94 if (label) button.textContent = label;
97function formatBytes(bytes) {
98 const units = ["B", "KB", "MB", "GB"];
99 let value = Number(bytes || 0);
100 let index = 0;
101 while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; }
102 return `${i18n.formatNumber(value, { maximumFractionDigits: 1 })} ${units[index]}`;
105function applyLanguage(language) {
106 const selected = i18n.setLanguage(language);
107 document.documentElement.lang = selected;
108 i18n.apply(document);
109 elements.languageSelect.value = selected;
110 elements.vaultLanguageSelect.value = selected;
111 const activeView = document.querySelector("[data-view].active")?.dataset.view || "overview";
112 showView(activeView);
113 if (state.update) renderUpdate(state.update);
114 if (state.config) elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
115 renderPlugins();
116 renderProfiles();
117 diagnosticsView?.refreshLanguage();
118 renderTerminalProfileSelect();
119 if (state.vault) configureVault(state.vault);
120 renderBiometrics();
121 const terminalStateKeys = { disconnected: "terminal.disconnected", connecting: "terminal.connecting", connected: "terminal.connected", error: "terminal.connectionError" };
122 setTerminalState(state.terminalStateKind, t(terminalStateKeys[state.terminalStateKind] || "terminal.disconnected"));
123 return selected;
126function renderUpdate(updateState) {
127 state.update = updateState;
128 elements.currentVersion.textContent = `v${updateState.currentVersion || state.info?.version || "0.0.1"}`;
129 const release = updateState.release;
130 const currentReleaseSelected = updateState.releaseRelation === "sameVersionCurrentRelease";
131 elements.downloadUpdateButton.hidden = currentReleaseSelected;
132 elements.downloadUpdateButton.disabled = !release || currentReleaseSelected;
133 renderReleaseCatalog(updateState);
134 if (updateState.downloaded) {
135 elements.updateHeadline.textContent = t("updates.downloaded", { version: release?.version || "?" });
136 elements.updateMessage.textContent = `${updateState.downloaded.filename} · ${formatBytes(updateState.downloaded.size)} · ${t("updates.signatureVerified")}`;
137 elements.updateHash.hidden = false;
138 elements.updateHash.textContent = `SHA-512 ${updateState.downloaded.sha512}`;
139 elements.revealUpdateButton.hidden = false;
140 elements.installUpdateButton.hidden = false;
141 } else if (release) {
142 const relationKey = {
143 newerVersion: "updates.newerVersion",
144 sameVersionNewerRelease: "updates.sameVersionNewerRelease",
145 sameVersionCurrentRelease: "updates.sameVersionCurrentRelease",
146 sameVersionOlderRelease: "updates.sameVersionOlderRelease",
147 olderVersion: "updates.olderVersion"
148 }[updateState.releaseRelation] || "updates.selected";
149 elements.updateHeadline.textContent = t(relationKey, { version: release.version });
150 const published = t("updates.publishedAt", { date: formatReleaseDate(release.publishedAt) });
151 elements.updateMessage.textContent = release.notes ? `${published} · ${release.notes}` : published;
152 elements.updateHash.hidden = false;
153 elements.updateHash.textContent = `SHA-512 ${release.sha512}`;
154 elements.revealUpdateButton.hidden = true;
155 elements.installUpdateButton.hidden = true;
156 } else {
157 elements.updateHeadline.textContent = t("updates.noneAvailable");
158 elements.updateMessage.textContent = t("updates.noCompatible");
159 elements.updateHash.hidden = true;
160 elements.revealUpdateButton.hidden = true;
161 elements.installUpdateButton.hidden = true;
162 }
165function formatReleaseDate(value) {
166 const date = new Date(value);
167 if (Number.isNaN(date.getTime())) return String(value || "—");
168 return new Intl.DateTimeFormat(i18n.getLocale(), { dateStyle: "medium", timeStyle: "medium" }).format(date);
171function renderReleaseCatalog(updateState) {
172 const releases = Array.isArray(updateState.releases) ? updateState.releases : [];
173 elements.updateReleaseCount.textContent = String(releases.length);
174 elements.updateReleaseRows.replaceChildren();
175 if (!releases.length) {
176 const empty = document.createElement("div");
177 empty.className = "update-release-empty";
178 empty.textContent = t(updateState.status === "idle" ? "updates.catalogBeforeCheck" : "updates.catalogEmpty");
179 elements.updateReleaseRows.append(empty);
180 return;
181 }
182 for (const release of releases) {
183 const row = document.createElement("button");
184 row.type = "button";
185 row.className = `update-release-row${release.id === updateState.selectedReleaseId ? " selected" : ""}`;
186 const version = document.createElement("strong"); version.textContent = `v${release.version}`;
187 const published = document.createElement("span"); published.textContent = formatReleaseDate(release.publishedAt);
188 const file = document.createElement("span"); file.textContent = `${release.filename} · ${formatBytes(release.size)}`;
189 const action = document.createElement("span"); action.className = "release-select-label"; action.textContent = t(release.id === updateState.selectedReleaseId ? "updates.selectedLabel" : "updates.selectLabel");
190 row.append(version, published, file, action);
191 row.addEventListener("click", async () => {
192 try { renderUpdate(await api.selectUpdateRelease(release.id)); }
193 catch (error) { elements.updateMessage.textContent = errorText(error); }
194 });
195 elements.updateReleaseRows.append(row);
196 }
199function renderPlugins() {
200 const plugins = state.plugins.filter((plugin) => plugin.id !== HASH_TOOL_ID);
201 elements.pluginCountValue.textContent = String(plugins.length);
202 elements.pluginRows.replaceChildren();
203 if (plugins.length === 0) {
204 const empty = document.createElement("div");
205 empty.className = "empty-state";
206 const strong = document.createElement("strong"); strong.textContent = t("plugins.none");
207 const span = document.createElement("span"); span.textContent = t("plugins.first");
208 empty.append(strong, span); elements.pluginRows.append(empty); renderPluginCatalog(); renderPluginTaskbar(); return;
209 }
210 for (const plugin of plugins) {
211 const row = document.createElement("div"); row.className = "plugin-row plugin-table-row";
212 const name = document.createElement("strong"); name.textContent = plugin.name;
213 const version = document.createElement("span"); version.textContent = plugin.version;
214 const permissions = document.createElement("span"); permissions.textContent = plugin.permissions.join(", ") || t("plugins.noPermissions");
215 const actions = document.createElement("div"); actions.className = "plugin-actions";
216 const open = document.createElement("button"); open.type = "button"; open.className = "button"; open.textContent = t("plugins.open");
217 open.addEventListener("click", async () => { try { await api.openPlugin(plugin.id); } catch (error) { window.alert(errorText(error)); } });
218 actions.append(open);
219 if (plugin.bundled) {
220 const bundled = document.createElement("span"); bundled.className = "release-select-label"; bundled.textContent = t("plugins.bundled"); actions.append(bundled);
221 } else {
222 const remove = document.createElement("button"); remove.type = "button"; remove.className = "button danger"; remove.textContent = t("plugins.remove");
223 remove.addEventListener("click", async () => {
224 if (!window.confirm(t("plugins.removeConfirm", { name: plugin.name }))) return;
225 remove.disabled = true;
226 try {
227 state.pluginState = await api.uninstallPlugin(plugin.id);
228 state.plugins = state.pluginState.installed;
229 renderPlugins();
230 } catch (error) { window.alert(errorText(error)); remove.disabled = false; }
231 });
232 actions.append(remove);
233 }
234 row.append(name, version, permissions, actions); elements.pluginRows.append(row);
235 }
236 renderPluginCatalog();
237 renderPluginTaskbar();
240function renderPluginTaskbar(windows = state.pluginWindows) {
241 state.pluginWindows = Array.isArray(windows) ? windows : [];
242 const minimized = state.pluginWindows.filter((item) => item.minimized);
243 elements.pluginTaskbar.hidden = minimized.length === 0;
244 elements.pluginTaskbarItems.replaceChildren();
245 for (const plugin of minimized) {
246 const button = document.createElement("button");
247 button.type = "button";
248 button.className = "plugin-taskbar-item";
249 button.title = t("plugins.restore", { name: plugin.name });
250 const icon = document.createElement("span");
251 icon.className = "plugin-taskbar-icon";
252 icon.textContent = String(plugin.name || plugin.pluginId).split(/\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase();
253 const name = document.createElement("strong");
254 name.textContent = plugin.name;
255 const action = document.createElement("small");
256 action.textContent = t("plugins.restoreAction");
257 button.append(icon, name, action);
258 button.addEventListener("click", async () => {
259 try { await api.openPlugin(plugin.pluginId); }
260 catch (error) { window.alert(errorText(error)); }
261 });
262 elements.pluginTaskbarItems.append(button);
263 }
266function renderPluginCatalog() {
267 if (!elements.pluginCatalogRows) return;
268 const catalog = (state.pluginState?.catalog || []).filter((plugin) => plugin.id !== HASH_TOOL_ID);
269 elements.pluginCatalogSource.textContent = state.pluginState?.sourceBaseUrl || "";
270 elements.pluginCatalogRows.replaceChildren();
271 if (!catalog.length) {
272 const empty = document.createElement("div"); empty.className = "plugin-catalog-empty";
273 empty.textContent = t(state.pluginState?.sourceBaseUrl ? "plugins.catalogEmpty" : "plugins.catalogBeforeCheck");
274 elements.pluginCatalogRows.append(empty); return;
275 }
276 for (const plugin of catalog) {
277 const row = document.createElement("div"); row.className = "plugin-row plugin-table-row";
278 const name = document.createElement("div");
279 const strong = document.createElement("strong"); strong.textContent = plugin.name;
280 const notes = document.createElement("small"); notes.textContent = plugin.notes || plugin.id;
281 name.append(strong, notes);
282 const version = document.createElement("span"); version.textContent = `v${plugin.version} · ${plugin.channel}`;
283 const permissions = document.createElement("span"); permissions.textContent = plugin.permissions.join(", ") || t("plugins.noPermissions");
284 const install = document.createElement("button"); install.type = "button"; install.className = `button${plugin.installed ? "" : " primary"}`;
285 install.disabled = plugin.installed;
286 install.textContent = t(plugin.installed ? "plugins.installed" : plugin.updateAvailable ? "plugins.update" : "plugins.install");
287 install.addEventListener("click", async () => {
288 if (!window.confirm(t("plugins.installConfirm", { name: plugin.name, version: plugin.version, permissions: plugin.permissions.join(", ") || t("plugins.noPermissions") }))) return;
289 install.disabled = true; install.textContent = t("plugins.installing");
290 try {
291 state.pluginState = await api.installPlugin(plugin.releaseId);
292 state.plugins = state.pluginState.installed;
293 renderPlugins();
294 } catch (error) { window.alert(errorText(error)); install.disabled = false; install.textContent = t("plugins.install"); }
295 });
296 row.append(name, version, permissions, install); elements.pluginCatalogRows.append(row);
297 }
300function configureVault(status) {
301 state.vault = status;
302 renderBiometrics();
303 $("#securityMasterPassword").value = "";
304 elements.vaultOverlay.hidden = Boolean(status.unlocked);
305 elements.vaultMessage.className = "form-message";
306 elements.vaultMessage.textContent = "";
307 elements.vaultPassword.value = "";
308 elements.vaultPasswordConfirm.value = "";
309 elements.vaultResetConfirmation.value = "";
310 elements.vaultResetPanel.hidden = true;
311 if (status.unlocked) return;
312 if (status.damaged) {
313 elements.vaultForm.dataset.mode = "damaged";
314 elements.vaultModeText.textContent = t("vault.damagedMode");
315 elements.vaultTitle.textContent = t("vault.damagedTitle");
316 elements.vaultDescription.textContent = t("vault.damagedDescription");
317 elements.vaultSubmit.disabled = true;
318 elements.vaultConfirmLabel.hidden = true;
319 elements.showVaultResetButton.hidden = false;
320 return;
321 }
322 const setup = status.needsSetup;
323 elements.vaultForm.dataset.mode = setup ? "setup" : "unlock";
324 elements.vaultModeText.textContent = t(setup ? "vault.setupMode" : "vault.lockedMode");
325 elements.vaultSubmit.disabled = false;
326 elements.vaultConfirmLabel.hidden = !setup;
327 elements.showVaultResetButton.hidden = setup;
328 elements.vaultPassword.autocomplete = setup ? "new-password" : "current-password";
329 elements.vaultTitle.textContent = t(setup ? "vault.setupTitle" : "vault.unlockTitle");
330 elements.vaultDescription.textContent = t(setup ? "vault.setupDescription" : "vault.lockedDescription");
331 elements.vaultSubmit.textContent = t(setup ? "vault.create" : "vault.unlock");
332 setTimeout(() => elements.vaultPassword.focus(), 20);
335function clearProtectedUi() {
336 state.profiles = [];
337 state.selectedProfileId = "";
338 state.terminalConnected = false;
339 renderProfiles();
340 diagnosticsView?.refreshLanguage();
341 renderTerminalProfileSelect();
342 setTerminalState("disconnected", t("terminal.disconnected"));
343 terminal?.reset();
346async function loadProfiles(selectId = state.selectedProfileId) {
347 state.profiles = await api.listProfiles();
348 if (!state.profiles.some((profile) => profile.id === selectId)) selectId = state.profiles[0]?.id || "";
349 state.selectedProfileId = selectId;
350 renderProfiles();
351 diagnosticsView?.refreshLanguage();
352 renderTerminalProfileSelect();
353 if (selectId) fillProfileForm(state.profiles.find((profile) => profile.id === selectId));
354 else resetProfileForm();
357function renderProfiles() {
358 elements.profileRows.replaceChildren();
359 if (!state.profiles.length) {
360 const empty = document.createElement("div"); empty.className = "profile-empty";
361 empty.textContent = t("profiles.none"); elements.profileRows.append(empty); return;
362 }
363 for (const profile of state.profiles) {
364 const row = document.createElement("button"); row.type = "button";
365 row.className = `profile-row${profile.id === state.selectedProfileId ? " active" : ""}`;
366 const name = document.createElement("strong"); name.textContent = profile.label;
367 const address = document.createElement("span"); address.textContent = `${profile.username}@${profile.host}:${profile.port}`;
368 const auth = document.createElement("span"); auth.className = "profile-auth"; auth.textContent = t(profile.authMethod === "privateKey" ? "profiles.key" : "profiles.password");
369 row.append(name, address, auth);
370 row.addEventListener("click", () => { state.selectedProfileId = profile.id; renderProfiles(); fillProfileForm(profile); });
371 elements.profileRows.append(row);
372 }
375function renderTerminalProfileSelect() {
376 const previous = elements.terminalProfileSelect.value || state.selectedProfileId;
377 elements.terminalProfileSelect.replaceChildren(new Option(t("terminal.selectServer"), ""));
378 for (const profile of state.profiles) elements.terminalProfileSelect.add(new Option(`${profile.label} — ${profile.host}`, profile.id));
379 elements.terminalProfileSelect.value = state.profiles.some((profile) => profile.id === previous) ? previous : (state.profiles[0]?.id || "");
380 updateTerminalProfile();
383function resetProfileForm() {
384 elements.profileForm.reset();
385 elements.profileId.value = "";
386 elements.profilePort.value = "22";
387 elements.profileAuthMethod.value = "password";
388 elements.profilePrivateKeyPath.value = "";
389 elements.profileStorePassword.checked = false;
390 elements.profileStoredPassword.value = "";
391 elements.profileStoredPassword.placeholder = t("profiles.storePasswordPlaceholder");
392 elements.deleteProfileButton.hidden = true;
393 elements.profileMessage.textContent = "";
394 updatePrivateKeyField();
397function fillProfileForm(profile) {
398 if (!profile) { resetProfileForm(); return; }
399 elements.profileId.value = profile.id;
400 elements.profileLabel.value = profile.label;
401 elements.profileHost.value = profile.host;
402 elements.profilePort.value = String(profile.port);
403 elements.profileUsername.value = profile.username;
404 elements.profileAuthMethod.value = profile.authMethod;
405 elements.profilePrivateKeyPath.value = profile.privateKeyPath || "";
406 elements.profileStorePassword.checked = Boolean(profile.hasStoredPassword);
407 elements.profileStoredPassword.value = "";
408 elements.profileStoredPassword.placeholder = t(profile.hasStoredPassword ? "profiles.passwordStoredPlaceholder" : "profiles.storePasswordPlaceholder");
409 elements.deleteProfileButton.hidden = false;
410 elements.profileMessage.textContent = "";
411 updatePrivateKeyField();
414function updatePrivateKeyField() {
415 const privateKey = elements.profileAuthMethod.value === "privateKey";
416 elements.privateKeyField.hidden = !privateKey;
417 elements.passwordStorageField.hidden = privateKey;
418 elements.profileStoredPassword.disabled = privateKey || !elements.profileStorePassword.checked;
421function updateTerminalProfile() {
422 const profile = state.profiles.find((item) => item.id === elements.terminalProfileSelect.value);
423 if (!profile) {
424 elements.terminalHost.textContent = t("terminal.noProfile");
425 elements.credentialLabelText.textContent = t("terminal.password");
426 elements.terminalCredential.placeholder = t("terminal.sshPassword");
427 return;
428 }
429 elements.terminalHost.textContent = `${profile.username}@${profile.host}:${profile.port} · ${profile.authMethod === "privateKey" ? profile.privateKeyPath : t("terminal.passwordAuth")}`;
430 elements.credentialLabelText.textContent = t(profile.authMethod === "privateKey" ? "terminal.passphrase" : "terminal.password");
431 elements.terminalCredential.placeholder = t(profile.authMethod === "privateKey" ? "terminal.keyPassphrase" : profile.hasStoredPassword ? "terminal.savedPasswordHint" : "terminal.sshPassword");
434function initTerminal() {
435 if (!window.Terminal || !window.FitAddon?.FitAddon) {
436 elements.terminalHost.textContent = t("terminal.libraryMissing");
437 return;
438 }
439 terminal = new window.Terminal({
440 cursorBlink: true,
441 fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
442 fontSize: 13,
443 scrollback: 8000,
444 theme: { background: "#050b12", foreground: "#d8e7f5", cursor: "#43d6a0", selectionBackground: "#264c6d" }
445 });
446 fitAddon = new window.FitAddon.FitAddon();
447 terminal.loadAddon(fitAddon);
448 terminal.open(elements.terminalElement);
449 terminal.writeln("\x1b[36mCH-J Server Manager Core\x1b[0m");
450 terminal.writeln(`${t("terminal.welcome")}\r\n`);
451 terminal.onData((data) => { if (state.terminalConnected) api.sendTerminalInput(TERMINAL_SESSION_ID, data); });
452 new ResizeObserver(() => fitTerminal()).observe(elements.terminalElement);
453 fitTerminal();
456function fitTerminal() {
457 if (!terminal || !fitAddon || elements.terminalElement.offsetParent === null) return;
458 try {
459 fitAddon.fit();
460 if (state.terminalConnected) void api.resizeTerminal(TERMINAL_SESSION_ID, terminal.cols, terminal.rows);
461 } catch {}
464function setTerminalState(kind, text) {
465 state.terminalStateKind = kind;
466 elements.terminalState.className = `connection-badge ${kind}`;
467 elements.terminalState.textContent = text;
468 if (kind !== "connected") state.terminalLatency = null;
469 renderTerminalLatency();
470 elements.connectButton.disabled = kind === "connecting" || kind === "connected";
471 elements.disconnectButton.disabled = kind !== "connected" && kind !== "connecting";
472 elements.terminalProfileSelect.disabled = kind === "connecting" || kind === "connected";
475function renderTerminalLatency() {
476 const element = $("#terminalLatency");
477 element.hidden = state.terminalStateKind !== "connected";
478 const format = (value) => typeof value === "number" && Number.isFinite(value) && value > 0
479 ? (value < 0.1 ? "<0.1 ms" : `${value.toFixed(1)} ms`) : `— (${t("terminal.latencyUnavailable")})`;
480 element.textContent = `${t("terminal.ping")}: ${format(state.terminalLatency?.pingMs)} · ${t("terminal.sshRtt")}: ${format(state.terminalLatency?.sshRttMs)}`;
481 element.title = t("terminal.latencyMeaning");
484async function connectTerminal(retry = false) {
485 const profile = state.profiles.find((item) => item.id === elements.terminalProfileSelect.value);
486 if (!profile) { showView("profiles"); elements.profileMessage.textContent = t("terminal.noProfile"); return; }
487 fitTerminal();
488 if (!retry) {
489 terminal?.clear();
490 terminal?.writeln(`\x1b[33m${t("terminal.connectingTo", { target: `${profile.username}@${profile.host}:${profile.port}` })}\x1b[0m`);
491 }
492 setTerminalState("connecting", t("terminal.connecting"));
493 const credential = elements.terminalCredential.value;
494 const result = await api.connectSsh({
495 sessionId: TERMINAL_SESSION_ID,
496 profileId: profile.id,
497 password: profile.authMethod === "password" ? credential : undefined,
498 passphrase: profile.authMethod === "privateKey" ? credential : undefined,
499 cols: terminal?.cols || 100,
500 rows: terminal?.rows || 30
501 });
502 if (result.ok) {
503 state.terminalConnected = true;
504 elements.terminalCredential.value = "";
505 setTerminalState("connected", t("terminal.connected"));
506 terminal?.focus();
507 return;
508 }
509 const issue = result.error || { code: "SSH_CONNECTION_FAILED", message: t("ssh.failed") };
510 if (issue.code === "HOST_KEY_UNKNOWN") {
511 const accepted = window.confirm(t("ssh.unknownHost", issue));
512 if (accepted) {
513 const trusted = await api.trustSshHostKey({ sessionId: TERMINAL_SESSION_ID, profileId: profile.id, fingerprint: issue.fingerprint });
514 if (trusted.ok) { await connectTerminal(true); return; }
515 terminal?.writeln(`\r\n\x1b[31m${trusted.error?.message || t("ssh.trustFailed")}\x1b[0m`);
516 }
517 } else if (issue.code === "HOST_KEY_MISMATCH") {
518 terminal?.writeln(`\r\n\x1b[31m${t("ssh.keyChanged")}\x1b[0m`);
519 terminal?.writeln(`${t("ssh.knownKey")}: ${issue.knownFingerprint}\r\n${t("ssh.newKey")}: ${issue.fingerprint}`);
520 const accepted = window.confirm(t("ssh.changedHostConfirm", issue));
521 if (accepted) {
522 const trusted = await api.trustSshHostKey({
523 sessionId: TERMINAL_SESSION_ID,
524 profileId: profile.id,
525 fingerprint: issue.fingerprint,
526 knownFingerprint: issue.knownFingerprint,
527 replaceKnown: true
528 });
529 if (trusted.ok) { await connectTerminal(true); return; }
530 terminal?.writeln(`\r\n\x1b[31m${trusted.error?.message || t("ssh.trustFailed")}\x1b[0m`);
531 }
532 } else {
533 terminal?.writeln(`\r\n\x1b[31m${errorText(issue)}\x1b[0m`);
534 }
535 state.terminalConnected = false;
536 setTerminalState("error", t("terminal.connectionError"));
539async function checkUpdates() {
540 setBusy(elements.checkUpdateButton, true, t("updates.checking"));
541 elements.updateHeadline.textContent = t("updates.checkingServer");
542 elements.updateMessage.textContent = t("updates.tryingEndpoints");
543 try { renderUpdate(await api.checkForUpdates()); }
544 catch (error) { elements.updateHeadline.textContent = t("updates.checkFailed"); elements.updateMessage.textContent = errorText(error); }
545 finally { setBusy(elements.checkUpdateButton, false, t("updates.check")); }
548async function initialize() {
549 if (!api || !i18n) throw new Error("Core preload or i18n API is unavailable.");
550 [state.info, state.config, state.pluginState, state.update, state.vault] = await Promise.all([
551 api.getInfo(), api.getConfig(), api.getPluginState(), api.getUpdateState(), api.getVaultStatus()
552 ]);
553 state.plugins = state.pluginState.installed;
554 state.pluginWindows = state.pluginState.windows || [];
555 i18n.setLanguage(state.config.ui.language);
556 document.documentElement.lang = i18n.getLanguage();
557 i18n.apply(document);
558 elements.runtimeText.textContent = `${state.info.platform} / ${state.info.arch}`;
559 elements.sidebarVersion.textContent = `v${state.info.version}`;
560 elements.coreApiValue.textContent = state.info.coreApi;
561 elements.dataSchemaValue.textContent = String(state.info.dataSchema);
562 elements.currentVersion.textContent = `v${state.info.version}`;
563 elements.languageSelect.value = state.config.ui.language;
564 elements.vaultLanguageSelect.value = state.config.ui.language;
565 elements.vaultLanguageSelect.disabled = false;
566 elements.channelSelect.value = state.config.updates.channel;
567 elements.updateViewChannelSelect.value = state.config.updates.channel;
568 elements.pluginChannelSelect.value = state.config.plugins.channel;
569 elements.pluginViewChannelSelect.value = state.config.plugins.channel;
570 elements.autoCheckInput.checked = state.config.updates.autoCheck;
571 elements.baseUrlsText.textContent = state.config.updates.baseUrls.join("\n");
572 elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
573 const security = state.config.security;
574 $("#autoLockEnabled").checked = security.autoLockMinutes > 0;
575 $("#autoLockMinutes").value = String(security.autoLockMinutes || 15);
576 $("#autoLockMinutes").disabled = !security.autoLockMinutes;
577 $("#lockOnBlur").checked = security.lockOnBlur;
578 $("#requireSystemAuthentication").checked = security.requireSystemAuthentication;
579 diagnosticsView = window.CHJ_DIAGNOSTICS.mount($("#diagnosticsView"), {
580 api: api.diagnostics, language: () => i18n.getLanguage(), getTheme: () => state.config.ui.theme,
581 setTheme: async (theme) => { state.config = await api.updateConfig({ ui: { theme } }); }
582 });
583 renderPlugins(); renderUpdate(state.update); initTerminal(); configureVault(state.vault);
584 void refreshBiometrics();
585 if (state.vault.unlocked) await loadProfiles();
588document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
589$("#hashToolButton").addEventListener("click", async () => {
590 try { await api.openPlugin(HASH_TOOL_ID); }
591 catch (error) { window.alert(errorText(error)); }
592});
593elements.vaultForm.addEventListener("submit", async (event) => {
594 event.preventDefault();
595 const password = elements.vaultPassword.value;
596 const passwordLength = Array.from(password).length;
597 if (passwordLength < 4 || passwordLength > 64) { elements.vaultMessage.textContent = t("vault.passwordLength"); return; }
598 if (state.vault.needsSetup && password !== elements.vaultPasswordConfirm.value) { elements.vaultMessage.textContent = t("vault.passwordMismatch"); return; }
599 setBusy(elements.vaultSubmit, true, t(state.vault.needsSetup ? "vault.creating" : "vault.unlocking"));
600 try {
601 const status = state.vault.needsSetup ? await api.createVault(password) : await api.unlockVault(password);
602 configureVault(status);
603 await loadProfiles();
604 } catch (error) { elements.vaultMessage.className = "form-message"; elements.vaultMessage.textContent = errorText(error); }
605 finally { if (!state.vault.unlocked) setBusy(elements.vaultSubmit, false, t(state.vault.needsSetup ? "vault.create" : "vault.unlock")); }
606});
607elements.lockVaultButton.addEventListener("click", async () => {
608 configureVault(await api.lockVault());
609 clearProtectedUi();
610 terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);
611});
612$("#biometricUnlockButton").addEventListener("click", async event => {
613 event.currentTarget.disabled = true;
614 try { configureVault(await api.unlockVaultWithBiometrics()); await loadProfiles(); }
615 catch (error) { elements.vaultMessage.textContent = errorText(error); }
616 finally { $("#biometricUnlockButton").disabled = false; void refreshBiometrics(); }
617});
618for (const [id, action, message] of [
619 ["enableBiometricsButton", () => api.enableBiometrics(), "security.enabled"],
620 ["disableBiometricsButton", () => api.disableBiometrics(), "security.disabled"],
621 ["testBiometricsButton", () => api.authenticateSensitiveAction(), "security.testPassed"]
622]) {
623 $("#" + id).addEventListener("click", async event => {
624 event.currentTarget.disabled = true;
625 try { await action(); $("#securityMessage").textContent = t(message); }
626 catch (error) { $("#securityMessage").textContent = errorText(error); }
627 finally { await refreshBiometrics(); }
628 });
630$("#autoLockEnabled").addEventListener("change", event => {
631 $("#autoLockMinutes").disabled = !event.target.checked;
632 if (event.target.checked && $("#autoLockMinutes").value === "0") $("#autoLockMinutes").value = "15";
633});
634$("#securityForm").addEventListener("submit", async event => {
635 event.preventDefault();
636 const password = $("#securityMasterPassword").value; $("#securityMasterPassword").value = "";
637 try {
638 state.config = await api.updateConfig({ masterPassword: password, security: {
639 autoLockMinutes: $("#autoLockEnabled").checked ? Number($("#autoLockMinutes").value) : 0,
640 lockOnBlur: $("#lockOnBlur").checked, requireSystemAuthentication: $("#requireSystemAuthentication").checked
641 } });
642 $("#securityMessage").textContent = t("settings.saved");
643 } catch (error) { $("#securityMessage").textContent = errorText(error); }
644});
645elements.showVaultResetButton.addEventListener("click", () => {
646 elements.vaultResetPanel.hidden = false;
647 elements.showVaultResetButton.hidden = true;
648 elements.vaultResetConfirmation.focus();
649});
650elements.cancelVaultResetButton.addEventListener("click", () => {
651 elements.vaultResetPanel.hidden = true;
652 elements.showVaultResetButton.hidden = false;
653 elements.vaultResetConfirmation.value = "";
654});
655elements.confirmVaultResetButton.addEventListener("click", async () => {
656 if (elements.vaultResetConfirmation.value !== "SMAZAT") {
657 elements.vaultMessage.textContent = t("vault.typeDelete");
658 return;
659 }
660 if (!window.confirm(t("vault.finalResetConfirm"))) return;
661 setBusy(elements.confirmVaultResetButton, true, t("vault.deleting"));
662 try {
663 const status = await api.resetVault("SMAZAT");
664 clearProtectedUi();
665 configureVault(status);
666 elements.vaultMessage.className = "form-message success";
667 elements.vaultMessage.textContent = t("vault.resetDone");
668 } catch (error) {
669 elements.vaultMessage.className = "form-message";
670 elements.vaultMessage.textContent = errorText(error);
671 } finally {
672 setBusy(elements.confirmVaultResetButton, false, t("vault.resetAction"));
673 }
674});
675elements.newProfileButton.addEventListener("click", () => { state.selectedProfileId = ""; renderProfiles(); resetProfileForm(); elements.profileLabel.focus(); });
676elements.profileAuthMethod.addEventListener("change", updatePrivateKeyField);
677elements.profileStorePassword.addEventListener("change", updatePrivateKeyField);
678elements.selectPrivateKeyButton.addEventListener("click", async () => {
679 const result = await api.selectPrivateKey();
680 if (!result.canceled) elements.profilePrivateKeyPath.value = result.path;
681});
682elements.profileForm.addEventListener("submit", async (event) => {
683 event.preventDefault(); elements.profileMessage.className = "form-message"; elements.profileMessage.textContent = "";
684 try {
685 const saved = await api.saveProfile({
686 id: elements.profileId.value || undefined, label: elements.profileLabel.value, host: elements.profileHost.value,
687 port: Number(elements.profilePort.value), username: elements.profileUsername.value,
688 authMethod: elements.profileAuthMethod.value, privateKeyPath: elements.profilePrivateKeyPath.value,
689 storePassword: elements.profileAuthMethod.value === "password" && elements.profileStorePassword.checked,
690 password: elements.profileStoredPassword.value
691 });
692 await loadProfiles(saved.id); elements.profileMessage.className = "form-message success"; elements.profileMessage.textContent = t("profiles.saved");
693 } catch (error) { elements.profileMessage.textContent = errorText(error); }
694});
695elements.deleteProfileButton.addEventListener("click", async () => {
696 const id = elements.profileId.value;
697 if (!id || !window.confirm(t("profiles.deleteConfirm"))) return;
698 await api.deleteProfile(id); state.selectedProfileId = ""; await loadProfiles("");
699});
700elements.terminalProfileSelect.addEventListener("change", () => { elements.terminalCredential.value = ""; updateTerminalProfile(); });
701elements.connectButton.addEventListener("click", () => connectTerminal().catch((error) => { setTerminalState("error", t("terminal.error")); terminal?.writeln(`\r\n\x1b[31m${errorText(error)}\x1b[0m`); }));
702elements.disconnectButton.addEventListener("click", async () => { await api.disconnectSsh(TERMINAL_SESSION_ID); });
703elements.checkUpdateButton.addEventListener("click", checkUpdates);
704elements.downloadUpdateButton.addEventListener("click", async () => {
705 setBusy(elements.downloadUpdateButton, true, t("updates.downloadingAndVerifying"));
706 elements.updateHeadline.textContent = t("updates.verifyingSignature");
707 try { renderUpdate(await api.downloadUpdate()); }
708 catch (_error) {
709 elements.updateHeadline.textContent = t("updates.downloadRejected");
710 elements.updateMessage.textContent = t("updates.signatureFailure");
711 elements.installUpdateButton.hidden = true;
712 elements.revealUpdateButton.hidden = true;
713 }
714 finally { setBusy(elements.downloadUpdateButton, false, t("updates.download")); }
715});
716elements.installUpdateButton.addEventListener("click", async () => {
717 const release = state.update?.release;
718 if (!release || !window.confirm(t("updates.installConfirm", { version: release.version, date: formatReleaseDate(release.publishedAt) }))) return;
719 setBusy(elements.installUpdateButton, true, t("updates.verifyingSignature"));
720 try { await api.installUpdate(); }
721 catch (_error) {
722 try { renderUpdate(await api.getUpdateState()); } catch {}
723 elements.updateHeadline.textContent = t("updates.installFailed");
724 elements.updateMessage.textContent = t("updates.installFailure");
725 elements.installUpdateButton.hidden = true;
726 } finally { setBusy(elements.installUpdateButton, false, t("updates.install")); }
727});
728elements.revealUpdateButton.addEventListener("click", () => api.revealUpdate());
729elements.updateViewChannelSelect.addEventListener("change", async () => {
730 state.config = await api.updateConfig({ updates: { channel: elements.updateViewChannelSelect.value } });
731 elements.channelSelect.value = state.config.updates.channel;
732 elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
733 await checkUpdates();
734});
735elements.pluginViewChannelSelect.addEventListener("change", async () => {
736 state.config = await api.updateConfig({ plugins: { channel: elements.pluginViewChannelSelect.value } });
737 elements.pluginChannelSelect.value = state.config.plugins.channel;
738 elements.checkPluginCatalogButton.click();
739});
740elements.checkPluginCatalogButton.addEventListener("click", async () => {
741 setBusy(elements.checkPluginCatalogButton, true, t("plugins.checking"));
742 try { state.pluginState = await api.checkPluginCatalog(); state.plugins = state.pluginState.installed; renderPlugins(); }
743 catch (error) { window.alert(errorText(error)); }
744 finally { setBusy(elements.checkPluginCatalogButton, false, t("plugins.checkCatalog")); }
745});
746elements.settingsForm.addEventListener("submit", async (event) => {
747 event.preventDefault();
748 state.config = await api.updateConfig({
749 ui: { language: elements.languageSelect.value },
750 updates: { channel: elements.channelSelect.value, autoCheck: elements.autoCheckInput.checked },
751 plugins: { channel: elements.pluginChannelSelect.value }
752 });
753 elements.updateViewChannelSelect.value = state.config.updates.channel;
754 elements.pluginViewChannelSelect.value = state.config.plugins.channel;
755 applyLanguage(state.config.ui.language);
756 elements.updateChannel.textContent = t("updates.channel", { channel: state.config.updates.channel });
757 elements.settingsStatus.textContent = t("settings.saved"); setTimeout(() => { elements.settingsStatus.textContent = ""; }, 1800);
758});
759elements.languageSelect.addEventListener("change", () => applyLanguage(elements.languageSelect.value));
760document.querySelectorAll("[data-legal-document]").forEach((button) => {
761 button.addEventListener("click", async () => {
762 try { await api.openLegalDocument(button.dataset.legalDocument); }
763 catch (error) { window.alert(errorText(error)); }
764 });
765});
766elements.vaultLanguageSelect.addEventListener("change", async () => {
767 const language = applyLanguage(elements.vaultLanguageSelect.value);
768 state.config = await api.updateConfig({ ui: { language } });
769});
771api?.onUpdateState((payload) => renderUpdate(payload));
772api?.onPluginWindowState((windows) => renderPluginTaskbar(windows));
773api?.onSshData((payload) => { if (payload.sessionId === TERMINAL_SESSION_ID) terminal?.write(payload.data); });
774api?.onSshState((payload) => {
775 if (payload.sessionId !== TERMINAL_SESSION_ID) return;
776 state.terminalConnected = payload.state === "connected";
777 state.terminalLatency = payload.latency;
778 if (payload.state === "connecting") setTerminalState("connecting", t("terminal.connecting"));
779 else if (payload.state === "connected") setTerminalState("connected", t("terminal.connected"));
780 else setTerminalState("disconnected", t("terminal.disconnected"));
781});
782api?.onSshError((payload) => { if (payload.sessionId === TERMINAL_SESSION_ID) terminal?.writeln(`\r\n\x1b[31m${errorText(payload)}\x1b[0m`); });
783api?.onVaultLocked(status => {
784 configureVault(status); clearProtectedUi(); void refreshBiometrics();
785 terminal?.writeln(`${t("terminal.vaultLocked")}\r\n`);
786});
788initialize().catch((error) => {
789 elements.runtimeText.textContent = t("errors.initialization");
790 elements.updateMessage.textContent = errorText(error);
791});

SHA-256: af20c37a4ae10ff76c9bf8a6fc23c1ddee2f4f2a5f40ee66c2a985b9dda46ad7

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0