Browse Source

feat(desktop): stablize updater ux and window state (#4798)

Shreyas 1 week ago
parent
commit
fac3bec988

+ 3 - 1
packages/hoppscotch-desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "hoppscotch-desktop",
   "private": true,
-  "version": "0.1.0",
+  "version": "25.2.0-0",
   "type": "module",
   "scripts": {
     "dev": "vite",
@@ -19,8 +19,10 @@
     "@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload",
     "@hoppscotch/ui": "0.2.1",
     "@tauri-apps/api": "2.1.1",
+    "@tauri-apps/plugin-process": "2.2.0",
     "@tauri-apps/plugin-shell": "2.0.1",
     "@tauri-apps/plugin-store": "2.2.0",
+    "@tauri-apps/plugin-updater": "2.5.1",
     "@vueuse/core": "11.1.0",
     "fp-ts": "2.16.9",
     "vue": "^3.3.4",

+ 12 - 1
packages/hoppscotch-desktop/src-tauri/Cargo.lock

@@ -2023,7 +2023,7 @@ dependencies = [
 
 [[package]]
 name = "hoppscotch-desktop"
-version = "0.1.0"
+version = "25.2.0"
 dependencies = [
  "axum",
  "portpicker",
@@ -2035,6 +2035,7 @@ dependencies = [
  "tauri-plugin-deep-link",
  "tauri-plugin-dialog",
  "tauri-plugin-fs",
+ "tauri-plugin-process",
  "tauri-plugin-relay",
  "tauri-plugin-shell",
  "tauri-plugin-store",
@@ -5066,6 +5067,16 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "tauri-plugin-process"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40cc553ab29581c8c43dfa5fb0c9d5aee8ba962ad3b42908eea26c79610441b7"
+dependencies = [
+ "tauri",
+ "tauri-plugin",
+]
+
 [[package]]
 name = "tauri-plugin-relay"
 version = "0.1.0"

+ 3 - 4
packages/hoppscotch-desktop/src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "hoppscotch-desktop"
-version = "0.1.0"
+version = "25.2.0"
 description = "Desktop App for hoppscotch.io"
 authors = ["CuriousCorrelation"]
 edition = "2021"
@@ -34,9 +34,8 @@ axum = "0.8.1"
 tower-http = { version = "0.6.2", features = ["cors"] }
 portpicker = "0.1.1"
 tokio = "1.43.0"
-
-[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
-tauri-plugin-updater = "2.3.1"
+tauri-plugin-process = "2.2.0"
 
 [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+tauri-plugin-updater = "2.3.1"
 tauri-plugin-window-state = "2.2.1"

+ 1 - 0
packages/hoppscotch-desktop/src-tauri/capabilities/default.json

@@ -14,6 +14,7 @@
     "shell:allow-open",
     "store:default",
     "dialog:default",
+    "process:default",
     "updater:default",
     "fs:allow-write-text-file",
     "deep-link:default",

+ 1 - 0
packages/hoppscotch-desktop/src-tauri/capabilities/desktop.json

@@ -6,6 +6,7 @@
     "linux"
   ],
   "permissions": [
+    "updater:default",
     "window-state:default"
   ]
 }

+ 14 - 7
packages/hoppscotch-desktop/src-tauri/src/lib.rs

@@ -6,6 +6,7 @@ use std::sync::OnceLock;
 use tauri::Emitter;
 use tauri_plugin_appload::VendorConfigBuilder;
 use tauri_plugin_deep_link::DeepLinkExt;
+use tauri_plugin_window_state::StateFlags;
 
 static SERVER_PORT: OnceLock<u16> = OnceLock::new();
 
@@ -19,16 +20,22 @@ pub fn run() {
     tracing::info!("Starting Hoppscotch Desktop v{}", env!("CARGO_PKG_VERSION"));
 
     tauri::Builder::default()
-        .plugin(tauri_plugin_window_state::Builder::new().build())
+        .plugin(
+            tauri_plugin_window_state::Builder::new()
+                .with_state_flags(
+                    StateFlags::SIZE
+                        | StateFlags::POSITION
+                        | StateFlags::MAXIMIZED
+                        | StateFlags::FULLSCREEN,
+                )
+                .with_denylist(&["main"])
+                .build(),
+        )
+        .plugin(tauri_plugin_process::init())
+        .plugin(tauri_plugin_updater::Builder::new().build())
         .plugin(tauri_plugin_store::Builder::new().build())
         .plugin(tauri_plugin_deep_link::init())
         .plugin(tauri_plugin_dialog::init())
-        .setup(|app| {
-            let _ = app
-                .handle()
-                .plugin(tauri_plugin_updater::Builder::new().build())?;
-            Ok(())
-        })
         .setup(|app| {
             let handle = app.handle().clone();
             tracing::info!(app_name = %app.package_info().name, "Configuring deep link handler");

+ 9 - 13
packages/hoppscotch-desktop/src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "Hoppscotch",
-  "version": "25.1.0-0",
+  "version": "25.2.0",
   "identifier": "io.hoppscotch.desktop",
   "build": {
     "beforeDevCommand": "pnpm dev",
@@ -12,14 +12,12 @@
   "app": {
     "windows": [
       {
-        "title": "hoppscotch-desktop",
-        "decorations": false,
+        "title": "main",
         "width": 500,
         "height": 600,
+        "decorations": false,
         "alwaysOnTop": true,
-        "resizable": false,
-        "visible": false,
-        "shadow": true
+        "resizable": false
       }
     ],
     "security": {
@@ -34,13 +32,6 @@
       }
     }
   },
-  "plugins": {
-    "deep-link": {
-      "desktop": {
-        "schemes": ["io.hoppscotch.desktop"]
-      }
-    }
-  },
   "bundle": {
     "active": true,
     "targets": "all",
@@ -54,6 +45,11 @@
     ]
   },
   "plugins": {
+    "deep-link": {
+      "desktop": {
+        "schemes": ["io.hoppscotch.desktop"]
+      }
+    },
     "updater": {
       "active": true,
       "endpoints": ["https://releases.hoppscotch.com/hoppscotch-selfhost-desktop.json"],

+ 1 - 0
packages/hoppscotch-desktop/src/components.d.ts

@@ -8,6 +8,7 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
+    HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
     HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
     LayoutHeader: typeof import('./components/layout/LayoutHeader.vue')['default']
     LayoutSidebar: typeof import('./components/layout/LayoutSidebar.vue')['default']

+ 28 - 0
packages/hoppscotch-desktop/src/types/index.ts

@@ -8,3 +8,31 @@ export interface RecentInstance {
 export interface StoreSchema {
   recentInstances: RecentInstance[]
 }
+
+export enum UpdateStatus {
+  IDLE = "idle",
+  CHECKING = "checking",
+  AVAILABLE = "available",
+  NOT_AVAILABLE = "not_available",
+  DOWNLOADING = "downloading",
+  INSTALLING = "installing",
+  READY_TO_RESTART = "ready_to_restart",
+  ERROR = "error"
+}
+
+export enum CheckResult {
+  AVAILABLE,
+  NOT_AVAILABLE,
+  TIMEOUT,
+  ERROR
+}
+
+export interface UpdateState {
+  status: UpdateStatus;
+  version?: string;
+  message?: string;
+  progress?: {
+    downloaded: number;
+    total?: number;
+  };
+}

+ 156 - 0
packages/hoppscotch-desktop/src/utils/updater.ts

@@ -0,0 +1,156 @@
+import { check, type DownloadEvent } from "@tauri-apps/plugin-updater";
+import { relaunch } from "@tauri-apps/plugin-process";
+import { type LazyStore } from "@tauri-apps/plugin-store";
+import { UpdateStatus, CheckResult, UpdateState } from "~/types";
+
+export class UpdaterService {
+  constructor(private store: LazyStore) {}
+
+  async initialize(): Promise<void> {
+    await this.saveUpdateState({
+      status: UpdateStatus.IDLE
+    });
+  }
+
+  async checkForUpdates(timeout = 2000): Promise<CheckResult> {
+    try {
+      await this.saveUpdateState({
+        status: UpdateStatus.CHECKING
+      });
+
+      // Create a timeout promise, this is just to make sure we don't keep checking for updates indefinitely
+      // NOTE: Also `checkUpdate` tends to hang indefinitely in dev mode, but works in build
+      const timeoutPromise = new Promise<null>((resolve) => {
+        setTimeout(() => {
+          console.log("Update check timeout reached, proceeding with app load");
+          resolve(null);
+        }, timeout);
+      });
+
+      const updateResult = await Promise.race([
+        check({ timeout }),
+        timeoutPromise
+      ]);
+
+      // If we got a timeout (null), we treat it as no update available
+      // NOTE: We could maybe show more info but for now this works fine
+      if (!updateResult) {
+        console.log("Update check timed out or no update available");
+        await this.saveUpdateState({
+          status: UpdateStatus.NOT_AVAILABLE
+        });
+        return CheckResult.TIMEOUT;
+      }
+
+      const hasUpdates = updateResult.available;
+
+      await this.saveUpdateState(
+        hasUpdates
+          ? {
+            status: UpdateStatus.AVAILABLE,
+            version: updateResult.version,
+            message: updateResult.body
+          }
+          : {
+            status: UpdateStatus.NOT_AVAILABLE
+          }
+      );
+
+      console.log("Update check result:", {
+        available: updateResult.available,
+        currentVersion: updateResult.currentVersion,
+        version: updateResult.version
+      });
+
+      return hasUpdates ? CheckResult.AVAILABLE : CheckResult.NOT_AVAILABLE;
+    } catch (error) {
+      console.error("Error checking for updates:", error);
+      await this.saveUpdateState({
+        status: UpdateStatus.ERROR,
+        message: String(error)
+      });
+      return CheckResult.ERROR;
+    }
+  }
+
+  async downloadAndInstall(): Promise<void> {
+    try {
+      const updateResult = await check();
+
+      if (!updateResult) {
+        throw new Error("No update available to install");
+      }
+
+      await this.saveUpdateState({
+        status: UpdateStatus.DOWNLOADING,
+        progress: {
+          downloaded: 0,
+          total: undefined
+        }
+      });
+
+      let totalBytes: number | undefined;
+      let downloadedBytes = 0;
+
+      await updateResult.downloadAndInstall(
+        (event: DownloadEvent) => {
+          if (event.event === 'Started') {
+            totalBytes = event.data.contentLength;
+            this.saveUpdateState({
+              status: UpdateStatus.DOWNLOADING,
+              progress: {
+                downloaded: 0,
+                total: totalBytes
+              }
+            });
+          } else if (event.event === 'Progress') {
+            downloadedBytes += event.data.chunkLength;
+            this.saveUpdateState({
+              status: UpdateStatus.DOWNLOADING,
+              progress: {
+                downloaded: downloadedBytes,
+                total: totalBytes
+              }
+            });
+          } else if (event.event === 'Finished') {
+            this.saveUpdateState({
+              status: UpdateStatus.INSTALLING
+            });
+          }
+        }
+      );
+
+      // If we reach here, it means the app hasn't restarted automatically
+      // Mark as ready to restart
+      await this.saveUpdateState({
+        status: UpdateStatus.READY_TO_RESTART
+      });
+
+    } catch (error) {
+      console.error("Error installing updates:", error);
+      await this.saveUpdateState({
+        status: UpdateStatus.ERROR,
+        message: String(error)
+      });
+      throw error;
+    }
+  }
+
+  async restartApp(): Promise<void> {
+    try {
+      await relaunch();
+    } catch (error) {
+      console.error("Failed to restart app:", error);
+      throw error;
+    }
+  }
+
+  private async saveUpdateState(state: UpdateState): Promise<void> {
+    try {
+      await this.store.set("updateState", state);
+      await this.store.save();
+    } catch (error) {
+      console.error("Failed to save update state:", error);
+    }
+  }
+}

Some files were not shown because too many files changed in this diff