From 5c23c778963387638e84b3c54691e9de2a6e66b3 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 30 May 2025 07:12:07 +0400 Subject: [PATCH] feat: self-updates --- .github/workflows/release.yml | 1 + src-tauri/src/app_auto_updater.rs | 528 +++++++++++++++++++++ src-tauri/src/lib.rs | 36 +- src/app/page.tsx | 8 +- src/components/app-update-toast.tsx | 123 +++++ src/hooks/use-app-update-notifications.tsx | 125 +++++ src/types.ts | 14 + 7 files changed, 829 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/app_auto_updater.rs create mode 100644 src/components/app-update-toast.tsx create mode 100644 src/hooks/use-app-update-notifications.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 860602b..4fbe198 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs new file mode 100644 index 0000000..f4b77b3 --- /dev/null +++ b/src-tauri/src/app_auto_updater.rs @@ -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, +} + +#[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, Box> { + 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, Box> { + 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 = 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-) + 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 { + 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> { + // 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> { + 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> { + // 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> { + // 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> { + // 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> { + 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, 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")); + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7bce5f..a0496f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use tauri_plugin_deep_link::DeepLinkExt; static PENDING_URLS: Mutex> = 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"); diff --git a/src/app/page.tsx b/src/app/page.tsx index 9d74fe8..9827e45 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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; } diff --git a/src/components/app-update-toast.tsx b/src/components/app-update-toast.tsx new file mode 100644 index 0000000..f1aaca9 --- /dev/null +++ b/src/components/app-update-toast.tsx @@ -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; + onDismiss: () => void; + isUpdating?: boolean; + updateProgress?: string; +} + +export function AppUpdateToast({ + updateInfo, + onUpdate, + onDismiss, + isUpdating = false, + updateProgress, +}: AppUpdateToastProps) { + const handleUpdateClick = async () => { + await onUpdate(updateInfo); + }; + + return ( +
+
+ {isUpdating ? ( + + ) : ( + + )} +
+ +
+
+
+
+ + Donut Browser Update Available + + + {updateInfo.is_nightly ? "Nightly" : "Stable"} + +
+
+ Update from {updateInfo.current_version} to{" "} + {updateInfo.new_version} +
+
+ + {!isUpdating && ( + + )} +
+ + {isUpdating && updateProgress && ( +
+

{updateProgress}

+
+ )} + + {!isUpdating && ( +
+ + +
+ )} + + {updateInfo.release_notes && !isUpdating && ( +
+
+ + Release Notes + +
+ {updateInfo.release_notes.length > 200 + ? `${updateInfo.release_notes.substring(0, 200)}...` + : updateInfo.release_notes} +
+
+
+ )} +
+
+ ); +} diff --git a/src/hooks/use-app-update-notifications.tsx b/src/hooks/use-app-update-notifications.tsx new file mode 100644 index 0000000..53587bd --- /dev/null +++ b/src/hooks/use-app-update-notifications.tsx @@ -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(null); + const [isUpdating, setIsUpdating] = useState(false); + const [updateProgress, setUpdateProgress] = useState(""); + 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( + "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( + "app-update-available", + (event) => { + console.log("App update available:", event.payload); + setUpdateInfo(event.payload); + }, + ); + + const unlistenProgress = listen("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( + () => ( + + ), + { + 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, + }; +} diff --git a/src/types.ts b/src/types.ts index 0d618ce..a26cd26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +}