diff --git a/devenv.lock b/devenv.lock
index b22ef4ed..1307a977 100644
--- a/devenv.lock
+++ b/devenv.lock
@@ -3,10 +3,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
- "lastModified": 1738772960,
+ "lastModified": 1761922975,
"owner": "cachix",
"repo": "devenv",
- "rev": "7f756cdf3fbb01cab243dcec4de0ca94e6aaa2af",
+ "rev": "c9f0b47815a4895fadac87812de8a4de27e0ace1",
"type": "github"
},
"original": {
@@ -24,10 +24,10 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
- "lastModified": 1738737274,
+ "lastModified": 1762238689,
"owner": "nix-community",
"repo": "fenix",
- "rev": "f82de9980822f3b1efcf54944939b1d514386827",
+ "rev": "0f94d1e67ea9ef983a9b5caf9c14bc52ae2eac44",
"type": "github"
},
"original": {
@@ -39,10 +39,10 @@
"flake-compat": {
"flake": false,
"locked": {
- "lastModified": 1733328505,
+ "lastModified": 1761588595,
"owner": "edolstra",
"repo": "flake-compat",
- "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
+ "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
@@ -60,10 +60,10 @@
]
},
"locked": {
- "lastModified": 1737465171,
+ "lastModified": 1760663237,
"owner": "cachix",
"repo": "git-hooks.nix",
- "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
+ "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github"
},
"original": {
@@ -94,10 +94,10 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1738734093,
+ "lastModified": 1762156382,
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "5b2753b0356d1c951d7a3ef1d086ba5a71fff43c",
+ "rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
"type": "github"
},
"original": {
@@ -115,16 +115,17 @@
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
- ]
+ ],
+ "rust-overlay": "rust-overlay"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
- "lastModified": 1738754241,
+ "lastModified": 1762201112,
"owner": "rust-lang",
"repo": "rust-analyzer",
- "rev": "ca47cddc31ae76a05e8709ed4aec805c5ef741d3",
+ "rev": "132d3338f4526b5c71046e5dc7ddf800e279daf4",
"type": "github"
},
"original": {
@@ -133,6 +134,25 @@
"repo": "rust-analyzer",
"type": "github"
}
+ },
+ "rust-overlay": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1762223900,
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "rev": "cfe1598d69a42a5edb204770e71b8df77efef2c3",
+ "type": "github"
+ },
+ "original": {
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "type": "github"
+ }
}
},
"root": "root",
diff --git a/devenv.nix b/devenv.nix
index 5b0b64ba..f9ff690b 100644
--- a/devenv.nix
+++ b/devenv.nix
@@ -7,12 +7,7 @@ let
else pkgs;
darwinPackages = with pkgs; [
- darwin.apple_sdk.frameworks.Security
- darwin.apple_sdk.frameworks.CoreServices
- darwin.apple_sdk.frameworks.CoreFoundation
- darwin.apple_sdk.frameworks.Foundation
- darwin.apple_sdk.frameworks.AppKit
- darwin.apple_sdk.frameworks.WebKit
+ apple-sdk
];
linuxPackages = with pkgs; [
@@ -172,6 +167,10 @@ in {
npm.enable = true;
pnpm.enable = true;
};
+ go = {
+ enable = true;
+ package = pkgs.go_1_24;
+ };
rust = {
enable = true;
channel = "nightly";
diff --git a/devenv.yaml b/devenv.yaml
index 9ee9ba34..d0169201 100644
--- a/devenv.yaml
+++ b/devenv.yaml
@@ -1,23 +1,14 @@
-# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
- # For NodeJS-22 and above
- nixpkgs:
- url: github:NixOS/nixpkgs/nixpkgs-unstable
- # nixpkgs:
- # url: github:cachix/devenv-nixpkgs/rolling
fenix:
url: github:nix-community/fenix
inputs:
nixpkgs:
follows: nixpkgs
-
-# If you're using non-OSS software, you can set allowUnfree to true.
+ nixpkgs:
+ url: github:NixOS/nixpkgs/nixpkgs-unstable
+ rust-overlay:
+ url: github:oxalica/rust-overlay
+ inputs:
+ nixpkgs:
+ follows: nixpkgs
allowUnfree: true
-
-# If you're willing to use a package that's vulnerable
-# permittedInsecurePackages:
-# - "openssl-1.1.1w"
-
-# If you have more than one devenv you can merge them
-#imports:
-# - ./backend
diff --git a/packages/hoppscotch-common/.eslintrc.js b/packages/hoppscotch-common/.eslintrc.js
index f69f0f80..63fdd568 100644
--- a/packages/hoppscotch-common/.eslintrc.js
+++ b/packages/hoppscotch-common/.eslintrc.js
@@ -47,8 +47,14 @@ module.exports = {
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
- "@typescript-eslint/no-unused-vars":
+ "@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
+ {
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ caughtErrorsIgnorePattern: "^_",
+ },
+ ],
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"import/default": "off",
diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json
index 22a6bf95..c14f3b4d 100644
--- a/packages/hoppscotch-common/locales/en.json
+++ b/packages/hoppscotch-common/locales/en.json
@@ -877,7 +877,28 @@
"add_new": "Add a new instance",
"confirm_remove": "Confirm Removal",
"remove_warning": "Are you sure you want to remove this instance?",
- "clear_cached_bundles": "Clear cached bundles"
+ "clear_cached_bundles": "Clear cached bundles",
+ "opening_add_modal": "Opening add instance dialog",
+ "closed_add_modal": "Add instance dialog closed",
+ "cancelled_removal": "Instance removal cancelled",
+ "connection_cancelled": "Connection cancelled by pre-connect validation",
+ "post_connect_completed": "Post-connection setup completed",
+ "connecting": "Connecting to instance...",
+ "confirm_removal": "Confirm removal of instance",
+ "removal_cancelled": "Instance removal cancelled by pre-removal validation",
+ "post_remove_completed": "Post-removal cleanup completed",
+ "removing": "Removing instance...",
+ "clearing_cache": "Clearing cache...",
+ "initialized": "Instance switcher initialized",
+ "connecting_state": "Establishing connection...",
+ "connected_state": "Successfully connected to instance",
+ "disconnected_state": "Disconnected from instance",
+ "stream_error": "Connection state monitoring failed",
+ "recent_instances_error": "Failed to load recent instances",
+ "instance_changed": "Switched to instance",
+ "current_instance_error": "Failed to track current instance",
+ "not_available": "Instance switching is not available",
+ "cleanup_completed": "Instance switcher cleanup completed"
},
"inspections": {
"description": "Inspect possible errors",
diff --git a/packages/hoppscotch-common/src/components/app/Header.vue b/packages/hoppscotch-common/src/components/app/Header.vue
index 11ac0b69..a1766f20 100644
--- a/packages/hoppscotch-common/src/components/app/Header.vue
+++ b/packages/hoppscotch-common/src/components/app/Header.vue
@@ -15,30 +15,21 @@
>
-
-
- {{ instanceDisplayName }}
-
-
- {{ platform.instance.displayConfig.description }}
-
-
+
+ {{
+ platform.instance.getCurrentInstance?.()?.displayName ||
+ "Hoppscotch"
+ }}
+
@@ -378,7 +369,6 @@ import {
BannerService,
} from "~/services/banner.service"
import { WorkspaceService } from "~/services/workspace.service"
-import { InstanceSwitcherService } from "~/services/instance-switcher.service"
import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings"
@@ -393,33 +383,14 @@ import { AdditionalLinksService } from "~/services/additionalLinks.service"
const t = useI18n()
const toast = useToast()
const kernelMode = getKernelMode()
-const instanceSwitcherService =
- kernelMode === "desktop" ? useService(InstanceSwitcherService) : null
-const instanceSwitcherRef =
- kernelMode === "desktop" ? ref(null) : ref(null)
+
const downloadableLinksRef =
kernelMode === "web" ? ref(null) : ref(null)
+const instanceSwitcherRef =
+ kernelMode === "desktop" ? ref(null) : ref(null)
const isUserAdmin = ref(false)
-const currentState =
- kernelMode === "desktop" && instanceSwitcherService
- ? useReadonlyStream(
- instanceSwitcherService.getStateStream(),
- instanceSwitcherService.getCurrentState().value
- )
- : ref({
- status: "disconnected",
- instance: { displayName: "Hoppscotch" },
- })
-
-const instanceDisplayName = computed(() => {
- if (currentState.value.status !== "connected") {
- return "Hoppscotch"
- }
- return currentState.value.instance.displayName
-})
-
/**
* Feature flag to enable the workspace selector login conversion
*/
diff --git a/packages/hoppscotch-common/src/components/instance/Switcher.vue b/packages/hoppscotch-common/src/components/instance/Switcher.vue
index e634fcbd..6cf78913 100644
--- a/packages/hoppscotch-common/src/components/instance/Switcher.vue
+++ b/packages/hoppscotch-common/src/components/instance/Switcher.vue
@@ -1,70 +1,54 @@
-
+
+
+
+
+
-
+
{{
- platform.instance.displayConfig.displayName
+ connectedInstance.displayName
}}
-
- Default
- app
+ {{ connectedInstance.kind }}
+
+ v{{ connectedInstance.version }}
+
-
+
-
+
{{ getHostname(instance.displayName) }}
+ {{ instance.displayName }}
+
- On-prem
-
+ {{ instance.kind }}
+
v{{ instance.version }}
@@ -87,26 +70,29 @@
+
{
- showAddModal = true
- $emit('close-dropdown')
- }
- "
+ @click="openAddModal"
/>
-
+
@@ -218,10 +196,7 @@
t("instances.remove_warning") ||
"Are you sure you want to remove this instance?"
}}
-
-
- {{ confirmedRemoveDisplayName }}
-
+ {{ instanceToRemove?.displayName }}
@@ -230,30 +205,40 @@
:label="t('action.cancel') || 'Cancel'"
outline
filled
- @click="showRemoveModal = false"
+ @click="closeRemoveModal"
/>
+
+
+
+ Instance switching not available
+
diff --git a/packages/hoppscotch-common/src/kernel/store.ts b/packages/hoppscotch-common/src/kernel/store.ts
index 25971db2..4c709543 100644
--- a/packages/hoppscotch-common/src/kernel/store.ts
+++ b/packages/hoppscotch-common/src/kernel/store.ts
@@ -6,9 +6,41 @@ import type {
} from "@hoppscotch/kernel"
import * as E from "fp-ts/Either"
import { getModule } from "."
+import { invoke } from "@tauri-apps/api/core"
+import { join } from "@tauri-apps/api/path"
const STORE_PATH = `${window.location.host}.hoppscotch.store`
+export const getConfigDir = async (): Promise => {
+ return await invoke("get_config_dir")
+}
+
+export const getBackupDir = async (): Promise => {
+ return await invoke("get_backup_dir")
+}
+
+export const getLatestDir = async (): Promise => {
+ return await invoke("get_latest_dir")
+}
+
+export const getStoreDir = async (): Promise => {
+ return await invoke("get_store_dir")
+}
+
+export const getInstanceDir = async (): Promise => {
+ return await invoke("get_instance_dir")
+}
+
+const getStorePath = async (): Promise => {
+ try {
+ const storeDir = await getStoreDir()
+ return join(storeDir, STORE_PATH)
+ } catch (error) {
+ console.error("Failed to get store directory:", error)
+ return "hoppscotch-unified.store"
+ }
+}
+
export const Store = (() => {
const module = () => getModule("store")
@@ -16,7 +48,8 @@ export const Store = (() => {
capabilities: () => module().capabilities,
init: async () => {
- return module().init(STORE_PATH)
+ const storePath = await getStorePath()
+ return module().init(storePath)
},
set: async (
@@ -25,49 +58,57 @@ export const Store = (() => {
value: unknown,
options?: StorageOptions
): Promise> => {
- return module().set(STORE_PATH, namespace, key, value, options)
+ const storePath = await getStorePath()
+ return module().set(storePath, namespace, key, value, options)
},
get: async (
namespace: string,
key: string
): Promise> => {
- return module().get(STORE_PATH, namespace, key)
+ const storePath = await getStorePath()
+ return module().get(storePath, namespace, key)
},
remove: async (
namespace: string,
key: string
): Promise> => {
- return module().remove(STORE_PATH, namespace, key)
+ const storePath = await getStorePath()
+ return module().remove(storePath, namespace, key)
},
clear: async (namespace?: string): Promise> => {
- return module().clear(STORE_PATH, namespace)
+ const storePath = await getStorePath()
+ return module().clear(storePath, namespace)
},
has: async (
namespace: string,
key: string
): Promise> => {
- return module().has(STORE_PATH, namespace, key)
+ const storePath = await getStorePath()
+ return module().has(storePath, namespace, key)
},
listNamespaces: async (): Promise> => {
- return module().listNamespaces(STORE_PATH)
+ const storePath = await getStorePath()
+ return module().listNamespaces(storePath)
},
listKeys: async (
namespace: string
): Promise> => {
- return module().listKeys(STORE_PATH, namespace)
+ const storePath = await getStorePath()
+ return module().listKeys(storePath, namespace)
},
watch: async (
namespace: string,
key: string
): Promise> => {
- return module().watch(STORE_PATH, namespace, key)
+ const storePath = await getStorePath()
+ return module().watch(storePath, namespace, key)
},
} as const
})()
diff --git a/packages/hoppscotch-common/src/platform/index.ts b/packages/hoppscotch-common/src/platform/index.ts
index cec460c4..bd721c90 100644
--- a/packages/hoppscotch-common/src/platform/index.ts
+++ b/packages/hoppscotch-common/src/platform/index.ts
@@ -42,6 +42,7 @@ export type PlatformDef = {
// NOTE: To be deprecated
// interceptors: InterceptorsPlatformDef
kernelInterceptors: KernelInterceptorsPlatformDef
+ instance?: InstancePlatformDef
additionalInspectors?: InspectorsPlatformDef
spotlight?: SpotlightPlatformDef
platformFeatureFlags: {
diff --git a/packages/hoppscotch-common/src/platform/instance.ts b/packages/hoppscotch-common/src/platform/instance.ts
index 98e8fbbe..f5cfc763 100644
--- a/packages/hoppscotch-common/src/platform/instance.ts
+++ b/packages/hoppscotch-common/src/platform/instance.ts
@@ -1,10 +1,222 @@
+import { Observable } from "rxjs"
+import { Component } from "vue"
+
+import { type LoadOptions } from "@hoppscotch/plugin-appload"
+
+export type InstanceKind = "on-prem" | "cloud" | "cloud-org" | "vendored"
+
+export type Instance = {
+ kind: InstanceKind
+ serverUrl: string
+ displayName: string
+ version: string
+ lastUsed: string
+ bundleName?: string
+}
+
+export const VENDORED_INSTANCE_CONFIG: Instance = {
+ kind: "vendored" as const,
+ serverUrl: "app://hoppscotch",
+ displayName: "Hoppscotch Desktop",
+ version: "25.9.0",
+ lastUsed: new Date().toISOString(),
+ bundleName: "Hoppscotch",
+}
+
+export type ConnectionState =
+ | { status: "idle" }
+ | { status: "connecting"; target: string }
+ | { status: "connected"; instance: Instance }
+ | { status: "error"; target: string; message: string }
+
+export type OperationResult = {
+ success: boolean
+ message: string
+ data?: any
+}
+
export type InstancePlatformDef = {
- instanceType: "vendored"
- displayConfig: {
- displayName: string
- description: string
- version: string
- connectingMessage: string
- connectedMessage: string
+ /**
+ * Whether instance switching is enabled for this platform
+ */
+ instanceSwitchingEnabled: boolean
+
+ /**
+ * Custom instance switcher component to replace the default UI
+ */
+ customInstanceSwitcherComponent?: Component
+
+ /**
+ * Returns an observable stream of the current connection state
+ */
+ getConnectionStateStream?: () => Observable
+
+ /**
+ * Returns an observable stream of recent instances
+ */
+ getRecentInstancesStream?: () => Observable
+
+ /**
+ * Returns an observable stream of the current active instance
+ */
+ getCurrentInstanceStream?: () => Observable
+
+ /**
+ * Gets the current connection state synchronously
+ */
+ getCurrentConnectionState?: () => ConnectionState
+
+ /**
+ * Gets the list of recent instances synchronously
+ */
+ getRecentInstances?: () => Instance[]
+
+ /**
+ * Gets the current active instance synchronously
+ */
+ getCurrentInstance?: () => Instance | null
+
+ /**
+ * Connects to an instance with the given options
+ */
+ connectToInstance?: (
+ serverUrl: string,
+ instanceKind: InstanceKind,
+ displayName?: string,
+ options?: Partial
+ ) => Promise
+
+ /**
+ * Downloads an instance bundle without connecting
+ */
+ downloadInstance?: (
+ serverUrl: string,
+ instanceKind: InstanceKind,
+ displayName?: string
+ ) => Promise
+
+ /**
+ * Loads a previously downloaded instance
+ */
+ loadInstance?: (
+ instance: Instance,
+ options?: Partial
+ ) => Promise
+
+ /**
+ * Removes an instance and its associated data
+ */
+ removeInstance?: (instance: Instance) => Promise
+
+ /**
+ * Disconnects from the current instance
+ */
+ disconnect?: () => Promise
+
+ /**
+ * Clears all cached instances and data
+ */
+ clearCache?: () => Promise
+
+ /**
+ * Validates and normalizes a server URL
+ */
+ normalizeUrl?: (url: string) => string | null
+
+ /**
+ * Hook called before connecting to an instance
+ * Return false to prevent the connection
+ */
+ beforeConnect?: (
+ serverUrl: string,
+ instanceKind: InstanceKind,
+ displayName?: string
+ ) => Promise
+
+ /**
+ * Hook called after successful connection
+ */
+ afterConnect?: (instance: Instance) => Promise
+
+ /**
+ * Hook called before disconnecting from an instance
+ * Return false to prevent the disconnection
+ */
+ beforeDisconnect?: (instance: Instance) => Promise
+
+ /**
+ * Hook called after successful disconnection
+ */
+ afterDisconnect?: () => Promise
+
+ /**
+ * Hook called before removing an instance
+ * Return false to prevent the removal
+ */
+ beforeRemove?: (instance: Instance) => Promise
+
+ /**
+ * Hook called after successful instance removal
+ */
+ afterRemove?: (instance: Instance) => Promise
+
+ /**
+ * Custom error handling for connection failures
+ */
+ onConnectionError?: (error: string, target: string) => void
+
+ /**
+ * Custom error handling for download failures
+ */
+ onDownloadError?: (error: string, serverUrl: string) => void
+
+ /**
+ * Custom error handling for load failures
+ */
+ onLoadError?: (error: string, instance: Instance) => void
+
+ /**
+ * Custom error handling for removal failures
+ */
+ onRemoveError?: (error: string, instance: Instance) => void
+
+ /**
+ * Validates if a server URL is reachable and compatible
+ */
+ validateServerUrl?: (url: string) => Promise<{
+ valid: boolean
+ version?: string
+ instanceKind?: InstanceKind
+ error?: string
+ }>
+
+ /**
+ * Configuration options for instance management
+ */
+ config?: {
+ /**
+ * Maximum number of recent instances to keep
+ */
+ maxRecentInstances?: number
+
+ /**
+ * Default window options for loaded instances
+ */
+ defaultWindowOptions?: LoadOptions["window"]
+
+ /**
+ * Whether to automatically reconnect to the last instance on startup
+ */
+ autoReconnect?: boolean
+
+ /**
+ * Timeout for connection attempts in milliseconds
+ */
+ connectionTimeout?: number
+
+ /**
+ * Whether to show confirmation dialogs for destructive operations
+ */
+ confirmDestructiveOperations?: boolean
}
}
diff --git a/packages/hoppscotch-common/src/services/instance-switcher.service.ts b/packages/hoppscotch-common/src/services/instance-switcher.service.ts
deleted file mode 100644
index 3bab0e5f..00000000
--- a/packages/hoppscotch-common/src/services/instance-switcher.service.ts
+++ /dev/null
@@ -1,508 +0,0 @@
-import { Service } from "dioc"
-import { BehaviorSubject, Observable } from "rxjs"
-import { computed } from "vue"
-import { LazyStore } from "@tauri-apps/plugin-store"
-import {
- getCurrentWebviewWindow,
- getAllWebviewWindows,
-} from "@tauri-apps/api/webviewWindow"
-import {
- download,
- load,
- clear,
- remove,
- close,
-} from "@hoppscotch/plugin-appload"
-import { useToast } from "~/composables/toast"
-import { platform } from "~/platform"
-
-const STORE_PATH = "hopp.store.json"
-const MAX_RECENT_INSTANCES = 10
-
-type ServerInstance = {
- type: "server"
- serverUrl: string
- displayName: string
- version: string
- lastUsed: string
- bundleName?: string
-}
-
-type VendoredInstance = {
- type: "vendored"
- displayName: string
- version: string
-}
-
-export type InstanceType = ServerInstance | VendoredInstance
-
-export type ConnectionState =
- | { status: "idle" }
- | { status: "connecting"; target: string }
- | { status: "connected"; instance: InstanceType }
- | { status: "error"; target: string; message: string }
-
-export class InstanceSwitcherService extends Service {
- public static readonly ID = "INSTANCE_SWITCHER_SERVICE"
-
- private state$ = new BehaviorSubject({ status: "idle" })
- private recentInstances$ = new BehaviorSubject([])
- private store!: LazyStore
- private toast = useToast()
-
- public getVendoredInstance(): VendoredInstance {
- const { instanceType, displayConfig } = platform.instance
- const { displayName, version } = displayConfig
-
- return {
- type: instanceType,
- displayName,
- version,
- }
- }
-
- override async onServiceInit(): Promise {
- this.store = new LazyStore(STORE_PATH)
- await this.store.init()
- await this.loadRecentInstances()
-
- if (this.inVendoredEnvironment()) {
- this.state$.next({
- status: "connected",
- instance: this.getVendoredInstance(),
- })
- this.emit(this.state$.value)
- } else {
- await this.loadSavedState()
- }
- }
-
- private inVendoredEnvironment(): boolean {
- try {
- return (
- window.location.hostname === "hoppscotch" &&
- window.location.protocol === "app:"
- )
- } catch {
- return false
- }
- }
-
- public getStateStream(): Observable {
- return this.state$
- }
-
- public getRecentInstancesStream(): Observable {
- return this.recentInstances$
- }
-
- public getCurrentState() {
- return computed(() => this.state$.value)
- }
-
- public getCurrentInstance() {
- return computed(() => {
- const state = this.state$.value
- return state.status === "connected" ? state.instance : null
- })
- }
-
- public getRecentInstances() {
- return computed(() => this.recentInstances$.value)
- }
-
- public isConnecting() {
- return computed(() => this.state$.value.status === "connecting")
- }
-
- public getConnectionError() {
- return computed(() => {
- const state = this.state$.value
- return state.status === "error" ? state.message : null
- })
- }
-
- public async connectToVendoredInstance(): Promise {
- if (this.isCurrentlyVendored()) {
- return true
- }
-
- this.state$.next({
- status: "connecting",
- target: this.getVendoredInstance().displayName,
- })
- this.emit(this.state$.value)
-
- try {
- this.state$.next({
- status: "connected",
- instance: this.getVendoredInstance(),
- })
- this.emit(this.state$.value)
-
- await this.saveCurrentState()
-
- this.toast.success(
- platform.instance.displayConfig.connectingMessage.replace(
- "{instanceName}",
- this.getVendoredInstance().displayName
- )
- )
-
- const loadResponse = await load({
- bundleName: "Hoppscotch",
- window: { title: "Hoppscotch" },
- })
-
- if (!loadResponse.success) {
- throw new Error(
- `Failed to load ${this.getVendoredInstance().type} bundle`
- )
- }
-
- this.toast.success(
- platform.instance.displayConfig.connectedMessage.replace(
- "{instanceName}",
- this.getVendoredInstance().displayName
- )
- )
-
- // Close current window AFTER successful load
- // NOTE: No need to await it.
- this.closeCurrentWindow()
-
- return true
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : String(error)
- this.state$.next({
- status: "error",
- target: this.getVendoredInstance().displayName,
- message: errorMessage,
- })
- this.emit(this.state$.value)
-
- this.toast.error(`Failed to connect: ${errorMessage}`)
- return false
- }
- }
-
- public async connectToServerInstance(serverUrl: string): Promise {
- if (this.isCurrentlyConnectedTo(serverUrl)) {
- const currentState = this.state$.value
- if (
- currentState.status === "connected" &&
- currentState.instance.type === "server"
- ) {
- const updatedInstance: ServerInstance = {
- ...currentState.instance,
- lastUsed: new Date().toISOString(),
- }
-
- await this.updateRecentInstance(updatedInstance)
- }
- return true
- }
-
- const normalizedUrl = this.normalizeUrl(serverUrl)
- const displayName = this.getDisplayNameFromUrl(normalizedUrl)
-
- this.state$.next({
- status: "connecting",
- target: displayName,
- })
- this.emit(this.state$.value)
-
- try {
- const downloadResponse = await download({ serverUrl: normalizedUrl })
- if (!downloadResponse.success) {
- throw new Error("Failed to download bundle")
- }
-
- const instance: ServerInstance = {
- type: "server",
- serverUrl: normalizedUrl,
- displayName,
- version: downloadResponse.version,
- lastUsed: new Date().toISOString(),
- bundleName: downloadResponse.bundleName,
- }
-
- await this.updateRecentInstance(instance)
-
- this.state$.next({
- status: "connected",
- instance,
- })
- this.emit(this.state$.value)
-
- await this.saveCurrentState()
-
- this.toast.success(`Connecting to ${displayName}`)
-
- const loadResponse = await load({
- bundleName: downloadResponse.bundleName,
- window: { title: "Hoppscotch" },
- })
-
- if (!loadResponse.success) {
- throw new Error("Failed to load bundle")
- }
-
- // Close current window AFTER successful load
- // NOTE: No need to await it.
- this.closeCurrentWindow()
-
- this.toast.success(`Connected to ${displayName}`)
- return true
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : String(error)
- this.state$.next({
- status: "error",
- target: displayName,
- message: errorMessage,
- })
- this.emit(this.state$.value)
-
- this.toast.error(`Connection failed: ${errorMessage}`)
- return false
- }
- }
-
- /**
- * Closes the current window using Tauri's window management
- */
- private async closeCurrentWindow(): Promise {
- try {
- const currentWindow = getCurrentWebviewWindow()
- const currentLabel = currentWindow.label
-
- // Don't close if we're the main window or if there are no other windows
- if (currentLabel === "main") {
- const allWindows = await getAllWebviewWindows()
- if (allWindows.length <= 1) {
- // Don't close the last window
- return
- }
- }
-
- const closeResponse = await close({ windowLabel: currentLabel })
- if (!closeResponse.success) {
- console.warn(`Failed to close window ${currentLabel}`)
- }
- } catch (error) {
- console.warn("Failed to close current window:", error)
- // Don't throw - window closing shouldn't block the operation
- }
- }
-
- public async removeInstance(serverUrl: string): Promise {
- try {
- const normalizedUrl = this.normalizeUrl(serverUrl)
-
- const instanceToRemove = this.recentInstances$.value.find(
- (instance) => instance.serverUrl === normalizedUrl
- )
-
- if (!instanceToRemove) {
- return false
- }
-
- if (instanceToRemove.bundleName) {
- try {
- await remove({
- bundleName: instanceToRemove.bundleName,
- serverUrl: normalizedUrl,
- })
- } catch (error) {
- console.error("Failed to remove bundle from storage:", error)
- // Continue with instance removal even if bundle removal fails
- }
- }
-
- const instances = this.recentInstances$.value.filter(
- (instance) => instance.serverUrl !== normalizedUrl
- )
-
- this.recentInstances$.next(instances)
- await this.saveRecentInstances()
-
- const displayName = this.getDisplayNameFromUrl(serverUrl)
- this.toast.success(`Removed ${displayName}`)
-
- // If we're currently connected to this instance, close the window and go to idle state
- if (this.isCurrentlyConnectedTo(serverUrl)) {
- await this.closeCurrentWindow()
- this.state$.next({ status: "idle" })
- this.emit(this.state$.value)
- await this.saveCurrentState()
- }
-
- return true
- } catch (error) {
- this.toast.error("Failed to remove instance")
- return false
- }
- }
-
- public async clearCache(): Promise {
- try {
- await this.closeCurrentWindow()
- await clear()
- this.toast.success("Cache cleared successfully")
- return true
- } catch (error) {
- this.toast.error("Failed to clear cache")
- return false
- }
- }
-
- public getCurrentInstanceDisplayName(): string {
- const state = this.state$.value
- if (state.status !== "connected") {
- return "Hoppscotch"
- }
- return state.instance.displayName
- }
-
- public getDisplayNameFromUrl(url: string): string {
- try {
- const urlObj = new URL(url)
- // We don't want entire hostname, only the specific org
- const hostnameParts = urlObj.hostname.split(".")
- const mainDomain = hostnameParts.slice(-2).join(".")
-
- if (mainDomain === "hoppscotch") {
- return "Hoppscotch"
- }
-
- return mainDomain || urlObj.hostname.replace(/^www\./, "")
- } catch {
- return url
- }
- }
-
- public isCurrentlyVendored(): boolean {
- const state = this.state$.value
- return (
- state.status === "connected" &&
- state.instance.type === this.getVendoredInstance().type
- )
- }
-
- public isCurrentlyConnectedTo(serverUrl: string): boolean {
- const state = this.state$.value
- if (state.status !== "connected") return false
-
- const instance = state.instance
- if (instance.type !== "server") return false
-
- return instance.serverUrl === this.normalizeUrl(serverUrl)
- }
-
- public normalizeUrl(url: string): string {
- try {
- const withProtocol = url.startsWith("http") ? url : `http://${url}`
- const urlObj = new URL(withProtocol)
-
- const pathSegments = urlObj.pathname.split("/").filter(Boolean)
-
- if (!pathSegments.includes("desktop-app-server")) {
- // We try `desktop-app-server` subpath first
- const withSubpath = new URL(withProtocol)
- withSubpath.pathname = `/desktop-app-server${urlObj.pathname}`
-
- // If it fails, fall back to the original URL with default port
- try {
- return withSubpath.toString().replace(/\/$/, "")
- } catch {
- if (!urlObj.port) {
- urlObj.port = "3200"
- }
- return urlObj.toString().replace(/\/$/, "")
- }
- }
-
- return urlObj.toString().replace(/\/$/, "")
- } catch (error) {
- return url
- }
- }
-
- private async loadSavedState(): Promise {
- try {
- const savedState =
- await this.store.get("connectionState")
-
- if (savedState && savedState.status === "connected") {
- if (savedState.instance.type === "server") {
- this.state$.next(savedState)
- this.emit(this.state$.value)
- }
- }
- } catch (error) {
- console.error("Failed to load saved state:", error)
- this.state$.next({ status: "idle" })
- }
- }
-
- private async saveCurrentState(): Promise {
- try {
- await this.store.set("connectionState", this.state$.value)
- await this.store.save()
- } catch (error) {
- console.error("Failed to save current state:", error)
- }
- }
-
- private async loadRecentInstances(): Promise {
- try {
- const instances =
- (await this.store.get("recentInstances")) || []
-
- const sortedInstances = instances.sort(
- (a, b) =>
- new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
- )
-
- this.recentInstances$.next(sortedInstances)
- } catch (error) {
- console.error("Failed to load recent instances:", error)
- this.recentInstances$.next([])
- }
- }
-
- private async saveRecentInstances(): Promise {
- try {
- await this.store.set("recentInstances", this.recentInstances$.value)
- await this.store.save()
- } catch (error) {
- console.error("Failed to save recent instances:", error)
- }
- }
-
- private async updateRecentInstance(instance: ServerInstance): Promise {
- try {
- const currentInstances = [...this.recentInstances$.value]
- const instances = currentInstances.filter(
- (item) => item.serverUrl !== instance.serverUrl
- )
-
- instances.unshift(instance)
-
- if (instances.length > MAX_RECENT_INSTANCES) {
- instances.length = MAX_RECENT_INSTANCES
- }
-
- this.recentInstances$.next(instances)
- await this.saveRecentInstances()
-
- console.log(
- `Updated recent instances. Current count: ${instances.length}`
- )
- } catch (error) {
- console.error("Failed to update recent instance:", error)
- }
- }
-}
diff --git a/packages/hoppscotch-desktop/.gitignore b/packages/hoppscotch-desktop/.gitignore
index e9280c27..99a74d3f 100644
--- a/packages/hoppscotch-desktop/.gitignore
+++ b/packages/hoppscotch-desktop/.gitignore
@@ -15,6 +15,10 @@ dist-ssr
bundle.zip
manifest.json
+src-tauri/hopp_bundle.zip
+src-tauri/hopp_manifest.json
+src-tauri/hoppscotch-desktop-data
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.js
index 5dbd5b7e..23af762d 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.js
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/dist-js/index.js
@@ -1,19 +1,22 @@
-import { invoke } from '@tauri-apps/api/core';
+import { invoke } from "@tauri-apps/api/core"
async function download(options) {
- return await invoke('plugin:appload|download', { options });
+ return await invoke("plugin:appload|download", { options })
}
async function load(options) {
- return await invoke('plugin:appload|load', { options });
+ return await invoke("plugin:appload|load", { options })
+}
+async function close(options) {
+ return await invoke("plugin:appload|close", { options })
}
async function close(options) {
return await invoke('plugin:appload|close', { options });
}
async function remove(options) {
- return await invoke('plugin:appload|remove', { options });
+ return await invoke("plugin:appload|remove", { options })
}
async function clear() {
- return await invoke('plugin:appload|clear');
+ return await invoke("plugin:appload|clear")
}
export { clear, close, download, load, remove };
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/main.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/main.js
index 6b4e1a96..c231d61a 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/main.js
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/src/main.js
@@ -1,8 +1,8 @@
-import "./style.css";
-import App from "./App.svelte";
+import "./style.css"
+import App from "./App.svelte"
const app = new App({
target: document.getElementById("app"),
-});
+})
-export default app;
+export default app
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/vite.config.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/vite.config.js
index 3b85afb9..789beb47 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/vite.config.js
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/examples/tauri-app/vite.config.js
@@ -1,7 +1,7 @@
-import { defineConfig } from "vite";
-import { svelte } from "@sveltejs/vite-plugin-svelte";
+import { defineConfig } from "vite"
+import { svelte } from "@sveltejs/vite-plugin-svelte"
-const host = process.env.TAURI_DEV_HOST;
+const host = process.env.TAURI_DEV_HOST
// https://vitejs.dev/config/
export default defineConfig({
@@ -15,10 +15,12 @@ export default defineConfig({
host: host || false,
port: 1420,
strictPort: true,
- hmr: host ? {
- protocol: 'ws',
- host,
- port: 1421
- } : undefined,
+ hmr: host
+ ? {
+ protocol: "ws",
+ host,
+ port: 1421,
+ }
+ : undefined,
},
})
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/guest-js/index.ts b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/guest-js/index.ts
index 58cb0772..24970cb1 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/guest-js/index.ts
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/guest-js/index.ts
@@ -1,4 +1,4 @@
-import { invoke } from '@tauri-apps/api/core'
+import { invoke } from "@tauri-apps/api/core"
export interface DownloadOptions {
serverUrl: string
@@ -47,22 +47,24 @@ export interface RemoveResponse {
bundleName: string
}
-export async function download(options: DownloadOptions): Promise {
- return await invoke('plugin:appload|download', { options })
+export async function download(
+ options: DownloadOptions
+): Promise {
+ return await invoke("plugin:appload|download", { options })
}
export async function load(options: LoadOptions): Promise {
- return await invoke('plugin:appload|load', { options })
+ return await invoke("plugin:appload|load", { options })
}
export async function close(options: CloseOptions): Promise {
- return await invoke('plugin:appload|close', { options })
+ return await invoke("plugin:appload|close", { options })
}
export async function remove(options: RemoveOptions): Promise {
- return await invoke('plugin:appload|remove', { options })
+ return await invoke("plugin:appload|remove", { options })
}
export async function clear(): Promise {
- return await invoke('plugin:appload|clear')
+ return await invoke("plugin:appload|clear")
}
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/schemas/schema.json b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/schemas/schema.json
index 4381a8e4..5d314db0 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/schemas/schema.json
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/permissions/schemas/schema.json
@@ -318,6 +318,16 @@
"const": "deny-close",
"markdownDescription": "Denies the close command without any pre-configured scope."
},
+ {
+ "description": "Enables the close command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-close"
+ },
+ {
+ "description": "Denies the close command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-close"
+ },
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/rollup.config.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/rollup.config.js
index d8343635..1574aab7 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/rollup.config.js
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/rollup.config.js
@@ -1,31 +1,31 @@
-import { readFileSync } from 'fs'
-import { join } from 'path'
-import { cwd } from 'process'
-import typescript from '@rollup/plugin-typescript'
+import { readFileSync } from "fs"
+import { join } from "path"
+import { cwd } from "process"
+import typescript from "@rollup/plugin-typescript"
-const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8'))
+const pkg = JSON.parse(readFileSync(join(cwd(), "package.json"), "utf8"))
export default {
- input: 'guest-js/index.ts',
+ input: "guest-js/index.ts",
output: [
{
file: pkg.exports.import,
- format: 'esm'
+ format: "esm",
},
{
file: pkg.exports.require,
- format: 'cjs'
- }
+ format: "cjs",
+ },
],
plugins: [
typescript({
declaration: true,
- declarationDir: `./${pkg.exports.import.split('/')[0]}`
- })
+ declarationDir: `./${pkg.exports.import.split("/")[0]}`,
+ }),
],
external: [
/^@tauri-apps\/api/,
...Object.keys(pkg.dependencies || {}),
- ...Object.keys(pkg.peerDependencies || {})
- ]
+ ...Object.keys(pkg.peerDependencies || {}),
+ ],
}
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/kernel.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/kernel.js
index 69660057..76f915fd 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/kernel.js
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-appload/src/kernel.js
@@ -1,4 +1,4 @@
-(() => {
- console.log('Setting desktop kernel mode');
- window.__KERNEL_MODE__ = 'desktop';
-})();
+;(() => {
+ console.log("Setting desktop kernel mode")
+ window.__KERNEL_MODE__ = "desktop"
+})()
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.js
index c5f0a1ab..36244960 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.js
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/dist-js/index.js
@@ -1,24 +1,24 @@
-import { invoke } from '@tauri-apps/api/core';
+import { invoke } from "@tauri-apps/api/core"
-var MediaType;
-(function (MediaType) {
- MediaType["TEXT_PLAIN"] = "text/plain";
- MediaType["TEXT_HTML"] = "text/html";
- MediaType["TEXT_CSS"] = "text/css";
- MediaType["TEXT_CSV"] = "text/csv";
- MediaType["APPLICATION_JSON"] = "application/json";
- MediaType["APPLICATION_LD_JSON"] = "application/ld+json";
- MediaType["APPLICATION_XML"] = "application/xml";
- MediaType["TEXT_XML"] = "text/xml";
- MediaType["APPLICATION_FORM"] = "application/x-www-form-urlencoded";
- MediaType["APPLICATION_OCTET"] = "application/octet-stream";
- MediaType["MULTIPART_FORM"] = "multipart/form-data";
-})(MediaType || (MediaType = {}));
+let MediaType
+;(function (MediaType) {
+ MediaType["TEXT_PLAIN"] = "text/plain"
+ MediaType["TEXT_HTML"] = "text/html"
+ MediaType["TEXT_CSS"] = "text/css"
+ MediaType["TEXT_CSV"] = "text/csv"
+ MediaType["APPLICATION_JSON"] = "application/json"
+ MediaType["APPLICATION_LD_JSON"] = "application/ld+json"
+ MediaType["APPLICATION_XML"] = "application/xml"
+ MediaType["TEXT_XML"] = "text/xml"
+ MediaType["APPLICATION_FORM"] = "application/x-www-form-urlencoded"
+ MediaType["APPLICATION_OCTET"] = "application/octet-stream"
+ MediaType["MULTIPART_FORM"] = "multipart/form-data"
+})(MediaType || (MediaType = {}))
async function execute(request) {
- return await invoke('plugin:relay|execute', { request });
+ return await invoke("plugin:relay|execute", { request })
}
async function cancel(requestId) {
- return await invoke('plugin:relay|cancel', { requestId });
+ return await invoke("plugin:relay|cancel", { requestId })
}
-export { MediaType, cancel, execute };
+export { MediaType, cancel, execute }
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/guest-js/index.ts b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/guest-js/index.ts
index 1a7500ff..f1f287a1 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/guest-js/index.ts
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/guest-js/index.ts
@@ -1,191 +1,220 @@
-import { invoke } from '@tauri-apps/api/core'
+import { invoke } from "@tauri-apps/api/core"
export type Method =
- | "GET" // Retrieve resource
- | "POST" // Create resource
- | "PUT" // Replace resource
- | "DELETE" // Remove resource
- | "PATCH" // Modify resource
- | "HEAD" // GET without body
+ | "GET" // Retrieve resource
+ | "POST" // Create resource
+ | "PUT" // Replace resource
+ | "DELETE" // Remove resource
+ | "PATCH" // Modify resource
+ | "HEAD" // GET without body
| "OPTIONS" // Get allowed methods
| "CONNECT" // Create tunnel
- | "TRACE" // Loop-back test
+ | "TRACE" // Loop-back test
export type Version = "HTTP/1.0" | "HTTP/1.1" | "HTTP/2.0" | "HTTP/3.0"
export type StatusCode =
- | 100 // Continue
- | 101 // Switching Protocols
- | 102 // Processing
- | 103 // Early Hints
- | 200 // OK
- | 201 // Created
- | 202 // Accepted
- | 203 // Non-Authoritative Info
- | 204 // No Content
- | 205 // Reset Content
- | 206 // Partial Content
- | 207 // Multi-Status
- | 208 // Already Reported
- | 226 // IM Used
- | 300 // Multiple Choices
- | 301 // Moved Permanently
- | 302 // Found
- | 303 // See Other
- | 304 // Not Modified
- | 305 // Use Proxy
- | 306 // Switch Proxy
- | 307 // Temporary Redirect
- | 308 // Permanent Redirect
- | 400 // Bad Request
- | 401 // Unauthorized
- | 402 // Payment Required
- | 403 // Forbidden
- | 404 // Not Found
- | 405 // Method Not Allowed
- | 406 // Not Acceptable
- | 407 // Proxy Auth Required
- | 408 // Request Timeout
- | 409 // Conflict
- | 410 // Gone
- | 411 // Length Required
- | 412 // Precondition Failed
- | 413 // Payload Too Large
- | 414 // URI Too Long
- | 415 // Unsupported Media
- | 416 // Range Not Satisfiable
- | 417 // Expectation Failed
- | 418 // I'm a teapot
- | 421 // Misdirected Request
- | 422 // Unprocessable Entity
- | 423 // Locked
- | 424 // Failed Dependency
- | 425 // Too Early
- | 426 // Upgrade Required
- | 428 // Precondition Required
- | 429 // Too Many Requests
- | 431 // Headers Too Large
- | 451 // Unavailable Legal
- | 500 // Server Error
- | 501 // Not Implemented
- | 502 // Bad Gateway
- | 503 // Service Unavailable
- | 504 // Gateway Timeout
- | 505 // HTTP Ver. Not Supported
- | 506 // Variant Negotiates
- | 507 // Insufficient Storage
- | 508 // Loop Detected
- | 510 // Not Extended
- | 511 // Network Auth Required
+ | 100 // Continue
+ | 101 // Switching Protocols
+ | 102 // Processing
+ | 103 // Early Hints
+ | 200 // OK
+ | 201 // Created
+ | 202 // Accepted
+ | 203 // Non-Authoritative Info
+ | 204 // No Content
+ | 205 // Reset Content
+ | 206 // Partial Content
+ | 207 // Multi-Status
+ | 208 // Already Reported
+ | 226 // IM Used
+ | 300 // Multiple Choices
+ | 301 // Moved Permanently
+ | 302 // Found
+ | 303 // See Other
+ | 304 // Not Modified
+ | 305 // Use Proxy
+ | 306 // Switch Proxy
+ | 307 // Temporary Redirect
+ | 308 // Permanent Redirect
+ | 400 // Bad Request
+ | 401 // Unauthorized
+ | 402 // Payment Required
+ | 403 // Forbidden
+ | 404 // Not Found
+ | 405 // Method Not Allowed
+ | 406 // Not Acceptable
+ | 407 // Proxy Auth Required
+ | 408 // Request Timeout
+ | 409 // Conflict
+ | 410 // Gone
+ | 411 // Length Required
+ | 412 // Precondition Failed
+ | 413 // Payload Too Large
+ | 414 // URI Too Long
+ | 415 // Unsupported Media
+ | 416 // Range Not Satisfiable
+ | 417 // Expectation Failed
+ | 418 // I'm a teapot
+ | 421 // Misdirected Request
+ | 422 // Unprocessable Entity
+ | 423 // Locked
+ | 424 // Failed Dependency
+ | 425 // Too Early
+ | 426 // Upgrade Required
+ | 428 // Precondition Required
+ | 429 // Too Many Requests
+ | 431 // Headers Too Large
+ | 451 // Unavailable Legal
+ | 500 // Server Error
+ | 501 // Not Implemented
+ | 502 // Bad Gateway
+ | 503 // Service Unavailable
+ | 504 // Gateway Timeout
+ | 505 // HTTP Ver. Not Supported
+ | 506 // Variant Negotiates
+ | 507 // Insufficient Storage
+ | 508 // Loop Detected
+ | 510 // Not Extended
+ | 511 // Network Auth Required
export type FormDataValue =
- | { kind: "text"; value: string }
- | { kind: "file"; filename: string; contentType: string; data: Uint8Array }
+ | { kind: "text"; value: string }
+ | { kind: "file"; filename: string; contentType: string; data: Uint8Array }
export type FormData = [string, FormDataValue[]][]
export enum MediaType {
- TEXT_PLAIN = "text/plain",
- TEXT_HTML = "text/html",
- TEXT_CSS = "text/css",
- TEXT_CSV = "text/csv",
- APPLICATION_JSON = "application/json",
- APPLICATION_LD_JSON = "application/ld+json",
- APPLICATION_XML = "application/xml",
- TEXT_XML = "text/xml",
- APPLICATION_FORM = "application/x-www-form-urlencoded",
- APPLICATION_OCTET = "application/octet-stream",
- MULTIPART_FORM = "multipart/form-data"
+ TEXT_PLAIN = "text/plain",
+ TEXT_HTML = "text/html",
+ TEXT_CSS = "text/css",
+ TEXT_CSV = "text/csv",
+ APPLICATION_JSON = "application/json",
+ APPLICATION_LD_JSON = "application/ld+json",
+ APPLICATION_XML = "application/xml",
+ TEXT_XML = "text/xml",
+ APPLICATION_FORM = "application/x-www-form-urlencoded",
+ APPLICATION_OCTET = "application/octet-stream",
+ MULTIPART_FORM = "multipart/form-data",
}
export type ContentType =
- | { kind: "text"; content: string; mediaType: MediaType.TEXT_PLAIN | MediaType.TEXT_HTML | MediaType.TEXT_CSS | MediaType.TEXT_CSV }
- | { kind: "json"; content: unknown; mediaType: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON }
- | { kind: "xml"; content: string; mediaType: MediaType.APPLICATION_XML | MediaType.TEXT_XML }
- | { kind: "form"; content: FormData; mediaType: MediaType.APPLICATION_FORM }
- | { kind: "binary"; content: Uint8Array; mediaType: MediaType.APPLICATION_OCTET | string; filename?: string }
- | { kind: "multipart"; content: FormData; mediaType: MediaType.MULTIPART_FORM }
- | { kind: "urlencoded"; content: string; mediaType: MediaType.APPLICATION_FORM }
- | { kind: "stream"; content: ReadableStream; mediaType: string }
+ | {
+ kind: "text"
+ content: string
+ mediaType:
+ | MediaType.TEXT_PLAIN
+ | MediaType.TEXT_HTML
+ | MediaType.TEXT_CSS
+ | MediaType.TEXT_CSV
+ }
+ | {
+ kind: "json"
+ content: unknown
+ mediaType: MediaType.APPLICATION_JSON | MediaType.APPLICATION_LD_JSON
+ }
+ | {
+ kind: "xml"
+ content: string
+ mediaType: MediaType.APPLICATION_XML | MediaType.TEXT_XML
+ }
+ | { kind: "form"; content: FormData; mediaType: MediaType.APPLICATION_FORM }
+ | {
+ kind: "binary"
+ content: Uint8Array
+ mediaType: MediaType.APPLICATION_OCTET | string
+ filename?: string
+ }
+ | {
+ kind: "multipart"
+ content: FormData
+ mediaType: MediaType.MULTIPART_FORM
+ }
+ | {
+ kind: "urlencoded"
+ content: string
+ mediaType: MediaType.APPLICATION_FORM
+ }
+ | { kind: "stream"; content: ReadableStream; mediaType: string }
export interface ResponseBody {
- body: Uint8Array
- mediaType: MediaType | string
+ body: Uint8Array
+ mediaType: MediaType | string
}
export type AuthType =
- | { kind: "none" }
- | { kind: "basic"; username: string; password: string }
- | { kind: "bearer"; token: string }
- | {
- kind: "digest"
- username: string
- password: string
- realm?: string
- nonce?: string
- opaque?: string
- algorithm?: "MD5" | "SHA-256" | "SHA-512"
- qop?: "auth" | "auth-int"
- nc?: string
- cnonce?: string
+ | { kind: "none" }
+ | { kind: "basic"; username: string; password: string }
+ | { kind: "bearer"; token: string }
+ | {
+ kind: "digest"
+ username: string
+ password: string
+ realm?: string
+ nonce?: string
+ opaque?: string
+ algorithm?: "MD5" | "SHA-256" | "SHA-512"
+ qop?: "auth" | "auth-int"
+ nc?: string
+ cnonce?: string
}
- | {
- kind: "oauth2"
- grantType:
+ | {
+ kind: "oauth2"
+ grantType:
| {
kind: "authorization_code"
authEndpoint: string
tokenEndpoint: string
clientId: string
clientSecret?: string
- }
+ }
| {
kind: "client_credentials"
tokenEndpoint: string
clientId: string
clientSecret?: string
- }
+ }
| {
kind: "password"
tokenEndpoint: string
username: string
password: string
- }
+ }
| {
kind: "implicit"
authEndpoint: string
clientId: string
- }
- accessToken?: string
- refreshToken?: string
+ }
+ accessToken?: string
+ refreshToken?: string
}
- | {
- kind: "apikey"
- key: string
- value: string
- in: "header" | "query"
+ | {
+ kind: "apikey"
+ key: string
+ value: string
+ in: "header" | "query"
}
- | {
- kind: "aws"
- accessKey: string
- secretKey: string
- region: string
- service: string
- sessionToken?: string
- in: "header" | "query"
+ | {
+ kind: "aws"
+ accessKey: string
+ secretKey: string
+ region: string
+ service: string
+ sessionToken?: string
+ in: "header" | "query"
}
export type CertificateType =
| {
- kind: "pem"
- cert: Uint8Array
- key: Uint8Array
- }
+ kind: "pem"
+ cert: Uint8Array
+ key: Uint8Array
+ }
| {
- kind: "pfx"
- data: Uint8Array
- password: string
- }
+ kind: "pfx"
+ data: Uint8Array
+ password: string
+ }
export interface RequestOptions {
timeout?: number
@@ -244,7 +273,7 @@ export interface Response {
expires?: Date
secure?: boolean
httpOnly?: boolean
- sameSite?: 'Strict' | 'Lax' | 'None'
+ sameSite?: "Strict" | "Lax" | "None"
}>
body: ResponseBody
@@ -277,13 +306,13 @@ export type RelayError =
| { kind: "abort"; message: string }
export type RequestResult =
- | { kind: 'success'; response: Response }
- | { kind: 'error'; error: RelayError }
+ | { kind: "success"; response: Response }
+ | { kind: "error"; error: RelayError }
export async function execute(request: Request): Promise {
- return await invoke('plugin:relay|execute', { request })
+ return await invoke("plugin:relay|execute", { request })
}
export async function cancel(requestId: number): Promise {
- return await invoke('plugin:relay|cancel', { requestId })
+ return await invoke("plugin:relay|cancel", { requestId })
}
diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/rollup.config.js b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/rollup.config.js
index d8343635..1574aab7 100644
--- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/rollup.config.js
+++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/rollup.config.js
@@ -1,31 +1,31 @@
-import { readFileSync } from 'fs'
-import { join } from 'path'
-import { cwd } from 'process'
-import typescript from '@rollup/plugin-typescript'
+import { readFileSync } from "fs"
+import { join } from "path"
+import { cwd } from "process"
+import typescript from "@rollup/plugin-typescript"
-const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8'))
+const pkg = JSON.parse(readFileSync(join(cwd(), "package.json"), "utf8"))
export default {
- input: 'guest-js/index.ts',
+ input: "guest-js/index.ts",
output: [
{
file: pkg.exports.import,
- format: 'esm'
+ format: "esm",
},
{
file: pkg.exports.require,
- format: 'cjs'
- }
+ format: "cjs",
+ },
],
plugins: [
typescript({
declaration: true,
- declarationDir: `./${pkg.exports.import.split('/')[0]}`
- })
+ declarationDir: `./${pkg.exports.import.split("/")[0]}`,
+ }),
],
external: [
/^@tauri-apps\/api/,
...Object.keys(pkg.dependencies || {}),
- ...Object.keys(pkg.peerDependencies || {})
- ]
+ ...Object.keys(pkg.peerDependencies || {}),
+ ],
}
diff --git a/packages/hoppscotch-desktop/src-tauri/src/lib.rs b/packages/hoppscotch-desktop/src-tauri/src/lib.rs
index f5e68221..5e54e717 100644
--- a/packages/hoppscotch-desktop/src-tauri/src/lib.rs
+++ b/packages/hoppscotch-desktop/src-tauri/src/lib.rs
@@ -212,6 +212,11 @@ pub fn run() {
quit_app,
backup::check_and_backup_on_version_change,
updater::check_for_updates,
+ updater::download_and_install_update,
+ updater::restart_application,
+ updater::cancel_update,
+ updater::get_download_progress,
+ updater::is_portable_mode,
path::get_config_dir,
path::get_latest_dir,
path::get_instance_dir,
diff --git a/packages/hoppscotch-desktop/src-tauri/src/updater.rs b/packages/hoppscotch-desktop/src-tauri/src/updater.rs
index ec99054d..2a57f58b 100644
--- a/packages/hoppscotch-desktop/src-tauri/src/updater.rs
+++ b/packages/hoppscotch-desktop/src-tauri/src/updater.rs
@@ -1,53 +1,308 @@
use crate::{dialog, util};
use native_dialog::MessageType;
-use tauri_plugin_updater::UpdaterExt;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use tauri::{AppHandle, Emitter};
+use tauri_plugin_updater::{Update, UpdaterExt};
+use tokio::sync::Mutex;
-/// Check for updates using the updater and return whether updates are available
-/// This mimics the behavior of `checkForUpdates` in `updater.ts` but uses native dialogs when needed
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateInfo {
+ pub available: bool,
+ pub current_version: String,
+ pub latest_version: Option,
+ pub release_notes: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DownloadProgress {
+ pub downloaded: u64,
+ pub total: Option,
+ pub percentage: f64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum UpdateEvent {
+ CheckStarted,
+ CheckCompleted {
+ info: UpdateInfo,
+ },
+ CheckFailed {
+ error: String,
+ },
+ DownloadStarted {
+ #[serde(rename = "totalBytes")]
+ total_bytes: Option,
+ },
+ DownloadProgress {
+ progress: DownloadProgress,
+ },
+ DownloadCompleted,
+ InstallStarted,
+ InstallCompleted,
+ RestartRequired,
+ UpdateCancelled,
+ Error {
+ message: String,
+ },
+}
+
+// Global state for tracking update progress
+// TODO: See if it's possible to let Tauri handle this state management
+static UPDATE_STATE: std::sync::LazyLock>>> =
+ std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
+
+static DOWNLOAD_STATE: std::sync::LazyLock>> =
+ std::sync::LazyLock::new(|| {
+ Arc::new(Mutex::new(DownloadProgress {
+ downloaded: 0,
+ total: None,
+ percentage: 0.0,
+ }))
+ });
+
+/// Check for updates and returns basic information
+/// For portable mode: Shows native dialog if update found
+/// For standard mode: Just returns information, UI handles the rest
#[tauri::command]
-pub async fn check_for_updates(app: tauri::AppHandle) -> Result {
- tracing::info!("Checking for portable updates");
+pub async fn check_for_updates(
+ app: AppHandle,
+ show_native_dialog: bool,
+) -> Result {
+ tracing::info!(portable_dialog = show_native_dialog, "Checking for updates");
- let updater = match app.updater() {
- Ok(updater) => updater,
- Err(e) => {
- tracing::error!(error = %e, "Failed to initialize updater");
- return Err(format!("Failed to initialize updater: {}", e));
- }
- };
+ let _ = app.emit("updater-event", UpdateEvent::CheckStarted);
+
+ let updater = app.updater().map_err(|e| {
+ let error_msg = format!("Failed to initialize updater: {}", e);
+ tracing::error!("{}", error_msg);
+ let _ = app.emit(
+ "updater-event",
+ UpdateEvent::CheckFailed {
+ error: error_msg.clone(),
+ },
+ );
+ error_msg
+ })?;
match updater.check().await {
Ok(Some(update)) => {
- tracing::info!(
- current_version = app.package_info().version.to_string(),
- update_version = update.version.to_string(),
- "Update available"
- );
- let download_url = "https://hoppscotch.com/download";
- let message = format!(
- "An update (version {}) is available for Hoppscotch Desktop (Portable).\n\nWould you like to download it now?\n\n• Yes = Download now\n• No = Remind me later",
- update.version
- );
+ let current_version = app.package_info().version.to_string();
+ let latest_version = update.version.to_string();
+ let release_notes = update.body.clone();
- if dialog::confirm("Download Update", &message, MessageType::Info) {
- if let None = util::open_link(download_url) {
- dialog::error(&format!(
- "Failed to open download page. Please visit {}",
- download_url
- ));
- return Err(format!("Failed to open download URL"));
- }
+ tracing::info!(current_version, latest_version, "Update available");
+
+ {
+ let mut state = UPDATE_STATE.lock().await;
+ *state = Some(update);
}
- Ok(true)
+ let update_info = UpdateInfo {
+ available: true,
+ current_version,
+ latest_version: Some(latest_version.clone()),
+ release_notes,
+ };
+
+ if show_native_dialog {
+ handle_portable_update_dialog(&app, &latest_version).await?;
+ }
+
+ let _ = app.emit(
+ "updater-event",
+ UpdateEvent::CheckCompleted {
+ info: update_info.clone(),
+ },
+ );
+
+ Ok(update_info)
}
Ok(None) => {
tracing::info!("No updates available");
- Ok(false)
+ let update_info = UpdateInfo {
+ available: false,
+ current_version: app.package_info().version.to_string(),
+ latest_version: None,
+ release_notes: None,
+ };
+
+ let _ = app.emit(
+ "updater-event",
+ UpdateEvent::CheckCompleted {
+ info: update_info.clone(),
+ },
+ );
+
+ Ok(update_info)
}
Err(e) => {
- tracing::error!(error = %e, "Failed to check for updates");
- Err(format!("Failed to check for updates: {}", e))
+ let error_msg = format!("Failed to check for updates: {}", e);
+ tracing::error!("{}", error_msg);
+ let _ = app.emit(
+ "updater-event",
+ UpdateEvent::CheckFailed {
+ error: error_msg.clone(),
+ },
+ );
+ Err(error_msg)
}
}
}
+
+/// Download and install update (for standard mode)
+/// Emits progress events that the frontend can listen to
+#[tauri::command]
+pub async fn download_and_install_update(app: AppHandle) -> Result<(), String> {
+ tracing::info!("Starting update download and installation");
+
+ let update = {
+ let mut state = UPDATE_STATE.lock().await;
+ state.take().ok_or("No update available to install")?
+ };
+
+ let _ = app.emit(
+ "updater-event",
+ UpdateEvent::DownloadStarted { total_bytes: None },
+ );
+
+ {
+ let mut download_state = DOWNLOAD_STATE.lock().await;
+ download_state.downloaded = 0;
+ download_state.total = None;
+ download_state.percentage = 0.0;
+ }
+
+ let app_progress = app.clone();
+ let app_callback = app.clone();
+
+ update
+ .download_and_install(
+ move |chunk_length, content_length| {
+ let app = app_progress.clone();
+ tauri::async_runtime::spawn(async move {
+ let mut download_state = DOWNLOAD_STATE.lock().await;
+
+ if let Some(total) = content_length {
+ if download_state.total.is_none() {
+ download_state.total = Some(total);
+ let _ = app.emit(
+ "updater-event",
+ UpdateEvent::DownloadStarted {
+ total_bytes: Some(total),
+ },
+ );
+ }
+ }
+
+ download_state.downloaded += chunk_length as u64;
+
+ if let Some(total) = download_state.total {
+ download_state.percentage =
+ (download_state.downloaded as f64 / total as f64) * 100.0;
+ }
+
+ let progress = download_state.clone();
+ // Release the lock before emitting
+ // TODO: See if it's possible to not have a lock at all
+ drop(download_state);
+
+ let _ = app.emit("updater-event", UpdateEvent::DownloadProgress { progress });
+ });
+ },
+ move || {
+ let app = app_callback.clone();
+ tauri::async_runtime::spawn(async move {
+ let _ = app.emit("updater-event", UpdateEvent::DownloadCompleted);
+ let _ = app.emit("updater-event", UpdateEvent::InstallStarted);
+ let _ = app.emit("updater-event", UpdateEvent::RestartRequired);
+ });
+ },
+ )
+ .await
+ .map_err(|e| {
+ let error_msg = format!("Failed to download and install update: {}", e);
+ let _ = app.emit(
+ "updater-event",
+ UpdateEvent::Error {
+ message: error_msg.clone(),
+ },
+ );
+ error_msg
+ })?;
+
+ tracing::info!("Update download and installation completed successfully");
+ Ok(())
+}
+
+/// Restart the application (for standard mode)
+#[tauri::command]
+pub async fn restart_application() -> Result<(), String> {
+ tracing::info!("Restarting application");
+ tauri::process::restart(&tauri::Env::default());
+ // This function never returns because the app restarts,
+ // so it's safe to allow `unreachable_code` here.
+ #[allow(unreachable_code)]
+ Ok(())
+}
+
+/// Cancel any ongoing update process
+#[tauri::command]
+pub async fn cancel_update(app: AppHandle) -> Result<(), String> {
+ tracing::info!("Cancelling update");
+
+ {
+ let mut state = UPDATE_STATE.lock().await;
+ *state = None;
+ }
+
+ {
+ let mut download_state = DOWNLOAD_STATE.lock().await;
+ download_state.downloaded = 0;
+ download_state.total = None;
+ download_state.percentage = 0.0;
+ }
+
+ let _ = app.emit("updater-event", UpdateEvent::UpdateCancelled);
+ Ok(())
+}
+
+/// Get current download progress (for polling if needed)
+#[tauri::command]
+pub async fn get_download_progress() -> Result {
+ let download_state = DOWNLOAD_STATE.lock().await;
+ Ok(download_state.clone())
+}
+
+/// Check if we're running in portable mode (for frontend convenience)
+#[tauri::command]
+pub fn is_portable_mode() -> bool {
+ cfg!(feature = "portable")
+}
+
+/// Helper function to handle portable mode update dialog
+async fn handle_portable_update_dialog(
+ _app: &AppHandle,
+ latest_version: &str,
+) -> Result<(), String> {
+ let download_url = "https://hoppscotch.com/download";
+ let message = format!(
+ "An update (version {}) is available for Hoppscotch Desktop (Portable).\n\nWould you like to download it now?\n\n• Yes = Download now\n• No = Remind me later",
+ latest_version
+ );
+
+ if dialog::confirm("Download Update", &message, MessageType::Info) {
+ if util::open_link(download_url).is_none() {
+ dialog::error(&format!(
+ "Failed to open download page. Please visit {}",
+ download_url
+ ));
+ return Err("Failed to open download URL".to_string());
+ }
+ }
+
+ Ok(())
+}
diff --git a/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts b/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts
new file mode 100644
index 00000000..847bf9ce
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts
@@ -0,0 +1,286 @@
+import { ref } from "vue"
+import { load, download, close } from "@hoppscotch/plugin-appload"
+import { getVersion } from "@tauri-apps/api/app"
+import { invoke } from "@tauri-apps/api/core"
+
+import { DesktopPersistenceService } from "~/services/persistence.service"
+import { InstanceStoreMigrationService } from "~/services/instance-store-migration.service"
+import type {
+ Instance,
+ ConnectionState,
+} from "@hoppscotch/common/platform/instance"
+import { VENDORED_INSTANCE_CONFIG } from "@hoppscotch/common/platform/instance"
+
+export enum AppState {
+ LOADING = "loading",
+ UPDATE_AVAILABLE = "update_available",
+ UPDATE_IN_PROGRESS = "update_in_progress",
+ UPDATE_READY = "update_ready",
+ ERROR = "error",
+ LOADED = "loaded",
+}
+
+export function useAppInitialization() {
+ const persistence = DesktopPersistenceService.getInstance()
+ const migration = InstanceStoreMigrationService.getInstance()
+
+ const appState = ref(AppState.LOADING)
+ const error = ref("")
+ const statusMessage = ref("Initializing...")
+ const appVersion = ref("...")
+
+ const saveConnectionState = async (state: ConnectionState) => {
+ try {
+ await persistence.setConnectionState(state)
+ } catch (err) {
+ console.error("Failed to save connection state:", err)
+ }
+ }
+
+ const findMostRecentInstance = (
+ instances: Instance[],
+ targetUrl: string
+ ): Instance | null => {
+ return (
+ instances.find(
+ (instance) =>
+ instance.serverUrl === targetUrl ||
+ instance.serverUrl.includes(targetUrl) ||
+ targetUrl.includes(instance.serverUrl)
+ ) || null
+ )
+ }
+
+ const loadVendoredInstance = async () => {
+ try {
+ statusMessage.value = "Loading Hoppscotch Desktop..."
+
+ await saveConnectionState({
+ status: "connected",
+ instance: VENDORED_INSTANCE_CONFIG,
+ })
+
+ console.log("Loading vendored app...")
+ const loadResp = await load({
+ bundleName: VENDORED_INSTANCE_CONFIG.bundleName!,
+ window: { title: "Hoppscotch" },
+ })
+
+ if (!loadResp.success) {
+ throw new Error("Failed to load Hoppscotch Vendored")
+ }
+
+ console.log("Vendored app loaded successfully")
+ close({ windowLabel: "main" })
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err)
+ console.error("Error loading vendored app:", errorMessage)
+ error.value = errorMessage
+
+ await saveConnectionState({
+ status: "error",
+ target: "Vendored",
+ message: errorMessage,
+ })
+
+ appState.value = AppState.ERROR
+ }
+ }
+
+ const loadVendoredIfMatches = async (instance: Instance) => {
+ if (
+ instance.kind === "vendored" ||
+ instance.bundleName === VENDORED_INSTANCE_CONFIG.bundleName
+ ) {
+ await loadVendoredInstance()
+ } else {
+ try {
+ statusMessage.value = `Loading ${instance.displayName}...`
+
+ await saveConnectionState({
+ status: "connecting",
+ target: instance.serverUrl,
+ })
+
+ await download({ serverUrl: instance.serverUrl })
+
+ const loadResp = await load({
+ bundleName: instance.bundleName!,
+ window: { title: "Hoppscotch" },
+ })
+
+ if (!loadResp.success) {
+ throw new Error(`Failed to load ${instance.displayName}`)
+ }
+
+ await saveConnectionState({
+ status: "connected",
+ instance: instance,
+ })
+
+ console.log(`Successfully loaded instance: ${instance.displayName}`)
+ close({ windowLabel: "main" })
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err)
+ console.error(
+ `Failed to load instance ${instance.displayName}:`,
+ errorMessage
+ )
+
+ await saveConnectionState({
+ status: "error",
+ target: instance.serverUrl,
+ message: errorMessage,
+ })
+
+ console.log("Falling back to vendored instance")
+ await loadVendoredInstance()
+ }
+ }
+ }
+
+ const loadRecent = async () => {
+ try {
+ statusMessage.value = "Loading application..."
+
+ const connectionState = await persistence.getConnectionState()
+ const recentInstances = await persistence.getRecentInstances()
+
+ console.log("Current connection state:", connectionState)
+ console.log("Recent instances:", recentInstances)
+
+ if (connectionState) {
+ switch (connectionState.status) {
+ case "connected":
+ if (connectionState.instance) {
+ statusMessage.value = `Connecting to ${connectionState.instance.displayName}...`
+ try {
+ await loadVendoredIfMatches(connectionState.instance)
+ return
+ } catch (err) {
+ console.warn(
+ "Failed to load previously connected instance:",
+ err
+ )
+ }
+ }
+ break
+
+ case "connecting":
+ if (connectionState.target) {
+ statusMessage.value = `Resuming connection to ${connectionState.target}...`
+ const targetInstance = findMostRecentInstance(
+ recentInstances,
+ connectionState.target
+ )
+ if (targetInstance) {
+ try {
+ await loadVendoredIfMatches(targetInstance)
+ return
+ } catch (err) {
+ console.warn("Failed to resume connection:", err)
+ }
+ }
+ }
+ break
+
+ case "error":
+ console.warn("Previous connection failed:", connectionState.message)
+ break
+
+ case "idle":
+ default:
+ break
+ }
+ }
+
+ const mostRecentInstance = recentInstances[0]
+
+ if (mostRecentInstance) {
+ statusMessage.value = `Connecting to ${mostRecentInstance.displayName}...`
+ try {
+ await loadVendoredIfMatches(mostRecentInstance)
+ return
+ } catch (err) {
+ console.warn("Failed to load most recent instance:", err)
+ }
+ }
+
+ console.log("No recent instances found, loading vendored as fallback")
+ await loadVendoredInstance()
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err)
+ console.error("Error in loadRecent:", errorMessage)
+ error.value = errorMessage
+ appState.value = AppState.ERROR
+ }
+ }
+
+ const performBasicInitialization = async () => {
+ try {
+ appVersion.value = await getVersion()
+ } catch (error) {
+ console.error("Failed to get app version:", error)
+ appVersion.value = "unknown"
+ }
+
+ statusMessage.value = "Checking for version changes..."
+ try {
+ await invoke("check_and_backup_on_version_change")
+ console.log("Version backup check completed")
+ } catch (err) {
+ console.warn("Version backup check failed:", err)
+ }
+
+ statusMessage.value = "Running data migration..."
+ await migration.initialize()
+
+ const migrationStatus = migration.getMigrationStatus()
+ if (migrationStatus.value.status === "failed") {
+ throw new Error(
+ `Migration failed: ${migration.getMigrationError().value}`
+ )
+ }
+
+ statusMessage.value = "Initializing stores..."
+ await persistence.init()
+ }
+
+ const initialize = async (customLogic?: () => Promise) => {
+ appState.value = AppState.LOADING
+ error.value = ""
+
+ try {
+ await performBasicInitialization()
+
+ if (customLogic) {
+ await customLogic()
+ } else {
+ await loadRecent()
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err)
+ console.error("Initialization error:", errorMessage)
+ error.value = errorMessage
+ appState.value = AppState.ERROR
+ }
+ }
+
+ return {
+ appState,
+ error,
+ statusMessage,
+ appVersion,
+
+ persistence,
+ migration,
+
+ saveConnectionState,
+ findMostRecentInstance,
+ loadVendoredInstance,
+ loadVendoredIfMatches,
+ loadRecent,
+ performBasicInitialization,
+ initialize,
+ }
+}
diff --git a/packages/hoppscotch-desktop/src/main.ts b/packages/hoppscotch-desktop/src/main.ts
index 05e2dd76..01fa7ea0 100644
--- a/packages/hoppscotch-desktop/src/main.ts
+++ b/packages/hoppscotch-desktop/src/main.ts
@@ -9,6 +9,10 @@ import "@fontsource-variable/inter"
import "@fontsource-variable/material-symbols-rounded"
import "@fontsource-variable/roboto-mono"
+import { initKernel } from "@hoppscotch/kernel"
+
const app = createApp(App)
app.use(router)
app.mount("#app")
+
+initKernel("desktop")
diff --git a/packages/hoppscotch-desktop/src/router.ts b/packages/hoppscotch-desktop/src/router.ts
index 7bf01771..6b603f0a 100644
--- a/packages/hoppscotch-desktop/src/router.ts
+++ b/packages/hoppscotch-desktop/src/router.ts
@@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from "vue-router"
-import Home from "./views/Home.vue"
+import { invoke } from "@tauri-apps/api/core"
const router = createRouter({
history: createWebHistory(),
@@ -7,7 +7,24 @@ const router = createRouter({
{
path: "/",
name: "home",
- component: Home,
+ component: async () => {
+ try {
+ const isPortable = await invoke("is_portable_mode")
+ // Dynamic import because otherwise `updater`
+ // tends to experience weird race conditions,
+ // not sure how or why
+ return isPortable
+ ? import("./views/PortableHome.vue")
+ : import("./views/StandardHome.vue")
+ } catch (error) {
+ console.error(
+ "Failed to detect portable mode, defaulting to standard:",
+ error
+ )
+
+ return import("./views/StandardHome.vue")
+ }
+ },
},
],
})
diff --git a/packages/hoppscotch-desktop/src/services/instance-store-migration.service.ts b/packages/hoppscotch-desktop/src/services/instance-store-migration.service.ts
new file mode 100644
index 00000000..ec39bb88
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/services/instance-store-migration.service.ts
@@ -0,0 +1,431 @@
+import { BehaviorSubject, Observable } from "rxjs"
+import { computed } from "vue"
+import { LazyStore } from "@tauri-apps/plugin-store"
+import {
+ getInstanceDir,
+ getConfigDir,
+ getStoreDir,
+ getLatestDir,
+ Store,
+} from "~/kernel/store"
+import * as E from "fp-ts/Either"
+import { join } from "@tauri-apps/api/path"
+import { exists, copyFile, remove, readDir, mkdir } from "@tauri-apps/plugin-fs"
+
+const STORE_NAMESPACE = "hoppscotch-desktop.v1"
+const MIGRATION_NAMESPACE = "migration.v1"
+const CURRENT_STORE_VERSION = 1
+
+type LegacyServerInstance = {
+ type: "server"
+ serverUrl: string
+ displayName: string
+ version: string
+ lastUsed: string
+ bundleName?: string
+}
+
+type LegacyCloudInstance = {
+ type: "cloud"
+ displayName: string
+ version: string
+}
+
+type LegacyInstanceKind = LegacyServerInstance | LegacyCloudInstance
+
+type LegacyConnectionState =
+ | { status: "idle" }
+ | { status: "connecting"; target: string }
+ | { status: "connected"; instance: LegacyInstanceKind }
+ | { status: "error"; target: string; message: string }
+
+type LegacyUpdateState = {
+ status: string
+ version?: string
+ message?: string
+ progress?: {
+ downloaded: number
+ total?: number
+ }
+}
+
+export type InstanceKind = "on-prem" | "cloud" | "cloud-org" | "vendored"
+
+export type Instance = {
+ kind: InstanceKind
+ serverUrl: string
+ displayName: string
+ version: string
+ lastUsed: string
+ bundleName?: string
+}
+
+export type ConnectionState =
+ | { status: "idle" }
+ | { status: "connecting"; target: string }
+ | { status: "connected"; instance: Instance }
+ | { status: "error"; target: string; message: string }
+
+export type MigrationStatus =
+ | { status: "pending" }
+ | { status: "in-progress" }
+ | { status: "completed" }
+ | { status: "failed"; error: string }
+
+export class InstanceStoreMigrationService {
+ private static instance: InstanceStoreMigrationService
+ private status$ = new BehaviorSubject({ status: "pending" })
+ private migrationLock = false
+
+ private constructor() {}
+
+ public static getInstance(): InstanceStoreMigrationService {
+ if (!InstanceStoreMigrationService.instance) {
+ InstanceStoreMigrationService.instance =
+ new InstanceStoreMigrationService()
+ }
+ return InstanceStoreMigrationService.instance
+ }
+
+ async initialize(): Promise {
+ if (this.migrationLock) {
+ console.log("Migration already in progress, skipping...")
+ return
+ }
+
+ this.migrationLock = true
+
+ try {
+ const initResult = await Store.init()
+ if (E.isLeft(initResult)) {
+ console.error(`Failed to initialize kernel store: ${initResult.left}`)
+ // Don't fail the migration, just log and continue,
+ // since there's a chance no services have started just yet,
+ // better to continue and fail with legitimate critical reasons,
+ // rather than failing here and not being able to see the actual cause.
+ }
+
+ const isMigrated = await this.isMigrationComplete()
+ if (isMigrated) {
+ console.log("Migration already completed")
+ this.status$.next({ status: "completed" })
+ return
+ }
+
+ await this.performMigration()
+ } catch (error) {
+ console.error("Migration error:", error)
+ // Even if migration fails, mark as completed to prevent retry loops,
+ // so there won't be partial state from the "source".
+ // There's always a way to restart migration is the source is safe.
+ await this.markMigrationComplete()
+ this.status$.next({ status: "completed" })
+ } finally {
+ this.migrationLock = false
+ }
+ }
+
+ private async isMigrationComplete(): Promise {
+ try {
+ const versionResult = await Store.get(
+ MIGRATION_NAMESPACE,
+ "migrationVersion"
+ )
+ const currentVersion = E.isRight(versionResult)
+ ? versionResult.right || 0
+ : 0
+ return currentVersion >= CURRENT_STORE_VERSION
+ } catch {
+ return false
+ }
+ }
+
+ private async markMigrationComplete(): Promise {
+ try {
+ await Store.set(
+ MIGRATION_NAMESPACE,
+ "migrationVersion",
+ CURRENT_STORE_VERSION
+ )
+ } catch (error) {
+ console.error("Failed to mark migration as complete:", error)
+ }
+ }
+
+ private async performMigration(): Promise {
+ this.status$.next({ status: "in-progress" })
+
+ try {
+ // Ensure directory structure exists
+ // (shouldn't fail, but good to check just in case)
+ await this.ensureDirectoryStructure()
+
+ // Migrate data from old stores.
+ // NOTE: This can fail if the store is using default path,
+ // and not the full specificed path.
+ // Remember to check that first.
+ await this.migrateDataSafely()
+
+ // Move .hoppscotch.store files
+ // (shouldn't fail, but good to check just in case)
+ await this.migrateHoppscotchStoreFiles()
+
+ // Mark migration as complete before cleanup
+ // (shouldn't fail, but good to check just in case)
+ await this.markMigrationComplete()
+
+ // Clean up old files
+ // (best effort, don't fail if this fails)
+ await this.cleanupOldFilesSafely()
+
+ this.status$.next({ status: "completed" })
+ console.log("Migration completed successfully")
+ } catch (error) {
+ console.error("Migration failed:", error)
+ // Mark as completed anyway to prevent retry loops
+ // (same reasoning as in init)
+ await this.markMigrationComplete()
+ this.status$.next({ status: "completed" })
+ }
+ }
+
+ private async ensureDirectoryStructure(): Promise {
+ try {
+ const latestDir = await getLatestDir()
+ const storeDir = await getStoreDir()
+ const instanceDir = await getInstanceDir()
+
+ // Create directories if they don't exist
+ for (const dir of [latestDir, storeDir, instanceDir]) {
+ try {
+ const dirExists = await exists(dir)
+ if (!dirExists) {
+ await mkdir(dir, { recursive: true })
+ console.log(`Created directory: ${dir}`)
+ }
+ } catch (error) {
+ // Directory might already exist or parent might not exist
+ console.log(`Directory ${dir} might already exist:`, error)
+ }
+ }
+ } catch (error) {
+ console.error("Failed to ensure directory structure:", error)
+ // Continue anyway, directories might already exist
+ }
+ }
+
+ private async migrateDataSafely(): Promise {
+ const configDir = await getConfigDir()
+
+ const hoppStorePath = await join(configDir, "hopp.store.json")
+ const desktopStorePath = await join(configDir, "hoppscotch-desktop.store")
+
+ let connectionState: ConnectionState = { status: "idle" }
+ let recentInstances: Instance[] = []
+ let updateState: any = null
+
+ // Read from hopp.store.json if it exists
+ try {
+ if (await exists(hoppStorePath)) {
+ const hoppStore = new LazyStore(hoppStorePath)
+ await hoppStore.init()
+
+ const hoppState =
+ await hoppStore.get("connectionState")
+ if (hoppState && hoppState.status === "connected") {
+ connectionState = {
+ status: "connected",
+ instance: this.convertInstanceToNewFormat(hoppState.instance),
+ }
+ }
+
+ const legacyInstances =
+ await hoppStore.get("recentInstances")
+ if (legacyInstances) {
+ recentInstances = legacyInstances.map((inst) =>
+ this.convertInstanceToNewFormat(inst)
+ )
+ }
+ }
+ } catch (error) {
+ console.log("Could not read hopp.store.json:", error)
+ }
+
+ // Read from hoppscotch-desktop.store if it exists
+ try {
+ if (await exists(desktopStorePath)) {
+ const desktopStore = new LazyStore(desktopStorePath)
+ await desktopStore.init()
+
+ // Only override connection state if we didn't get one from hopp.store
+ if (connectionState.status === "idle") {
+ const desktopState =
+ await desktopStore.get("connectionState")
+ if (desktopState && desktopState.status === "connected") {
+ connectionState = {
+ status: "connected",
+ instance: this.convertInstanceToNewFormat(desktopState.instance),
+ }
+ }
+ }
+
+ updateState = await desktopStore.get("updateState")
+ }
+ } catch (error) {
+ console.log("Could not read hoppscotch-desktop.store:", error)
+ }
+
+ try {
+ await Store.set(STORE_NAMESPACE, "connectionState", connectionState)
+ console.log("Migrated connection state")
+ } catch (error) {
+ console.error("Failed to save connection state:", error)
+ }
+
+ try {
+ await Store.set(STORE_NAMESPACE, "recentInstances", recentInstances)
+ console.log(`Migrated ${recentInstances.length} recent instances`)
+ } catch (error) {
+ console.error("Failed to save recent instances:", error)
+ }
+
+ if (updateState) {
+ try {
+ await Store.set(STORE_NAMESPACE, "updateState", updateState)
+ console.log("Migrated update state")
+ } catch (error) {
+ console.error("Failed to save update state:", error)
+ }
+ }
+ }
+
+ private async migrateHoppscotchStoreFiles(): Promise {
+ try {
+ const configDir = await getConfigDir()
+ const storeDir = await getStoreDir()
+
+ const entries = await readDir(configDir)
+ const storeFiles = entries
+ .filter(
+ (entry: any) =>
+ !entry.isDirectory && entry.name.endsWith(".hoppscotch.store")
+ )
+ .map((entry: any) => entry.name)
+
+ for (const fileName of storeFiles) {
+ try {
+ const sourcePath = await join(configDir, fileName)
+ const targetPath = await join(storeDir, fileName)
+
+ // Check if source still exists and target doesn't
+ const sourceExists = await exists(sourcePath)
+ const targetExists = await exists(targetPath)
+
+ if (sourceExists && !targetExists) {
+ await copyFile(sourcePath, targetPath)
+ console.log(`Migrated ${fileName} to store directory`)
+ } else if (sourceExists && targetExists) {
+ console.log(`${fileName} already exists in target, skipping`)
+ }
+ } catch (error) {
+ console.error(`Failed to migrate ${fileName}:`, error)
+ // Continue with other files
+ }
+ }
+ } catch (error) {
+ console.error("Failed to migrate .hoppscotch.store files:", error)
+ // Non-critical, continue
+ }
+ }
+
+ private async cleanupOldFilesSafely(): Promise {
+ const configDir = await getConfigDir()
+
+ // List of files to potentially remove
+ const filesToClean = ["hopp.store.json", "hoppscotch-desktop.store"]
+
+ for (const fileName of filesToClean) {
+ try {
+ const filePath = await join(configDir, fileName)
+ if (await exists(filePath)) {
+ await remove(filePath)
+ console.log(`Cleaned up old file: ${fileName}`)
+ }
+ } catch (error) {
+ console.log(`Could not remove ${fileName}:`, error)
+ // Non-critical, continue
+ }
+ }
+
+ // Also clean up .hoppscotch.store files that were successfully migrated
+ try {
+ const storeDir = await getStoreDir()
+ const entries = await readDir(configDir)
+ const storeFiles = entries
+ .filter(
+ (entry: any) =>
+ !entry.isDirectory && entry.name.endsWith(".hoppscotch.store")
+ )
+ .map((entry: any) => entry.name)
+
+ for (const fileName of storeFiles) {
+ try {
+ const sourcePath = await join(configDir, fileName)
+ const targetPath = await join(storeDir, fileName)
+
+ // Only remove if successfully copied to target
+ if ((await exists(targetPath)) && (await exists(sourcePath))) {
+ await remove(sourcePath)
+ console.log(`Cleaned up migrated file: ${fileName}`)
+ }
+ } catch (error) {
+ console.log(`Could not remove ${fileName}:`, error)
+ }
+ }
+ } catch (error) {
+ console.log("Could not clean up .hoppscotch.store files:", error)
+ }
+ }
+
+ private convertInstanceToNewFormat(
+ legacyInstance: LegacyInstanceKind
+ ): Instance {
+ if (legacyInstance.type === "cloud") {
+ return {
+ kind: "cloud",
+ serverUrl: "hoppscotch.io",
+ displayName: legacyInstance.displayName,
+ version: legacyInstance.version,
+ lastUsed: new Date().toISOString(),
+ }
+ } else {
+ return {
+ kind: "on-prem",
+ serverUrl: legacyInstance.serverUrl,
+ displayName: legacyInstance.displayName,
+ version: legacyInstance.version,
+ lastUsed: legacyInstance.lastUsed,
+ bundleName: legacyInstance.bundleName,
+ }
+ }
+ }
+
+ public getMigrationStatusStream(): Observable {
+ return this.status$
+ }
+
+ public getMigrationStatus() {
+ return computed(() => this.status$.value)
+ }
+
+ public isMigrationCompleted() {
+ return computed(() => this.status$.value.status === "completed")
+ }
+
+ public getMigrationError() {
+ return computed(() => {
+ const status = this.status$.value
+ return status.status === "failed" ? status.error : null
+ })
+ }
+}
diff --git a/packages/hoppscotch-desktop/src/services/persistence.service.ts b/packages/hoppscotch-desktop/src/services/persistence.service.ts
new file mode 100644
index 00000000..025e7b14
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/services/persistence.service.ts
@@ -0,0 +1,262 @@
+import * as E from "fp-ts/Either"
+import { z } from "zod"
+import { StoreError } from "@hoppscotch/kernel"
+import { Store } from "~/kernel/store"
+import { UpdateState, PortableSettings } from "~/types"
+
+export const STORE_NAMESPACE = "hoppscotch-desktop.v1"
+
+export const STORE_KEYS = {
+ UPDATE_STATE: "updateState",
+ CONNECTION_STATE: "connectionState",
+ RECENT_INSTANCES: "recentInstances",
+ SCHEMA_VERSION: "schema_version",
+ PORTABLE_SETTINGS: "portableSettings",
+} as const
+
+export const UPDATE_STATE_SCHEMA = z.object({
+ status: z.enum([
+ "idle",
+ "checking",
+ "available",
+ "not_available",
+ "downloading",
+ "installing",
+ "ready_to_restart",
+ "error",
+ ]),
+ version: z.string().optional(),
+ message: z.string().optional(),
+ progress: z
+ .object({
+ downloaded: z.number(),
+ total: z.number().optional(),
+ })
+ .optional(),
+})
+
+export const INSTANCE_SCHEMA = z.object({
+ kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]),
+ serverUrl: z.string(),
+ displayName: z.string(),
+ version: z.string(),
+ lastUsed: z.string(),
+ bundleName: z.string().optional(),
+})
+
+export const CONNECTION_STATE_SCHEMA = z.object({
+ status: z.enum(["idle", "connecting", "connected", "error"]),
+ instance: INSTANCE_SCHEMA.optional(),
+ target: z.string().optional(),
+ message: z.string().optional(),
+})
+
+export const PORTABLE_SETTINGS_SCHEMA = z.object({
+ disableUpdateNotifications: z.boolean(),
+ autoSkipWelcome: z.boolean(),
+})
+
+export type InstanceKind = z.infer["kind"]
+export type Instance = z.infer
+export type ConnectionState = z.infer
+
+interface Migration {
+ version: number
+ migrate: () => Promise
+}
+
+const migrations: Migration[] = [
+ {
+ version: 1,
+ migrate: async () => {},
+ },
+]
+
+export class DesktopPersistenceService {
+ private static instance: DesktopPersistenceService
+
+ private constructor() {}
+
+ public static getInstance(): DesktopPersistenceService {
+ if (!DesktopPersistenceService.instance) {
+ DesktopPersistenceService.instance = new DesktopPersistenceService()
+ }
+ return DesktopPersistenceService.instance
+ }
+
+ async init(): Promise> {
+ const initResult = await Store.init()
+ if (E.isLeft(initResult)) {
+ console.error(
+ "[PersistenceService] Failed to initialize store:",
+ initResult.left
+ )
+ return initResult
+ }
+ await this.runMigrations()
+ return initResult
+ }
+
+ private async runMigrations() {
+ const versionResult = await Store.get(
+ STORE_NAMESPACE,
+ STORE_KEYS.SCHEMA_VERSION
+ )
+ const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1"
+ const currentVersion = perhapsVersion ?? "1"
+ const targetVersion = "1"
+
+ if (currentVersion !== targetVersion) {
+ for (const migration of migrations) {
+ if (migration.version > parseInt(currentVersion)) {
+ await migration.migrate()
+ }
+ }
+
+ await Store.set(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION, targetVersion)
+ }
+ }
+
+ async setUpdateState(state: UpdateState): Promise {
+ const result = await Store.set(
+ STORE_NAMESPACE,
+ STORE_KEYS.UPDATE_STATE,
+ state
+ )
+ if (E.isLeft(result)) {
+ console.error("Failed to save update state:", result.left)
+ }
+ }
+
+ async getUpdateState(): Promise {
+ const result = await Store.get(
+ STORE_NAMESPACE,
+ STORE_KEYS.UPDATE_STATE
+ )
+ if (E.isRight(result) && result.right) {
+ return result.right
+ }
+ return null
+ }
+
+ async watchUpdateState(
+ handler: (state: UpdateState) => void
+ ): Promise<() => void> {
+ const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.UPDATE_STATE)
+ return watcher.on("change", ({ value }: { value?: unknown }) => {
+ if (value) {
+ handler(value as UpdateState)
+ }
+ })
+ }
+
+ async setConnectionState(state: ConnectionState): Promise {
+ const result = await Store.set(
+ STORE_NAMESPACE,
+ STORE_KEYS.CONNECTION_STATE,
+ state
+ )
+ if (E.isLeft(result)) {
+ console.error("Failed to save connection state:", result.left)
+ }
+ }
+
+ async getConnectionState(): Promise {
+ const result = await Store.get(
+ STORE_NAMESPACE,
+ STORE_KEYS.CONNECTION_STATE
+ )
+ if (E.isRight(result) && result.right) {
+ return result.right
+ }
+ return null
+ }
+
+ async setRecentInstances(instances: Instance[]): Promise {
+ const result = await Store.set(
+ STORE_NAMESPACE,
+ STORE_KEYS.RECENT_INSTANCES,
+ instances
+ )
+ if (E.isLeft(result)) {
+ console.error("Failed to save recent instances:", result.left)
+ }
+ }
+
+ async getRecentInstances(): Promise {
+ const result = await Store.get(
+ STORE_NAMESPACE,
+ STORE_KEYS.RECENT_INSTANCES
+ )
+ if (E.isRight(result) && result.right) {
+ return result.right
+ }
+ return []
+ }
+
+ async addRecentInstance(instance: Instance): Promise {
+ const instances = await this.getRecentInstances()
+ const existingIndex = instances.findIndex(
+ (i) => i.kind === instance.kind && i.serverUrl === instance.serverUrl
+ )
+
+ if (existingIndex >= 0) {
+ instances[existingIndex] = {
+ ...instance,
+ lastUsed: new Date().toISOString(),
+ }
+ } else {
+ instances.unshift({ ...instance, lastUsed: new Date().toISOString() })
+ }
+
+ const sortedInstances = instances
+ .sort(
+ (a, b) =>
+ new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
+ )
+ .slice(0, 10)
+
+ await this.setRecentInstances(sortedInstances)
+ }
+
+ async removeRecentInstance(serverUrl: string): Promise {
+ const instances = await this.getRecentInstances()
+ const filtered = instances.filter((i) => i.serverUrl !== serverUrl)
+ await this.setRecentInstances(filtered)
+ }
+
+ async setPortableSettings(settings: PortableSettings): Promise {
+ console.log("Setting portable settings:", settings)
+ const result = await Store.set(
+ STORE_NAMESPACE,
+ STORE_KEYS.PORTABLE_SETTINGS,
+ settings
+ )
+ if (E.isLeft(result)) {
+ console.error("Failed to save portable settings:", result.left)
+ throw new Error(`Failed to save portable settings: ${result.left}`)
+ } else {
+ console.log("Successfully saved portable settings")
+ }
+ }
+
+ async getPortableSettings(): Promise {
+ const result = await Store.get(
+ STORE_NAMESPACE,
+ STORE_KEYS.PORTABLE_SETTINGS
+ )
+
+ const defaultSettings = {
+ disableUpdateNotifications: false,
+ autoSkipWelcome: false,
+ }
+
+ if (E.isRight(result) && result.right) {
+ console.log("Loaded portable settings from store:", result.right)
+ return result.right
+ }
+
+ console.log("No portable settings found, using defaults:", defaultSettings)
+ return defaultSettings
+ }
+}
diff --git a/packages/hoppscotch-desktop/src/services/updater.client.ts b/packages/hoppscotch-desktop/src/services/updater.client.ts
new file mode 100644
index 00000000..45a26f9b
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/services/updater.client.ts
@@ -0,0 +1,73 @@
+import { invoke } from "@tauri-apps/api/core"
+import { listen, type UnlistenFn } from "@tauri-apps/api/event"
+
+export interface UpdateInfo {
+ available: boolean
+ currentVersion: string
+ latestVersion?: string
+ releaseNotes?: string
+}
+
+export interface DownloadProgress {
+ downloaded: number
+ total?: number
+ percentage: number
+}
+
+// TODO: Type safety just like `persistence.serivce.ts`?
+export type UpdateEvent =
+ | { type: "CheckStarted" }
+ | { type: "CheckCompleted"; info: UpdateInfo }
+ | { type: "CheckFailed"; error: string }
+ | { type: "DownloadStarted"; totalBytes?: number }
+ | { type: "DownloadProgress"; progress: DownloadProgress }
+ | { type: "DownloadCompleted" }
+ | { type: "InstallStarted" }
+ | { type: "InstallCompleted" }
+ | { type: "RestartRequired" }
+ | { type: "UpdateCancelled" }
+ | { type: "Error"; message: string }
+
+export class UpdaterClient {
+ private unlistenFn?: UnlistenFn
+
+ async checkForUpdates(showNativeDialog = false): Promise {
+ return invoke("check_for_updates", { showNativeDialog })
+ }
+
+ async downloadAndInstall(): Promise {
+ return invoke("download_and_install_update")
+ }
+
+ async restart(): Promise {
+ return invoke("restart_application")
+ }
+
+ async cancel(): Promise {
+ return invoke("cancel_update")
+ }
+
+ async getDownloadProgress(): Promise {
+ return invoke("get_download_progress")
+ }
+
+ async isPortableMode(): Promise {
+ return invoke("is_portable_mode")
+ }
+
+ async listenToUpdates(
+ callback: (event: UpdateEvent) => void
+ ): Promise {
+ this.unlistenFn = await listen("updater-event", (event) => {
+ callback(event.payload as UpdateEvent)
+ })
+ return this.unlistenFn
+ }
+
+ stopListening(): void {
+ if (this.unlistenFn) {
+ this.unlistenFn()
+ this.unlistenFn = undefined
+ }
+ }
+}
diff --git a/packages/hoppscotch-desktop/src/types/index.ts b/packages/hoppscotch-desktop/src/types/index.ts
index b7c0921e..2f44a6d7 100644
--- a/packages/hoppscotch-desktop/src/types/index.ts
+++ b/packages/hoppscotch-desktop/src/types/index.ts
@@ -32,3 +32,8 @@ export interface UpdateState {
version?: string
message?: string
}
+
+export interface PortableSettings {
+ disableUpdateNotifications: boolean
+ autoSkipWelcome: boolean
+}
diff --git a/packages/hoppscotch-desktop/src/views/PortableHome.vue b/packages/hoppscotch-desktop/src/views/PortableHome.vue
new file mode 100644
index 00000000..e37768c3
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/views/PortableHome.vue
@@ -0,0 +1,281 @@
+
+
+
+
+
+
+
+
+ Portable Mode Information
+
+
+
+
+
Data Storage
+
+ -
+ • Your data is in the directory you launched this app from.
+
+ - • This won't sync with the installed version.
+
+
+
+
+
+ Why no automatic data transfer?
+
+
+ Portable apps avoid accessing system directories to maintain
+ compatibility with enterprise security policies.
+
+
+
+
+
+ Updates & Migration
+
+
+ - • Updates require manual download
+ - • Data persists when updating portable versions
+ -
+ • Manually copy files to transfer from installed version
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-desktop/src/views/StandardHome.vue b/packages/hoppscotch-desktop/src/views/StandardHome.vue
new file mode 100644
index 00000000..e0a89e3a
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/views/StandardHome.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
diff --git a/packages/hoppscotch-desktop/src/views/shared/AppHeader.vue b/packages/hoppscotch-desktop/src/views/shared/AppHeader.vue
new file mode 100644
index 00000000..b6a7a5af
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/views/shared/AppHeader.vue
@@ -0,0 +1,21 @@
+
+
+

+
+
Hoppscotch
+
+ Desktop {{ mode ? `(${mode})` : "" }}
+
+
+
+
+
+
diff --git a/packages/hoppscotch-desktop/src/views/shared/ErrorState.vue b/packages/hoppscotch-desktop/src/views/shared/ErrorState.vue
new file mode 100644
index 00000000..fb1fdc65
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/views/shared/ErrorState.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Something went wrong
+
+
{{ error }}
+
+
+
+
+
+
diff --git a/packages/hoppscotch-desktop/src/views/shared/LoadingState.vue b/packages/hoppscotch-desktop/src/views/shared/LoadingState.vue
new file mode 100644
index 00000000..6fb4e2ee
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/views/shared/LoadingState.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue b/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue
new file mode 100644
index 00000000..71d47852
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
Update Available
+
+ {{ message || "A new version of Hoppscotch is available" }}
+
+
+
+
+
+
+ {{ Math.round(progress.percentage) }}%
+
+ {{ formatBytes(progress.downloaded) }} /
+ {{ formatBytes(progress.total) }}
+
+
+
+
+
+
+
+ Downloaded {{ formatBytes(progress.downloaded) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-desktop/src/views/shared/VersionInfo.vue b/packages/hoppscotch-desktop/src/views/shared/VersionInfo.vue
new file mode 100644
index 00000000..0406f85b
--- /dev/null
+++ b/packages/hoppscotch-desktop/src/views/shared/VersionInfo.vue
@@ -0,0 +1,15 @@
+
+
+
Version {{ version }}
+
Data: {{ dataDirectory }}
+
+
+
+
diff --git a/packages/hoppscotch-selfhost-desktop/.envrc b/packages/hoppscotch-selfhost-desktop/.envrc
deleted file mode 100644
index 5bf8fc15..00000000
--- a/packages/hoppscotch-selfhost-desktop/.envrc
+++ /dev/null
@@ -1,3 +0,0 @@
-source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
-
-use devenv
\ No newline at end of file
diff --git a/packages/hoppscotch-selfhost-desktop/.gitignore b/packages/hoppscotch-selfhost-desktop/.gitignore
deleted file mode 100644
index 8aa22c98..00000000
--- a/packages/hoppscotch-selfhost-desktop/.gitignore
+++ /dev/null
@@ -1,39 +0,0 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-ssr
-*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
-
-# Sitemap
-.sitemap-gen
-
-# Backend Code generation
-src/api/generated
-# Devenv
-.devenv*
-devenv.local.nix
-
-# direnv
-.direnv
-
-# pre-commit
-.pre-commit-config.yaml
diff --git a/packages/hoppscotch-selfhost-desktop/.vscode/extensions.json b/packages/hoppscotch-selfhost-desktop/.vscode/extensions.json
deleted file mode 100644
index cf4385bd..00000000
--- a/packages/hoppscotch-selfhost-desktop/.vscode/extensions.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "recommendations": [
- "Vue.volar",
- "tauri-apps.tauri-vscode",
- "rust-lang.rust-analyzer"
- ]
-}
diff --git a/packages/hoppscotch-selfhost-desktop/README.md b/packages/hoppscotch-selfhost-desktop/README.md
deleted file mode 100644
index e6b0bd5e..00000000
--- a/packages/hoppscotch-selfhost-desktop/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Tauri + Vue 3 + TypeScript
-
-This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `
-
-
-