mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-09 20:54:28 +02:00
fix: prevent settings page from crashing on some systems
This commit is contained in:
@@ -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<T, F>(work: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce() -> Result<T, String> + 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;
|
||||
|
||||
@@ -232,16 +232,30 @@ export function SettingsDialog({
|
||||
[t],
|
||||
);
|
||||
|
||||
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
|
||||
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<string, string>, 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<boolean>("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<string | null>(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 () => {
|
||||
|
||||
@@ -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<ThemeContextValue>({
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+29
-3
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user