From 6d9a44faad9592d1a68b7396401e79ed749a1433 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:36:51 +0400 Subject: [PATCH] fix: prevent settings page from crashing on some systems --- src-tauri/src/default_browser.rs | 22 ++++++++- src/components/settings-dialog.tsx | 76 +++++++++++++++++++++++------- src/components/theme-provider.tsx | 30 ++++++++---- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/ko.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/tr.json | 4 +- src/i18n/locales/vi.json | 4 +- src/i18n/locales/zh.json | 4 +- src/lib/themes.ts | 32 +++++++++++-- 14 files changed, 151 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/default_browser.rs b/src-tauri/src/default_browser.rs index 03625bc..fcd22a0 100644 --- a/src-tauri/src/default_browser.rs +++ b/src-tauri/src/default_browser.rs @@ -18,8 +18,13 @@ impl DefaultBrowser { #[cfg(target_os = "windows")] return windows::is_default_browser(); + // Linux answers this by running `xdg-mime`, a shell script that forks + // further. That is blocking work with no upper bound, and this command + // runs on the same async runtime as every other command, the REST API and + // the sync scheduler — so doing it inline occupies a worker for as long as + // the desktop takes to answer. The Settings page polls this on a timer. #[cfg(target_os = "linux")] - return linux::is_default_browser(); + return blocking(linux::is_default_browser).await; #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] Err("Unsupported platform".to_string()) @@ -32,14 +37,27 @@ impl DefaultBrowser { #[cfg(target_os = "windows")] return windows::set_as_default_browser(); + // Same reasoning, and this one additionally sleeps 500ms before verifying. #[cfg(target_os = "linux")] - return linux::set_as_default_browser(); + return blocking(linux::set_as_default_browser).await; #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] Err("Unsupported platform".to_string()) } } +/// Run blocking work off the async runtime's worker threads. +#[cfg(target_os = "linux")] +async fn blocking(work: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(work) + .await + .map_err(|e| format!("Default browser check did not run: {e}"))? +} + #[cfg(target_os = "macos")] mod macos { use core_foundation::base::OSStatus; diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 9a931d8..60ec2c2 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -232,16 +232,30 @@ export function SettingsDialog({ [t], ); - const applyCustomTheme = useCallback((vars: Record) => { - withThemeTransition(() => { - applyThemeColors(vars); - }); - }, []); + // `animate: false` on the restore paths. Opening Settings re-applies the + // theme already on screen, so a whole-document cross-fade to an identical + // palette animates nothing. Worse, the mount effect below did it twice in a + // row, and the second transition aborts the first mid-snapshot. + const applyCustomTheme = useCallback( + (vars: Record, options?: { animate?: boolean }) => { + const apply = () => { + applyThemeColors(vars); + }; + if (options?.animate === false) { + apply(); + return; + } + withThemeTransition(apply); + }, + [], + ); - const clearCustomTheme = useCallback(() => { - withThemeTransition(() => { + const clearCustomTheme = useCallback((options?: { animate?: boolean }) => { + if (options?.animate === false) { clearThemeColors(); - }); + return; + } + withThemeTransition(clearThemeColors); }, []); const loadSettings = useCallback(async () => { @@ -363,12 +377,24 @@ export function SettingsDialog({ isMicrophoneAccessGranted, ]); + // The Linux implementation shells out to `which` plus two `xdg-mime query` + // calls, and `xdg-mime` is a shell script that forks further. Without this + // guard a slow desktop lets the poll below stack one unfinished call on top + // of another every few seconds, and each one occupies a worker of the same + // runtime every other Tauri command shares. + const defaultBrowserCheckInFlight = useRef(false); const checkDefaultBrowserStatus = useCallback(async () => { + if (defaultBrowserCheckInFlight.current) { + return; + } + defaultBrowserCheckInFlight.current = true; try { const isDefault = await invoke("is_default_browser"); setIsDefaultBrowser(isDefault); } catch (error) { console.error("Failed to check default browser status:", error); + } finally { + defaultBrowserCheckInFlight.current = false; } }, []); @@ -565,11 +591,13 @@ export function SettingsDialog({ const handleClose = useCallback(() => { // Restore original theme when closing without saving + // Only a revert the user can see is worth animating. + const changed = originalSettings.theme !== settings.theme; if (originalSettings.theme === "custom" && originalSettings.custom_theme) { - applyCustomTheme(originalSettings.custom_theme); + applyCustomTheme(originalSettings.custom_theme, { animate: changed }); } else { - clearCustomTheme(); - setTheme(originalSettings.theme); + clearCustomTheme({ animate: false }); + setTheme(originalSettings.theme, { animate: changed }); } // Reset custom theme state to original @@ -589,16 +617,29 @@ export function SettingsDialog({ clearCustomTheme, onClose, setTheme, + settings.theme, ]); // Only clear custom theme when switching away from custom, don't apply live // changes. Gated on the async settings load: before it resolves the state // still holds the "system" default, and clearing then wipes the user's // custom theme vars on every Settings visit (the theme-reverts-to-dark bug). + // + // This effect is both the restore-on-open and the live switch when the user + // picks a theme, so it animates only a real change: the first run after the + // settings load is re-applying the palette already on screen. Clearing the + // inline custom vars is never the animated half — switching to a stylesheet + // palette makes them invisible either way, and running two transitions + // back to back just aborts the first one mid-snapshot. + const appliedThemeRef = useRef(null); useEffect(() => { if (hasLoadedSettings && settings.theme !== "custom") { - clearCustomTheme(); - setTheme(settings.theme); + const previous = appliedThemeRef.current; + appliedThemeRef.current = settings.theme; + clearCustomTheme({ animate: false }); + setTheme(settings.theme, { + animate: previous !== null && previous !== settings.theme, + }); } }, [hasLoadedSettings, settings.theme, clearCustomTheme, setTheme]); @@ -616,7 +657,7 @@ export function SettingsDialog({ // stylesheet palette — strip any leftover inline custom vars so a // just-saved switch away from custom isn't reverted on unmount. clearThemeColors(); - setTheme(s.theme); + setTheme(s.theme, { animate: false }); } }; }, [setTheme]); @@ -634,12 +675,15 @@ export function SettingsDialog({ loadPermissions(); } - // Set up interval to check default browser status + // Re-check periodically so the badge follows a change the user made in + // their desktop settings. Ten seconds rather than two: on Linux each + // check is three subprocesses, and nobody flips their default browser + // often enough to notice the difference. const intervalId = setInterval(() => { checkDefaultBrowserStatus().catch((err: unknown) => { console.error(err); }); - }, 2000); + }, 10000); // Cleanup interval on component unmount or dialog close return () => { diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 09be7f9..ac3e963 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -22,7 +22,9 @@ interface AppSettings { interface ThemeContextValue { theme: string; - setTheme: (theme: string) => void; + /// `animate: false` applies the theme without a view transition, for the + /// restore paths where nothing visually changes. + setTheme: (theme: string, options?: { animate?: boolean }) => void; } const ThemeContext = createContext({ @@ -56,14 +58,26 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) { const [isLoading, setIsLoading] = useState(true); const [theme, setThemeState] = useState("system"); - const setTheme = useCallback((newTheme: string) => { - setThemeState(newTheme); - withThemeTransition(() => { - if (newTheme !== "custom") { - applyClassToHtml(newTheme); + // `animate: false` is for restoring the theme the app is already showing — + // opening or leaving Settings re-applies the current theme, and cross-fading + // the whole document to the palette already on screen animates nothing while + // still paying for a full-document snapshot. + const setTheme = useCallback( + (newTheme: string, options?: { animate?: boolean }) => { + setThemeState(newTheme); + const apply = () => { + if (newTheme !== "custom") { + applyClassToHtml(newTheme); + } + }; + if (options?.animate === false) { + apply(); + return; } - }); - }, []); + withThemeTransition(apply); + }, + [], + ); // Load initial theme from Tauri settings useEffect(() => { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5ebd490..f68bdc0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2178,7 +2178,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on. It needs a Pro or Team plan." + "hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on. It needs a paid plan." }, "empty": { "title": "No profiles are enrolled", @@ -2428,7 +2428,7 @@ }, "actionBar": { "enrol": "Enrol in Cookie Bot", - "proRequired": "Cookie Bot requires a Pro or Team plan", + "proRequired": "Cookie Bot requires a paid plan", "noneEligible": "None of the selected profiles can be warmed remotely" }, "actions": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e84b4bd..04cc441 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2185,7 +2185,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido. Requiere un plan Pro o Team." + "hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido. Requiere un plan de pago." }, "empty": { "title": "No hay perfiles inscritos", @@ -2456,7 +2456,7 @@ }, "actionBar": { "enrol": "Inscribir en Cookie Bot", - "proRequired": "Cookie Bot requiere un plan Pro o Team", + "proRequired": "Cookie Bot requiere un plan de pago", "noneEligible": "Ninguno de los perfiles seleccionados se puede calentar en remoto" }, "actions": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 78c9178..075b8a6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2185,7 +2185,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé. Nécessite un forfait Pro ou Team." + "hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé. Nécessite un forfait payant." }, "empty": { "title": "Aucun profil inscrit", @@ -2456,7 +2456,7 @@ }, "actionBar": { "enrol": "Inscrire à Cookie Bot", - "proRequired": "Cookie Bot nécessite un forfait Pro ou Team", + "proRequired": "Cookie Bot nécessite un forfait payant", "noneEligible": "Aucun des profils sélectionnés ne peut être chauffé à distance" }, "actions": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3df9dab..f969a47 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2178,7 +2178,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。Pro または Team プランが必要です。" + "hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。有料プランが必要です。" }, "empty": { "title": "登録されたプロファイルはありません", @@ -2428,7 +2428,7 @@ }, "actionBar": { "enrol": "Cookie Bot に登録", - "proRequired": "Cookie Bot には Pro または Team プランが必要です", + "proRequired": "Cookie Bot には有料プランが必要です", "noneEligible": "選択したプロファイルはいずれもリモートでウォームアップできません" }, "actions": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 836cbd3..af37d6b 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2178,7 +2178,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. Pro 또는 Team 요금제가 필요합니다." + "hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. 유료 요금제가 필요합니다." }, "empty": { "title": "등록된 프로필이 없습니다", @@ -2428,7 +2428,7 @@ }, "actionBar": { "enrol": "Cookie Bot에 등록", - "proRequired": "Cookie Bot에는 Pro 또는 Team 요금제가 필요합니다", + "proRequired": "Cookie Bot에는 유료 요금제가 필요합니다", "noneEligible": "선택한 프로필 중 원격으로 예열할 수 있는 것이 없습니다" }, "actions": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 4d005e9..54e5831 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2185,7 +2185,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado. Requer um plano Pro ou Team." + "hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado. Requer um plano pago." }, "empty": { "title": "Nenhum perfil inscrito", @@ -2456,7 +2456,7 @@ }, "actionBar": { "enrol": "Inscrever no Cookie Bot", - "proRequired": "O Cookie Bot requer um plano Pro ou Team", + "proRequired": "O Cookie Bot requer um plano pago", "noneEligible": "Nenhum dos perfis selecionados pode ser aquecido remotamente" }, "actions": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5635268..a85080a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2192,7 +2192,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется тариф Pro или Team." + "hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется платный тариф." }, "empty": { "title": "Нет подключённых профилей", @@ -2484,7 +2484,7 @@ }, "actionBar": { "enrol": "Подключить к Cookie Bot", - "proRequired": "Для Cookie Bot нужен тариф Pro или Team", + "proRequired": "Для Cookie Bot нужен платный тариф", "noneEligible": "Ни один из выбранных профилей нельзя прогреть удалённо" }, "actions": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index ab5fc33..b2bc323 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2178,7 +2178,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar. Pro veya Team planı gerekir." + "hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar. Ücretli bir plan gerekir." }, "empty": { "title": "Kayıtlı profil yok", @@ -2428,7 +2428,7 @@ }, "actionBar": { "enrol": "Cookie Bot'a kaydet", - "proRequired": "Cookie Bot için Pro veya Team planı gerekir", + "proRequired": "Cookie Bot için ücretli bir plan gerekir", "noneEligible": "Seçili profillerin hiçbiri uzaktan ısıtılamaz" }, "actions": { diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 89d91ac..85c09ac 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -2178,7 +2178,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn. Cần gói Pro hoặc Team." + "hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn. Cần gói trả phí." }, "empty": { "title": "Chưa có hồ sơ nào được đăng ký", @@ -2428,7 +2428,7 @@ }, "actionBar": { "enrol": "Đăng ký vào Cookie Bot", - "proRequired": "Cookie Bot cần gói Pro hoặc Team", + "proRequired": "Cookie Bot cần gói trả phí", "noneEligible": "Không hồ sơ nào đã chọn có thể làm ấm từ xa" }, "actions": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 493147e..8357f6a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2178,7 +2178,7 @@ }, "locked": { "title": "Cookie Bot", - "hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要 Pro 或 Team 套餐。" + "hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要付费套餐。" }, "empty": { "title": "尚未加入任何配置文件", @@ -2428,7 +2428,7 @@ }, "actionBar": { "enrol": "加入 Cookie Bot", - "proRequired": "Cookie Bot 需要 Pro 或 Team 套餐", + "proRequired": "Cookie Bot 需要付费套餐", "noneEligible": "所选配置文件都无法远程养号" }, "actions": { diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 9f040d7..7c12014 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -1176,11 +1176,33 @@ export function clearThemeColors(): void { }); } +/** + * WebKitGTK, which is the webview on Linux and nowhere else. + * + * Windows runs WebView2 (a Chromium user agent) and macOS runs WKWebView + * (`Macintosh`), so an `AppleWebKit` user agent claiming X11/Linux is + * WebKitGTK and only WebKitGTK. + */ +function isWebKitGtk(): boolean { + if (typeof navigator === "undefined") { + return false; + } + const ua = navigator.userAgent; + return /\b(?:X11|Linux)\b/.test(ua) && ua.includes("AppleWebKit"); +} + /** * Run a theme mutation inside a View Transition so the whole UI cross-fades * (~200ms, tuned in globals.css) instead of hard-cutting between palettes. - * Falls back to an instant switch when the API is unavailable or the user - * prefers reduced motion. + * Falls back to an instant switch when the API is unavailable, the user + * prefers reduced motion, or the webview is WebKitGTK. + * + * A view transition asks the engine to snapshot the whole document into + * compositor layers and hold rendering until it can cross-fade them. That is + * the newest and least-exercised path in WebKitGTK, and it is reached from + * exactly one screen here, which is the screen a Linux user reported the app + * segfaulting on. The cross-fade is decoration; not taking that path on Linux + * costs nothing anyone will miss. */ export function withThemeTransition(mutate: () => void): void { if (typeof document === "undefined") { @@ -1193,7 +1215,11 @@ export function withThemeTransition(mutate: () => void): void { const doc = document as Document & { startViewTransition?: (callback: () => void) => unknown; }; - if (reduced || typeof doc.startViewTransition !== "function") { + if ( + reduced || + isWebKitGtk() || + typeof doc.startViewTransition !== "function" + ) { mutate(); return; }