feat: linux support preview

This commit is contained in:
zhom
2025-06-05 21:15:05 +04:00
parent 6836d73ffa
commit 0da34f04cb
39 changed files with 3877 additions and 942 deletions
+68 -32
View File
@@ -26,6 +26,8 @@ import {
} from "@/components/ui/tooltip";
import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
@@ -60,9 +62,6 @@ export function CreateProfileDialog({
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser");
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [supportedBrowsers, setSupportedBrowsers] = useState<
BrowserTypeString[]
>([]);
const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[],
@@ -84,13 +83,29 @@ export function CreateProfileDialog({
isVersionDownloaded,
} = useBrowserDownload();
const {
supportedBrowsers,
isLoading: isLoadingSupport,
isBrowserSupported,
} = useBrowserSupport();
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
void loadExistingProfiles();
}
}, [isOpen]);
useEffect(() => {
if (supportedBrowsers.length > 0) {
// Set default browser to first supported browser
if (supportedBrowsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (supportedBrowsers.length > 0) {
setSelectedBrowser(supportedBrowsers[0] as BrowserTypeString);
}
}
}, [supportedBrowsers]);
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected version when browser changes
@@ -105,7 +120,7 @@ export function CreateProfileDialog({
if (availableVersions.length > 0 && selectedBrowser) {
// Always reset version when browser changes or versions are loaded
// Find the latest stable version (not alpha/beta)
const stableVersions = availableVersions.filter((v) => !v.is_alpha);
const stableVersions = availableVersions.filter((v) => !v.is_nightly);
if (stableVersions.length > 0) {
// Select the first stable version (they're already sorted newest first)
@@ -117,22 +132,6 @@ export function CreateProfileDialog({
}
}, [availableVersions, selectedBrowser]);
const loadSupportedBrowsers = async () => {
try {
const browsers = await invoke<BrowserTypeString[]>(
"get_supported_browsers",
);
setSupportedBrowsers(browsers);
if (browsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (browsers.length > 0) {
setSelectedBrowser(browsers[0]);
}
} catch (error) {
console.error("Failed to load supported browsers:", error);
}
};
const loadExistingProfiles = async () => {
try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
@@ -261,21 +260,58 @@ export function CreateProfileDialog({
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
disabled={isLoadingSupport}
>
<SelectTrigger>
<SelectValue placeholder="Select browser" />
<SelectValue
placeholder={
isLoadingSupport ? "Loading browsers..." : "Select browser"
}
/>
</SelectTrigger>
<SelectContent>
{supportedBrowsers.map((browser) => (
<SelectItem key={browser} value={browser}>
{browser
.split("-")
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1),
)
.join(" ")}
</SelectItem>
))}
{(
[
"mullvad-browser",
"firefox",
"firefox-developer",
"chromium",
"brave",
"zen",
"tor-browser",
] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
if (!isSupported) {
return (
<Tooltip key={browser}>
<TooltipTrigger asChild>
<SelectItem
value={browser}
disabled={true}
className="opacity-50"
>
{displayName} (Not supported on this platform)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
<p>
{displayName} is not supported on your current
platform or architecture.
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<SelectItem key={browser} value={browser}>
{displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
+98 -2
View File
@@ -28,6 +28,7 @@ interface AppSettings {
show_settings_on_startup: boolean;
theme: string;
auto_updates_enabled: boolean;
auto_delete_unused_binaries: boolean;
}
interface SettingsDialogProps {
@@ -41,17 +42,21 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
auto_delete_unused_binaries: true,
});
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isCleaningBinaries, setIsCleaningBinaries] = useState(false);
const { setTheme } = useTheme();
@@ -106,6 +111,39 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
}
};
const handleClearCache = async () => {
setIsClearingCache(true);
try {
await invoke("clear_all_version_cache");
// Optionally show a success message
console.log("Cache cleared successfully");
} catch (error) {
console.error("Failed to clear cache:", error);
} finally {
setIsClearingCache(false);
}
};
const handleCleanupBinaries = async () => {
setIsCleaningBinaries(true);
try {
const cleanedUp = await invoke<string[]>("cleanup_unused_binaries");
if (cleanedUp.length > 0) {
console.log(
`Cleaned up ${cleanedUp.length} unused binaries:`,
cleanedUp,
);
// You could show a toast with the results
} else {
console.log("No unused binaries to clean up");
}
} catch (error) {
console.error("Failed to cleanup unused binaries:", error);
} finally {
setIsCleaningBinaries(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
@@ -130,7 +168,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
settings.show_settings_on_startup !==
originalSettings.show_settings_on_startup ||
settings.theme !== originalSettings.theme ||
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled;
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled ||
settings.auto_delete_unused_binaries !==
originalSettings.auto_delete_unused_binaries;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -216,9 +256,26 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-delete-binaries"
checked={settings.auto_delete_unused_binaries}
onCheckedChange={(checked) => {
updateSetting(
"auto_delete_unused_binaries",
checked as boolean,
);
}}
/>
<Label htmlFor="auto-delete-binaries" className="text-sm">
Automatically delete unused browser binaries
</Label>
</div>
<p className="text-xs text-muted-foreground">
When enabled, Donut Browser will check for browser updates and
notify you when updates are available for your profiles.
notify you when updates are available for your profiles. Unused
binaries will be automatically deleted to save disk space.
</p>
</div>
@@ -244,6 +301,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
starts.
</p>
</div>
{/* Advanced Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
void handleClearCache();
}}
variant="outline"
className="w-full"
>
Clear All Version Cache
</LoadingButton>
<p className="text-xs text-muted-foreground">
Clear all cached browser version data. This will force a fresh
download of version information on the next app restart or manual
refresh.
</p>
<LoadingButton
isLoading={isCleaningBinaries}
onClick={() => {
void handleCleanupBinaries();
}}
variant="outline"
className="w-full"
>
Clean Up Unused Binaries
</LoadingButton>
<p className="text-xs text-muted-foreground">
Manually remove browser binaries that are not used by any profile.
This can help free up disk space. Note: This will run
automatically when the setting above is enabled.
</p>
</div>
</div>
<DialogFooter className="flex-shrink-0">
+71 -3
View File
@@ -9,6 +9,10 @@ interface AppSettings {
theme: string;
}
interface SystemTheme {
theme: string;
}
interface CustomThemeProviderProps {
children: React.ReactNode;
}
@@ -24,6 +28,25 @@ function getSystemTheme(): string {
return "light";
}
// Function to get native system theme (fallback to CSS media query)
async function getNativeSystemTheme(): Promise<string> {
try {
const systemTheme = await invoke<SystemTheme>("get_system_theme");
if (systemTheme.theme === "dark" || systemTheme.theme === "light") {
return systemTheme.theme;
}
// Fallback to CSS media query if native detection returns "unknown"
return getSystemTheme();
} catch (error) {
console.warn(
"Failed to get native system theme, falling back to CSS media query:",
error,
);
// Fallback to CSS media query
return getSystemTheme();
}
}
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
@@ -41,7 +64,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
} catch (error) {
console.error("Failed to load theme settings:", error);
// For first-time users, detect system preference and apply it
const systemTheme = getSystemTheme();
const systemTheme = await getNativeSystemTheme();
console.log(
"First-time user detected, applying system theme:",
systemTheme,
@@ -69,6 +92,50 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
void loadTheme();
}, []);
// Monitor system theme changes when using "system" theme
useEffect(() => {
if (!mounted || defaultTheme !== "system") {
return;
}
const checkSystemTheme = async () => {
try {
const currentSystemTheme = await getNativeSystemTheme();
// Force re-evaluation by toggling the theme
const html = document.documentElement;
const currentClass = html.className;
// Apply the system theme class
if (currentSystemTheme === "dark") {
if (!html.classList.contains("dark")) {
html.classList.add("dark");
html.classList.remove("light");
}
} else {
if (
!html.classList.contains("light") ||
html.classList.contains("dark")
) {
html.classList.add("light");
html.classList.remove("dark");
}
}
} catch (error) {
console.warn("Failed to check system theme:", error);
}
};
// Check system theme every 2 seconds when using system theme
const intervalId = setInterval(() => void checkSystemTheme(), 2000);
// Initial check
void checkSystemTheme();
return () => {
clearInterval(intervalId);
};
}, [mounted, defaultTheme]);
if (isLoading) {
// Use a consistent loading screen that doesn't depend on system theme during SSR
// This prevents hydration mismatch by ensuring server and client render the same initially
@@ -77,6 +144,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
// Only apply system theme detection after component is mounted (client-side only)
if (mounted) {
// Use CSS media query for loading screen since async call would complicate this
const systemTheme = getSystemTheme();
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
spinnerColor =
@@ -85,10 +153,10 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
return (
<div
className={`fixed inset-0 ${loadingBgColor} flex items-center justify-center`}
className={`flex fixed inset-0 justify-center items-center ${loadingBgColor}`}
>
<div
className={`animate-spin rounded-full h-8 w-8 border-2 ${spinnerColor} border-t-transparent`}
className={`w-8 h-8 rounded-full border-2 animate-spin ${spinnerColor} border-t-transparent`}
/>
</div>
);
+6 -6
View File
@@ -30,7 +30,7 @@ interface GithubRelease {
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
is_nightly: boolean;
}
interface VersionSelectorProps {
@@ -75,7 +75,7 @@ export function VersionSelector({
className="justify-between w-full"
>
{selectedVersion ?? placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
@@ -114,11 +114,11 @@ export function VersionSelector({
: "opacity-0",
)}
/>
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<span>{version.tag_name}</span>
{version.is_alpha && (
{version.is_nightly && (
<Badge variant="secondary" className="text-xs">
Alpha
Nightly
</Badge>
)}
{isDownloaded && (
@@ -147,7 +147,7 @@ export function VersionSelector({
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 h-4 w-4" />
<LuDownload className="mr-2 w-4 h-4" />
{isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton>
)}
+19 -3
View File
@@ -19,7 +19,7 @@ interface GithubRelease {
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
is_nightly: boolean;
}
interface BrowserVersionInfo {
@@ -231,7 +231,7 @@ export function useBrowserDownload() {
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
is_nightly: versionInfo.is_prerelease,
}),
);
@@ -272,7 +272,7 @@ export function useBrowserDownload() {
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
is_nightly: versionInfo.is_prerelease,
}),
);
@@ -325,6 +325,22 @@ export function useBrowserDownload() {
setIsDownloading(true);
try {
// Check browser compatibility before attempting download
const isSupported = await invoke<boolean>(
"is_browser_supported_on_platform",
{ browserStr },
);
if (!isSupported) {
const supportedBrowsers = await invoke<string[]>(
"get_supported_browsers",
);
throw new Error(
`${browserName} is not supported on your platform. Supported browsers: ${supportedBrowsers
.map(getBrowserDisplayName)
.join(", ")}`,
);
}
await invoke("download_browser", { browserStr, version });
await loadDownloadedVersions(browserStr);
} catch (error) {
+59
View File
@@ -0,0 +1,59 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
export interface BrowserSupportInfo {
supportedBrowsers: string[];
isLoading: boolean;
error: string | null;
}
export function useBrowserSupport() {
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadSupportedBrowsers = async () => {
try {
setIsLoading(true);
setError(null);
const browsers = await invoke<string[]>("get_supported_browsers");
setSupportedBrowsers(browsers);
} catch (err) {
console.error("Failed to load supported browsers:", err);
setError(
err instanceof Error
? err.message
: "Failed to load supported browsers",
);
} finally {
setIsLoading(false);
}
};
void loadSupportedBrowsers();
}, []);
const isBrowserSupported = (browser: string): boolean => {
return supportedBrowsers.includes(browser);
};
const checkBrowserSupport = async (browser: string): Promise<boolean> => {
try {
return await invoke<boolean>("is_browser_supported_on_platform", {
browserStr: browser,
});
} catch (err) {
console.error(`Failed to check support for browser ${browser}:`, err);
return false;
}
};
return {
supportedBrowsers,
isLoading,
error,
isBrowserSupported,
checkBrowserSupport,
};
}