mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-01 21:01:35 +02:00
feat: self-updates
This commit is contained in:
@@ -8,6 +8,7 @@ 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:
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
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;
|
||||
|
||||
#[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
|
||||
// Otherwise, it's a nightly build
|
||||
option_env!("STABLE_RELEASE").is_none()
|
||||
}
|
||||
|
||||
/// Get current app version from Cargo.toml
|
||||
pub fn get_current_version() -> String {
|
||||
env!("CARGO_PKG_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!(
|
||||
"Checking for updates - Current version: {}, Is nightly: {}",
|
||||
current_version, is_nightly
|
||||
);
|
||||
|
||||
let releases = self.fetch_app_releases().await?;
|
||||
|
||||
// Filter releases based on build type
|
||||
let filtered_releases: Vec<&AppRelease> = if is_nightly {
|
||||
// For nightly builds, look for nightly releases
|
||||
releases
|
||||
.iter()
|
||||
.filter(|release| release.tag_name.starts_with("nightly-"))
|
||||
.collect()
|
||||
} else {
|
||||
// For stable builds, look for stable releases (semver format)
|
||||
releases
|
||||
.iter()
|
||||
.filter(|release| {
|
||||
release.tag_name.starts_with('v') && !release.tag_name.starts_with("nightly-")
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if filtered_releases.is_empty() {
|
||||
println!("No releases found for build type");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get the latest release
|
||||
let latest_release = filtered_releases[0];
|
||||
|
||||
// Check if we need to update
|
||||
if self.should_update(¤t_version, &latest_release.tag_name, is_nightly) {
|
||||
// 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(),
|
||||
};
|
||||
|
||||
return Ok(Some(update_info));
|
||||
}
|
||||
}
|
||||
|
||||
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 is_nightly {
|
||||
// For nightly builds, always update if there's a newer nightly
|
||||
// Compare the commit hashes (assuming format: nightly-<commit_hash>)
|
||||
if let (Some(current_hash), Some(new_hash)) = (
|
||||
current_version.strip_prefix("nightly-"),
|
||||
new_version.strip_prefix("nightly-"),
|
||||
) {
|
||||
return new_hash != current_hash;
|
||||
}
|
||||
// If current version doesn't have nightly prefix, it's an upgrade from stable to nightly
|
||||
return !current_version.starts_with("nightly-");
|
||||
} else {
|
||||
// For stable builds, use semantic versioning comparison
|
||||
return self.is_version_newer(new_version, current_version);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
"x64"
|
||||
};
|
||||
|
||||
// Look for macOS DMG with the appropriate architecture
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg") && asset.name.contains(arch) {
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for any macOS DMG
|
||||
for asset in assets {
|
||||
if asset.name.contains(".dmg") {
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
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('/')
|
||||
.last()
|
||||
.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 (DMG on macOS)
|
||||
async fn extract_update(
|
||||
&self,
|
||||
dmg_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// For DMG files on macOS, we need to mount and copy the .app
|
||||
let mount_point = dest_dir.join("mount");
|
||||
fs::create_dir_all(&mount_point)?;
|
||||
|
||||
// Mount the DMG
|
||||
let output = Command::new("hdiutil")
|
||||
.args([
|
||||
"attach",
|
||||
"-nobrowse",
|
||||
"-mountpoint",
|
||||
mount_point.to_str().unwrap(),
|
||||
dmg_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to mount DMG: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Find the .app in the mount point
|
||||
let app_entry = fs::read_dir(&mount_point)?
|
||||
.filter_map(Result::ok)
|
||||
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
|
||||
.ok_or("No .app found in DMG")?;
|
||||
|
||||
let app_path = dest_dir.join("extracted_app");
|
||||
if app_path.exists() {
|
||||
fs::remove_dir_all(&app_path)?;
|
||||
}
|
||||
|
||||
// Copy the .app to extraction directory
|
||||
let output = Command::new("cp")
|
||||
.args([
|
||||
"-R",
|
||||
app_entry.path().to_str().unwrap(),
|
||||
app_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to copy app: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Unmount the DMG
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(["detach", mount_point.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
Ok(app_path)
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
|
||||
// Use open command to restart the app
|
||||
let _ = Command::new("open")
|
||||
.args([app_path.to_str().unwrap()])
|
||||
.spawn()?;
|
||||
|
||||
// Exit current process after a short delay
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
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(),
|
||||
))
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
+31
-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,10 @@ use auto_updater::{
|
||||
mark_auto_update_download, remove_auto_update_download, start_browser_update,
|
||||
};
|
||||
|
||||
use app_auto_updater::{
|
||||
check_for_app_updates, download_and_install_app_update, get_app_version_info,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
fn greet() -> String {
|
||||
let now = SystemTime::now();
|
||||
@@ -172,6 +177,28 @@ 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;
|
||||
|
||||
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
|
||||
let _ = app_handle_update.emit("app-update-available", &update_info);
|
||||
}
|
||||
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 +209,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 +226,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 +251,9 @@ pub fn run() {
|
||||
mark_auto_update_download,
|
||||
remove_auto_update_download,
|
||||
is_auto_update_download,
|
||||
check_for_app_updates,
|
||||
download_and_install_app_update,
|
||||
get_app_version_info,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+7
-1
@@ -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";
|
||||
@@ -53,6 +54,9 @@ export default function Home() {
|
||||
const updateNotifications = useUpdateNotifications();
|
||||
const { checkForUpdates, isUpdating } = updateNotifications;
|
||||
|
||||
// App auto-update functionality
|
||||
const appUpdateNotifications = useAppUpdateNotifications();
|
||||
|
||||
// Ensure we're on the client side to prevent hydration mismatches
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
@@ -249,7 +253,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"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);
|
||||
|
||||
// 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",
|
||||
);
|
||||
setUpdateInfo(update);
|
||||
} catch (error) {
|
||||
console.error("Failed to 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;
|
||||
|
||||
setUpdateInfo(null);
|
||||
toast.dismiss("app-update");
|
||||
}, [isClient]);
|
||||
|
||||
// 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-right",
|
||||
},
|
||||
);
|
||||
}, [
|
||||
updateInfo,
|
||||
handleAppUpdate,
|
||||
dismissAppUpdate,
|
||||
isUpdating,
|
||||
updateProgress,
|
||||
isClient,
|
||||
]);
|
||||
|
||||
return {
|
||||
updateInfo,
|
||||
isUpdating,
|
||||
checkForAppUpdates,
|
||||
dismissAppUpdate,
|
||||
};
|
||||
}
|
||||
@@ -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