Files
Firelink/src/components/SettingsView.tsx
T
NimBold 607d193f8e fix(proxy): preserve system proxy schemes
Detect platform system proxy schemes before normalizing proxy values.

Fail normal aria2 downloads clearly when the selected proxy is SOCKS, since aria2 only supports HTTP-style all-proxy URLs.
2026-07-07 22:25:58 +03:30

1460 lines
66 KiB
TypeScript

import { useCallback, useRef, useState, useEffect } from 'react';
import {
type AppFontSize,
type ListRowDensity,
type SettingsState,
SettingsTab,
useSettingsStore
} from '../store/useSettingsStore';
import {
Download, Palette, Globe, Folder, Key,
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code, ShieldAlert, Check,
ExternalLink, BadgeCheck, AlertCircle
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { invokeCommand as invoke } from '../ipc';
import { useToast, ToastVariant } from '../contexts/ToastContext';
import type { EngineStatusItem } from '../bindings/EngineStatusItem';
import { WindowDragRegion } from './WindowDragRegion';
import appIcon from '../assets/app-icon.png';
import {
DEFAULT_CATEGORY_SUBFOLDERS,
DOWNLOAD_CATEGORIES,
formatDerivedCategoryPath,
normalizeCategorySubfolder,
subfolderFromDerivedCategoryPath
} from '../utils/downloadLocations';
import { usePlatformInfo } from '../utils/platform';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
{ type: 'lookandfeel', label: 'Look and feel', icon: Palette },
{ type: 'network', label: 'Network', icon: Globe },
{ type: 'locations', label: 'Locations', icon: Folder },
{ type: 'sitelogins', label: 'Site Logins', icon: Key },
{ type: 'power', label: 'Power', icon: Moon },
{ type: 'engine', label: 'Engine', icon: Terminal },
{ type: 'integrations', label: 'Integrations', icon: Puzzle },
{ type: 'about', label: 'About', icon: Info },
];
const engineChecks = [
{ kind: 'aria2', name: 'Aria2', command: 'get_aria2_engine_status' },
{ kind: 'ytdlp', name: 'yt-dlp', command: 'get_ytdlp_engine_status' },
{ kind: 'ffmpeg', name: 'FFmpeg', command: 'get_ffmpeg_engine_status' },
{ kind: 'deno', name: 'Deno', command: 'get_deno_engine_status' },
] as const;
const browserCookieSourceOptions = [
{ value: 'none', label: 'None' },
{ value: 'safari', label: 'Safari' },
{ value: 'chrome', label: 'Chrome' },
{ value: 'chromium', label: 'Chromium' },
{ value: 'firefox', label: 'Firefox' },
{ value: 'edge', label: 'Edge' },
{ value: 'brave', label: 'Brave' },
{ value: 'opera', label: 'Opera' },
{ value: 'vivaldi', label: 'Vivaldi' },
{ value: 'whale', label: 'Whale' },
] as const;
type EngineCheck = typeof engineChecks[number];
const FIRELINK_SOURCE_URL = 'https://github.com/nimbold/Firelink';
const FIRELINK_LICENSE_URL = 'https://github.com/nimbold/Firelink/blob/main/LICENSE';
const FIRELINK_FIREFOX_ADDON_URL = 'https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/';
const FIRELINK_EXTENSION_RELEASES_URL = 'https://github.com/nimbold/Firelink-Extension/releases';
type ManualUpdateStatus =
| { type: 'idle' }
| { type: 'checking' }
| { type: 'up-to-date'; latestVersion: string; localVersion: string }
| { type: 'update-available'; version: string; releaseUrl: string }
| { type: 'error'; message: string };
const engineStatusCache = new Map<string, EngineStatusItem>();
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
const upsertEngineStatus = (items: EngineStatusItem[], item: EngineStatusItem) => {
const next = items.filter(existing => existing.kind !== item.kind);
next.push(item);
return next;
};
const USER_AGENT_SUGGESTIONS = [
{
label: 'Chrome (Windows)',
detail: 'Windows desktop',
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'
},
{
label: 'Chrome (macOS)',
detail: 'macOS desktop',
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'
},
{
label: 'Edge (Windows)',
detail: 'Windows desktop',
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0'
},
{
label: 'Firefox (Windows)',
detail: 'Windows desktop',
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0'
},
{
label: 'Firefox (macOS)',
detail: 'macOS desktop',
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0'
},
{
label: 'Safari (macOS)',
detail: 'macOS desktop',
value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15'
}
] as const;
const buildEngineStatusError = (check: EngineCheck, error: unknown): EngineStatusItem => ({
name: check.name,
kind: check.kind,
expected_sidecar: '',
resolved_path: null,
version: null,
ready: false,
error: String(error),
stderr_tail: null,
remediation_hint: null,
rpc_port: null,
daemon_alive: null,
rpc_ready: null,
last_stderr_tail: null,
expects_internal_dir: null,
has_internal_dir: null,
has_python_framework: null,
});
const runEngineStatusCheck = (check: EngineCheck, force: boolean) => {
if (!force) {
const cached = engineStatusCache.get(check.kind);
if (cached) return Promise.resolve(cached);
}
if (!force) {
const inFlight = engineStatusInFlight.get(check.kind);
if (inFlight) return inFlight;
}
if (force) engineStatusCache.delete(check.kind);
const promise = invoke(check.command)
.then(item => {
if (item.ready) engineStatusCache.set(item.kind, item);
return item;
})
.catch(error => buildEngineStatusError(check, error))
.finally(() => {
if (engineStatusInFlight.get(check.kind) === promise) {
engineStatusInFlight.delete(check.kind);
}
});
engineStatusInFlight.set(check.kind, promise);
return promise;
};
const CategoryFolderInput = ({
category,
settings,
onBrowse,
disabled = false
}: {
category: string;
settings: SettingsState;
onBrowse: () => void;
disabled?: boolean;
}) => {
const base = settings.baseDownloadFolder || '~/Downloads';
const sub = Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category)
? settings.categorySubfolders[category]
: DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS];
const override = settings.categoryDirectoryOverrides[category];
const displayPath = override ?? formatDerivedCategoryPath(base, sub);
const [localValue, setLocalValue] = useState<string | null>(null);
const value = localValue !== null ? localValue : displayPath;
return (
<div className="flex items-center gap-2 flex-1 justify-end">
<input
type="text"
value={value}
disabled={disabled}
onFocus={() => setLocalValue(displayPath)}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={() => {
const val = localValue ?? displayPath;
const derivedSubfolder = subfolderFromDerivedCategoryPath(val, base);
if (!val.trim()) {
settings.setCategoryDirectoryOverride(category, undefined);
settings.setCategorySubfolder(category, '');
} else if (derivedSubfolder !== null) {
settings.setCategoryDirectoryOverride(category, undefined);
settings.setCategorySubfolder(
category,
normalizeCategorySubfolder(
derivedSubfolder,
DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS]
)
);
} else {
settings.setCategoryDirectoryOverride(category, val.trim());
}
setLocalValue(null);
}}
className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay disabled:opacity-60 disabled:cursor-not-allowed"
aria-label={`${category} subfolder`}
/>
<button
onClick={onBrowse}
disabled={disabled}
className="app-button px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary hover:bg-surface-overlay disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-secondary"
>
Custom folder
</button>
{override && (
<button
onClick={() => {
settings.setCategoryDirectoryOverride(category, undefined);
setLocalValue(null);
}}
className="app-button px-3 py-1.5 text-xs text-text-secondary hover:text-red-400 hover:bg-red-500/10 hover:border-red-500/30"
>
Use automatic
</button>
)}
</div>
);
};
export default function SettingsView() {
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
const platform = usePlatformInfo();
const platformName =
platform.os === 'macos'
? 'macOS'
: platform.os === 'windows'
? 'Windows'
: platform.os === 'linux'
? 'Linux'
: 'this OS';
const trayIconLabel =
platform.os === 'macos'
? 'Show menu bar icon'
: platform.os === 'linux'
? 'Show status indicator icon'
: 'Show system tray icon';
const trayIconDescription =
platform.os === 'macos'
? 'Provides quick access from the macOS menu bar.'
: platform.os === 'windows'
? 'Provides quick access from the Windows notification area.'
: platform.os === 'linux'
? 'Provides quick access from the desktop tray or status area when available.'
: 'Provides quick access from the OS tray area when available.';
const userAgentMenuRef = useRef<HTMLDivElement>(null);
const [isUserAgentMenuOpen, setIsUserAgentMenuOpen] = useState(false);
// Local state for engine status
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
const engineRunId = useRef(0);
const [appVersion, setAppVersion] = useState('1.0.1');
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
// Local state for adding site login
const [loginPattern, setLoginPattern] = useState('');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
// Toast notifications
const { addToast } = useToast();
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
const [manualUpdateStatus, setManualUpdateStatus] = useState<ManualUpdateStatus>({ type: 'idle' });
useEffect(() => {
getVersion().then(setAppVersion).catch(() => undefined);
}, []);
useEffect(() => {
if (settings.activeView !== 'settings' || activeTab !== 'integrations') return;
let active = true;
const refresh = () => {
invoke('get_extension_server_port')
.then(port => {
if (active) setExtensionServerPort(port);
})
.catch(() => {
if (active) setExtensionServerPort(null);
});
};
refresh();
const timer = window.setInterval(refresh, 3000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [settings.activeView, activeTab]);
const runEngineChecks = useCallback((force = false) => {
const runId = ++engineRunId.current;
const cached = engineChecks
.map(check => engineStatusCache.get(check.kind))
.filter((item): item is EngineStatusItem => Boolean(item));
setEngineStatus(force ? [] : cached);
setExpandedEngine(null);
const checksToRun = force
? engineChecks
: engineChecks.filter(check => !engineStatusCache.has(check.kind));
if (checksToRun.length === 0) {
setIsRecheckingEngines(false);
return;
}
setIsRecheckingEngines(true);
let remaining = checksToRun.length;
checksToRun.forEach(check => {
runEngineStatusCheck(check, force)
.then(item => {
if (engineRunId.current === runId) {
setEngineStatus(current => upsertEngineStatus(current ?? [], item));
}
})
.finally(() => {
remaining -= 1;
if (remaining === 0 && engineRunId.current === runId) {
setIsRecheckingEngines(false);
}
});
});
}, []);
// Fetch engine status when Engine tab is opened
useEffect(() => {
if (settings.activeView === 'settings' && activeTab === 'engine') {
runEngineChecks(false);
}
}, [settings.activeView, activeTab, runEngineChecks]);
const showToast = (msg: string, variant: ToastVariant = 'info') => {
addToast({ message: msg, variant });
};
const openExternalUrl = async (url: string, label: string) => {
try {
await openUrl(url);
} catch (error) {
showToast(`Could not open ${label}: ${String(error)}`, 'error');
}
};
const findEngine = (kind: string) => engineStatus?.find(e => e.kind === kind) ?? null;
const renderEngineStatus = (item: EngineStatusItem | null) => {
if (!item) return <span className="text-text-muted font-medium">Checking...</span>;
if (item.ready) return <span className="text-green-500 font-medium">Ready</span>;
return <span className="text-red-500 font-medium">Error / Missing</span>;
};
const renderEngineVersion = (item: EngineStatusItem | null) => {
if (!item) return 'Checking...';
if (item.version) return item.version;
if (item.error) return `Error: ${item.error.length > 80 ? item.error.substring(0, 80) + '…' : item.error}`;
return 'Unknown';
};
const renderEngineDetails = (item: EngineStatusItem | null) => {
if (!item || item.ready || (!item.error && !item.remediation_hint && !item.stderr_tail)) return null;
const isExpanded = expandedEngine === item.kind;
return (
<>
<button
onClick={() => setExpandedEngine(isExpanded ? null : item.kind)}
className="text-accent text-[11px] font-medium hover:underline mt-1"
>
{isExpanded ? 'Hide technical details' : 'Show technical details'}
</button>
{isExpanded && (
<div className="mt-2 p-2 bg-bg-modal rounded text-[11px] font-mono text-text-muted space-y-1 leading-relaxed">
{item.resolved_path && <p>Binary: {item.resolved_path}</p>}
{item.expected_sidecar && <p>Expected: {item.expected_sidecar}</p>}
{item.error && <p className="text-red-400">Error: {item.error}</p>}
{item.remediation_hint && <p className="text-yellow-500">Tip: {item.remediation_hint}</p>}
{item.stderr_tail && <details><summary className="cursor-pointer text-text-muted">stderr</summary><pre className="mt-1 whitespace-pre-wrap">{item.stderr_tail}</pre></details>}
{item.daemon_alive != null && <p>Daemon process alive: {String(item.daemon_alive)}</p>}
{item.rpc_ready != null && <p>RPC ready: {String(item.rpc_ready)}</p>}
{item.rpc_port != null && <p>RPC port: {item.rpc_port}</p>}
{item.last_stderr_tail && <details><summary className="cursor-pointer text-text-muted">daemon stderr</summary><pre className="mt-1 whitespace-pre-wrap">{item.last_stderr_tail}</pre></details>}
{item.expects_internal_dir === true && <p>Packaging: PyInstaller onedir (_internal required)</p>}
{item.has_internal_dir === true && <p>_internal directory found: true</p>}
{item.has_python_framework != null && <p>Python runtime found: {String(item.has_python_framework)}</p>}
</div>
)}
</>
);
};
const handleCheckForUpdates = async () => {
if (isCheckingForUpdates) return;
setIsCheckingForUpdates(true);
setManualUpdateStatus({ type: 'checking' });
try {
const result = await invoke('check_for_updates');
if (result.type === 'UpToDate') {
setManualUpdateStatus({
type: 'up-to-date',
latestVersion: result.latest_version,
localVersion: result.local_version
});
} else if (result.type === 'UpdateAvailable') {
setManualUpdateStatus({
type: 'update-available',
version: result.update.version,
releaseUrl: result.update.release_url
});
} else {
setManualUpdateStatus({
type: 'error',
message: 'The update check returned an unexpected response.'
});
}
} catch (error) {
setManualUpdateStatus({
type: 'error',
message: `Update check failed: ${String(error)}`
});
} finally {
setIsCheckingForUpdates(false);
}
};
const handleBrowseCategory = async (category: string) => {
const currentPath = settings.categoryDirectoryOverrides[category] || settings.baseDownloadFolder;
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
});
if (selected && typeof selected === 'string') {
const approvedPath = await settings.approveDownloadRoot(selected);
settings.setCategoryDirectoryOverride(category, approvedPath);
}
} catch (e) {
console.error(`Failed to select folder for ${category}:`, e);
}
};
const handleBrowseBase = async () => {
try {
const base = await open({
directory: true,
multiple: false,
defaultPath: settings.baseDownloadFolder.startsWith('~')
? undefined
: settings.baseDownloadFolder
});
if (base && typeof base === 'string') {
const approvedBase = await settings.approveDownloadRoot(base);
settings.setBaseDownloadFolder(approvedBase);
try {
if (settings.categorySubfoldersEnabled) {
const safeSubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
category,
normalizeCategorySubfolder(
Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category)
? settings.categorySubfolders[category]
: DEFAULT_CATEGORY_SUBFOLDERS[category],
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
])
);
await invoke('create_category_directories', {
baseFolder: approvedBase,
subfolders: safeSubfolders
});
}
} catch (e) {
console.error("Failed to create directories on disk:", e);
showToast(`Base folder saved, but category folders could not be created: ${String(e)}`, 'warning');
return;
}
showToast("Base download folder updated", 'success');
}
} catch (e) {
console.error("Failed to browse base path:", e);
}
};
const handleAddLogin = async () => {
if (!loginPattern.trim() || !loginUser.trim()) {
setLoginError("Please enter a URL pattern and a username.");
return;
}
const id = crypto.randomUUID();
if (loginPass) {
try {
await invoke('set_keychain_password', { id, password: loginPass });
} catch (e) {
console.error("Failed to save password to keychain:", e);
setLoginError("Failed to save password securely.");
return;
}
}
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim()
});
setLoginPattern('');
setLoginUser('');
setLoginPass('');
setLoginError('');
showToast("Added site credential", 'success');
};
const copyToken = async () => {
try {
await navigator.clipboard.writeText(settings.extensionPairingToken);
showToast("Token copied to clipboard!", 'success');
} catch (error) {
showToast(`Could not copy token: ${String(error)}`, 'error');
}
};
const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads';
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
const active = activeTab === type;
return (
<button
type="button"
data-active={active}
onClick={() => settings.setActiveSettingsTab(type)}
className={`settings-tab-button flex min-w-0 flex-1 flex-col items-center justify-center px-1 text-center cursor-default ${
active
? 'text-white'
: 'text-text-primary hover:bg-item-hover'
}`}
>
<Icon size={16} strokeWidth={2} />
<span className="settings-tab-label mt-1 w-full whitespace-nowrap font-medium">{label}</span>
</button>
);
};
return (
<div className="settings-view flex-1 flex flex-col relative h-full overflow-hidden">
<WindowDragRegion />
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
<div className="settings-toolbar">
<div className="settings-tab-strip flex items-stretch gap-1">
{settingsTabs.map(tab => (
<TabButton key={tab.type} {...tab} />
))}
</div>
</div>
{/* Content Area */}
<div className="settings-scroll flex-1 overflow-y-auto">
<div className="settings-content-shell w-full">
<h1 className="settings-title text-text-primary">{activeTabLabel}</h1>
<div className="settings-content max-w-[720px]">
{/* Downloads Pane */}
{activeTab === 'downloads' && (
<div className="settings-pane max-w-[720px]">
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Default connections:</span>
<small>For new downloads</small>
</div>
<input
type="number" min="1" max="16"
value={settings.perServerConnections}
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
onBlur={(e) => {
const val = Number(e.target.value);
if (val < 1) settings.setPerServerConnections(1);
if (val > 16) settings.setPerServerConnections(16);
}}
className="app-control w-24 text-center"
/>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Parallel downloads:</span>
<small>Max simultaneous active files</small>
</div>
<input
type="number" min="1" max="12"
value={settings.maxConcurrentDownloads}
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
onBlur={(e) => {
const val = Number(e.target.value);
if (val < 1) settings.setMaxConcurrentDownloads(1);
if (val > 12) settings.setMaxConcurrentDownloads(12);
}}
className="app-control w-24 text-center"
/>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Automatic retries:</span>
<small>If a connection fails</small>
</div>
<input
type="number" min="0" max="10"
value={settings.maxAutomaticRetries}
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
onBlur={(e) => {
const val = Number(e.target.value);
if (val < 0) settings.setMaxAutomaticRetries(0);
if (val > 10) settings.setMaxAutomaticRetries(10);
}}
className="app-control w-24 text-center"
/>
</div>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show system notification when download completes</span>
<small>Uses your operating system notification settings</small>
</div>
<input
type="checkbox"
checked={settings.showNotifications}
onChange={(e) => settings.setShowNotifications(e.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Play in-app completion chime</span>
<small>Optional Firelink sound, independent of system notifications</small>
</div>
<input
type="checkbox"
checked={settings.playCompletionSound}
onChange={(e) => settings.setPlayCompletionSound(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
{/* Look & Feel Pane */}
{activeTab === 'lookandfeel' && (
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">App Theme</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row">
<span className="text-[13px] text-text-primary pt-0.5">Theme</span>
<div className="theme-option-grid" role="radiogroup" aria-label="App theme">
{[
{ value: 'system', label: 'System', colors: ['#f4f4f5', '#252525'] },
{ value: 'light', label: 'Light', colors: ['#ffffff', '#e9e9ec'] },
{ value: 'dark', label: 'Dark', colors: ['#1a1a1a', '#292929'] },
{ value: 'dracula', label: 'Dracula', colors: ['#282a36', '#ff79c6'] },
{ value: 'nord', label: 'Nord', colors: ['#2e3440', '#88c0d0'] },
].map(({ value, label, colors }) => (
<label
key={value}
className="theme-option"
data-active={settings.theme === value}
>
<input
type="radio"
name="app-theme"
checked={settings.theme === value}
onChange={() => settings.setTheme(value as typeof settings.theme)}
/>
<span className="theme-option-preview" aria-hidden="true">
<span style={{ background: colors[0] }} />
<span style={{ background: colors[1] }} />
</span>
<span>{label}</span>
</label>
))}
</div>
</div>
<p className="settings-group-footer">System follows the current {platformName} light or dark appearance.</p>
</div>
<h2 className="settings-section-title">Display</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Font size</span>
<small>Scales the download list and compact controls.</small>
</div>
<select
value={settings.appFontSize}
onChange={(e) => settings.setAppFontSize(e.target.value as AppFontSize)}
className="app-control w-40"
>
<option value="small">Small</option>
<option value="standard">Standard</option>
<option value="large">Large</option>
</select>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>List density</span>
<small>Changes row height, spacing, and progress bar scale.</small>
</div>
<select
value={settings.listRowDensity}
onChange={(e) => settings.setListRowDensity(e.target.value as ListRowDensity)}
className="app-control w-40"
>
<option value="compact">Compact</option>
<option value="standard">Comfortable</option>
<option value="relaxed">Relaxed</option>
</select>
</div>
</div>
<h2 className="settings-section-title">OS Integration</h2>
<div className="mac-settings-group">
{platform.os === 'macos' && (
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show badge on Dock icon</span>
<small>Displays active download count on Firelink Dock icon.</small>
</div>
<input
type="checkbox"
checked={settings.showDockBadge}
onChange={(e) => settings.setShowDockBadge(e.target.checked)}
className="mac-switch"
/>
</label>
)}
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>{trayIconLabel}</span>
<small>{trayIconDescription}</small>
</div>
<input
type="checkbox"
checked={settings.showMenuBarIcon}
onChange={(e) => settings.setShowMenuBarIcon(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
{/* Network Pane */}
{activeTab === 'network' && (
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">Proxy</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row settings-choice-row">
<div className="settings-row-label">
<span>Mode</span>
<small>Controls proxy use for new download requests.</small>
</div>
<div className="settings-radio-group">
{[
['none', 'No Proxy'],
['system', 'System Proxy'],
['custom', 'Custom Proxy'],
].map(([value, label]) => (
<label key={value}>
<input
type="radio"
name="proxy-mode"
checked={settings.proxyMode === value}
onChange={() => settings.setProxyMode(value as typeof settings.proxyMode)}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{settings.proxyMode === 'custom' && (
<>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>Proxy host</span>
<small>Host name, IP address, or HTTP proxy URL.</small>
</div>
<input
type="text"
value={settings.proxyHost}
onChange={(e) => settings.setProxyHost(e.target.value)}
placeholder="127.0.0.1 or http://127.0.0.1"
className="app-control settings-network-input font-mono"
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>Proxy port</span>
<small>Valid range is 1 to 65535.</small>
</div>
<input
type="number" min="1" max="65535"
value={settings.proxyPort}
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
className="app-control settings-port-input text-center"
/>
</div>
</>
)}
<p className="settings-group-footer">
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.`}
{settings.proxyMode === 'custom' && (settings.proxyHost
? 'Downloads use the configured proxy URL for HTTP, HTTPS, and media engines.'
: 'Enter a proxy host and port to enable the custom proxy.')}
</p>
</div>
<h2 className="settings-section-title">Identity</h2>
<div className="mac-settings-group settings-popup-group">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>Custom User-Agent</span>
<small>Applied to metadata fetches and download engines.</small>
</div>
<div
className="settings-combobox"
ref={userAgentMenuRef}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setIsUserAgentMenuOpen(false);
}
}}
>
<input
type="text"
value={settings.customUserAgent}
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
onFocus={() => setIsUserAgentMenuOpen(true)}
placeholder="Leave blank for Firelink default"
className="app-control settings-network-input font-mono"
role="combobox"
aria-expanded={isUserAgentMenuOpen}
aria-controls="user-agent-suggestions"
/>
{isUserAgentMenuOpen && (
<div id="user-agent-suggestions" className="settings-combobox-menu" role="listbox">
{USER_AGENT_SUGGESTIONS.map(option => (
<button
key={option.label}
type="button"
className="settings-combobox-option"
role="option"
aria-selected={settings.customUserAgent === option.value}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
settings.setCustomUserAgent(option.value);
setIsUserAgentMenuOpen(false);
}}
>
<span className="settings-combobox-value">{option.value}</span>
<span className="settings-combobox-meta">{option.label} · {option.detail}</span>
</button>
))}
</div>
)}
</div>
</div>
<p className="settings-group-footer">Overrides the outbound User-Agent header. Leave blank for Firelink defaults.</p>
</div>
</div>
)}
{/* Locations Pane */}
{activeTab === 'locations' && (
<div className="settings-pane max-w-[760px]">
<div className="mac-settings-group">
<div className="mac-settings-row">
<div>
<span className="text-[13px] font-semibold text-text-primary">Base Download Folder</span>
<p className="mt-0.5 text-[11px] text-text-muted">Automatic category folders are created inside this folder.</p>
</div>
<div className="flex gap-2">
<input
type="text"
value={settings.baseDownloadFolder}
onChange={(e) => settings.setBaseDownloadFolder(e.target.value)}
onBlur={() => {
settings.setBaseDownloadFolder(settings.baseDownloadFolder.trim() || '~/Downloads');
}}
className="app-control w-64 text-[11px] px-2"
placeholder="~/Downloads"
/>
<button
onClick={handleBrowseBase}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Browse
</button>
</div>
</div>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<span className="text-[13px] text-text-primary">Ask where to save when adding downloads</span>
<input
type="checkbox"
checked={settings.askWhereToSaveEachFile}
onChange={(e) => settings.setAskWhereToSaveEachFile(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div>
<span className="text-[13px] text-text-primary">Automatically save files to category subfolders</span>
<p className="mt-0.5 text-[11px] text-text-muted">When off, automatic downloads use the base folder.</p>
</div>
<input
type="checkbox"
checked={settings.categorySubfoldersEnabled}
onChange={(e) => settings.setCategorySubfoldersEnabled(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
<div className="mac-settings-group">
<div className="mac-settings-row bg-item-hover/20">
<span className="text-[13px] font-semibold text-text-primary">Category Subfolders</span>
<span className="text-[11px] text-text-muted">
{settings.categorySubfoldersEnabled ? 'Relative to the base folder' : 'Disabled'}
</span>
</div>
<div
className={`flex flex-col divide-y divide-border-color/30 ${
settings.categorySubfoldersEnabled ? '' : 'opacity-50'
}`}
aria-disabled={!settings.categorySubfoldersEnabled}
>
{DOWNLOAD_CATEGORIES.map((category) => (
<div
key={category}
className={`flex flex-col gap-2 px-4 py-3 transition-colors ${
settings.categorySubfoldersEnabled ? 'hover:bg-item-hover/20' : ''
}`}
>
<div className="flex justify-between items-center">
<span className="text-[13px] font-medium text-text-primary w-32 shrink-0">{category}</span>
<CategoryFolderInput
category={category}
settings={settings}
onBrowse={() => handleBrowseCategory(category)}
disabled={!settings.categorySubfoldersEnabled}
/>
</div>
</div>
))}
</div>
<div className="mac-settings-row justify-end border-t-0">
<button
onClick={() => {
settings.resetCategoryLocations();
showToast("Reset category locations to default", 'success');
}}
disabled={!settings.categorySubfoldersEnabled}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
Reset Defaults
</button>
</div>
</div>
</div>
)}
{/* Site Logins Pane */}
{activeTab === 'sitelogins' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Site Credentials</h3>
{/* Site Logins List */}
<div className="space-y-2 max-h-[200px] overflow-y-auto border border-border-modal rounded-lg p-2 bg-item-hover/10">
{(settings.siteLogins || []).length === 0 ? (
<p className="text-center text-text-muted text-[13px] py-6">No saved logins.</p>
) : (
(settings.siteLogins || []).map((login) => (
<div key={login.id} className="flex justify-between items-center p-2 rounded bg-bg-modal border border-border-modal/40">
<div className="text-[13px] space-y-0.5">
<p className="font-bold text-text-primary font-mono text-[11px]">{login.urlPattern}</p>
<p className="text-text-secondary text-xs">User: {login.username}</p>
</div>
<button
onClick={async () => {
try {
await invoke('delete_keychain_password', { id: login.id });
settings.removeSiteLogin(login.id);
showToast("Deleted credential", 'success');
} catch (error) {
showToast(`Could not delete credential: ${String(error)}`, 'error');
}
}}
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
title="Delete credential"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Add Site Login Form */}
<div className="border-t border-border-color/30 pt-4 space-y-4">
<h4 className="text-[13px] font-bold text-text-primary">Add Site Credentials</h4>
{loginError && (
<p className="text-red-500 text-xs">{loginError}</p>
)}
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">URL Pattern:</label>
<input
type="text"
value={loginPattern}
onChange={(e) => setLoginPattern(e.target.value)}
placeholder="e.g. *.example.com or example.com/downloads"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">Username:</label>
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
placeholder="Username"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">Password:</label>
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
placeholder="Password"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleAddLogin}
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
>
<Plus size={14} /> Add Login
</button>
</div>
</div>
</div>
)}
{/* Power Pane */}
{activeTab === 'power' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Power Management</h3>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.preventsSleepWhileDownloading}
onChange={(e) => settings.setPreventsSleepWhileDownloading(e.target.checked)}
className="mt-0.5 rounded accent-accent"
/>
<div>
<p className="font-semibold text-text-primary">Prevent system sleep while downloads are active</p>
<p className="text-text-muted text-xs mt-0.5">The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.</p>
</div>
</label>
</div>
)}
{/* Engine Pane */}
{activeTab === 'engine' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<div className="flex items-center justify-between gap-3 border-b border-border-color/30 pb-2">
<div>
<h3 className="text-base font-bold text-text-primary">Media Downloader & Engines</h3>
<p className="text-[11px] text-text-muted mt-0.5">Successful results are reused for this app session. Recheck runs real validation again.</p>
</div>
<button
type="button"
onClick={() => runEngineChecks(true)}
disabled={isRecheckingEngines}
className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled:opacity-60"
>
<RefreshCw size={13} className={isRecheckingEngines ? 'animate-spin' : ''} />
{isRecheckingEngines ? 'Checking…' : 'Recheck engines'}
</button>
</div>
{(() => {
const a2 = findEngine('aria2'); const yt = findEngine('ytdlp');
const ff = findEngine('ffmpeg'); const dn = findEngine('deno');
return (
<div className="space-y-4">
{/* aria2 card */}
<div className="border border-border-modal rounded-lg p-4 space-y-2 bg-item-hover/5">
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
<Terminal size={14} className="text-accent" /> Core Downloader (Aria2)
</h4>
<div className="grid grid-cols-[100px_1fr] text-[13px] items-center gap-x-2">
<span className="text-text-secondary">Version:</span>
<span className="font-mono text-xs text-text-muted select-all truncate">{renderEngineVersion(a2)}</span>
</div>
<div className="grid grid-cols-[100px_1fr] text-[13px] items-center gap-x-2">
<span className="text-text-secondary">Status:</span>
{renderEngineStatus(a2)}
</div>
{renderEngineDetails(a2)}
</div>
{/* yt-dlp / ffmpeg / deno card */}
<div className="border border-border-modal rounded-lg p-4 space-y-2 bg-item-hover/5">
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
<Terminal size={14} className="text-orange-500" /> Media Extractors
</h4>
{[
{ key: 'ytdlp', item: yt, label: 'yt-dlp' },
{ key: 'ffmpeg', item: ff, label: 'FFmpeg' },
{ key: 'deno', item: dn, label: 'Deno' },
].map(({ key, item, label }) => (
<div key={key}>
<div className="grid grid-cols-[100px_1fr_80px] text-[13px] items-center gap-x-2">
<span className="text-text-secondary font-semibold">{label}:</span>
<span className="font-mono text-xs text-text-muted select-all truncate">{renderEngineVersion(item)}</span>
{renderEngineStatus(item)}
</div>
{renderEngineDetails(item)}
</div>
))}
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
<label className="text-text-secondary font-semibold pt-1.5">Browser Cookies Source:</label>
<select
value={browserCookieSourceOptions.some(option => option.value === settings.mediaCookieSource) ? settings.mediaCookieSource : 'none'}
onChange={(e) => settings.setMediaCookieSource(e.target.value)}
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent w-full"
>
{browserCookieSourceOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
</div>
</div>
);
})()}
</div>
)}
{/* Integrations Pane */}
{activeTab === 'integrations' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<div className="flex items-center gap-3 border-b border-border-color/30 pb-3">
<Puzzle size={28} className="text-orange-500" />
<div>
<h3 className="text-base font-bold text-text-primary">Connect Browser Extension</h3>
<p className="text-text-secondary text-xs">Capture downloads directly from your browser in three easy steps.</p>
</div>
</div>
{settings.isPairingTokenPersistent ? (
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-center gap-3">
<div className="p-1.5 bg-green-500/20 rounded-full text-green-500 flex-shrink-0">
<Check size={16} strokeWidth={2.5} />
</div>
<div className="flex-1">
<h4 className="text-sm font-semibold text-green-500 m-0">Credential Storage Available</h4>
<p className="text-xs text-text-secondary m-0 mt-0.5">
Your pairing token is securely saved in this system's credential store and will persist across restarts.
</p>
</div>
</div>
) : (
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 flex items-start gap-3">
<ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-text-primary mb-1">Credential Storage Needed</h4>
<p className="text-xs text-text-secondary mb-3">
Firelink needs access to this system's credential store to securely save your pairing token across app restarts.
Currently, your extension will only stay connected for this session.
</p>
<button
onClick={() => settings.setShowKeychainModal(true)}
className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-white hover:bg-accent/90 shadow-sm"
>
Grant Credential Access
</button>
</div>
</div>
)}
{/* Step Guide Cards */}
<div className="grid grid-cols-3 gap-4">
{/* Step 1 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
<div>
<div className="flex justify-between items-center mb-2">
<span className="bg-accent/25 text-accent font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">1</span>
<Copy size={16} className="text-accent" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Copy Token</h4>
<p className="text-text-muted text-[11px] leading-relaxed">This secure token authorizes your browser extension.</p>
</div>
<div className="space-y-2">
<button
onClick={() => void copyToken()}
className="w-full bg-accent hover:bg-accent text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
>
<Copy size={11} /> Copy Token
</button>
<button
onClick={async () => {
try {
await settings.regeneratePairingToken();
showToast("Pairing token regenerated", 'success');
} catch (error) {
showToast(`Could not regenerate pairing token: ${String(error)}`, 'error');
}
}}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
<RefreshCw size={11} /> Regenerate
</button>
</div>
</div>
{/* Step 2 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
<div>
<div className="flex justify-between items-center mb-2">
<span className="bg-orange-600/25 text-orange-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">2</span>
<Globe size={16} className="text-orange-500" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Get Extension</h4>
<p className="text-text-muted text-[11px] leading-relaxed">Install Firelink Companion for Firefox or Chromium browsers.</p>
</div>
<div className="space-y-2">
<button
type="button"
onClick={() => void openExternalUrl(FIRELINK_FIREFOX_ADDON_URL, 'Firefox Add-ons')}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
Firefox Add-ons
<ExternalLink size={11} />
</button>
<button
type="button"
onClick={() => void openExternalUrl(FIRELINK_EXTENSION_RELEASES_URL, 'Chromium release ZIP')}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
Chromium ZIP
<ExternalLink size={11} />
</button>
</div>
</div>
{/* Step 3 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col h-[190px]">
<div className="flex justify-between items-center mb-2">
<span className="bg-green-600/25 text-green-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">3</span>
<Puzzle size={16} className="text-green-500" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Paste & Connect</h4>
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste the copied token.</p>
</div>
</div>
{/* Status Info */}
<div className="border border-border-modal/70 rounded-lg p-3 bg-item-hover/10 flex justify-between items-center text-[12px]">
<span className="text-text-secondary font-medium">Extension Server Status:</span>
<span className={`${extensionServerPort ? 'text-green-500' : 'text-orange-400'} font-semibold flex items-center gap-1`}>
{extensionServerPort
? `● Listening on 127.0.0.1:${extensionServerPort}`
: ' Server unavailable'}
</span>
</div>
</div>
)}
{/* About Pane */}
{activeTab === 'about' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<div className="bg-bg-modal border border-border-modal/40 rounded-lg p-5 flex items-center justify-between gap-5">
<div className="flex items-center gap-4 min-w-0">
<img src={appIcon} alt="Firelink Icon" className="w-[68px] h-[68px] drop-shadow-md rounded-xl shrink-0" />
<div className="space-y-1 min-w-0">
<h3 className="text-[18px] font-bold text-text-primary leading-tight">Firelink</h3>
<p className="text-text-secondary text-[12px] font-medium">Version {appVersion}</p>
<p className="text-text-muted text-[11px]">
Cross-platform download manager for macOS, Windows, and Linux.
</p>
</div>
</div>
<button
type="button"
onClick={() => void openExternalUrl(FIRELINK_SOURCE_URL, 'Source Code')}
className="app-button px-3 py-1.5 text-xs shrink-0 flex items-center gap-1.5"
>
<Code size={13} />
Source
<ExternalLink size={12} />
</button>
</div>
{manualUpdateStatus.type !== 'idle' && manualUpdateStatus.type !== 'checking' && (
<div
className={`rounded-lg border px-4 py-3 flex items-center justify-between gap-3 text-[12px] ${
manualUpdateStatus.type === 'up-to-date'
? 'bg-green-500/10 border-green-500/20 text-green-500'
: manualUpdateStatus.type === 'update-available'
? 'bg-blue-500/10 border-blue-500/20 text-blue-400'
: 'bg-red-500/10 border-red-500/20 text-red-400'
}`}
>
<div className="flex items-center gap-2 min-w-0">
{manualUpdateStatus.type === 'up-to-date' ? (
<BadgeCheck size={16} className="shrink-0" />
) : manualUpdateStatus.type === 'update-available' ? (
<Info size={16} className="shrink-0" />
) : (
<AlertCircle size={16} className="shrink-0" />
)}
<span className="font-medium">
{manualUpdateStatus.type === 'up-to-date' &&
`Firelink ${manualUpdateStatus.latestVersion} is up to date.`}
{manualUpdateStatus.type === 'update-available' &&
`Firelink ${manualUpdateStatus.version} is available.`}
{manualUpdateStatus.type === 'error' && manualUpdateStatus.message}
</span>
</div>
{manualUpdateStatus.type === 'update-available' && (
<button
type="button"
onClick={() => void openExternalUrl(manualUpdateStatus.releaseUrl, 'latest release')}
className="app-button px-3 py-1 text-xs text-text-primary shrink-0 flex items-center gap-1.5"
>
View release
<ExternalLink size={12} />
</button>
)}
</div>
)}
<div className="space-y-2">
<h4 className="text-[12px] font-bold text-text-primary px-1">Updates</h4>
<div className="bg-bg-modal border border-border-modal/40 rounded-lg overflow-hidden">
<div className="p-4 flex items-center justify-between border-b border-border-modal/40">
<div>
<p className="text-[13px] font-bold text-text-primary">Check for Updates</p>
<p className="text-text-muted text-[11px] mt-0.5">Firelink checks GitHub Releases for new versions.</p>
</div>
<button
onClick={handleCheckForUpdates}
disabled={isCheckingForUpdates}
className="app-button px-4 text-xs disabled:opacity-50"
>
{isCheckingForUpdates ? (
<>
<RefreshCw size={13} className="animate-spin" />
Checking...
</>
) : 'Check Now'}
</button>
</div>
<label className="p-4 flex items-center justify-between cursor-default">
<span className="text-[13px] font-bold text-text-primary">Automatically check for updates</span>
<button
type="button"
role="switch"
aria-checked={settings.autoCheckUpdates}
onClick={() => settings.setAutoCheckUpdates(!settings.autoCheckUpdates)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-default items-center rounded-full transition-colors duration-200 ease-in-out border border-transparent ${settings.autoCheckUpdates ? 'bg-accent' : 'bg-border-color'}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${settings.autoCheckUpdates ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div>
</div>
<div className="bg-bg-modal border border-border-modal/40 rounded-lg p-4 text-[11px] space-y-3">
<div className="flex justify-between items-center">
<span className="text-text-primary font-bold">Created by NimBold</span>
<button
type="button"
onClick={() => void openExternalUrl(FIRELINK_LICENSE_URL, 'MIT License')}
className="flex items-center gap-1.5 text-text-secondary hover:text-accent transition-colors font-medium"
>
MIT License
<ExternalLink size={12} />
</button>
</div>
<div className="text-text-muted">
Credits: aria2, yt-dlp, FFmpeg, and Deno.
</div>
<div className="text-text-muted pt-1 border-t border-border-modal/40">
Copyright © 2026 NimBold. All rights reserved.
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};