feat(desktop): stablize updater ux and window state (#4798)
This commit is contained in:
parent
8bfa8c98f5
commit
fac3bec988
12 changed files with 462 additions and 212 deletions
|
|
@ -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",
|
||||
|
|
|
|||
13
packages/hoppscotch-desktop/src-tauri/Cargo.lock
generated
13
packages/hoppscotch-desktop/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"shell:allow-open",
|
||||
"store:default",
|
||||
"dialog:default",
|
||||
"process:default",
|
||||
"updater:default",
|
||||
"fs:allow-write-text-file",
|
||||
"deep-link:default",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"linux"
|
||||
],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"window-state:default"
|
||||
]
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
packages/hoppscotch-desktop/src/utils/updater.ts
Normal file
156
packages/hoppscotch-desktop/src/utils/updater.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,91 @@
|
|||
<template>
|
||||
<div class="flex flex-col items-center justify-center w-full h-screen bg-primary">
|
||||
<div class="flex flex-col items-center space-y-6">
|
||||
<div class="flex flex-col items-center space-y-6 max-w-md text-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<IconLucideCloud class="h-16 w-16 text-secondaryDark" />
|
||||
<div class="flex flex-col">
|
||||
<img src="/logo.svg" alt="Hoppscotch" class="h-7 w-7" />
|
||||
<div class="flex flex-col items-start">
|
||||
<h1 class="text-2xl font-semibold text-secondaryDark">Hoppscotch</h1>
|
||||
<p class="text-secondary text-sm">Desktop</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex flex-col items-center space-y-4">
|
||||
<div v-if="appState === AppState.LOADING" class="flex flex-col items-center space-y-4">
|
||||
<HoppSmartSpinner />
|
||||
<p class="text-secondaryDark">{{ statusMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex flex-col items-center space-y-4">
|
||||
<IconLucideAlertCircle class="h-10 w-10 text-red-500" />
|
||||
<p class="text-red-500">{{ error }}</p>
|
||||
<div
|
||||
v-else-if="appState === AppState.UPDATE_AVAILABLE || appState === AppState.UPDATE_IN_PROGRESS || appState === AppState.UPDATE_READY"
|
||||
class="flex flex-col items-center space-y-4"
|
||||
>
|
||||
<IconLucideDownload class="h-16 w-16 text-accent" />
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-semibold text-secondaryDark">Update Available</h2>
|
||||
<p class="text-secondary mt-1">{{ updateMessage || 'A new version of Hoppscotch is available, downloading...' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadProgress.total && downloadProgress.downloaded" class="w-full">
|
||||
<div class="w-full bg-primaryLight rounded-full h-2.5">
|
||||
<div
|
||||
class="bg-accent h-2.5 rounded-full"
|
||||
:style="{ width: `${(downloadProgress.downloaded / downloadProgress.total) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm text-secondaryLight mt-1">
|
||||
<span>{{ Math.round((downloadProgress.downloaded / downloadProgress.total) * 100) }}%</span>
|
||||
<span class="text-xs">
|
||||
{{ formatBytes(downloadProgress.downloaded) }} / {{ formatBytes(downloadProgress.total) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="downloadProgress.downloaded > 0" class="w-full">
|
||||
<div class="w-full bg-primaryLight rounded-full h-2.5">
|
||||
<div class="bg-accent h-2.5 rounded-full animate-pulse" style="width: 100%"></div>
|
||||
</div>
|
||||
<p class="text-sm text-secondaryLight text-center mt-1">
|
||||
Downloaded {{ formatBytes(downloadProgress.downloaded) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
v-if="appState === AppState.UPDATE_AVAILABLE"
|
||||
label="Install Update"
|
||||
:icon="IconLucideDownload"
|
||||
@click="installUpdate"
|
||||
/>
|
||||
<HoppButtonPrimary
|
||||
v-else-if="appState === AppState.UPDATE_READY"
|
||||
label="Restart Now"
|
||||
:icon="IconLucideRefreshCw"
|
||||
@click="restartApp"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="appState === AppState.UPDATE_AVAILABLE"
|
||||
label="Later"
|
||||
outline
|
||||
@click="skipUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="appState === AppState.ERROR" class="flex flex-col items-center space-y-4">
|
||||
<IconLucideAlertCircle class="h-16 w-16 text-red-500" />
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-semibold text-secondaryDark">Something went wrong</h2>
|
||||
<p class="text-red-500 mt-2">{{ error }}</p>
|
||||
</div>
|
||||
<HoppButtonPrimary
|
||||
label="Try Again"
|
||||
:icon="IconLucideRefreshCw"
|
||||
@click="loadVendored"
|
||||
@click="initialize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-secondaryLight text-xs mt-4">
|
||||
<p>Version {{ appVersion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -29,14 +94,25 @@
|
|||
import { ref, onMounted } from "vue";
|
||||
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||
import { load } from "@hoppscotch/plugin-appload";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
|
||||
import IconLucideCloud from "~icons/lucide/cloud"
|
||||
import IconLucideAlertCircle from "~icons/lucide/alert-circle"
|
||||
import IconLucideRefreshCw from "~icons/lucide/refresh-cw"
|
||||
import { UpdateStatus, CheckResult, UpdateState } from "~/types";
|
||||
import { UpdaterService } from "~/utils/updater";
|
||||
|
||||
const HOME_STORE_PATH = "hopp.store.json";
|
||||
const APP_STORE_PATH = "hoppscotch.hoppscotch.store";
|
||||
import IconLucideAlertCircle from "~icons/lucide/alert-circle";
|
||||
import IconLucideRefreshCw from "~icons/lucide/refresh-cw";
|
||||
import IconLucideDownload from "~icons/lucide/download";
|
||||
|
||||
const APP_STORE_PATH = "hoppscotch-desktop.store";
|
||||
|
||||
enum AppState {
|
||||
LOADING = "loading",
|
||||
UPDATE_AVAILABLE = "update_available",
|
||||
UPDATE_IN_PROGRESS = "update_in_progress",
|
||||
UPDATE_READY = "update_ready",
|
||||
ERROR = "error",
|
||||
LOADED = "loaded"
|
||||
}
|
||||
|
||||
interface VendoredInstance {
|
||||
type: "vendored";
|
||||
|
|
@ -51,188 +127,115 @@ interface ConnectionState {
|
|||
message?: string;
|
||||
}
|
||||
|
||||
interface StoredData {
|
||||
schemaVersion: number;
|
||||
metadata: {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
namespace: string;
|
||||
};
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface StoreData {
|
||||
data: {
|
||||
[namespace: string]: {
|
||||
[key: string]: StoredData;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const home_store = new LazyStore(HOME_STORE_PATH);
|
||||
const app_store = new LazyStore(APP_STORE_PATH);
|
||||
const isLoading = ref(true);
|
||||
const appStore = new LazyStore(APP_STORE_PATH);
|
||||
const appState = ref<AppState>(AppState.LOADING);
|
||||
const updateStatus = ref("");
|
||||
const updateMessage = ref("");
|
||||
const downloadProgress = ref<{ downloaded: number; total?: number }>({ downloaded: 0 });
|
||||
const error = ref("");
|
||||
const statusMessage = ref("Initializing...");
|
||||
const appVersion = ref("...");
|
||||
|
||||
const updaterService = new UpdaterService(appStore);
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const saveConnectionState = async (state: ConnectionState) => {
|
||||
try {
|
||||
await home_store.set("connectionState", state);
|
||||
await home_store.save();
|
||||
await appStore.set("connectionState", state);
|
||||
await appStore.save();
|
||||
} catch (err) {
|
||||
console.error("Failed to save connection state:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const migrateFromLocalStorage = async () => {
|
||||
const keyMappings = {
|
||||
settings: "settings",
|
||||
collections: "restCollections",
|
||||
collectionsGraphql: "gqlCollections",
|
||||
environments: "environments",
|
||||
history: "restHistory",
|
||||
graphqlHistory: "gqlHistory",
|
||||
WebsocketRequest: "websocket",
|
||||
SocketIORequest: "socketio",
|
||||
SSERequest: "sse",
|
||||
MQTTRequest: "mqtt",
|
||||
globalEnv: "globalEnv",
|
||||
restTabState: "restTabs",
|
||||
gqlTabState: "gqlTabs",
|
||||
secretEnvironments: "secretEnvironments"
|
||||
};
|
||||
const setupUpdateStateWatcher = async () => {
|
||||
const unsubscribe = await appStore.onKeyChange<UpdateState>("updateState", (newValue) => {
|
||||
if (!newValue) return;
|
||||
|
||||
console.log("Starting migration from localStorage to home_store...");
|
||||
let migratedCount = 0;
|
||||
updateStatus.value = newValue.status;
|
||||
updateMessage.value = newValue.message || "";
|
||||
|
||||
const storeData: StoreData = {
|
||||
data: {
|
||||
"persistence.v1": {}
|
||||
if (newValue.progress) {
|
||||
downloadProgress.value = newValue.progress;
|
||||
}
|
||||
};
|
||||
|
||||
console.log("Current localStorage keys:", Object.keys(localStorage));
|
||||
|
||||
for (const [oldKey, newKey] of Object.entries(keyMappings)) {
|
||||
const data = localStorage.getItem(oldKey);
|
||||
if (data) {
|
||||
try {
|
||||
console.log(`Migrating ${oldKey} to persistence.v1.${newKey}...`);
|
||||
let parsedData;
|
||||
|
||||
try {
|
||||
parsedData = JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${oldKey} data:`, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
const storedData: StoredData = {
|
||||
schemaVersion: 1,
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
namespace: "persistence.v1",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
data: parsedData
|
||||
};
|
||||
|
||||
storeData.data["persistence.v1"][newKey] = storedData;
|
||||
|
||||
localStorage.removeItem(oldKey);
|
||||
migratedCount++;
|
||||
console.log(`Successfully migrated ${oldKey}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to migrate ${oldKey}:`, err);
|
||||
}
|
||||
if (newValue.status === UpdateStatus.AVAILABLE) {
|
||||
appState.value = AppState.UPDATE_AVAILABLE;
|
||||
} else if (newValue.status === UpdateStatus.ERROR) {
|
||||
error.value = newValue.message || "Unknown error";
|
||||
appState.value = AppState.ERROR;
|
||||
} else if (newValue.status === UpdateStatus.DOWNLOADING || newValue.status === UpdateStatus.INSTALLING) {
|
||||
appState.value = AppState.UPDATE_IN_PROGRESS;
|
||||
} else if (newValue.status === UpdateStatus.READY_TO_RESTART) {
|
||||
appState.value = AppState.UPDATE_READY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await app_store.set('data', storeData.data);
|
||||
await app_store.save();
|
||||
console.log(`Migration complete. Migrated ${migratedCount} items.`);
|
||||
return unsubscribe;
|
||||
};
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: "completed" | "timeout";
|
||||
hasUpdates?: boolean;
|
||||
}
|
||||
const installUpdate = async () => {
|
||||
try {
|
||||
appState.value = AppState.UPDATE_IN_PROGRESS;
|
||||
await updaterService.downloadAndInstall();
|
||||
// In a rare occurance where we reach here but automatic restart didn't happen,
|
||||
// we'll just show a restart button instead
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
error.value = `Failed to install update: ${errorMessage}`;
|
||||
appState.value = AppState.ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
const skipUpdate = async () => {
|
||||
await loadVendored();
|
||||
};
|
||||
|
||||
const restartApp = async () => {
|
||||
try {
|
||||
await updaterService.restartApp();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
error.value = `Failed to restart app: ${errorMessage}`;
|
||||
appState.value = AppState.ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
const loadVendored = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
console.log("Initializing home_store and starting migration process");
|
||||
await home_store.init();
|
||||
await app_store.init();
|
||||
statusMessage.value = "Loading application...";
|
||||
|
||||
console.log(`Using home_store path: ${HOME_STORE_PATH}`);
|
||||
console.log("home_Store initialized successfully");
|
||||
const vendoredInstance: VendoredInstance = {
|
||||
type: "vendored",
|
||||
displayName: "Vendored",
|
||||
version: "vendored"
|
||||
};
|
||||
|
||||
try {
|
||||
await migrateFromLocalStorage();
|
||||
console.log("Migration completed successfully");
|
||||
} catch (migrationError) {
|
||||
console.error("Migration error:", migrationError);
|
||||
await saveConnectionState({
|
||||
status: "connected",
|
||||
instance: vendoredInstance
|
||||
});
|
||||
|
||||
console.log("Loading vendored app...");
|
||||
const loadResp = await load({
|
||||
bundleName: "Hoppscotch",
|
||||
window: { title: "Hoppscotch" }
|
||||
});
|
||||
|
||||
if (!loadResp.success) {
|
||||
throw new Error("Failed to load Hoppscotch Vendored");
|
||||
}
|
||||
|
||||
let shouldProceedWithLoad = true;
|
||||
|
||||
try {
|
||||
console.log("Checking for updates before loading app...");
|
||||
|
||||
const timeoutPromise: Promise<UpdateCheckResult> = new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log("Update check timeout reached, proceeding with app load");
|
||||
resolve({ status: "timeout" });
|
||||
}, 2000); // TODO: 2s shoud be good?
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
invoke('check_updates_available').then(hasUpdates => ({ status: "completed", hasUpdates })),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
console.log("Update check result:", result);
|
||||
|
||||
if (result.status === "completed" && result.hasUpdates) {
|
||||
console.log("Updates available, handling before loading app");
|
||||
shouldProceedWithLoad = false;
|
||||
|
||||
await invoke('install_updates_and_restart');
|
||||
// This point would only be reached if install_updates_and_restart
|
||||
// doesn't actually restart the app
|
||||
return;
|
||||
}
|
||||
} catch (updateError) {
|
||||
console.error("Update check error:", updateError);
|
||||
// Continue with loading the app despite update check errors
|
||||
}
|
||||
|
||||
if (shouldProceedWithLoad) {
|
||||
const vendoredInstance: VendoredInstance = {
|
||||
type: "vendored",
|
||||
displayName: "Vendored",
|
||||
version: "vendored"
|
||||
};
|
||||
|
||||
await saveConnectionState({
|
||||
status: "connected",
|
||||
instance: vendoredInstance
|
||||
});
|
||||
|
||||
console.log("Loading vendored app...");
|
||||
const loadResp = await load({
|
||||
bundleName: "Hoppscotch",
|
||||
window: { title: "Hoppscotch" }
|
||||
});
|
||||
|
||||
if (!loadResp.success) {
|
||||
throw new Error("Failed to load Hoppscotch Vendored");
|
||||
}
|
||||
|
||||
console.log("Vendored app loaded successfully");
|
||||
}
|
||||
console.log("Vendored app loaded successfully");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error loading vendored app:", errorMessage);
|
||||
|
|
@ -244,23 +247,48 @@ const loadVendored = async () => {
|
|||
message: errorMessage
|
||||
});
|
||||
|
||||
isLoading.value = false;
|
||||
appState.value = AppState.ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
appState.value = AppState.LOADING;
|
||||
error.value = "";
|
||||
downloadProgress.value = { downloaded: 0 };
|
||||
|
||||
try {
|
||||
try {
|
||||
appVersion.value = await getVersion();
|
||||
} catch (error) {
|
||||
console.error("Failed to get app version:", error);
|
||||
appVersion.value = "unknown";
|
||||
}
|
||||
|
||||
statusMessage.value = "Initializing stores...";
|
||||
await appStore.init();
|
||||
await updaterService.initialize();
|
||||
|
||||
await setupUpdateStateWatcher();
|
||||
|
||||
statusMessage.value = "Checking for updates...";
|
||||
const checkResult = await updaterService.checkForUpdates();
|
||||
|
||||
if (checkResult === CheckResult.AVAILABLE) {
|
||||
console.log("Updates available, prompting for install");
|
||||
appState.value = AppState.UPDATE_AVAILABLE;
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVendored();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error("Initialization error:", errorMessage);
|
||||
error.value = errorMessage;
|
||||
appState.value = AppState.ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadVendored();
|
||||
initialize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-spinner {
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -984,12 +984,18 @@ importers:
|
|||
'@tauri-apps/api':
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: 2.0.1
|
||||
version: 2.0.1
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: 2.5.1
|
||||
version: 2.5.1
|
||||
'@vueuse/core':
|
||||
specifier: 11.1.0
|
||||
version: 11.1.0(vue@3.5.12(typescript@5.7.2))
|
||||
|
|
@ -5247,6 +5253,9 @@ packages:
|
|||
'@tauri-apps/plugin-fs@2.0.2':
|
||||
resolution: {integrity: sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.2.0':
|
||||
resolution: {integrity: sha512-uypN2Crmyop9z+KRJr3zl71OyVFgTuvHFjsJ0UxxQ/J5212jVa5w4nPEYjIewcn8bUEXacRebwE6F7owgrbhSw==}
|
||||
|
||||
'@tauri-apps/plugin-shell@2.0.0':
|
||||
resolution: {integrity: sha512-OpW2+ycgJLrEoZityWeWYk+6ZWP9VyiAfbO+N/O8VfLkqyOym8kXh7odKDfINx9RAotkSGBtQM4abyKfJDkcUg==}
|
||||
|
||||
|
|
@ -5256,6 +5265,9 @@ packages:
|
|||
'@tauri-apps/plugin-store@2.2.0':
|
||||
resolution: {integrity: sha512-hJTRtuJis4w5fW1dkcgftsYxKXK0+DbAqurZ3CURHG5WkAyyZgbxpeYctw12bbzF9ZbZREXZklPq8mocCC3Sgg==}
|
||||
|
||||
'@tauri-apps/plugin-updater@2.5.1':
|
||||
resolution: {integrity: sha512-7fNJraKRbKkxguMY5lG2W20pBvAUkLu+cqnbu0UcK7DqeZgrAnNECcGBIDG6fJ6C+0fAp2V2dMIgznhffOBCcg==}
|
||||
|
||||
'@trysound/sax@0.2.0':
|
||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
|
@ -17430,6 +17442,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-process@2.2.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-shell@2.0.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
|
|
@ -17442,6 +17458,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@tauri-apps/plugin-updater@2.5.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.1.1
|
||||
|
||||
'@trysound/sax@0.2.0':
|
||||
optional: true
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue