Compare commits

...

11 Commits

Author SHA1 Message Date
zhom 1950ef0098 docs: typo fix 2025-06-02 05:10:48 +04:00
zhom 814875c28e docs: add option for urgent contact 2025-06-02 05:10:10 +04:00
zhom b06ca4f11e chore: set the correct license in package.json 2025-06-01 05:32:27 +04:00
zhom 3ab1ea61e8 fix: run correct lint command in ci 2025-05-31 18:25:32 +04:00
zhom a0599ecfc1 test: fix update from dev to nightly test 2025-05-31 18:19:47 +04:00
zhom 6c834b3003 test: fix dev to nightly update check 2025-05-31 18:10:21 +04:00
zhom 269b4dbe77 chore: version bump 2025-05-31 17:23:56 +04:00
zhom ef00854307 build: don't check for updates on dev version 2025-05-31 17:19:29 +04:00
zhom 03d915e5c7 fix: prevent update browser toasts getting stuck in a cycle 2025-05-31 17:14:10 +04:00
zhom 91b12e80e5 fix: treat 'twilight' release as alpha and rolling 2025-05-31 16:53:11 +04:00
zhom 3af581c4ab fix: prevent hydration errors for theme provider 2025-05-31 16:48:36 +04:00
22 changed files with 955 additions and 852 deletions
+1 -1
View File
@@ -49,4 +49,4 @@ jobs:
pnpm install --frozen-lockfile
- name: Run lint step
run: pnpm lint
run: pnpm run lint:js
+2 -2
View File
@@ -30,8 +30,8 @@ yarn-error.log*
.pnpm-debug.log*
# nodecar
nodecar/dist
nodecar/node_modules
**/dist
**/node_modules
# local env files
.env*.local
+4
View File
@@ -54,6 +54,10 @@ Have questions or want to contribute? We'd love to hear from you!
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
## Contact
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
## License
This project is licensed under the AGPL-3.0 License - see the [LICENSE](LICENSE) file for details.
+1 -1
View File
@@ -17,7 +17,7 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"license": "AGPL-3.0",
"packageManager": "pnpm@10.6.1",
"dependencies": {
"@types/node": "^22.15.17",
+7 -5
View File
@@ -1,21 +1,23 @@
{
"name": "donutbrowser",
"private": true,
"version": "0.2.3",
"license": "AGPL-3.0",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/ && tsc --noEmit && next lint",
"lint": "pnpm lint:js && pnpm lint:rust",
"lint:js": "biome check src/ && tsc --noEmit && next lint",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky",
"format:js": "biome check src/ --fix",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"format:biome": "biome check src/ --fix",
"format": "pnpm format:js && pnpm format:rust"
"format:js": "biome check src/ --fix",
"format": "pnpm format:js && pnpm format:rust",
"cargo": "cd src-tauri && cargo"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
+471 -624
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.2.3"
version = "0.2.4"
description = "Browser Orchestrator"
authors = ["zhom@github"]
edition = "2021"
+1 -1
View File
@@ -13,7 +13,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.2.3</string>
<string>0.2.4</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
+188 -67
View File
@@ -36,12 +36,16 @@ impl VersionComponent {
let version = version.trim();
// Handle special case for Zen Browser twilight releases
if version.to_lowercase().contains("twilight") {
if version.to_lowercase() == "twilight" {
// Pure twilight release without base version
return VersionComponent {
major: u32::MAX,
minor: u32::MAX,
patch: u32::MAX,
pre_release: None,
major: 999, // High major version to indicate it's a rolling release
minor: 0,
patch: 0,
pre_release: Some(PreRelease {
kind: PreReleaseKind::Alpha,
number: Some(999), // High number to indicate it's a rolling release
}),
};
}
@@ -140,6 +144,38 @@ impl Ord for VersionComponent {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
// Check for twilight versions
let self_is_twilight = self
.pre_release
.as_ref()
.map(|pr| pr.kind == PreReleaseKind::Alpha && pr.number == Some(999))
.unwrap_or(false);
let other_is_twilight = other
.pre_release
.as_ref()
.map(|pr| pr.kind == PreReleaseKind::Alpha && pr.number == Some(999))
.unwrap_or(false);
// If one is twilight and the other isn't, twilight always has priority
if self_is_twilight && !other_is_twilight {
return Ordering::Greater; // twilight > non-twilight
}
if !self_is_twilight && other_is_twilight {
return Ordering::Less; // non-twilight < twilight
}
// Both are twilight or both are not twilight - use normal comparison
match (self_is_twilight, other_is_twilight) {
(true, true) => {
// Both are twilight, compare by base version
return (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
}
(false, false) => {
// Neither is twilight, continue with normal comparison
}
_ => unreachable!(), // Already handled above
}
// Compare major.minor.patch first
match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) {
Ordering::Equal => {
@@ -193,6 +229,12 @@ pub fn is_alpha_version(version: &str) -> bool {
version_comp.pre_release.is_some()
}
// Browser-specific alpha version detection for Zen Browser
pub fn is_zen_alpha_version(version: &str) -> bool {
// For Zen Browser, only "twilight" is considered alpha/pre-release
version.to_lowercase() == "twilight"
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FirefoxRelease {
pub build_number: u32,
@@ -273,7 +315,7 @@ impl ApiClient {
}
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let app_name = if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -343,7 +385,7 @@ impl ApiClient {
&self,
browser: &str,
versions: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cache_dir = Self::get_cache_dir()?;
let cache_file = cache_dir.join(format!("{browser}_versions.json"));
@@ -378,7 +420,7 @@ impl ApiClient {
&self,
browser: &str,
releases: &[GithubRelease],
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cache_dir = Self::get_cache_dir()?;
let cache_file = cache_dir.join(format!("{browser}_github.json"));
@@ -569,13 +611,6 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_mullvad_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.fetch_mullvad_releases_with_caching(false).await
}
pub async fn fetch_mullvad_releases_with_caching(
&self,
no_caching: bool,
@@ -622,13 +657,6 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_zen_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.fetch_zen_releases_with_caching(false).await
}
pub async fn fetch_zen_releases_with_caching(
&self,
no_caching: bool,
@@ -654,7 +682,25 @@ impl ApiClient {
.json::<Vec<GithubRelease>>()
.await?;
// Sort releases using the new version sorting system (twilight releases will be at top)
// Check for twilight updates and mark alpha releases
for release in &mut releases {
// Use browser-specific alpha detection for Zen Browser
release.is_alpha = is_zen_alpha_version(&release.tag_name) || release.prerelease;
// Check for twilight update if this is a twilight release
if release.tag_name.to_lowercase() == "twilight" {
if let Ok(has_update) = self.check_twilight_update(release).await {
if has_update {
println!(
"Detected update for Zen twilight release: {}",
release.tag_name
);
}
}
}
}
// Sort releases using the new version sorting system
sort_github_releases(&mut releases);
// Cache the results (unless bypassing cache)
@@ -667,13 +713,6 @@ impl ApiClient {
Ok(releases)
}
#[allow(dead_code)]
pub async fn fetch_brave_releases(
&self,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.fetch_brave_releases_with_caching(false).await
}
pub async fn fetch_brave_releases_with_caching(
&self,
no_caching: bool,
@@ -935,6 +974,64 @@ impl ApiClient {
// Check if there's a macOS DMG file in this version directory
Ok(html.contains("tor-browser-macos-") && html.contains(".dmg"))
}
/// Check if a Zen twilight release has been updated by comparing file size
pub async fn check_twilight_update(
&self,
release: &GithubRelease,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
if release.tag_name.to_lowercase() != "twilight" {
return Ok(false); // Not a twilight release
}
// Find the macOS universal DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg")
.ok_or("No macOS universal asset found for twilight release")?;
// Check if we have cached file size information
let cache_dir = Self::get_cache_dir()?;
let twilight_cache_file = cache_dir.join("zen_twilight_info.json");
#[derive(serde::Serialize, serde::Deserialize)]
struct TwilightInfo {
file_size: u64,
last_updated: u64,
download_url: String,
}
let current_info = TwilightInfo {
file_size: asset.size,
last_updated: Self::get_current_timestamp(),
download_url: asset.browser_download_url.clone(),
};
if !twilight_cache_file.exists() {
// No cache exists, save current info and return true (new)
let content = serde_json::to_string_pretty(&current_info)?;
fs::write(&twilight_cache_file, content)?;
return Ok(true);
}
let cached_content = fs::read_to_string(&twilight_cache_file)?;
let cached_info: TwilightInfo = serde_json::from_str(&cached_content)?;
// Check if file size has changed
if cached_info.file_size != current_info.file_size {
// File size changed, update cache and return true
let content = serde_json::to_string_pretty(&current_info)?;
fs::write(&twilight_cache_file, content)?;
println!(
"Zen twilight release updated: file size changed from {} to {}",
cached_info.file_size, current_info.file_size
);
return Ok(true);
}
Ok(false) // No update detected
}
}
#[cfg(test)]
@@ -989,10 +1086,14 @@ mod tests {
assert_eq!(pre.number, Some(5));
// Test twilight version (Zen Browser)
let v4 = VersionComponent::parse("1.0.0-twilight");
assert_eq!(v4.major, u32::MAX);
assert_eq!(v4.minor, u32::MAX);
assert_eq!(v4.patch, u32::MAX);
let v4 = VersionComponent::parse("twilight");
assert_eq!(v4.major, 999);
assert_eq!(v4.minor, 0);
assert_eq!(v4.patch, 0);
assert!(v4.pre_release.is_some());
let pre = v4.pre_release.unwrap();
assert_eq!(pre.kind, PreReleaseKind::Alpha);
assert_eq!(pre.number, Some(999));
}
#[test]
@@ -1022,10 +1123,15 @@ mod tests {
let v10 = VersionComponent::parse("137.0b5");
assert!(v10 > v9); // b5 > b4
// Test twilight version (should be highest)
let v11 = VersionComponent::parse("1.0.0-twilight");
let v12 = VersionComponent::parse("999.999.999");
assert!(v11 > v12);
// Test twilight version (should have highest priority)
let v11 = VersionComponent::parse("twilight");
let v12 = VersionComponent::parse("1.0.0");
assert!(v11 > v12); // twilight > stable due to high major version
// Test twilight vs other pre-releases
let v13 = VersionComponent::parse("twilight");
let v14 = VersionComponent::parse("1.0.0a1");
assert!(v13 > v14); // twilight > a1 due to high major version
}
#[test]
@@ -1037,14 +1143,14 @@ mod tests {
"137.0b4".to_string(),
"137.0b5".to_string(),
"137.0".to_string(),
"1.0.0-twilight".to_string(),
"twilight".to_string(),
"2.0.0a1".to_string(),
];
sort_versions(&mut versions);
// Expected order: twilight, 137.0, 137.0b5, 137.0b4, 2.0.0a1, 1.12.6b, 1.10.0, 1.9.9b
assert_eq!(versions[0], "1.0.0-twilight");
// Expected order with twilight priority: twilight first due to high major version (999), then normal semantic versioning
assert_eq!(versions[0], "twilight");
assert_eq!(versions[1], "137.0");
assert_eq!(versions[2], "137.0b5");
assert_eq!(versions[3], "137.0b4");
@@ -1054,6 +1160,31 @@ mod tests {
assert_eq!(versions[7], "1.9.9b");
}
#[test]
fn test_sort_versions_comprehensive() {
let mut versions = vec![
"1.0.0".to_string(),
"1.0.1".to_string(),
"1.1.0".to_string(),
"2.0.0a1".to_string(),
"2.0.0b1".to_string(),
"2.0.0rc1".to_string(),
"2.0.0".to_string(),
"10.0.0".to_string(),
"twilight".to_string(),
];
sort_versions(&mut versions);
// Expected order with twilight priority: twilight first due to high major version (999), then normal semantic versioning
assert_eq!(versions[0], "twilight");
assert_eq!(versions[1], "10.0.0");
assert_eq!(versions[2], "2.0.0");
assert_eq!(versions[3], "2.0.0rc1");
assert_eq!(versions[4], "2.0.0b1");
assert_eq!(versions[5], "2.0.0a1");
}
#[tokio::test]
async fn test_firefox_api() {
let server = setup_mock_server().await;
@@ -1167,7 +1298,8 @@ mod tests {
"assets": [
{
"name": "mullvad-browser-macos-14.5a6.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg"
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
"size": 100000000
}
]
}
@@ -1200,14 +1332,15 @@ mod tests {
let mock_response = r#"[
{
"tag_name": "1.0.0-twilight",
"tag_name": "twilight",
"name": "Zen Browser Twilight",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "zen.macos-universal.dmg",
"browser_download_url": "https://example.com/zen-twilight.dmg"
"browser_download_url": "https://example.com/zen-twilight.dmg",
"size": 120000000
}
]
}
@@ -1229,7 +1362,7 @@ mod tests {
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "1.0.0-twilight");
assert_eq!(releases[0].tag_name, "twilight");
}
#[tokio::test]
@@ -1246,7 +1379,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"size": 200000000
}
]
}
@@ -1472,28 +1606,15 @@ mod tests {
}
#[test]
fn test_sort_versions_comprehensive() {
let mut versions = vec![
"1.0.0".to_string(),
"1.0.1".to_string(),
"1.1.0".to_string(),
"2.0.0a1".to_string(),
"2.0.0b1".to_string(),
"2.0.0rc1".to_string(),
"2.0.0".to_string(),
"10.0.0".to_string(),
"1.0.0-twilight".to_string(),
];
fn test_is_zen_alpha_version() {
// Only "twilight" should be considered alpha for Zen Browser
assert!(is_zen_alpha_version("twilight"));
assert!(is_zen_alpha_version("TWILIGHT")); // Case insensitive
sort_versions(&mut versions);
// Twilight should be first, then normal semantic versioning
assert_eq!(versions[0], "1.0.0-twilight");
assert_eq!(versions[1], "10.0.0");
assert_eq!(versions[2], "2.0.0");
assert_eq!(versions[3], "2.0.0rc1");
assert_eq!(versions[4], "2.0.0b1");
assert_eq!(versions[5], "2.0.0a1");
// Versions with "b" should NOT be considered alpha for Zen Browser
assert!(!is_zen_alpha_version("1.12.8b"));
assert!(!is_zen_alpha_version("1.0.0b1"));
assert!(!is_zen_alpha_version("2.0.0"));
}
#[tokio::test]
+8 -2
View File
@@ -170,6 +170,10 @@ impl AppAutoUpdater {
/// Determine if an update should be performed
fn should_update(&self, current_version: &str, new_version: &str, is_nightly: bool) -> bool {
if current_version.starts_with("dev-") {
return false;
}
println!(
"Comparing versions: current={current_version}, new={new_version}, is_nightly={is_nightly}"
);
@@ -608,8 +612,10 @@ mod tests {
// Upgrade from stable to nightly
assert!(updater.should_update("v1.0.0", "nightly-abc123", true));
// Upgrade from dev to nightly
assert!(updater.should_update("dev-0.1.0", "nightly-abc123", true));
// Don't upgrade dev, ever
assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", false));
assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", true));
assert!(!updater.should_update("dev-0.1.0", "v1.2.3", false));
}
#[test]
+2
View File
@@ -303,6 +303,8 @@ pub struct GithubRelease {
pub struct GithubAsset {
pub name: String,
pub browser_download_url: String,
#[serde(default)]
pub size: u64,
}
#[cfg(test)]
+1 -1
View File
@@ -695,7 +695,7 @@ mod tests {
async fn setup_zen_mocks(server: &MockServer) {
let mock_response = r#"[
{
"tag_name": "1.0.0-twilight",
"tag_name": "twilight",
"name": "Zen Browser Twilight",
"prerelease": false,
"published_at": "2024-01-15T10:00:00Z",
+30 -9
View File
@@ -144,6 +144,10 @@ impl Downloader {
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
// Check if this is a twilight release for special handling
let is_twilight =
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
// Emit initial progress
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -153,7 +157,11 @@ impl Downloader {
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "downloading".to_string(),
stage: if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
},
};
let _ = app_handle.emit("download-progress", &progress);
@@ -205,6 +213,12 @@ impl Downloader {
None
};
let stage_description = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
version: version.to_string(),
@@ -213,7 +227,7 @@ impl Downloader {
percentage,
speed_bytes_per_sec: speed,
eta_seconds: eta,
stage: "downloading".to_string(),
stage: stage_description,
};
let _ = app_handle.emit("download-progress", &progress);
@@ -267,7 +281,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"size": 200000000
}
]
}
@@ -314,7 +329,8 @@ mod tests {
"assets": [
{
"name": "zen.macos-universal.dmg",
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg"
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg",
"size": 120000000
}
]
}
@@ -361,7 +377,8 @@ mod tests {
"assets": [
{
"name": "mullvad-browser-macos-14.5a6.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg"
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
"size": 100000000
}
]
}
@@ -471,7 +488,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.8-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
"size": 200000000
}
]
}
@@ -520,7 +538,8 @@ mod tests {
"assets": [
{
"name": "zen.linux-universal.tar.bz2",
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2"
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2",
"size": 150000000
}
]
}
@@ -663,7 +682,8 @@ mod tests {
"assets": [
{
"name": "mullvad-browser-linux-14.5a6.tar.xz",
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz"
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz",
"size": 80000000
}
]
}
@@ -712,7 +732,8 @@ mod tests {
"assets": [
{
"name": "brave-v1.81.9-universal.dmg",
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
"size": 200000000
}
]
}
+34
View File
@@ -12,6 +12,9 @@ pub struct DownloadedBrowserInfo {
pub file_path: PathBuf,
pub verified: bool,
pub actual_version: Option<String>, // For browsers like Chromium where we track the actual version
pub file_size: Option<u64>, // For tracking file size changes (useful for rolling releases)
#[serde(default)] // Add default value (false) for backwards compatibility
pub is_rolling_release: bool, // True for Zen's twilight releases and other rolling releases
}
#[derive(Debug, Serialize, Deserialize, Default)]
@@ -98,6 +101,7 @@ impl DownloadedBrowsersRegistry {
}
pub fn mark_download_started(&mut self, browser: &str, version: &str, file_path: PathBuf) {
let is_rolling = Self::is_rolling_release(browser, version);
let info = DownloadedBrowserInfo {
browser: browser.to_string(),
version: version.to_string(),
@@ -108,6 +112,8 @@ impl DownloadedBrowsersRegistry {
file_path,
verified: false,
actual_version: None,
file_size: None,
is_rolling_release: is_rolling,
};
self.add_browser(info);
}
@@ -131,6 +137,11 @@ impl DownloadedBrowsersRegistry {
}
}
fn is_rolling_release(browser: &str, version: &str) -> bool {
// Check if this is a rolling release like twilight
browser == "zen" && version.to_lowercase() == "twilight"
}
pub fn cleanup_failed_download(
&mut self,
browser: &str,
@@ -186,6 +197,8 @@ mod tests {
file_path: PathBuf::from("/test/path"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
registry.add_browser(info.clone());
@@ -206,6 +219,8 @@ mod tests {
file_path: PathBuf::from("/test/path1"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
let info2 = DownloadedBrowserInfo {
@@ -215,6 +230,8 @@ mod tests {
file_path: PathBuf::from("/test/path2"),
verified: false, // Not verified, should not be included
actual_version: None,
file_size: None,
is_rolling_release: false,
};
let info3 = DownloadedBrowserInfo {
@@ -224,6 +241,8 @@ mod tests {
file_path: PathBuf::from("/test/path3"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
registry.add_browser(info1);
@@ -266,6 +285,8 @@ mod tests {
file_path: PathBuf::from("/test/path"),
verified: true,
actual_version: None,
file_size: None,
is_rolling_release: false,
};
registry.add_browser(info);
@@ -275,4 +296,17 @@ mod tests {
assert!(removed.is_some());
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
}
#[test]
fn test_twilight_rolling_release() {
let mut registry = DownloadedBrowsersRegistry::new();
// Mark twilight download started
registry.mark_download_started("zen", "twilight", PathBuf::from("/test/zen-twilight"));
// Check that it's marked as rolling release
let zen_versions = &registry.browsers["zen"];
let twilight_info = &zen_versions["twilight"];
assert!(twilight_info.is_rolling_release);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.2.3",
"version": "0.2.4",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+45 -105
View File
@@ -48,24 +48,26 @@ export default function Home() {
useState<BrowserProfile | null>(null);
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
useState<BrowserProfile | null>(null);
const [isClient, setIsClient] = useState(false);
// Auto-update functionality - only initialize on client
const updateNotifications = useUpdateNotifications();
const { checkForUpdates, isUpdating } = updateNotifications;
// App auto-update functionality
const appUpdateNotifications = useAppUpdateNotifications();
const { checkForAppUpdatesManual } = appUpdateNotifications;
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
// Simple profiles loader without updates check (for use as callback)
const loadProfiles = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
setProfiles(profileList);
} catch (err: unknown) {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, []);
const loadProfiles = useCallback(async () => {
if (!isClient) return; // Only run on client side
// Auto-update functionality - pass loadProfiles to refresh profiles after updates
const updateNotifications = useUpdateNotifications(loadProfiles);
const { checkForUpdates, isUpdating } = updateNotifications;
// Profiles loader with update check (for initial load and manual refresh)
const loadProfilesWithUpdateCheck = useCallback(async () => {
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
@@ -78,12 +80,12 @@ export default function Home() {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, [checkForUpdates, isClient]);
}, [checkForUpdates]);
useAppUpdateNotifications();
useEffect(() => {
if (!isClient) return; // Only run on client side
void loadProfiles();
void loadProfilesWithUpdateCheck();
// Check for startup default browser prompt
void checkStartupPrompt();
@@ -105,11 +107,9 @@ export default function Home() {
return () => {
clearInterval(updateInterval);
};
}, [loadProfiles, checkForUpdates, isClient]);
}, [loadProfilesWithUpdateCheck, checkForUpdates]);
const checkStartupPrompt = async () => {
if (!isClient) return; // Only run on client side
try {
const shouldShow = await invoke<boolean>(
"should_show_settings_on_startup",
@@ -123,8 +123,6 @@ export default function Home() {
};
const checkStartupUrls = async () => {
if (!isClient) return; // Only run on client side
try {
const hasStartupUrl = await invoke<boolean>(
"check_and_handle_startup_url",
@@ -138,8 +136,6 @@ export default function Home() {
};
const listenForUrlEvents = async () => {
if (!isClient) return; // Only run on client side
try {
// Listen for URL open events from the deep link handler (when app is already running)
await listen<string>("url-open-request", (event) => {
@@ -173,8 +169,6 @@ export default function Home() {
};
const handleUrlOpen = async (url: string) => {
if (!isClient) return; // Only run on client side
try {
// Use smart profile selection
const result = await invoke<string>("smart_open_url", {
@@ -270,40 +264,33 @@ export default function Home() {
const runningProfilesRef = useRef<Set<string>>(new Set());
const checkBrowserStatus = useCallback(
async (profile: BrowserProfile) => {
if (!isClient) return; // Only run on client side
const checkBrowserStatus = useCallback(async (profile: BrowserProfile) => {
try {
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
try {
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
const currentRunning = runningProfilesRef.current.has(profile.name);
if (isRunning !== currentRunning) {
setRunningProfiles((prev) => {
const next = new Set(prev);
if (isRunning) {
next.add(profile.name);
} else {
next.delete(profile.name);
}
runningProfilesRef.current = next;
return next;
});
const currentRunning = runningProfilesRef.current.has(profile.name);
if (isRunning !== currentRunning) {
setRunningProfiles((prev) => {
const next = new Set(prev);
if (isRunning) {
next.add(profile.name);
} else {
next.delete(profile.name);
}
runningProfilesRef.current = next;
return next;
});
}
} catch (err) {
console.error("Failed to check browser status:", err);
}
},
[isClient],
);
} catch (err) {
console.error("Failed to check browser status:", err);
}
}, []);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
if (!isClient) return; // Only run on client side
setError(null);
// Check if browser is disabled due to ongoing update
@@ -337,11 +324,11 @@ export default function Home() {
setError(`Failed to launch browser: ${JSON.stringify(err)}`);
}
},
[loadProfiles, checkBrowserStatus, isUpdating, isClient],
[loadProfiles, checkBrowserStatus, isUpdating],
);
useEffect(() => {
if (profiles.length === 0 || !isClient) return;
if (profiles.length === 0) return;
const interval = setInterval(() => {
for (const profile of profiles) {
@@ -352,7 +339,7 @@ export default function Home() {
return () => {
clearInterval(interval);
};
}, [profiles, checkBrowserStatus, isClient]);
}, [profiles, checkBrowserStatus]);
useEffect(() => {
runningProfilesRef.current = runningProfiles;
@@ -408,53 +395,6 @@ export default function Home() {
[loadProfiles],
);
// Don't render anything until we're on the client side to prevent hydration issues
if (!isClient) {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 gap-8 sm:p-12 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center w-full max-w-3xl">
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Profiles</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
disabled
className="flex items-center gap-2"
>
<GoGear className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
disabled
className="flex items-center gap-2"
>
<GoPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="p-8 text-center">
<div className="animate-pulse">Loading...</div>
</CardContent>
</Card>
</main>
</div>
);
}
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 gap-8 sm:p-12 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center w-full max-w-3xl">
+39 -2
View File
@@ -71,7 +71,12 @@ interface ErrorToastProps extends BaseToastProps {
interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?: "downloading" | "extracting" | "verifying" | "completed";
stage?:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
progress?: {
percentage: number;
speed?: string;
@@ -93,13 +98,20 @@ interface FetchingToastProps extends BaseToastProps {
browserName?: string;
}
interface TwilightUpdateToastProps extends BaseToastProps {
type: "twilight-update";
browserName?: string;
hasUpdate?: boolean;
}
type ToastProps =
| LoadingToastProps
| SuccessToastProps
| ErrorToastProps
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps;
| FetchingToastProps
| TwilightUpdateToastProps;
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
@@ -122,6 +134,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
);
case "twilight-update":
return (
<LuRefreshCw className="h-4 w-4 text-purple-500 animate-spin flex-shrink-0" />
);
default:
return (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
@@ -186,6 +202,22 @@ export function UnifiedToast(props: ToastProps) {
</div>
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
<p className="text-xs text-gray-600 dark:text-gray-300">
{"hasUpdate" in props && props.hasUpdate
? "New twilight build available for download"
: "Checking for twilight updates..."}
</p>
{props.browserName && (
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
{props.browserName} Rolling Release
</p>
)}
</div>
)}
{/* Description */}
{description && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300 leading-tight">
@@ -206,6 +238,11 @@ export function UnifiedToast(props: ToastProps) {
Verifying installation...
</p>
)}
{stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
Downloading rolling release build...
</p>
)}
</>
)}
</div>
+2 -2
View File
@@ -401,7 +401,7 @@ export function ProfilesDataTable({
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Rename profile
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -411,7 +411,7 @@ export function ProfilesDataTable({
className="text-red-600"
disabled={!isClient || isRunning || isBrowserUpdating}
>
Delete profile
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+17 -5
View File
@@ -27,6 +27,11 @@ function getSystemTheme(): string {
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const loadTheme = async () => {
@@ -65,11 +70,18 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
}, []);
if (isLoading) {
// Detect system theme to show appropriate loading screen
const systemTheme = getSystemTheme();
const loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
const spinnerColor =
systemTheme === "dark" ? "border-white" : "border-gray-900";
// 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
let loadingBgColor = "bg-white";
let spinnerColor = "border-gray-900";
// Only apply system theme detection after component is mounted (client-side only)
if (mounted) {
const systemTheme = getSystemTheme();
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
spinnerColor =
systemTheme === "dark" ? "border-white" : "border-gray-900";
}
return (
<div
+1 -1
View File
@@ -134,7 +134,7 @@ export function useAppUpdateNotifications() {
{
id: "app-update",
duration: Number.POSITIVE_INFINITY, // Persistent until user action
position: "top-right",
position: "top-left",
},
);
}, [
+54 -18
View File
@@ -13,35 +13,66 @@ interface UpdateNotification {
affected_profiles: string[];
is_stable_update: boolean;
timestamp: number;
is_rolling_release: boolean;
}
export function useUpdateNotifications() {
export function useUpdateNotifications(
onProfilesUpdated?: () => Promise<void>,
) {
const [notifications, setNotifications] = useState<UpdateNotification[]>([]);
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
new Set(),
);
const [isClient, setIsClient] = useState(false);
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
}, []);
const [dismissedNotifications, setDismissedNotifications] = useState<
Set<string>
>(new Set());
const checkForUpdates = useCallback(async () => {
if (!isClient) return; // Only run on client side
try {
const updates = await invoke<UpdateNotification[]>(
"check_for_browser_updates",
);
setNotifications(updates);
// Filter out dismissed notifications unless they're for a newer version
const filteredUpdates = updates.filter((notification) => {
// Check if this exact notification was dismissed
if (dismissedNotifications.has(notification.id)) {
return false;
}
// Check if we dismissed an older version for this browser
const dismissedForBrowser = Array.from(dismissedNotifications).find(
(dismissedId) => {
const parts = dismissedId.split("_");
if (parts.length >= 2) {
const browser = parts[0];
return browser === notification.browser;
}
return false;
},
);
if (dismissedForBrowser) {
// Extract the dismissed version to compare
const dismissedParts = dismissedForBrowser.split("_to_");
if (dismissedParts.length === 2) {
const dismissedToVersion = dismissedParts[1];
// Only show if this is a newer version than what was dismissed
return notification.new_version !== dismissedToVersion;
}
}
return true;
});
setNotifications(filteredUpdates);
// Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately
// to avoid circular dependencies
} catch (error) {
console.error("Failed to check for updates:", error);
}
}, [isClient]);
}, [dismissedNotifications]);
const handleUpdate = useCallback(
async (browser: string, newVersion: string) => {
@@ -117,6 +148,11 @@ export function useUpdateNotifications() {
duration: 5000,
});
}
// Trigger profile refresh to update UI with new versions
if (onProfilesUpdated) {
void onProfilesUpdated();
}
} catch (downloadError) {
console.error("Failed to download browser:", downloadError);
@@ -158,28 +194,28 @@ export function useUpdateNotifications() {
});
}
},
[notifications, checkForUpdates],
[notifications, checkForUpdates, onProfilesUpdated],
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!isClient) return; // Only run on client side
try {
toast.dismiss(notificationId);
await invoke("dismiss_update_notification", { notificationId });
// Track this notification as dismissed to prevent showing it again
setDismissedNotifications((prev) => new Set(prev).add(notificationId));
await checkForUpdates();
} catch (error) {
console.error("Failed to dismiss notification:", error);
}
},
[checkForUpdates, isClient],
[checkForUpdates],
);
// Separate effect to show toasts when notifications change
useEffect(() => {
if (!isClient) return;
for (const notification of notifications) {
const isUpdating = updatingBrowsers.has(notification.browser);
@@ -201,7 +237,7 @@ export function useUpdateNotifications() {
},
);
}
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss, isClient]);
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss]);
return {
notifications,
+45 -4
View File
@@ -24,7 +24,12 @@ export interface ErrorToastProps extends BaseToastProps {
export interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?: "downloading" | "extracting" | "verifying" | "completed";
stage?:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)";
progress?: {
percentage: number;
speed?: string;
@@ -46,13 +51,20 @@ export interface FetchingToastProps extends BaseToastProps {
browserName?: string;
}
export interface TwilightUpdateToastProps extends BaseToastProps {
type: "twilight-update";
browserName?: string;
hasUpdate?: boolean;
}
export type ToastProps =
| LoadingToastProps
| SuccessToastProps
| ErrorToastProps
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps;
| FetchingToastProps
| TwilightUpdateToastProps;
// Unified toast function
export function showToast(props: ToastProps & { id?: string }) {
@@ -81,6 +93,9 @@ export function showToast(props: ToastProps & { id?: string }) {
case "version-update":
duration = 15000;
break;
case "twilight-update":
duration = 10000;
break;
case "success":
duration = 3000;
break;
@@ -149,7 +164,12 @@ export function showLoadingToast(
export function showDownloadToast(
browserName: string,
version: string,
stage: "downloading" | "extracting" | "verifying" | "completed",
stage:
| "downloading"
| "extracting"
| "verifying"
| "completed"
| "downloading (twilight rolling release)",
progress?: { percentage: number; speed?: string; eta?: string },
options?: { suppressCompletionToast?: boolean },
) {
@@ -160,7 +180,9 @@ export function showDownloadToast(
? `Downloading ${browserName} ${version}`
: stage === "extracting"
? `Extracting ${browserName} ${version}`
: `Verifying ${browserName} ${version}`;
: stage === "downloading (twilight rolling release)"
? `Downloading ${browserName} ${version}`
: `Verifying ${browserName} ${version}`;
// Don't show completion toast if suppressed (for auto-update scenarios)
if (stage === "completed" && options?.suppressCompletionToast) {
@@ -245,6 +267,25 @@ export function showErrorToast(
});
}
export function showTwilightUpdateToast(
browserName: string,
options?: {
id?: string;
description?: string;
hasUpdate?: boolean;
duration?: number;
},
) {
return showToast({
type: "twilight-update",
title: options?.hasUpdate
? `${browserName} twilight update available`
: `Checking for ${browserName} twilight updates...`,
browserName,
...options,
});
}
// Generic helper for dismissing toasts
export function dismissToast(id: string) {
sonnerToast.dismiss(id);