Files
codeg/src/components/settings/system-network-settings.tsx

723 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
ArrowUpCircle,
CheckCircle2,
Languages,
Loader2,
MonitorCog,
RefreshCw,
Wifi,
} from "lucide-react"
import { Github } from "@lobehub/icons"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Update = any
import { useLocale, useTranslations } from "next-intl"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { toast } from "sonner"
import { useAppI18n } from "@/components/i18n-provider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
getSystemProxySettings,
getSystemRenderingSettings,
updateSystemLanguageSettings,
updateSystemProxySettings,
updateSystemRenderingSettings,
} from "@/lib/api"
import { isDesktop, openUrl } from "@/lib/platform"
import type { AppLocale } from "@/lib/types"
import { usePlatform } from "@/hooks/use-platform"
import {
checkAppUpdate,
closeAppUpdate,
getCurrentAppVersion,
installAppUpdate,
normalizeAppUpdateError,
relaunchApp,
} from "@/lib/updater"
import type { DownloadEvent } from "@/lib/updater"
import { APP_LOCALES } from "@/lib/i18n"
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const PROXY_EXAMPLE = "http://127.0.0.1:7890"
const APP_LANGUAGE_VALUES = APP_LOCALES
type LanguageSelectValue = "system" | AppLocale
function isAppLocale(value: string): value is AppLocale {
return APP_LANGUAGE_VALUES.includes(value as AppLocale)
}
type UpdateAction = "check" | "install"
// Captured the first time settings page loads: represents the value that the
// running webview process was launched with. Survives settings-shell remounts
// so the "Restart now" banner doesn't vanish if the user navigates away and
// back without restarting.
let processStartDisableHwAccel: boolean | null = null
export function SystemNetworkSettings() {
const t = useTranslations("SystemSettings")
const tLanguage = useTranslations("Language")
const locale = useLocale()
const { languageSettings, languageSettingsLoaded, setLanguageSettings } =
useAppI18n()
const { isWindows } = usePlatform()
const renderingSettingsLoadable = isDesktop()
const renderingSectionVisible = renderingSettingsLoadable && isWindows
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [savingLanguage, setSavingLanguage] = useState(false)
const [enabled, setEnabled] = useState(false)
const [proxyUrl, setProxyUrl] = useState("")
const [loadError, setLoadError] = useState<string | null>(null)
const [disableHwAccel, setDisableHwAccel] = useState(false)
const [savingRendering, setSavingRendering] = useState(false)
const [persistedDisableHwAccel, setPersistedDisableHwAccel] = useState(false)
const [processStartLoaded, setProcessStartLoaded] = useState(
processStartDisableHwAccel !== null
)
const renderingDirty =
processStartLoaded && persistedDisableHwAccel !== processStartDisableHwAccel
const [currentVersion, setCurrentVersion] = useState<string>("")
const [availableUpdate, setAvailableUpdate] = useState<Update | null>(null)
const [checkingUpdate, setCheckingUpdate] = useState(false)
const [installingUpdate, setInstallingUpdate] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null)
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(null)
const [downloadProgress, setDownloadProgress] = useState<{
downloaded: number
total: number | null
phase: "downloading" | "installing"
} | null>(null)
const [appLanguage, setAppLanguage] = useState<LanguageSelectValue>(
languageSettings.mode === "system" ? "system" : languageSettings.language
)
useEffect(() => {
setAppLanguage(
languageSettings.mode === "system" ? "system" : languageSettings.language
)
}, [languageSettings])
const languageLabels = useMemo(
() => ({
en: tLanguage("english"),
zh_cn: tLanguage("simplifiedChinese"),
zh_tw: tLanguage("traditionalChinese"),
ja: tLanguage("japanese"),
ko: tLanguage("korean"),
es: tLanguage("spanish"),
de: tLanguage("german"),
fr: tLanguage("french"),
pt: tLanguage("portuguese"),
ar: tLanguage("arabic"),
}),
[tLanguage]
)
const formattedLastCheckedAt = useMemo(() => {
if (!lastCheckedAt) return null
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(lastCheckedAt)
}, [lastCheckedAt, locale])
const formattedUpdateDate = useMemo(() => {
if (!availableUpdate?.date) return null
const parsed = new Date(availableUpdate.date)
if (Number.isNaN(parsed.getTime())) return availableUpdate.date
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
}).format(parsed)
}, [availableUpdate?.date, locale])
const updateNotes = useMemo(
() => availableUpdate?.body?.trim() ?? "",
[availableUpdate?.body]
)
const updateStatusMessage = useMemo(() => {
if (checkingUpdate) return t("checking")
if (installingUpdate) return t("updating")
if (availableUpdate) return null
if (lastCheckedAt) return t("alreadyLatest")
return null
}, [availableUpdate, checkingUpdate, installingUpdate, lastCheckedAt, t])
const loadSettings = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const [proxySettings, version, renderingSettings] = await Promise.all([
getSystemProxySettings(),
getCurrentAppVersion(),
renderingSettingsLoadable
? getSystemRenderingSettings()
: Promise.resolve(null),
])
setEnabled(proxySettings.enabled)
setProxyUrl(proxySettings.proxy_url ?? "")
setCurrentVersion(version)
if (renderingSettings) {
const value = renderingSettings.disable_hardware_acceleration
setDisableHwAccel(value)
setPersistedDisableHwAccel(value)
if (processStartDisableHwAccel === null) {
processStartDisableHwAccel = value
setProcessStartLoaded(true)
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setLoadError(message)
console.error("[Settings] load system settings failed:", err)
} finally {
setLoading(false)
}
}, [renderingSettingsLoadable])
useEffect(() => {
loadSettings().catch((err) => {
console.error("[Settings] load system settings failed:", err)
})
checkForUpdates().catch((err) => {
console.error("[Settings] auto check update failed:", err)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
return () => {
if (!availableUpdate) return
closeAppUpdate(availableUpdate).catch((err) => {
console.error("[Settings] release updater resource failed:", err)
})
}
}, [availableUpdate])
const saveProxySettings = useCallback(
async (nextEnabled: boolean, nextProxyUrl: string) => {
if (nextEnabled && !nextProxyUrl.trim()) return
setSaving(true)
try {
const next = await updateSystemProxySettings({
enabled: nextEnabled,
proxy_url: nextProxyUrl.trim() || null,
})
setEnabled(next.enabled)
setProxyUrl(next.proxy_url ?? "")
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("saveFailed", { message }))
} finally {
setSaving(false)
}
},
[t]
)
const saveRenderingSettings = useCallback(
async (next: boolean, prev: boolean) => {
setSavingRendering(true)
try {
const result = await updateSystemRenderingSettings({
disable_hardware_acceleration: next,
})
setDisableHwAccel(result.disable_hardware_acceleration)
setPersistedDisableHwAccel(result.disable_hardware_acceleration)
} catch (err) {
setDisableHwAccel(prev)
const message = err instanceof Error ? err.message : String(err)
toast.error(t("renderingSaveFailed", { message }))
} finally {
setSavingRendering(false)
}
},
[t]
)
const restartNow = useCallback(async () => {
try {
await relaunchApp()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("restartFailed", { message }))
}
}, [t])
const saveLanguage = useCallback(
async (lang: LanguageSelectValue) => {
setSavingLanguage(true)
try {
const next = await updateSystemLanguageSettings({
mode: lang === "system" ? "system" : "manual",
language: lang === "system" ? languageSettings.language : lang,
})
setLanguageSettings(next)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("languageSaveFailed", { message }))
} finally {
setSavingLanguage(false)
}
},
[languageSettings.language, setLanguageSettings, t]
)
const formatUpdateError = useCallback(
(error: unknown, action: UpdateAction): string => {
const { kind, rawMessage } = normalizeAppUpdateError(error)
switch (kind) {
case "source_unreachable":
return t("updateErrors.sourceUnavailable")
case "network":
return t("updateErrors.network")
case "download_failed":
return t("updateErrors.downloadFailed")
case "install_failed":
return t("updateErrors.installFailed")
case "unknown":
default:
if (action === "install") {
return t("updateErrors.installFailed")
}
console.error("[Settings] updater unknown error:", rawMessage)
return t("updateErrors.unknown")
}
},
[t]
)
const checkForUpdates = useCallback(async () => {
setCheckingUpdate(true)
setUpdateError(null)
try {
const previousUpdate = availableUpdate
const result = await checkAppUpdate()
setCurrentVersion(result.currentVersion)
setLastCheckedAt(new Date())
if (result.update) {
setAvailableUpdate(result.update)
} else {
setAvailableUpdate(null)
}
if (previousUpdate && previousUpdate !== result.update) {
await closeAppUpdate(previousUpdate)
}
} catch (err) {
const message = formatUpdateError(err, "check")
setUpdateError(message)
toast.error(t("checkUpdateFailed", { message }))
console.error("[Settings] check app update failed:", err)
} finally {
setCheckingUpdate(false)
}
}, [availableUpdate, formatUpdateError, t])
const installUpdate = useCallback(async () => {
if (!availableUpdate) return
setInstallingUpdate(true)
setUpdateError(null)
setDownloadProgress(null)
let downloaded = 0
try {
await installAppUpdate(availableUpdate, (event: DownloadEvent) => {
switch (event.event) {
case "Started":
setDownloadProgress({
downloaded: 0,
total: event.data.contentLength ?? null,
phase: "downloading",
})
break
case "Progress":
downloaded += event.data.chunkLength
setDownloadProgress((prev) => ({
downloaded,
total: prev?.total ?? null,
phase: "downloading",
}))
break
case "Finished":
setDownloadProgress((prev) => ({
downloaded: prev?.downloaded ?? downloaded,
total: prev?.total ?? null,
phase: "installing",
}))
break
}
})
toast.success(t("installSuccess"))
await relaunchApp()
} catch (err) {
const message = formatUpdateError(err, "install")
setUpdateError(message)
toast.error(t("installFailed", { message }))
console.error("[Settings] install app update failed:", err)
} finally {
setInstallingUpdate(false)
setDownloadProgress(null)
}
}, [availableUpdate, formatUpdateError, t])
if (loading) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("loading")}
</div>
)
}
return (
<ScrollArea className="h-full">
<div className="w-full space-y-4 p-3 md:p-4">
<section className="space-y-1">
<div className="flex items-center justify-between">
<h1 className="text-sm font-semibold">{t("sectionTitle")}</h1>
<Button
variant="ghost"
className="size-5 rounded-full"
onClick={() => openUrl("https://github.com/xintaofei/codeg")}
>
<Github className="size-5" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("sectionDescription")}
</p>
</section>
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
{checkingUpdate ? (
<RefreshCw className="h-4 w-4 text-muted-foreground animate-spin" />
) : availableUpdate ? (
<ArrowUpCircle className="h-4 w-4 text-muted-foreground" />
) : lastCheckedAt ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<RefreshCw className="h-4 w-4 text-muted-foreground" />
)}
<h2 className="text-sm font-semibold">{t("versionTitle")}</h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("updateDescription")}
</p>
<div className="rounded-md border bg-muted/20 px-3 py-3 text-xs space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground">
{t("currentVersion")}
{currentVersion ? `v${currentVersion}` : "-"}
</p>
{checkingUpdate ? (
<Button
key="checking-update"
size="sm"
disabled
aria-busy="true"
className="w-[9.5rem] justify-center transition-none"
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("checking")}
</Button>
) : availableUpdate ? (
<Button
size="sm"
onClick={installUpdate}
disabled={installingUpdate}
>
{installingUpdate ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("updating")}
</>
) : (
<>
<ArrowUpCircle className="h-3.5 w-3.5" />
{t("upgradeTo", { version: availableUpdate.version })}
</>
)}
</Button>
) : (
<Button
key="check-update"
size="sm"
onClick={checkForUpdates}
disabled={installingUpdate}
className="w-[9.5rem] justify-center transition-none"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("checkUpdate")}
</Button>
)}
</div>
{!availableUpdate && formattedLastCheckedAt && (
<p className="text-muted-foreground">
{t("lastChecked", { time: formattedLastCheckedAt })}
</p>
)}
{updateStatusMessage && !downloadProgress && (
<p className="text-muted-foreground">{updateStatusMessage}</p>
)}
{downloadProgress && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-muted-foreground">
<span>
{downloadProgress.phase === "downloading"
? t("downloading")
: t("updating")}
</span>
<span>
{formatBytes(downloadProgress.downloaded)}
{downloadProgress.total
? ` / ${formatBytes(downloadProgress.total)}`
: ""}
</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{
width:
downloadProgress.total && downloadProgress.total > 0
? `${Math.min(100, (downloadProgress.downloaded / downloadProgress.total) * 100)}%`
: "30%",
}}
/>
</div>
</div>
)}
{availableUpdate && (
<div className="space-y-2 pt-2 border-t border-border/70">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">
{t("upgradableVersion")}v{availableUpdate.version}
</span>
{formattedUpdateDate && (
<span className="text-muted-foreground text-[11px]">
{formattedUpdateDate}
</span>
)}
</div>
<div
className={
"mt-3 max-h-72 overflow-auto rounded-md border bg-background/70 px-3 py-3 leading-6 break-words text-muted-foreground " +
"[&_h1]:text-sm [&_h1]:font-semibold [&_h1]:mb-2 [&_h1]:text-foreground " +
"[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-foreground " +
"[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-foreground " +
"[&_p]:mb-2 [&_p:last-child]:mb-0 " +
"[&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-2 [&_li]:mb-1 " +
"[&_code]:font-mono [&_code]:text-[11px] [&_code]:bg-muted [&_code]:rounded [&_code]:px-1 " +
"[&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-2 [&_pre]:overflow-x-auto [&_pre]:mb-2 " +
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 " +
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground/80 " +
"[&_hr]:my-2 [&_hr]:border-border"
}
>
{updateNotes ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{updateNotes}
</ReactMarkdown>
) : (
t("none")
)}
</div>
</div>
)}
</div>
{updateError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{t("updateError", { message: updateError })}
</div>
)}
</section>
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t("proxyTitle")}</h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("proxyDescription")}
</p>
{loadError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{t("loadFailed", { message: loadError })}
</div>
)}
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
disabled={saving}
onChange={(event) => {
const next = event.target.checked
setEnabled(next)
saveProxySettings(next, proxyUrl)
}}
/>
{t("enableProxy")}
</label>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("proxyAddress")}
</label>
<Input
value={proxyUrl}
onChange={(event) => setProxyUrl(event.target.value)}
onBlur={() => saveProxySettings(enabled, proxyUrl)}
placeholder={PROXY_EXAMPLE}
disabled={saving}
/>
<p className="text-[11px] text-muted-foreground">
{t("proxyHint", { example: PROXY_EXAMPLE })}
</p>
</div>
</section>
{renderingSectionVisible && (
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<MonitorCog className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t("renderingTitle")}</h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("renderingDescription")}
</p>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={disableHwAccel}
disabled={savingRendering}
onChange={(event) => {
const next = event.target.checked
const prev = disableHwAccel
setDisableHwAccel(next)
saveRenderingSettings(next, prev)
}}
/>
{t("disableHardwareAcceleration")}
</label>
{renderingDirty && (
<div className="flex items-center justify-between gap-3 rounded-md border bg-muted/20 px-3 py-2 text-xs">
<span className="text-muted-foreground">
{t("restartRequired")}
</span>
<Button
size="sm"
onClick={restartNow}
disabled={savingRendering}
>
<RefreshCw className="h-3.5 w-3.5" />
{t("restartNow")}
</Button>
</div>
)}
</section>
)}
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<Languages className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t("languageTitle")}</h2>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("languageDescription")}
</p>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("appLanguage")}
</label>
<Select
value={appLanguage}
onValueChange={(value) => {
let nextLang: LanguageSelectValue
if (value === "system") {
nextLang = "system"
} else if (isAppLocale(value)) {
nextLang = value
} else {
return
}
setAppLanguage(nextLang)
saveLanguage(nextLang)
}}
disabled={savingLanguage || !languageSettingsLoaded}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="system">
{tLanguage("followSystem")}
</SelectItem>
<SelectItem value="en">{languageLabels.en}</SelectItem>
<SelectItem value="zh_cn">{languageLabels.zh_cn}</SelectItem>
<SelectItem value="zh_tw">{languageLabels.zh_tw}</SelectItem>
<SelectItem value="ja">{languageLabels.ja}</SelectItem>
<SelectItem value="ko">{languageLabels.ko}</SelectItem>
<SelectItem value="es">{languageLabels.es}</SelectItem>
<SelectItem value="de">{languageLabels.de}</SelectItem>
<SelectItem value="fr">{languageLabels.fr}</SelectItem>
<SelectItem value="pt">{languageLabels.pt}</SelectItem>
<SelectItem value="ar">{languageLabels.ar}</SelectItem>
</SelectContent>
</Select>
</div>
</section>
</div>
</ScrollArea>
)
}