mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-04 17:45:11 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a5b807828 | |||
| d0a5c16ce9 | |||
| e2e1ad1582 | |||
| cb61861503 | |||
| 1950ef0098 | |||
| 814875c28e | |||
| b06ca4f11e | |||
| 3ab1ea61e8 | |||
| a0599ecfc1 | |||
| 6c834b3003 | |||
| 269b4dbe77 | |||
| ef00854307 | |||
| 03d915e5c7 | |||
| 91b12e80e5 | |||
| 3af581c4ab | |||
| 7a85edfb8a | |||
| 141a5f06a4 | |||
| 7a3857c06a | |||
| ed26786fdb | |||
| 966268ff05 | |||
| 87ae696d7a | |||
| 7e92b290b6 | |||
| eb62e0abf9 | |||
| 0ed5adf2ba | |||
| dd91aaeea0 | |||
| 6a3407796d | |||
| eaa1a823db | |||
| 63900bd0ad | |||
| 31326c2d1f | |||
| 006a146770 | |||
| e3275248f7 | |||
| 5c23c77896 | |||
| 03a3e9fc56 | |||
| 26a5be55f1 | |||
| a58a814369 |
@@ -19,16 +19,3 @@ jobs:
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Approve Dependabot PR
|
||||
run: gh pr review --approve "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Auto-merge (squash) Dependabot PR
|
||||
if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
name: Lint Node.js
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -18,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -48,4 +49,4 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run lint step
|
||||
run: pnpm lint
|
||||
run: pnpm run lint:js
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
name: Lint Rust
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -22,7 +23,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [macos-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
|
||||
@@ -8,9 +8,21 @@ on:
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
STABLE_RELEASE: "true"
|
||||
|
||||
jobs:
|
||||
lint-js:
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
|
||||
release:
|
||||
needs: [lint-js, lint-rust]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -114,10 +126,11 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "Donut Browser ${{ github.ref_name }}"
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseDraft: true
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
@@ -10,7 +10,18 @@ env:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
jobs:
|
||||
lint-js:
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
|
||||
rolling-release:
|
||||
needs: [lint-js, lint-rust]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -83,10 +94,13 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_TAG: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
GITHUB_REF_NAME: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
tagName: "alpha-${{ steps.commit.outputs.hash }}"
|
||||
releaseName: "Donut Browser Alpha (Build ${{ steps.commit.outputs.hash }})"
|
||||
releaseBody: "⚠️ **Alpha Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
|
||||
tagName: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
|
||||
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# nodecar
|
||||
nodecar/dist
|
||||
nodecar/node_modules
|
||||
**/dist
|
||||
**/node_modules
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
|
||||
## Download
|
||||
|
||||
> As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it. The app automatically checks for updates on each launch.
|
||||
|
||||
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
|
||||
## Supported Platforms
|
||||
@@ -52,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.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"license": "AGPL-3.0",
|
||||
"packageManager": "pnpm@10.6.1",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.15.17",
|
||||
|
||||
+10
-8
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"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:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all",
|
||||
"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",
|
||||
@@ -35,7 +37,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"next": "^15.3.2",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -60,7 +62,7 @@
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.3.0",
|
||||
"lint-staged": "^16.1.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
|
||||
Generated
+928
-942
File diff suppressed because it is too large
Load Diff
Generated
+554
-614
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
version = "0.2.4"
|
||||
description = "Browser Orchestrator"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -20,7 +20,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri = { version = "2", features = ["devtools", "test"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -41,6 +41,7 @@ core-foundation="0.10"
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13.0"
|
||||
tokio-test = "0.4.4"
|
||||
wiremock = "0.6"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>0.2.4</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@@ -5,5 +5,26 @@ fn main() {
|
||||
println!("cargo:rustc-link-lib=framework=CoreServices");
|
||||
}
|
||||
|
||||
// Inject build version based on environment variables set by CI
|
||||
if let Ok(build_tag) = std::env::var("BUILD_TAG") {
|
||||
// Custom BUILD_TAG takes highest priority (used for nightly builds)
|
||||
println!("cargo:rustc-env=BUILD_VERSION={build_tag}");
|
||||
} else if let Ok(tag_name) = std::env::var("GITHUB_REF_NAME") {
|
||||
// This is set by GitHub Actions to the tag name (e.g., "v1.0.0")
|
||||
println!("cargo:rustc-env=BUILD_VERSION={tag_name}");
|
||||
} else if std::env::var("STABLE_RELEASE").is_ok() {
|
||||
// Fallback for stable releases - use CARGO_PKG_VERSION with 'v' prefix
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
|
||||
println!("cargo:rustc-env=BUILD_VERSION=v{version}");
|
||||
} else if let Ok(commit_hash) = std::env::var("GITHUB_SHA") {
|
||||
// For nightly builds, use commit hash
|
||||
let short_hash = &commit_hash[0..7.min(commit_hash.len())];
|
||||
println!("cargo:rustc-env=BUILD_VERSION=nightly-{short_hash}");
|
||||
} else {
|
||||
// Development build fallback
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
|
||||
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
+708
-253
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::extraction::Extractor;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppRelease {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub prerelease: bool,
|
||||
pub assets: Vec<AppReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppUpdateInfo {
|
||||
pub current_version: String,
|
||||
pub new_version: String,
|
||||
pub release_notes: String,
|
||||
pub download_url: String,
|
||||
pub is_nightly: bool,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
pub struct AppAutoUpdater {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AppAutoUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if running a nightly build based on environment variable
|
||||
pub fn is_nightly_build() -> bool {
|
||||
// If STABLE_RELEASE env var is set at compile time, it's a stable build
|
||||
if option_env!("STABLE_RELEASE").is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also check if the current version starts with "nightly-"
|
||||
let current_version = Self::get_current_version();
|
||||
if current_version.starts_with("nightly-") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If STABLE_RELEASE is not set and version doesn't start with "nightly-",
|
||||
// it's still considered a nightly build (dev builds, main branch builds, etc.)
|
||||
true
|
||||
}
|
||||
|
||||
/// Get current app version from build-time injection
|
||||
pub fn get_current_version() -> String {
|
||||
// Use build-time injected version instead of CARGO_PKG_VERSION
|
||||
env!("BUILD_VERSION").to_string()
|
||||
}
|
||||
|
||||
/// Check for app updates
|
||||
pub async fn check_for_updates(
|
||||
&self,
|
||||
) -> Result<Option<AppUpdateInfo>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let current_version = Self::get_current_version();
|
||||
let is_nightly = Self::is_nightly_build();
|
||||
|
||||
println!("=== App Update Check ===");
|
||||
println!("Current version: {current_version}");
|
||||
println!("Is nightly build: {is_nightly}");
|
||||
println!("STABLE_RELEASE env: {:?}", option_env!("STABLE_RELEASE"));
|
||||
|
||||
let releases = self.fetch_app_releases().await?;
|
||||
println!("Fetched {} releases from GitHub", releases.len());
|
||||
|
||||
// Filter releases based on build type
|
||||
let filtered_releases: Vec<&AppRelease> = if is_nightly {
|
||||
// For nightly builds, look for nightly releases
|
||||
let nightly_releases: Vec<&AppRelease> = releases
|
||||
.iter()
|
||||
.filter(|release| release.tag_name.starts_with("nightly-"))
|
||||
.collect();
|
||||
println!("Found {} nightly releases", nightly_releases.len());
|
||||
nightly_releases
|
||||
} else {
|
||||
// For stable builds, look for stable releases (semver format)
|
||||
let stable_releases: Vec<&AppRelease> = releases
|
||||
.iter()
|
||||
.filter(|release| {
|
||||
release.tag_name.starts_with('v') && !release.tag_name.starts_with("nightly-")
|
||||
})
|
||||
.collect();
|
||||
println!("Found {} stable releases", stable_releases.len());
|
||||
stable_releases
|
||||
};
|
||||
|
||||
if filtered_releases.is_empty() {
|
||||
println!("No releases found for build type (nightly: {is_nightly})");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get the latest release
|
||||
let latest_release = filtered_releases[0];
|
||||
println!(
|
||||
"Latest release: {} ({})",
|
||||
latest_release.tag_name, latest_release.name
|
||||
);
|
||||
|
||||
// Check if we need to update
|
||||
if self.should_update(¤t_version, &latest_release.tag_name, is_nightly) {
|
||||
println!("Update available!");
|
||||
|
||||
// Find the appropriate asset for current platform
|
||||
if let Some(download_url) = self.get_download_url_for_platform(&latest_release.assets) {
|
||||
let update_info = AppUpdateInfo {
|
||||
current_version,
|
||||
new_version: latest_release.tag_name.clone(),
|
||||
release_notes: latest_release.body.clone(),
|
||||
download_url,
|
||||
is_nightly,
|
||||
published_at: latest_release.published_at.clone(),
|
||||
};
|
||||
|
||||
println!(
|
||||
"Update info prepared: {} -> {}",
|
||||
update_info.current_version, update_info.new_version
|
||||
);
|
||||
return Ok(Some(update_info));
|
||||
} else {
|
||||
println!("No suitable download asset found for current platform");
|
||||
}
|
||||
} else {
|
||||
println!("No update needed");
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Fetch app releases from GitHub
|
||||
async fn fetch_app_releases(
|
||||
&self,
|
||||
) -> Result<Vec<AppRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = "https://api.github.com/repos/zhom/donutbrowser/releases";
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "donutbrowser")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API request failed: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let releases: Vec<AppRelease> = response.json().await?;
|
||||
Ok(releases)
|
||||
}
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
|
||||
if is_nightly {
|
||||
// For nightly builds, always update if there's a newer nightly
|
||||
if let (Some(current_hash), Some(new_hash)) = (
|
||||
current_version.strip_prefix("nightly-"),
|
||||
new_version.strip_prefix("nightly-"),
|
||||
) {
|
||||
// Different commit hashes mean we should update
|
||||
let should_update = new_hash != current_hash;
|
||||
println!("Nightly comparison: current_hash={current_hash}, new_hash={new_hash}, should_update={should_update}");
|
||||
return should_update;
|
||||
}
|
||||
|
||||
// If current version doesn't have nightly prefix but we're in nightly mode,
|
||||
// this could be a dev build or stable build upgrading to nightly
|
||||
if !current_version.starts_with("nightly-") {
|
||||
println!("Upgrading from non-nightly to nightly: {new_version}");
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// For stable builds, use semantic versioning comparison
|
||||
let should_update = self.is_version_newer(new_version, current_version);
|
||||
println!("Stable comparison: {new_version} > {current_version} = {should_update}");
|
||||
return should_update;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Compare semantic versions (returns true if version1 > version2)
|
||||
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
|
||||
let v1 = self.parse_semver(version1);
|
||||
let v2 = self.parse_semver(version2);
|
||||
v1 > v2
|
||||
}
|
||||
|
||||
/// Parse semantic version string into comparable tuple
|
||||
fn parse_semver(&self, version: &str) -> (u32, u32, u32) {
|
||||
let clean_version = version.trim_start_matches('v');
|
||||
let parts: Vec<&str> = clean_version.split('.').collect();
|
||||
|
||||
let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
|
||||
(major, minor, patch)
|
||||
}
|
||||
|
||||
/// Get the appropriate download URL for the current platform
|
||||
fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
|
||||
let arch = if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
} else if cfg!(target_arch = "x86_64") {
|
||||
"x64"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
println!("Looking for assets with architecture: {arch}");
|
||||
for asset in assets {
|
||||
println!("Found asset: {}", asset.name);
|
||||
}
|
||||
|
||||
// Priority 1: Look for exact architecture match in DMG
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.contains(&format!("_{arch}.dmg"))
|
||||
|| asset.name.contains(&format!("-{arch}.dmg"))
|
||||
|| asset.name.contains(&format!("_{arch}_"))
|
||||
|| asset.name.contains(&format!("-{arch}-")))
|
||||
{
|
||||
println!("Found exact architecture match: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Look for x86_64 variations if we're looking for x64
|
||||
if arch == "x64" {
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
|
||||
{
|
||||
println!("Found x86_64 variant: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Look for arm64 variations if we're looking for aarch64
|
||||
if arch == "aarch64" {
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.contains("arm64") || asset.name.contains("aarch64"))
|
||||
{
|
||||
println!("Found arm64 variant: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Fallback to any macOS DMG
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg")
|
||||
&& (asset.name.to_lowercase().contains("macos")
|
||||
|| asset.name.to_lowercase().contains("darwin")
|
||||
|| !asset.name.contains(".app.tar.gz"))
|
||||
{
|
||||
// Exclude app.tar.gz files
|
||||
println!("Found fallback DMG: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
println!("No suitable asset found for platform");
|
||||
None
|
||||
}
|
||||
|
||||
/// Download and install app update
|
||||
pub async fn download_and_install_update(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
update_info: &AppUpdateInfo,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create temporary directory for download
|
||||
let temp_dir = std::env::temp_dir().join("donut_app_update");
|
||||
fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
// Extract filename from URL
|
||||
let filename = update_info
|
||||
.download_url
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("update.dmg")
|
||||
.to_string();
|
||||
|
||||
// Emit download start event
|
||||
let _ = app_handle.emit("app-update-progress", "Downloading update...");
|
||||
|
||||
// Download the update
|
||||
let download_path = self
|
||||
.download_update(&update_info.download_url, &temp_dir, &filename)
|
||||
.await?;
|
||||
|
||||
// Emit extraction start event
|
||||
let _ = app_handle.emit("app-update-progress", "Preparing update...");
|
||||
|
||||
// Extract the update
|
||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
||||
|
||||
// Emit installation start event
|
||||
let _ = app_handle.emit("app-update-progress", "Installing update...");
|
||||
|
||||
// Install the update (overwrite current app)
|
||||
self.install_update(&extracted_app_path).await?;
|
||||
|
||||
// Clean up temporary files
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
// Emit completion event
|
||||
let _ = app_handle.emit("app-update-progress", "Update completed. Restarting...");
|
||||
|
||||
// Restart the application
|
||||
self.restart_application().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the update file
|
||||
async fn download_update(
|
||||
&self,
|
||||
download_url: &str,
|
||||
dest_dir: &Path,
|
||||
filename: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = dest_dir.join(filename);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(download_url)
|
||||
.header("User-Agent", "donutbrowser")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let mut file = fs::File::create(&file_path)?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
file.write_all(&chunk)?;
|
||||
}
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Extract the update using the extraction module
|
||||
async fn extract_update(
|
||||
&self,
|
||||
archive_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let extractor = Extractor::new();
|
||||
|
||||
let extension = archive_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match extension {
|
||||
"dmg" => extractor.extract_dmg(archive_path, dest_dir).await,
|
||||
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the update by replacing the current app
|
||||
async fn install_update(
|
||||
&self,
|
||||
new_app_path: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get the current application bundle path
|
||||
let current_app_path = self.get_current_app_path()?;
|
||||
|
||||
// Create a backup of the current app
|
||||
let backup_path = current_app_path.with_extension("app.backup");
|
||||
if backup_path.exists() {
|
||||
fs::remove_dir_all(&backup_path)?;
|
||||
}
|
||||
|
||||
// Move current app to backup
|
||||
fs::rename(¤t_app_path, &backup_path)?;
|
||||
|
||||
// Move new app to current location
|
||||
fs::rename(new_app_path, ¤t_app_path)?;
|
||||
|
||||
// Remove quarantine attributes from the new app
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
// Clean up backup after successful installation
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current application bundle path
|
||||
fn get_current_app_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get the current executable path
|
||||
let exe_path = std::env::current_exe()?;
|
||||
|
||||
// Navigate up to find the .app bundle
|
||||
let mut current = exe_path.as_path();
|
||||
while let Some(parent) = current.parent() {
|
||||
if parent.extension().is_some_and(|ext| ext == "app") {
|
||||
return Ok(parent.to_path_buf());
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
Err("Could not find application bundle".into())
|
||||
}
|
||||
|
||||
/// Restart the application
|
||||
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
|
||||
// Create a temporary restart script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.sh");
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Wait a bit more to ensure clean exit
|
||||
sleep 1
|
||||
|
||||
# Start the new application
|
||||
open "{}"
|
||||
|
||||
# Clean up this script
|
||||
rm "{}"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap(),
|
||||
script_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
|
||||
// Make the script executable
|
||||
let _ = Command::new("chmod")
|
||||
.args(["+x", script_path.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
|
||||
let _child = cmd.spawn()?;
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||
let updater = AppAutoUpdater::new();
|
||||
updater
|
||||
.check_for_updates()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check for app updates: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_app_update(
|
||||
app_handle: tauri::AppHandle,
|
||||
update_info: AppUpdateInfo,
|
||||
) -> Result<(), String> {
|
||||
let updater = AppAutoUpdater::new();
|
||||
updater
|
||||
.download_and_install_update(&app_handle, &update_info)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to install app update: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version_info() -> Result<(String, bool), String> {
|
||||
Ok((
|
||||
AppAutoUpdater::get_current_version(),
|
||||
AppAutoUpdater::is_nightly_build(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
|
||||
println!("Manual app update check triggered");
|
||||
let updater = AppAutoUpdater::new();
|
||||
updater
|
||||
.check_for_updates()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check for app updates: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_nightly_build() {
|
||||
// This will depend on whether STABLE_RELEASE is set during test compilation
|
||||
let is_nightly = AppAutoUpdater::is_nightly_build();
|
||||
println!("Is nightly build: {is_nightly}");
|
||||
|
||||
// The result should be true for test builds since STABLE_RELEASE is not set
|
||||
// unless the test is run in a stable release environment
|
||||
assert!(is_nightly || option_env!("STABLE_RELEASE").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_comparison() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Test semantic version comparison
|
||||
assert!(updater.is_version_newer("v1.1.0", "v1.0.0"));
|
||||
assert!(updater.is_version_newer("v2.0.0", "v1.9.9"));
|
||||
assert!(updater.is_version_newer("v1.0.1", "v1.0.0"));
|
||||
assert!(!updater.is_version_newer("v1.0.0", "v1.0.0"));
|
||||
assert!(!updater.is_version_newer("v1.0.0", "v1.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
assert_eq!(updater.parse_semver("v1.2.3"), (1, 2, 3));
|
||||
assert_eq!(updater.parse_semver("1.2.3"), (1, 2, 3));
|
||||
assert_eq!(updater.parse_semver("v2.0.0"), (2, 0, 0));
|
||||
assert_eq!(updater.parse_semver("0.1.0"), (0, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_update_stable() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Stable version updates
|
||||
assert!(updater.should_update("v1.0.0", "v1.1.0", false));
|
||||
assert!(updater.should_update("v1.0.0", "v2.0.0", false));
|
||||
assert!(!updater.should_update("v1.1.0", "v1.0.0", false));
|
||||
assert!(!updater.should_update("v1.0.0", "v1.0.0", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_update_nightly() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Nightly version updates
|
||||
assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
|
||||
assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));
|
||||
|
||||
// Upgrade from stable to nightly
|
||||
assert!(updater.should_update("v1.0.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]
|
||||
fn test_should_update_edge_cases() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Test with different nightly formats
|
||||
assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
|
||||
assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));
|
||||
|
||||
// Test stable version edge cases
|
||||
assert!(updater.should_update("v0.9.9", "v1.0.0", false));
|
||||
assert!(!updater.should_update("v1.0.0", "v0.9.9", false));
|
||||
assert!(!updater.should_update("v1.0.0", "v1.0.0", false));
|
||||
|
||||
// Test version without 'v' prefix
|
||||
assert!(updater.should_update("0.9.9", "v1.0.0", false));
|
||||
assert!(updater.should_update("v0.9.9", "1.0.0", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_download_url_for_platform() {
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
let assets = vec![
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 12345,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||
size: 12345,
|
||||
},
|
||||
];
|
||||
|
||||
let url = updater.get_download_url_for_platform(&assets);
|
||||
assert!(url.is_some());
|
||||
|
||||
// The exact URL depends on the target architecture
|
||||
let url = url.unwrap();
|
||||
assert!(url.contains(".dmg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_update_uses_extractor() {
|
||||
// This test verifies that the extract_update method properly uses the Extractor
|
||||
// We can't run the actual extraction in unit tests without real DMG files,
|
||||
// but we can verify the method signature and basic logic
|
||||
let updater = AppAutoUpdater::new();
|
||||
|
||||
// Test that unsupported formats would be rejected
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let unsupported_file = temp_dir.join("test.rar");
|
||||
|
||||
// Create a mock runtime to test the logic
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
// This would fail because .rar is not supported, which proves
|
||||
// our method is using the Extractor logic
|
||||
let result = rt.block_on(async { updater.extract_update(&unsupported_file, &temp_dir).await });
|
||||
|
||||
// Should fail with unsupported format error
|
||||
assert!(result.is_err());
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("Unsupported archive format: rar"));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -35,6 +35,11 @@ impl BrowserVersionService {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_api_client(api_client: ApiClient) -> Self {
|
||||
Self { api_client }
|
||||
}
|
||||
|
||||
/// Get cached browser versions immediately (returns None if no cache exists)
|
||||
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
|
||||
self.api_client.load_cached_versions(browser)
|
||||
@@ -541,6 +546,335 @@ impl BrowserVersionService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
MockServer::start().await
|
||||
}
|
||||
|
||||
fn create_test_api_client(server: &MockServer) -> ApiClient {
|
||||
let base_url = server.uri();
|
||||
ApiClient::new_with_base_urls(
|
||||
base_url.clone(), // firefox_api_base
|
||||
base_url.clone(), // firefox_dev_api_base
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
base_url.clone(), // tor_archive_base
|
||||
base_url.clone(), // mozilla_download_base
|
||||
)
|
||||
}
|
||||
|
||||
fn create_test_service(api_client: ApiClient) -> BrowserVersionService {
|
||||
BrowserVersionService::new_with_api_client(api_client)
|
||||
}
|
||||
|
||||
async fn setup_firefox_mocks(server: &MockServer) {
|
||||
let mock_response = r#"{
|
||||
"releases": {
|
||||
"firefox-139.0": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-15",
|
||||
"description": "Firefox 139.0 Release",
|
||||
"is_security_driven": false,
|
||||
"product": "firefox",
|
||||
"version": "139.0"
|
||||
},
|
||||
"firefox-138.0": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-01",
|
||||
"description": "Firefox 138.0 Release",
|
||||
"is_security_driven": false,
|
||||
"product": "firefox",
|
||||
"version": "138.0"
|
||||
},
|
||||
"firefox-137.0": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2023-12-15",
|
||||
"description": "Firefox 137.0 Release",
|
||||
"is_security_driven": false,
|
||||
"product": "firefox",
|
||||
"version": "137.0"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/firefox.json"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_firefox_dev_mocks(server: &MockServer) {
|
||||
let mock_response = r#"{
|
||||
"releases": {
|
||||
"devedition-140.0b1": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-20",
|
||||
"description": "Firefox Developer Edition 140.0b1",
|
||||
"is_security_driven": false,
|
||||
"product": "devedition",
|
||||
"version": "140.0b1"
|
||||
},
|
||||
"devedition-139.0b5": {
|
||||
"build_number": 1,
|
||||
"category": "major",
|
||||
"date": "2024-01-10",
|
||||
"description": "Firefox Developer Edition 139.0b5",
|
||||
"is_security_driven": false,
|
||||
"product": "devedition",
|
||||
"version": "139.0b5"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/devedition.json"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_mullvad_mocks(server: &MockServer) {
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a6.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
|
||||
"size": 100000000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "14.5a5",
|
||||
"name": "Mullvad Browser 14.5a5",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-10T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a5.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a5.dmg",
|
||||
"size": 99000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_zen_mocks(server: &MockServer) {
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"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",
|
||||
"size": 120000000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-10T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.macos-universal.dmg",
|
||||
"browser_download_url": "https://example.com/zen-1.11b.dmg",
|
||||
"size": 115000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_brave_mocks(server: &MockServer) {
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.81.8",
|
||||
"name": "Brave Release 1.81.8",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-10T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.8-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
|
||||
"size": 199000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_chromium_mocks(server: &MockServer) {
|
||||
let arch = if cfg!(target_arch = "aarch64") {
|
||||
"Mac_Arm"
|
||||
} else {
|
||||
"Mac"
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{arch}/LAST_CHANGE")))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string("1465660")
|
||||
.insert_header("content-type", "text/plain"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn setup_tor_mocks(server: &MockServer) {
|
||||
let mock_html = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="../">../</a>
|
||||
<a href="14.0.4/">14.0.4/</a>
|
||||
<a href="14.0.3/">14.0.3/</a>
|
||||
<a href="14.0.2/">14.0.2/</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let version_html_144 = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="tor-browser-macos-14.0.4.dmg">tor-browser-macos-14.0.4.dmg</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let version_html_143 = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="tor-browser-macos-14.0.3.dmg">tor-browser-macos-14.0.3.dmg</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let version_html_142 = r#"
|
||||
<html>
|
||||
<body>
|
||||
<a href="tor-browser-macos-14.0.2.dmg">tor-browser-macos-14.0.2.dmg</a>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_html)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/14.0.4/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(version_html_144)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/14.0.3/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(version_html_143)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/14.0.2/"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(version_html_142)
|
||||
.insert_header("content-type", "text/html"),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_browser_version_service_creation() {
|
||||
@@ -550,7 +884,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_firefox_versions() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// Test with caching
|
||||
let result_cached = service.fetch_browser_versions("firefox", false).await;
|
||||
@@ -561,15 +899,13 @@ mod tests {
|
||||
|
||||
if let Ok(versions) = result_cached {
|
||||
assert!(!versions.is_empty(), "Should have Firefox versions");
|
||||
assert_eq!(versions[0], "139.0", "Should have latest version first");
|
||||
println!(
|
||||
"Firefox cached test passed. Found {versions_count} versions",
|
||||
versions_count = versions.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Test without caching
|
||||
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
|
||||
assert!(
|
||||
@@ -582,6 +918,7 @@ mod tests {
|
||||
!versions.is_empty(),
|
||||
"Should have Firefox versions without caching"
|
||||
);
|
||||
assert_eq!(versions[0], "139.0", "Should have latest version first");
|
||||
println!(
|
||||
"Firefox no-cache test passed. Found {versions_count} versions",
|
||||
versions_count = versions.len()
|
||||
@@ -591,7 +928,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_browser_versions_with_count() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let result = service
|
||||
.fetch_browser_versions_with_count("firefox", false)
|
||||
@@ -605,6 +946,10 @@ mod tests {
|
||||
result.versions.len(),
|
||||
"Total count should match versions length"
|
||||
);
|
||||
assert_eq!(
|
||||
result.versions[0], "139.0",
|
||||
"Should have latest version first"
|
||||
);
|
||||
println!(
|
||||
"Firefox count test passed. Found {} versions, new: {}",
|
||||
result.total_versions_count,
|
||||
@@ -615,7 +960,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_detailed_versions() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let result = service
|
||||
.fetch_browser_versions_detailed("firefox", false)
|
||||
@@ -631,6 +980,12 @@ mod tests {
|
||||
!first_version.version.is_empty(),
|
||||
"Version should not be empty"
|
||||
);
|
||||
assert_eq!(
|
||||
first_version.version, "139.0",
|
||||
"Should have latest version first"
|
||||
);
|
||||
assert_eq!(first_version.date, "2024-01-15", "Should have correct date");
|
||||
assert!(!first_version.is_prerelease, "Should be stable release");
|
||||
println!(
|
||||
"Firefox detailed test passed. Found {versions_count} detailed versions",
|
||||
versions_count = versions.len()
|
||||
@@ -640,7 +995,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unsupported_browser() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let result = service.fetch_browser_versions("unsupported", false).await;
|
||||
assert!(
|
||||
@@ -658,7 +1015,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_incremental_update() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// This test might fail if there are no cached versions yet, which is fine
|
||||
let result = service
|
||||
@@ -678,7 +1039,20 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_supported_browsers() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
|
||||
// Setup all browser mocks
|
||||
setup_firefox_mocks(&server).await;
|
||||
setup_firefox_dev_mocks(&server).await;
|
||||
setup_mullvad_mocks(&server).await;
|
||||
setup_zen_mocks(&server).await;
|
||||
setup_brave_mocks(&server).await;
|
||||
setup_chromium_mocks(&server).await;
|
||||
setup_tor_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
let browsers = vec![
|
||||
"firefox",
|
||||
"firefox-developer",
|
||||
@@ -690,30 +1064,30 @@ mod tests {
|
||||
];
|
||||
|
||||
for browser in browsers {
|
||||
// Test that we can at least call the function without panicking
|
||||
let result = service.fetch_browser_versions(browser, false).await;
|
||||
|
||||
match result {
|
||||
Ok(versions) => {
|
||||
assert!(!versions.is_empty(), "Should have versions for {browser}");
|
||||
println!(
|
||||
"{browser} test passed. Found {versions_count} versions",
|
||||
versions_count = versions.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Some browsers might fail due to network issues, but shouldn't panic
|
||||
println!("{browser} test failed (network issue): {e}");
|
||||
panic!("{browser} test failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between requests to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_caching_parameter() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// Test with caching enabled (default)
|
||||
let result_cached = service.fetch_browser_versions("firefox", false).await;
|
||||
@@ -722,9 +1096,6 @@ mod tests {
|
||||
"Should fetch Firefox versions with caching"
|
||||
);
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Test with caching disabled (no_caching = true)
|
||||
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
|
||||
assert!(
|
||||
@@ -742,6 +1113,10 @@ mod tests {
|
||||
!no_cache_versions.is_empty(),
|
||||
"No-cache versions should not be empty"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_versions, no_cache_versions,
|
||||
"Both should return same versions"
|
||||
);
|
||||
println!(
|
||||
"No-caching test passed. Cached: {} versions, No-cache: {} versions",
|
||||
cached_versions.len(),
|
||||
@@ -752,7 +1127,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detailed_versions_with_no_caching() {
|
||||
let service = BrowserVersionService::new();
|
||||
let server = setup_mock_server().await;
|
||||
setup_firefox_mocks(&server).await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
|
||||
// Test detailed versions with caching
|
||||
let result_cached = service
|
||||
@@ -763,9 +1142,6 @@ mod tests {
|
||||
"Should fetch detailed Firefox versions with caching"
|
||||
);
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Test detailed versions without caching
|
||||
let result_no_cache = service
|
||||
.fetch_browser_versions_detailed("firefox", true)
|
||||
@@ -799,6 +1175,17 @@ mod tests {
|
||||
"No-cache version should not be empty"
|
||||
);
|
||||
|
||||
assert_eq!(first_cached.version, "139.0", "Should have correct version");
|
||||
assert_eq!(
|
||||
first_no_cache.version, "139.0",
|
||||
"Should have correct version"
|
||||
);
|
||||
assert_eq!(first_cached.date, "2024-01-15", "Should have correct date");
|
||||
assert_eq!(
|
||||
first_no_cache.date, "2024-01-15",
|
||||
"Should have correct date"
|
||||
);
|
||||
|
||||
println!(
|
||||
"Detailed no-caching test passed. Cached: {} versions, No-cache: {} versions",
|
||||
cached_versions.len(),
|
||||
|
||||
@@ -153,8 +153,8 @@ pub async fn open_url_with_profile(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn smart_open_url(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_url: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
url: String,
|
||||
_is_startup: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
@@ -171,10 +171,75 @@ pub async fn smart_open_url(
|
||||
}
|
||||
|
||||
println!(
|
||||
"URL opening - Total profiles: {}, showing profile selector",
|
||||
"URL opening - Total profiles: {}, checking for running profiles",
|
||||
profiles.len()
|
||||
);
|
||||
|
||||
// Always show the profile selector so the user can choose
|
||||
// Check for running profiles and find the first one that can handle URLs
|
||||
for profile in &profiles {
|
||||
// Check if this profile is running
|
||||
let is_running = runner
|
||||
.check_browser_status(app_handle.clone(), profile)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_running {
|
||||
println!(
|
||||
"Found running profile '{}', attempting to open URL",
|
||||
profile.name
|
||||
);
|
||||
|
||||
// For TOR browser: Check if any other TOR browser is running
|
||||
if profile.browser == "tor-browser" {
|
||||
let mut other_tor_running = false;
|
||||
for p in &profiles {
|
||||
if p.browser == "tor-browser"
|
||||
&& p.name != profile.name
|
||||
&& runner
|
||||
.check_browser_status(app_handle.clone(), p)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
other_tor_running = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if other_tor_running {
|
||||
continue; // Skip this one, can't have multiple TOR instances
|
||||
}
|
||||
}
|
||||
|
||||
// For Mullvad browser: skip if running (can't open URLs in running Mullvad)
|
||||
if profile.browser == "mullvad-browser" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to open the URL with this running profile
|
||||
match runner
|
||||
.launch_or_open_url(app_handle.clone(), profile, Some(url.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
println!(
|
||||
"Successfully opened URL '{}' with running profile '{}'",
|
||||
url, profile.name
|
||||
);
|
||||
return Ok(format!("opened_with_profile:{}", profile.name));
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Failed to open URL with running profile '{}': {}",
|
||||
profile.name, e
|
||||
);
|
||||
// Continue to try other profiles or show selector
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("No suitable running profiles found, showing profile selector");
|
||||
|
||||
// No suitable running profile found, show the profile selector
|
||||
Err("show_selector".to_string())
|
||||
}
|
||||
|
||||
+525
-71
@@ -34,6 +34,14 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_api_client(api_client: ApiClient) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the actual download URL for browsers that need dynamic asset resolution
|
||||
pub async fn resolve_download_url(
|
||||
&self,
|
||||
@@ -44,7 +52,10 @@ impl Downloader {
|
||||
match browser_type {
|
||||
BrowserType::Brave => {
|
||||
// For Brave, we need to find the actual macOS asset
|
||||
let releases = self.api_client.fetch_brave_releases().await?;
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_brave_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
// Find the release with the matching version
|
||||
let release = releases
|
||||
@@ -67,7 +78,10 @@ impl Downloader {
|
||||
}
|
||||
BrowserType::Zen => {
|
||||
// For Zen, verify the asset exists
|
||||
let releases = self.api_client.fetch_zen_releases().await?;
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_zen_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
let release = releases
|
||||
.iter()
|
||||
@@ -87,7 +101,10 @@ impl Downloader {
|
||||
}
|
||||
BrowserType::MullvadBrowser => {
|
||||
// For Mullvad, verify the asset exists
|
||||
let releases = self.api_client.fetch_mullvad_releases().await?;
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_mullvad_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
let release = releases
|
||||
.iter()
|
||||
@@ -112,9 +129,9 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_browser(
|
||||
pub async fn download_browser<R: tauri::Runtime>(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
browser_type: BrowserType,
|
||||
version: &str,
|
||||
download_info: &DownloadInfo,
|
||||
@@ -127,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(),
|
||||
@@ -136,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);
|
||||
@@ -149,6 +174,11 @@ impl Downloader {
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Check if the response is successful
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let total_size = response.content_length();
|
||||
let mut downloaded = 0u64;
|
||||
let start_time = std::time::Instant::now();
|
||||
@@ -183,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(),
|
||||
@@ -191,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);
|
||||
@@ -206,12 +242,63 @@ impl Downloader {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser_version_service::DownloadInfo;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
MockServer::start().await
|
||||
}
|
||||
|
||||
fn create_test_api_client(server: &MockServer) -> ApiClient {
|
||||
let base_url = server.uri();
|
||||
ApiClient::new_with_base_urls(
|
||||
base_url.clone(), // firefox_api_base
|
||||
base_url.clone(), // firefox_dev_api_base
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
base_url.clone(), // tor_archive_base
|
||||
base_url.clone(), // mozilla_download_base
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Test with a known Brave version
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
@@ -222,23 +309,43 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert!(url.contains("github.com/brave/brave-browser"));
|
||||
assert!(url.contains(".dmg"));
|
||||
assert!(url.contains("universal"));
|
||||
println!("Brave download URL resolved: {url}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Brave URL resolution failed (expected if version doesn't exist): {e}");
|
||||
// This might fail if the version doesn't exist, which is okay for testing
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.macos-universal.dmg",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg",
|
||||
"size": 120000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
@@ -250,21 +357,43 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert!(url.contains("github.com/zen-browser/desktop"));
|
||||
assert!(url.contains("zen.macos-universal.dmg"));
|
||||
println!("Zen download URL resolved: {url}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Zen URL resolution failed (expected if version doesn't exist): {e}");
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/zen-1.11b-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a6.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
|
||||
"size": 100000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
@@ -276,21 +405,16 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert!(url.contains("github.com/mullvad/mullvad-browser"));
|
||||
assert!(url.contains(".dmg"));
|
||||
println!("Mullvad download URL resolved: {url}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Mullvad URL resolution failed (expected if version doesn't exist): {e}");
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/mullvad-14.5a6.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_firefox_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
|
||||
@@ -302,20 +426,16 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Firefox, "139.0", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert_eq!(url, download_info.url);
|
||||
println!("Firefox download URL (passthrough): {url}");
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Firefox URL resolution should not fail: {e}");
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_chromium_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
|
||||
@@ -327,20 +447,16 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::Chromium, "1465660", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert_eq!(url, download_info.url);
|
||||
println!("Chromium download URL (passthrough): {url}");
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Chromium URL resolution should not fail: {e}");
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_tor_download_url() {
|
||||
let downloader = Downloader::new();
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://archive.torproject.org/tor-package-archive/torbrowser/14.0.4/tor-browser-macos-14.0.4.dmg".to_string(),
|
||||
@@ -352,14 +468,352 @@ mod tests {
|
||||
.resolve_download_url(BrowserType::TorBrowser, "14.0.4", &download_info)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(url) => {
|
||||
assert_eq!(url, download_info.url);
|
||||
println!("TOR download URL (passthrough): {url}");
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_version_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.8",
|
||||
"name": "Brave Release 1.81.8",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.8-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("TOR URL resolution should not fail: {e}");
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Brave version v1.81.9 not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.linux-universal.tar.bz2",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2",
|
||||
"size": 150000000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "zen-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No macOS universal asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_with_progress() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
// Create a temporary directory for the test
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Create test file content (simulating a small download)
|
||||
let test_content = b"This is a test file content for download simulation";
|
||||
|
||||
// Mock the download endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test-download"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(test_content)
|
||||
.insert_header("content-length", test_content.len().to_string())
|
||||
.insert_header("content-type", "application/octet-stream"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/test-download", server.uri()),
|
||||
filename: "test-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
// Create a mock app handle for testing
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Firefox,
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let downloaded_file = result.unwrap();
|
||||
assert!(downloaded_file.exists());
|
||||
|
||||
// Verify file content
|
||||
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
||||
assert_eq!(downloaded_content, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_network_error() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Mock a 404 response
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/missing-file"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/missing-file", server.uri()),
|
||||
filename: "missing-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Firefox,
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-linux-14.5a6.tar.xz",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz",
|
||||
"size": 80000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "mullvad-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No macOS asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_brave_version_with_v_prefix() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
// Test with version without v prefix
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_chunked_response() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Create larger test content to simulate chunked transfer
|
||||
let test_content = vec![42u8; 1024]; // 1KB of data
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chunked-download"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(test_content.clone())
|
||||
.insert_header("content-length", test_content.len().to_string())
|
||||
.insert_header("content-type", "application/octet-stream"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/chunked-download", server.uri()),
|
||||
filename: "chunked-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Chromium,
|
||||
"1465660",
|
||||
&download_info,
|
||||
dest_path,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let downloaded_file = result.unwrap();
|
||||
assert!(downloaded_file.exists());
|
||||
|
||||
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
||||
assert_eq!(downloaded_content.len(), test_content.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ®istry.browsers["zen"];
|
||||
let twilight_info = &zen_versions["twilight"];
|
||||
assert!(twilight_info.is_rolling_release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Extractor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn extract_dmg(
|
||||
pub async fn extract_dmg(
|
||||
&self,
|
||||
dmg_path: &Path,
|
||||
dest_dir: &Path,
|
||||
@@ -149,7 +149,7 @@ impl Extractor {
|
||||
Ok(app_path)
|
||||
}
|
||||
|
||||
async fn extract_zip(
|
||||
pub async fn extract_zip(
|
||||
&self,
|
||||
zip_path: &Path,
|
||||
dest_dir: &Path,
|
||||
|
||||
+41
-5
@@ -8,6 +8,7 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
mod api_client;
|
||||
mod app_auto_updater;
|
||||
mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
@@ -52,6 +53,11 @@ use auto_updater::{
|
||||
mark_auto_update_download, remove_auto_update_download, start_browser_update,
|
||||
};
|
||||
|
||||
use app_auto_updater::{
|
||||
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
|
||||
get_app_version_info,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
fn greet() -> String {
|
||||
let now = SystemTime::now();
|
||||
@@ -172,6 +178,36 @@ pub fn run() {
|
||||
updater_guard.start_background_updates().await;
|
||||
});
|
||||
|
||||
// Check for app updates at startup
|
||||
let app_handle_update = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Add a small delay to ensure the app is fully loaded
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
println!("Starting app update check at startup...");
|
||||
let updater = app_auto_updater::AppAutoUpdater::new();
|
||||
match updater.check_for_updates().await {
|
||||
Ok(Some(update_info)) => {
|
||||
println!(
|
||||
"App update available: {} -> {}",
|
||||
update_info.current_version, update_info.new_version
|
||||
);
|
||||
// Emit update available event to the frontend
|
||||
if let Err(e) = app_handle_update.emit("app-update-available", &update_info) {
|
||||
eprintln!("Failed to emit app update event: {e}");
|
||||
} else {
|
||||
println!("App update event emitted successfully");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("No app updates available");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to check for app updates: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -182,7 +218,7 @@ pub fn run() {
|
||||
is_browser_downloaded,
|
||||
check_browser_exists,
|
||||
create_browser_profile_new,
|
||||
create_browser_profile, // Keep for backward compatibility
|
||||
create_browser_profile,
|
||||
list_browser_profiles,
|
||||
launch_browser_profile,
|
||||
fetch_browser_versions,
|
||||
@@ -199,26 +235,22 @@ pub fn run() {
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
// Settings commands
|
||||
get_app_settings,
|
||||
save_app_settings,
|
||||
should_show_settings_on_startup,
|
||||
disable_default_browser_prompt,
|
||||
get_table_sorting_settings,
|
||||
save_table_sorting_settings,
|
||||
// Default browser commands
|
||||
is_default_browser,
|
||||
open_url_with_profile,
|
||||
set_as_default_browser,
|
||||
smart_open_url,
|
||||
handle_url_open,
|
||||
check_and_handle_startup_url,
|
||||
// Version update commands
|
||||
trigger_manual_version_update,
|
||||
get_version_update_status,
|
||||
check_version_update_needed,
|
||||
force_version_update_check,
|
||||
// Auto-update commands
|
||||
check_for_browser_updates,
|
||||
start_browser_update,
|
||||
complete_browser_update,
|
||||
@@ -228,6 +260,10 @@ pub fn run() {
|
||||
mark_auto_update_download,
|
||||
remove_auto_update_download,
|
||||
is_auto_update_download,
|
||||
check_for_app_updates,
|
||||
check_for_app_updates_manual,
|
||||
download_and_install_app_update,
|
||||
get_app_version_info,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.4",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+53
-108
@@ -13,6 +13,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, ProxySettings } from "@/types";
|
||||
@@ -47,20 +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;
|
||||
|
||||
// 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",
|
||||
@@ -73,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();
|
||||
@@ -100,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",
|
||||
@@ -118,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",
|
||||
@@ -133,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) => {
|
||||
@@ -145,10 +146,7 @@ export default function Home() {
|
||||
// Listen for show profile selector events
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
setPendingUrls((prev) => [
|
||||
...prev,
|
||||
{ id: Date.now().toString(), url: event.payload },
|
||||
]);
|
||||
setPendingUrls([{ id: Date.now().toString(), url: event.payload }]);
|
||||
});
|
||||
|
||||
// Listen for show create profile dialog events
|
||||
@@ -168,15 +166,13 @@ 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", {
|
||||
url,
|
||||
});
|
||||
console.log("Smart URL opening succeeded:", result);
|
||||
// URL was handled successfully
|
||||
// URL was handled successfully, no need to show selector
|
||||
} catch (error: unknown) {
|
||||
console.log(
|
||||
"Smart URL opening failed or requires profile selection:",
|
||||
@@ -184,7 +180,8 @@ export default function Home() {
|
||||
);
|
||||
|
||||
// Show profile selector for manual selection
|
||||
setPendingUrls((prev) => [...prev, { id: Date.now().toString(), url }]);
|
||||
// Replace any existing pending URL with the new one
|
||||
setPendingUrls([{ id: Date.now().toString(), url }]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,7 +246,9 @@ export default function Home() {
|
||||
await loadProfiles();
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to create profile: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to create profile: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@@ -263,40 +262,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
|
||||
@@ -330,11 +322,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) {
|
||||
@@ -345,7 +337,7 @@ export default function Home() {
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [profiles, checkBrowserStatus, isClient]);
|
||||
}, [profiles, checkBrowserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
runningProfilesRef.current = runningProfiles;
|
||||
@@ -401,53 +393,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">
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
import { FaDownload, FaTimes } from "react-icons/fa";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
|
||||
interface AppUpdateInfo {
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
release_notes: string;
|
||||
download_url: string;
|
||||
is_nightly: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface AppUpdateToastProps {
|
||||
updateInfo: AppUpdateInfo;
|
||||
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
isUpdating?: boolean;
|
||||
updateProgress?: string;
|
||||
}
|
||||
|
||||
export function AppUpdateToast({
|
||||
updateInfo,
|
||||
onUpdate,
|
||||
onDismiss,
|
||||
isUpdating = false,
|
||||
updateProgress,
|
||||
}: AppUpdateToastProps) {
|
||||
const handleUpdateClick = async () => {
|
||||
await onUpdate(updateInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-lg max-w-md">
|
||||
<div className="mr-3 mt-0.5">
|
||||
{isUpdating ? (
|
||||
<LuRefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
|
||||
) : (
|
||||
<FaDownload className="h-5 w-5 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground text-sm">
|
||||
Donut Browser Update Available
|
||||
</span>
|
||||
<Badge
|
||||
variant={updateInfo.is_nightly ? "secondary" : "default"}
|
||||
className="text-xs"
|
||||
>
|
||||
{updateInfo.is_nightly ? "Nightly" : "Stable"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Update from {updateInfo.current_version} to{" "}
|
||||
<span className="font-medium">{updateInfo.new_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUpdating && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
>
|
||||
<FaTimes className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUpdating && updateProgress && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted-foreground">{updateProgress}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUpdating && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button
|
||||
onClick={() => void handleUpdateClick()}
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<FaDownload className="h-3 w-3" />
|
||||
Update Now
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDismiss}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo.release_notes && !isUpdating && (
|
||||
<div className="mt-2">
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Release Notes
|
||||
</summary>
|
||||
<div className="mt-1 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
|
||||
{updateInfo.release_notes.length > 200
|
||||
? `${updateInfo.release_notes.substring(0, 200)}...`
|
||||
: updateInfo.release_notes}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -78,6 +78,11 @@ export function ProfilesDataTable({
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [newProfileName, setNewProfileName] = React.useState("");
|
||||
const [renameError, setRenameError] = React.useState<string | null>(null);
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [deleteConfirmationName, setDeleteConfirmationName] =
|
||||
React.useState("");
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const [isClient, setIsClient] = React.useState(false);
|
||||
|
||||
// Ensure we're on the client side to prevent hydration mismatches
|
||||
@@ -117,6 +122,26 @@ export function ProfilesDataTable({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!profileToDelete || !deleteConfirmationName.trim()) return;
|
||||
|
||||
if (deleteConfirmationName.trim() !== profileToDelete.name) {
|
||||
setDeleteError(
|
||||
"Profile name doesn't match. Please type the exact name to confirm deletion.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onDeleteProfile(profileToDelete);
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
} catch (err) {
|
||||
setDeleteError(err as string);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -376,14 +401,17 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
>
|
||||
Rename profile
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void onDeleteProfile(profile)}
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
setDeleteConfirmationName("");
|
||||
}}
|
||||
className="text-red-600"
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
>
|
||||
Delete profile
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -400,7 +428,6 @@ export function ProfilesDataTable({
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onProxySettings,
|
||||
onDeleteProfile,
|
||||
onChangeVersion,
|
||||
],
|
||||
);
|
||||
@@ -514,6 +541,69 @@ export function ProfilesDataTable({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={profileToDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
profile "{profileToDelete?.name}" and all its associated
|
||||
data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="delete-confirmation">
|
||||
Please type <strong>{profileToDelete?.name}</strong> to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-confirmation"
|
||||
value={deleteConfirmationName}
|
||||
onChange={(e) => {
|
||||
setDeleteConfirmationName(e.target.value);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
placeholder="Type the profile name here"
|
||||
/>
|
||||
</div>
|
||||
{deleteError && (
|
||||
<p className="text-sm text-red-600">{deleteError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={
|
||||
!deleteConfirmationName.trim() ||
|
||||
deleteConfirmationName !== profileToDelete?.name
|
||||
}
|
||||
>
|
||||
Delete Profile
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,16 +69,29 @@ export function ProfileSelectorDialog({
|
||||
|
||||
// Auto-select first available profile for link opening
|
||||
if (profileList.length > 0) {
|
||||
// Find the first profile that can be used for opening links
|
||||
const availableProfile = profileList.find((profile) => {
|
||||
return canUseProfileForLinks(profile, profileList, runningProfiles);
|
||||
// First, try to find a running profile that can be used for opening links
|
||||
const runningAvailableProfile = profileList.find((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
return (
|
||||
isRunning &&
|
||||
canUseProfileForLinks(profile, profileList, runningProfiles)
|
||||
);
|
||||
});
|
||||
|
||||
if (availableProfile) {
|
||||
setSelectedProfile(availableProfile.name);
|
||||
if (runningAvailableProfile) {
|
||||
setSelectedProfile(runningAvailableProfile.name);
|
||||
} else {
|
||||
// If no suitable profile found, still select the first one to show UI
|
||||
setSelectedProfile(profileList[0].name);
|
||||
// If no running profile is suitable, find the first profile that can be used for opening links
|
||||
const availableProfile = profileList.find((profile) => {
|
||||
return canUseProfileForLinks(profile, profileList, runningProfiles);
|
||||
});
|
||||
|
||||
if (availableProfile) {
|
||||
setSelectedProfile(availableProfile.name);
|
||||
} else {
|
||||
// If no suitable profile found, still select the first one to show UI
|
||||
setSelectedProfile(profileList[0].name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -277,7 +290,7 @@ export function ProfileSelectorDialog({
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent cursor-pointer">
|
||||
<div className="flex items-center gap-3 py-1 px-2 rounded-lg hover:bg-accent cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { AppUpdateToast } from "@/components/app-update-toast";
|
||||
import { showToast } from "@/lib/toast-utils";
|
||||
import type { AppUpdateInfo } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useAppUpdateNotifications() {
|
||||
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateProgress, setUpdateProgress] = useState<string>("");
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
|
||||
|
||||
// Ensure we're on the client side to prevent hydration mismatches
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const checkForAppUpdates = useCallback(async () => {
|
||||
if (!isClient) return;
|
||||
|
||||
try {
|
||||
const update = await invoke<AppUpdateInfo | null>(
|
||||
"check_for_app_updates",
|
||||
);
|
||||
|
||||
// Don't show update if this version was already dismissed
|
||||
if (update && update.new_version !== dismissedVersion) {
|
||||
setUpdateInfo(update);
|
||||
} else if (update) {
|
||||
console.log("Update available but dismissed:", update.new_version);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check for app updates:", error);
|
||||
}
|
||||
}, [isClient, dismissedVersion]);
|
||||
|
||||
const checkForAppUpdatesManual = useCallback(async () => {
|
||||
if (!isClient) return;
|
||||
|
||||
try {
|
||||
console.log("Triggering manual app update check...");
|
||||
const update = await invoke<AppUpdateInfo | null>(
|
||||
"check_for_app_updates_manual",
|
||||
);
|
||||
console.log("Manual check result:", update);
|
||||
|
||||
// Always show manual check results, even if previously dismissed
|
||||
setUpdateInfo(update);
|
||||
} catch (error) {
|
||||
console.error("Failed to manually check for app updates:", error);
|
||||
}
|
||||
}, [isClient]);
|
||||
|
||||
const handleAppUpdate = useCallback(async (appUpdateInfo: AppUpdateInfo) => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
setUpdateProgress("Starting update...");
|
||||
|
||||
await invoke("download_and_install_app_update", {
|
||||
updateInfo: appUpdateInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update app:", error);
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Failed to update Donut Browser",
|
||||
description: String(error),
|
||||
duration: 6000,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
setUpdateProgress("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissAppUpdate = useCallback(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
// Remember the dismissed version so we don't show it again
|
||||
if (updateInfo) {
|
||||
setDismissedVersion(updateInfo.new_version);
|
||||
console.log("Dismissed app update version:", updateInfo.new_version);
|
||||
}
|
||||
|
||||
setUpdateInfo(null);
|
||||
toast.dismiss("app-update");
|
||||
}, [isClient, updateInfo]);
|
||||
|
||||
// Listen for app update availability
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
const unlistenUpdate = listen<AppUpdateInfo>(
|
||||
"app-update-available",
|
||||
(event) => {
|
||||
console.log("App update available:", event.payload);
|
||||
setUpdateInfo(event.payload);
|
||||
},
|
||||
);
|
||||
|
||||
const unlistenProgress = listen<string>("app-update-progress", (event) => {
|
||||
console.log("App update progress:", event.payload);
|
||||
setUpdateProgress(event.payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlistenUpdate.then((unlisten) => {
|
||||
unlisten();
|
||||
});
|
||||
void unlistenProgress.then((unlisten) => {
|
||||
unlisten();
|
||||
});
|
||||
};
|
||||
}, [isClient]);
|
||||
|
||||
// Show toast when update is available
|
||||
useEffect(() => {
|
||||
if (!isClient || !updateInfo) return;
|
||||
|
||||
toast.custom(
|
||||
() => (
|
||||
<AppUpdateToast
|
||||
updateInfo={updateInfo}
|
||||
onUpdate={handleAppUpdate}
|
||||
onDismiss={dismissAppUpdate}
|
||||
isUpdating={isUpdating}
|
||||
updateProgress={updateProgress}
|
||||
/>
|
||||
),
|
||||
{
|
||||
id: "app-update",
|
||||
duration: Number.POSITIVE_INFINITY, // Persistent until user action
|
||||
position: "top-left",
|
||||
},
|
||||
);
|
||||
}, [
|
||||
updateInfo,
|
||||
handleAppUpdate,
|
||||
dismissAppUpdate,
|
||||
isUpdating,
|
||||
updateProgress,
|
||||
isClient,
|
||||
]);
|
||||
|
||||
// Check for app updates on startup
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
// Check for updates immediately on startup
|
||||
void checkForAppUpdates();
|
||||
}, [isClient, checkForAppUpdates]);
|
||||
|
||||
return {
|
||||
updateInfo,
|
||||
isUpdating,
|
||||
checkForAppUpdates,
|
||||
checkForAppUpdatesManual,
|
||||
dismissAppUpdate,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -19,3 +19,17 @@ export interface BrowserProfile {
|
||||
process_id?: number;
|
||||
last_launch?: number;
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
release_notes: string;
|
||||
download_url: string;
|
||||
is_nightly: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
export interface AppVersionInfo {
|
||||
version: string;
|
||||
is_nightly: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user