From 96ceb84df9a1aa48d8f5326124bf20a726946cab Mon Sep 17 00:00:00 2001 From: Shreyas Date: Tue, 28 Apr 2026 14:59:10 +0530 Subject: [PATCH] fix(desktop): unified store scope and migration reroute (#6238) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- .../src/composables/desktop-settings.ts | 18 ++- .../src/composables/update-check.ts | 17 ++- .../hoppscotch-common/src/kernel/store.ts | 141 ++++++++++++------ .../src/services/persistence.service.ts | 41 +++++ 4 files changed, 170 insertions(+), 47 deletions(-) diff --git a/packages/hoppscotch-common/src/composables/desktop-settings.ts b/packages/hoppscotch-common/src/composables/desktop-settings.ts index effc69c9..c8f66f8b 100644 --- a/packages/hoppscotch-common/src/composables/desktop-settings.ts +++ b/packages/hoppscotch-common/src/composables/desktop-settings.ts @@ -2,7 +2,13 @@ import { reactive, ref, readonly } from "vue" import * as E from "fp-ts/Either" import { invoke } from "@tauri-apps/api/core" -import { Store } from "~/kernel/store" +// Bind to the unified, process-wide store rather than the org-scoped +// default `Store`. Desktop settings are machine-level configuration +// (for example the "disable update checks" toggle), and the Tauri +// shell reads them through its own `kernel/store.ts` wrapper at the +// same physical path. Going through the org-scoped store would route +// writes to a different file and the shell would never see them. +import { UnifiedStore as Store } from "~/kernel/store" import { DESKTOP_SETTINGS_SCHEMA, DESKTOP_SETTINGS_STORE_KEY, @@ -52,6 +58,16 @@ const loaded = ref(false) let initPromise: Promise | undefined async function loadInitial(): Promise { + // Open the unified store before reading. The shell already inits this + // path through its own `DesktopPersistenceService.init`, but the + // webview runs in a separate window with its own process state, so + // the underlying Tauri store still needs to be opened here. Repeat + // calls land on the same on-disk file and are harmless. + const initResult = await Store.init() + if (E.isLeft(initResult)) { + Log.warn(LOG_TAG, "Failed to init unified store", initResult.left) + } + const result = await Store.get( DESKTOP_SETTINGS_STORE_NAMESPACE, DESKTOP_SETTINGS_STORE_KEY diff --git a/packages/hoppscotch-common/src/composables/update-check.ts b/packages/hoppscotch-common/src/composables/update-check.ts index 8b223b86..101a6942 100644 --- a/packages/hoppscotch-common/src/composables/update-check.ts +++ b/packages/hoppscotch-common/src/composables/update-check.ts @@ -3,7 +3,12 @@ import * as E from "fp-ts/Either" import { invoke } from "@tauri-apps/api/core" import { listen, type UnlistenFn } from "@tauri-apps/api/event" -import { Store } from "~/kernel/store" +// Bind to the unified, process-wide store rather than the org-scoped +// default `Store`. Persisted `UpdateState` is machine-level, not +// per-org, and the Tauri shell reads the same physical file through +// its own `kernel/store.ts` wrapper. Going through the org-scoped +// store would route writes to a file the shell never reads. +import { UnifiedStore as Store } from "~/kernel/store" import { UPDATE_STATE_SCHEMA, UPDATE_STATE_STORE_KEY, @@ -251,6 +256,16 @@ function nextState(current: UpdateState, event: UpdateEvent): UpdateState { } async function loadPersistedState(): Promise { + // Open the unified store before reading. The shell already opens + // this path through `DesktopPersistenceService.init`, but the + // webview runs in a separate window with its own process state, so + // the underlying Tauri store still needs to be opened here. Repeat + // calls land on the same on-disk file and are harmless. + const initResult = await Store.init() + if (E.isLeft(initResult)) { + Log.warn(LOG_TAG, "Failed to init unified store", initResult.left) + } + const result = await Store.get( UPDATE_STATE_STORE_NAMESPACE, UPDATE_STATE_STORE_KEY diff --git a/packages/hoppscotch-common/src/kernel/store.ts b/packages/hoppscotch-common/src/kernel/store.ts index 57b49ec5..eced46a0 100644 --- a/packages/hoppscotch-common/src/kernel/store.ts +++ b/packages/hoppscotch-common/src/kernel/store.ts @@ -25,21 +25,29 @@ import { diag } from "./log" // a beforeEach guard in modules/router.ts, and survives full-page reloads // because Tauri sets it on the initial webview URL const orgParam = new URLSearchParams(window.location.search).get("org") -const STORE_PATH = orgParam +const HOST_SCOPED_STORE_PATH = orgParam ? `${orgParam.replace(/[^a-zA-Z0-9]/g, "_")}.hoppscotch.store` : `${window.location.host}.hoppscotch.store` +// process-wide store file shared across orgs. holds machine-level state +// (desktop settings, recent-instances list, update state) that should +// not vary per organization. file name matches the path each shell's +// own `kernel/store.ts` wrapper writes to and the path +// `DesktopPersistenceService` uses on the Tauri side, so common +// composables that bind here read/write the same physical file the +// shell does. +const UNIFIED_STORE_PATH = "hoppscotch-unified.store" + diag("store", "--- COMMON store.ts module evaluated ---") diag("store", "orgParam:", orgParam ?? "(none)") -diag("store", "STORE_PATH:", STORE_PATH) +diag("store", "HOST_SCOPED_STORE_PATH:", HOST_SCOPED_STORE_PATH) +diag("store", "UNIFIED_STORE_PATH:", UNIFIED_STORE_PATH) diag("store", "window.location.host:", window.location.host) diag("store", "window.location.href:", window.location.href) -let cachedStorePath: string | undefined - -// These are only defined functions if in desktop mode. -// For more context, take a look at how `hoppscotch-kernel/.../store/v1/` works -// and how the `web` mode store kernel ignores the first file directory input. +// Lazy-loaded Tauri APIs. Module-scoped so every scoped store shares +// the init step and the loaded modules. Web mode never resolves these +// because `isInitd` returns early outside desktop. let invoke: | ((cmd: string, args?: Record) => Promise) | undefined @@ -102,40 +110,56 @@ export const getLogsDir = async (): Promise => { return await invoke("get_logs_dir") } -const getStorePath = async (): Promise => { - if (cachedStorePath) { - diag("store", "getStorePath: returning cached:", cachedStorePath) +// Factory for a Store wrapper bound to a specific store file. Each +// instance keeps its own resolved-path cache so two scoped stores +// never alias their absolute paths. Tauri-API loading and kernel +// module access are module-scoped above, so the factory only +// handles the per-store concerns. +function createScopedStore(staticPath: string) { + let cachedStorePath: string | undefined + + const getStorePath = async (): Promise => { + if (cachedStorePath) { + diag( + "store", + `getStorePath(${staticPath}): returning cached:`, + cachedStorePath + ) + return cachedStorePath + } + + if (getKernelMode() === "desktop") { + await isInitd() + if (join) { + try { + const storeDir = await getStoreDir() + cachedStorePath = await join(storeDir, staticPath) + diag( + "store", + `getStorePath(${staticPath}): resolved desktop path:`, + cachedStorePath + ) + return cachedStorePath + } catch (error) { + diag( + "store", + `getStorePath(${staticPath}): failed to get store dir:`, + String(error) + ) + console.error("Failed to get store directory:", error) + } + } + } + + cachedStorePath = staticPath + diag( + "store", + `getStorePath(${staticPath}): using fallback path:`, + cachedStorePath + ) return cachedStorePath } - if (getKernelMode() === "desktop") { - await isInitd() - if (join) { - try { - const storeDir = await getStoreDir() - cachedStorePath = await join(storeDir, STORE_PATH) - diag( - "store", - "getStorePath: resolved desktop path:", - cachedStorePath, - "(STORE_PATH:", - STORE_PATH, - ")" - ) - return cachedStorePath - } catch (error) { - diag("store", "getStorePath: failed to get store dir:", String(error)) - console.error("Failed to get store directory:", error) - } - } - } - - cachedStorePath = STORE_PATH - diag("store", "getStorePath: using fallback STORE_PATH:", cachedStorePath) - return cachedStorePath -} - -export const Store = (() => { const module = () => getModule("store") return { @@ -143,9 +167,9 @@ export const Store = (() => { init: async () => { const storePath = await getStorePath() - diag("store", "Store.init() called with path:", storePath) + diag("store", `Store.init(${staticPath}) called with path:`, storePath) const result = await module().init(storePath) - diag("store", "Store.init() completed for path:", storePath) + diag("store", `Store.init(${staticPath}) completed for path:`, storePath) return result }, @@ -156,7 +180,11 @@ export const Store = (() => { options?: StorageOptions ): Promise> => { const storePath = await getStorePath() - diag("store", `Store.set(${namespace}, ${key}) on path:`, storePath) + diag( + "store", + `Store.set(${namespace}, ${key}) on ${staticPath}:`, + storePath + ) return module().set(storePath, namespace, key, value, options) }, @@ -165,7 +193,11 @@ export const Store = (() => { key: string ): Promise> => { const storePath = await getStorePath() - diag("store", `Store.get(${namespace}, ${key}) on path:`, storePath) + diag( + "store", + `Store.get(${namespace}, ${key}) on ${staticPath}:`, + storePath + ) const result = await module().get(storePath, namespace, key) if (E.isRight(result)) { const val = result.right @@ -177,9 +209,16 @@ export const Store = (() => { : typeof val === "object" ? `object(${Object.keys(val as Record).length} keys)` : typeof val - diag("store", `Store.get(${namespace}, ${key}) => Right(${shape})`) + diag( + "store", + `Store.get(${namespace}, ${key}) on ${staticPath} => Right(${shape})` + ) } else { - diag("store", `Store.get(${namespace}, ${key}) => Left:`, result.left) + diag( + "store", + `Store.get(${namespace}, ${key}) on ${staticPath} => Left:`, + result.left + ) } return result }, @@ -230,4 +269,16 @@ export const Store = (() => { return extendStore(module(), storePath, namespace) }, } as const -})() +} + +// Org-scoped store. Holds per-org state (auth tokens, collections, +// environments, settings that vary by organization). Default Store +// for almost every consumer in common. +export const Store = createScopedStore(HOST_SCOPED_STORE_PATH) + +// Process-wide store shared across orgs. Holds machine-level state +// like desktop settings, the recent-instances list, and update state. +// Use this for anything that should persist regardless of which org +// the user is viewing, and for state that the desktop shell also +// reads or writes through its own kernel/store wrapper. +export const UnifiedStore = createScopedStore(UNIFIED_STORE_PATH) diff --git a/packages/hoppscotch-desktop/src/services/persistence.service.ts b/packages/hoppscotch-desktop/src/services/persistence.service.ts index 44d54bcb..adbf4c25 100644 --- a/packages/hoppscotch-desktop/src/services/persistence.service.ts +++ b/packages/hoppscotch-desktop/src/services/persistence.service.ts @@ -111,6 +111,47 @@ const migrations: Migration[] = [ // migration can prune it once the v2 definition has stabilized. version: 2, migrate: async () => { + // Decide whether to skip based on the existing `desktopSettings` + // payload. Two paths can re-run this migration after it succeeded + // once. A user downgrades to a pre-v2 build, which resets + // `SCHEMA_VERSION` to "1" because the older code does not + // recognize "2" and rolls it back. A re-upgrade then sees v1 + // again and tries to migrate. The other path is a corrupted + // `SCHEMA_VERSION` value, which the `runMigrations` parse-defense + // coerces to "1" so every migration reruns from scratch. In both + // cases, blindly running the legacy carry-forward would + // overwrite any user-set v2 fields with + // `disableUpdateNotifications` plus schema defaults, undoing the + // user's work. + // + // Three reachable cases here, each handled explicitly. A `Left` + // from `Store.get` means the store is degraded (file I/O + // failure, or not yet open) and there is no way to tell whether + // v2 already ran. Propagating the `Left` aborts the migration + // before `runMigrations` bumps `SCHEMA_VERSION`, so the next + // launch retries on a hopefully-recovered store. A `Right` with + // a present and schema-valid payload is the canonical "v2 + // already happened" signal, since the migration itself is what + // writes a valid payload, so a stored value implies the + // migration ran successfully at least once. A `Right` with + // either no payload (fresh install) or a malformed payload + // (partial object, wrong field types) falls through to the + // legacy carry-forward, which writes a fresh schema-defaults + // `desktopSettings` and self-heals the corruption. + const existingResult = await Store.get( + STORE_NAMESPACE, + STORE_KEYS.DESKTOP_SETTINGS + ) + if (E.isLeft(existingResult)) { + return existingResult + } + if ( + existingResult.right !== undefined && + DESKTOP_SETTINGS_SCHEMA.safeParse(existingResult.right).success + ) { + return E.right(undefined) + } + const legacyResult = await Store.get>( STORE_NAMESPACE, STORE_KEYS.PORTABLE_SETTINGS