/** * Settings System Tab - System information, temp file management, session logs, and global hotkey */ import { ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, RefreshCw, RotateCcw, Trash2 } from "lucide-react"; import React, { useCallback, useEffect, useState } from "react"; import { useI18n } from "../../../application/i18n/I18nProvider"; import type { AppLockSystemUnlockStatus } from "../../../application/state/useAppLockState"; import type { AppLockSettings, AppLockSettingsChangeError, AppLockTimeoutMinutes } from "../../../domain/appLock"; import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection"; import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"; import type { UpdateState } from '../../../application/state/useUpdateCheck'; import { SessionLogFormat, keyEventToString } from "../../../domain/models"; import type { HttpNetworkProxyMode, HttpNetworkProxySettings } from "../../../domain/httpNetworkProxy"; import { Button } from "../../ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip"; import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingHint, SettingsAnchor, SettingsTabContent } from "../settings-ui"; import { cn } from "../../../lib/utils"; import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom'; import { AppLockSettingsSection } from './AppLockSettingsSection'; interface CrashLogFile { fileName: string; date: string; size: number; entryCount: number; } interface CrashLogEntry { timestamp: string; source: string; message: string; stack?: string; errorMeta?: Record; extra?: Record; pid?: number; platform?: string; arch?: string; version?: string; electronVersion?: string; osVersion?: string; memoryMB?: { rss: number; heapUsed: number; heapTotal: number }; activeSessionCount?: number; uptimeSeconds?: number; } interface TempDirInfo { path: string; fileCount: number; totalSize: number; } interface SshDebugLogInfo { enabled: boolean; path: string; exists: boolean; size: number; } function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } /** Returns a locale-agnostic relative time string for the given timestamp. */ function formatLastChecked( timestamp: number | null, t: (key: string) => string, ): string { if (!timestamp) return ''; const diffMs = Date.now() - timestamp; if (diffMs < 0) return t('settings.update.lastCheckedJustNow'); const diffMins = Math.floor(diffMs / 60000); if (diffMins < 1) return t('settings.update.lastCheckedJustNow'); if (diffMins < 60) return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins)); const diffHours = Math.floor(diffMins / 60); return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours)); } interface SettingsSystemTabProps { appLockSettings: AppLockSettings; setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void; requestAppLockDisable: (currentPassword: string) => Promise; requestAppLockPasswordChange: (input: { currentPassword?: string; nextPassword: string; }) => Promise; appLockSystemUnlockStatus?: AppLockSystemUnlockStatus; setAppLockSystemUnlockEnabled?: (input: { enabled: boolean; currentPassword?: string; autoPromptEnabled?: boolean; }) => Promise; sessionLogsEnabled: boolean; setSessionLogsEnabled: (enabled: boolean) => void; sessionLogsDir: string; setSessionLogsDir: (dir: string) => void; sessionLogsFormat: SessionLogFormat; setSessionLogsFormat: (format: SessionLogFormat) => void; sessionLogsTimestampsEnabled: boolean; setSessionLogsTimestampsEnabled: (enabled: boolean) => void; sshDebugLogsEnabled: boolean; setSshDebugLogsEnabled: (enabled: boolean) => void; sshDeepLinkEnabled: boolean; setSshDeepLinkEnabled: (enabled: boolean) => void; jmsDeepLinkEnabled: boolean; setJmsDeepLinkEnabled: (enabled: boolean) => void; explorerContextMenuEnabled: boolean; setExplorerContextMenuEnabled: (enabled: boolean) => void; explorerContextMenuSupported: boolean; restorePreviousSession: boolean; setRestorePreviousSession: (enabled: boolean) => void; restoreTerminalCwd: boolean; setRestoreTerminalCwd: (enabled: boolean) => void; startupLanding: "vault" | "local-terminal"; setStartupLanding: (landing: "vault" | "local-terminal") => void; toggleWindowHotkey: string; setToggleWindowHotkey: (hotkey: string) => void; closeToTray: boolean; setCloseToTray: (enabled: boolean) => void; autoLaunchEnabled: boolean; setAutoLaunchEnabled: (enabled: boolean) => void; autoLaunchSupported: boolean; httpNetworkProxy: HttpNetworkProxySettings; setHttpNetworkProxy: (settings: HttpNetworkProxySettings | ((prev: HttpNetworkProxySettings) => HttpNetworkProxySettings)) => void; hotkeyRegistrationError: string | null; globalHotkeyEnabled: boolean; setGlobalHotkeyEnabled: (enabled: boolean) => void; autoUpdateEnabled: boolean; setAutoUpdateEnabled: (enabled: boolean) => void; // Unified update state — from useUpdateCheck hook in SettingsPageContent updateState: UpdateState; checkNow: () => Promise; installUpdate: () => void; openReleasePage: () => void; startDownload: () => void; } const SettingsSystemTab: React.FC = ({ appLockSettings, setAppLockTimeoutMinutes, requestAppLockDisable, requestAppLockPasswordChange, appLockSystemUnlockStatus, setAppLockSystemUnlockEnabled, sessionLogsEnabled, setSessionLogsEnabled, sessionLogsDir, setSessionLogsDir, sessionLogsFormat, setSessionLogsFormat, sessionLogsTimestampsEnabled, setSessionLogsTimestampsEnabled, sshDebugLogsEnabled, setSshDebugLogsEnabled, sshDeepLinkEnabled, setSshDeepLinkEnabled, jmsDeepLinkEnabled, setJmsDeepLinkEnabled, explorerContextMenuEnabled, setExplorerContextMenuEnabled, explorerContextMenuSupported, restorePreviousSession, setRestorePreviousSession, restoreTerminalCwd, setRestoreTerminalCwd, startupLanding, setStartupLanding, toggleWindowHotkey, setToggleWindowHotkey, closeToTray, setCloseToTray, autoLaunchEnabled, setAutoLaunchEnabled, autoLaunchSupported, httpNetworkProxy, setHttpNetworkProxy, hotkeyRegistrationError, globalHotkeyEnabled, setGlobalHotkeyEnabled, autoUpdateEnabled, setAutoUpdateEnabled, updateState, checkNow, installUpdate, openReleasePage, startDownload, }) => { const { t } = useI18n(); const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const [tempDirInfo, setTempDirInfo] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isClearing, setIsClearing] = useState(false); const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null); const [isRecordingHotkey, setIsRecordingHotkey] = useState(false); const [hotkeyError, setHotkeyError] = useState(null); const [credentialsAvailable, setCredentialsAvailable] = useState(null); const [isCheckingCredentials, setIsCheckingCredentials] = useState(false); const [crashLogs, setCrashLogs] = useState([]); const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false); const [expandedLog, setExpandedLog] = useState(null); const [logEntries, setLogEntries] = useState([]); const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false); const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null); const [sshDebugLogInfo, setSshDebugLogInfo] = useState(null); const [isLoadingSshDebugLogInfo, setIsLoadingSshDebugLogInfo] = useState(false); const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false); const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null); const [appVersion, setAppVersion] = useState(''); // Load app version on mount useEffect(() => { const promise = netcattyBridge.get()?.getAppInfo?.(); if (promise) { promise.then((info) => { setAppVersion(info?.version ?? ''); }).catch(() => {}); } }, []); const loadTempDirInfo = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.getTempDirInfo) return; setIsLoading(true); try { const info = await bridge.getTempDirInfo(); setTempDirInfo(info); } catch (err) { console.error("[SettingsSystemTab] Failed to get temp dir info:", err); } finally { setIsLoading(false); } }, []); useEffect(() => { loadTempDirInfo(); }, [loadTempDirInfo]); const loadCredentialProtectionStatus = useCallback(async () => { setIsCheckingCredentials(true); try { const available = await getCredentialProtectionAvailability(); setCredentialsAvailable(available); } finally { setIsCheckingCredentials(false); } }, []); useEffect(() => { void loadCredentialProtectionStatus(); }, [loadCredentialProtectionStatus]); const loadCrashLogs = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.getCrashLogs) return; setIsLoadingCrashLogs(true); try { const logs = await bridge.getCrashLogs(); setCrashLogs(logs); } catch (err) { console.error("[SettingsSystemTab] Failed to load crash logs:", err); } finally { setIsLoadingCrashLogs(false); } }, []); useEffect(() => { void loadCrashLogs(); }, [loadCrashLogs]); const loadSshDebugLogInfo = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.getSshDebugLogInfo) return; setIsLoadingSshDebugLogInfo(true); try { const info = await bridge.getSshDebugLogInfo(); setSshDebugLogInfo(info); } catch (err) { console.error("[SettingsSystemTab] Failed to load SSH debug log info:", err); } finally { setIsLoadingSshDebugLogInfo(false); } }, []); useEffect(() => { void loadSshDebugLogInfo(); }, [loadSshDebugLogInfo, sshDebugLogsEnabled]); const expandRequestRef = React.useRef(0); const handleExpandCrashLog = useCallback(async (fileName: string) => { if (expandedLog === fileName) { setExpandedLog(null); setLogEntries([]); return; } const bridge = netcattyBridge.get(); if (!bridge?.readCrashLog) return; const requestId = ++expandRequestRef.current; // Optimistically show expanded state while loading setExpandedLog(fileName); setLogEntries([]); try { const entries = await bridge.readCrashLog(fileName); // Discard if user clicked a different file while awaiting if (expandRequestRef.current !== requestId) return; setLogEntries(entries); } catch (err) { if (expandRequestRef.current !== requestId) return; console.error("[SettingsSystemTab] Failed to read crash log:", err); } }, [expandedLog]); const handleClearCrashLogs = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.clearCrashLogs) return; setIsClearingCrashLogs(true); setCrashLogClearResult(null); try { const result = await bridge.clearCrashLogs(); setCrashLogClearResult(result); setExpandedLog(null); setLogEntries([]); // Reload the list so partial failures still show remaining files await loadCrashLogs(); } catch (err) { console.error("[SettingsSystemTab] Failed to clear crash logs:", err); } finally { setIsClearingCrashLogs(false); } }, [loadCrashLogs]); const handleOpenCrashLogsDir = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.openCrashLogsDir) return; await bridge.openCrashLogsDir(); }, []); const handleClearTempFiles = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.clearTempDir) return; setIsClearing(true); setClearResult(null); try { const result = await bridge.clearTempDir(); setClearResult(result); // Refresh info after clearing await loadTempDirInfo(); } catch (err) { console.error("[SettingsSystemTab] Failed to clear temp dir:", err); } finally { setIsClearing(false); } }, [loadTempDirInfo]); const handleOpenTempDir = useCallback(async () => { const bridge = netcattyBridge.get(); if (!tempDirInfo?.path || !bridge?.openTempDir) return; await bridge.openTempDir(); }, [tempDirInfo]); const handleSelectSessionLogsDir = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.selectSessionLogsDir) return; try { const result = await bridge.selectSessionLogsDir(); if (result.success && result.directory) { setSessionLogsDir(result.directory); } } catch (err) { console.error("[SettingsSystemTab] Failed to select directory:", err); } }, [setSessionLogsDir]); const handleOpenSessionLogsDir = useCallback(async () => { const bridge = netcattyBridge.get(); if (!sessionLogsDir || !bridge?.openSessionLogsDir) return; try { await bridge.openSessionLogsDir(sessionLogsDir); } catch (err) { console.error("[SettingsSystemTab] Failed to open directory:", err); } }, [sessionLogsDir]); const handleClearSessionLogs = useCallback(async () => { const bridge = netcattyBridge.get(); if (!sessionLogsDir || !bridge?.clearSessionLogsDir) return; if (!window.confirm(t("settings.sessionLogs.clearConfirm"))) return; setIsClearingSessionLogs(true); setSessionLogsClearResult(null); try { const result = await bridge.clearSessionLogsDir(sessionLogsDir); if (result.success) { setSessionLogsClearResult({ deletedCount: result.deletedCount, failedCount: result.failedCount }); } } catch (err) { console.error("[SettingsSystemTab] Failed to clear session logs:", err); } finally { setIsClearingSessionLogs(false); } }, [sessionLogsDir, t]); const handleOpenSshDebugLogDir = useCallback(async () => { const bridge = netcattyBridge.get(); if (!bridge?.openSshDebugLogDir) return; await bridge.openSshDebugLogDir(); }, []); // Handle global toggle hotkey recording const cancelHotkeyRecording = useCallback(() => { setIsRecordingHotkey(false); }, []); const handleResetHotkey = useCallback(() => { // Reset to default hotkey (Ctrl+` or ⌃+` on Mac) const defaultHotkey = isMac ? '⌃ + `' : 'Ctrl + `'; setToggleWindowHotkey(defaultHotkey); setHotkeyError(null); }, [isMac, setToggleWindowHotkey]); // Hotkey recording effect useEffect(() => { if (!isRecordingHotkey) return; const handleKeyDown = (e: KeyboardEvent) => { if (isAppLockOverlayActive()) return; e.preventDefault(); e.stopPropagation(); if (e.key === "Escape") { cancelHotkeyRecording(); return; } // Ignore modifier-only keys if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return; const keyString = keyEventToString(e, isMac); setToggleWindowHotkey(keyString); setHotkeyError(null); cancelHotkeyRecording(); }; const handleClick = () => { cancelHotkeyRecording(); }; const timer = setTimeout(() => { window.addEventListener("click", handleClick, true); }, 100); window.addEventListener("keydown", handleKeyDown, true); return () => { clearTimeout(timer); window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("click", handleClick, true); }; }, [isRecordingHotkey, isMac, setToggleWindowHotkey, cancelHotkeyRecording]); const formatOptions = [ { value: "txt", label: t("settings.sessionLogs.formatTxt") }, { value: "raw", label: t("settings.sessionLogs.formatRaw") }, { value: "html", label: t("settings.sessionLogs.formatHtml") }, ]; return ( {/* Current version */}
{t('settings.update.currentVersion')} {updateState.currentVersion || appVersion || '...'}
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */} {updateState.autoDownloadStatus === 'downloading' && (

{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}

)} {updateState.autoDownloadStatus === 'ready' && (

{t('settings.update.readyToInstall')}

)} {updateState.autoDownloadStatus === 'error' && (

{updateState.downloadError || t('settings.update.error')}

)} {updateState.autoDownloadStatus === 'idle' && ( <> {updateState.manualCheckStatus === 'up-to-date' && (

{t('settings.update.upToDate')}

)} {(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (

{t('settings.update.available').replace( '{version}', updateState.latestRelease?.version ?? '' )}

)} {updateState.manualCheckStatus === 'error' && (

{updateState.error || t('settings.update.error')}

)} )} {/* Action buttons */}
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */} {(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') && (updateState.isChecking || updateState.manualCheckStatus === 'checking') ? ( ) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? ( /* Check button — shown in idle states and in error state (allows retry) */ ) : null} {/* Install button — shown when download is complete */} {updateState.autoDownloadStatus === 'ready' && ( )} {/* Open releases — shown on download error */} {updateState.autoDownloadStatus === 'error' && ( )} {/* Download button — shown when update found and no download in progress */} {updateState.autoDownloadStatus === 'idle' && updateState.manualCheckStatus === 'available' && ( )} {/* Open releases — fallback for unsupported platforms or check errors */} {updateState.autoDownloadStatus === 'idle' && (updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && ( )}
{updateState.lastCheckedAt && ( {t('settings.update.lastCheckedPrefix')} {formatLastChecked(updateState.lastCheckedAt, t)} {' '} )} {t('settings.update.hint')} { const url = e.target.value; setHttpNetworkProxy((prev) => ({ ...prev, url })); }} placeholder={t("settings.system.networkProxy.url.placeholder")} className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" spellCheck={false} autoComplete="off" /> { const bypass = e.target.value; setHttpNetworkProxy((prev) => ({ ...prev, bypass })); }} placeholder={t("settings.system.networkProxy.bypass.placeholder")} className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" spellCheck={false} autoComplete="off" /> )} {t("settings.system.networkProxy.hint")}

{t("settings.system.credentials.status")}

{isCheckingCredentials ? t("settings.system.credentials.checking") : credentialsAvailable === true ? t("settings.system.credentials.available") : credentialsAvailable === false ? t("settings.system.credentials.unavailable") : t("settings.system.credentials.unknown")}

{credentialsAvailable === false && (

{t("settings.system.credentials.unavailableHint")}

)}

{t("settings.system.credentials.portabilityHint")}

{t("settings.system.crashLogs.description")}

{crashLogs.length === 0 && !isLoadingCrashLogs && (

{t("settings.system.crashLogs.noLogs")}

)} {crashLogs.length > 0 && (
{crashLogs.map((log) => (
{expandedLog === log.fileName && logEntries.length > 0 && (
{logEntries.map((entry, idx) => (
{new Date(entry.timestamp).toLocaleTimeString()} {entry.source}

{entry.message}

{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
{Object.entries(entry.errorMeta).map(([k, v]) => ( {k}={String(v)} ))}
)} {entry.extra && Object.keys(entry.extra).length > 0 && (
{Object.entries(entry.extra).map(([k, v]) => ( {k}={String(v)} ))}
)} {(() => { const parts: string[] = []; if (entry.version) parts.push(`v${entry.version}`); if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`); if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`); if (entry.osVersion) parts.push(`OS ${entry.osVersion}`); if (entry.pid) parts.push(`PID ${entry.pid}`); if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`); if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`); if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`); const text = parts.join(' '); return text ? (
{text}
{text}
) : null; })()} {entry.stack && (
                                  {entry.stack}
                                
)}
))}
)}
))}
)} {/* Actions */}
{t("settings.system.openFolder")}
{crashLogClearResult && (

{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}

)}
{t("settings.system.crashLogs.hint")}
{/* Path */}

{t("settings.system.location")}

{isLoading ? "..." : (tempDirInfo?.path ?? "-")}

{t("settings.system.openFolder")}
{/* Stats */}
{t("settings.system.fileCount")}:{" "} {isLoading ? "..." : (tempDirInfo?.fileCount ?? 0)}
{t("settings.system.totalSize")}:{" "} {isLoading ? "..." : formatBytes(tempDirInfo?.totalSize ?? 0)}
{/* Actions */}
{/* Clear Result */} {clearResult && (

{t("settings.system.clearResult", { deleted: clearResult.deletedCount, failed: clearResult.failedCount, })}

)}
{t("settings.system.tempDirectoryHint")}
setSessionLogsFormat(val as SessionLogFormat)} className="w-44" /> {/* Clear All Logs */}

{t("settings.sessionLogs.clearAll")}

{t("settings.sessionLogs.clearAllDesc")}

{sessionLogsClearResult && (

{t("settings.system.clearResult", { deleted: sessionLogsClearResult.deletedCount, failed: sessionLogsClearResult.failedCount, })}

)}
{t("settings.sessionLogs.hint")} {explorerContextMenuSupported ? ( <> ) : ( )}
{t("settings.sshDebugLogs.location")}
{isLoadingSshDebugLogInfo ? "..." : (sshDebugLogInfo?.path || "-")}
{t("settings.system.openFolder")}
{t("settings.sshDebugLogs.status")}:{" "} {sshDebugLogsEnabled ? t("settings.sshDebugLogs.statusOn") : t("settings.sshDebugLogs.statusOff")} {t("settings.sshDebugLogs.size")}: {formatBytes(sshDebugLogInfo?.size ?? 0)}
{t("settings.sshDebugLogs.hint")} {/* Enable/Disable Global Hotkey */}
{/* Toggle Window Hotkey */}
{toggleWindowHotkey && ( {t("settings.globalHotkey.reset")} )}
{(hotkeyError || hotkeyRegistrationError) && (

{hotkeyError || hotkeyRegistrationError}

)}
{/* Close to Tray */}
{t("settings.globalHotkey.hint")} ); }; export default React.memo(SettingsSystemTab);