fix(desktop): unified store scope and migration reroute (#6238)

Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Shreyas 2026-04-28 14:59:10 +05:30 committed by GitHub
parent df97d11753
commit 96ceb84df9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 170 additions and 47 deletions

View file

@ -2,7 +2,13 @@ import { reactive, ref, readonly } from "vue"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { invoke } from "@tauri-apps/api/core" 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 { import {
DESKTOP_SETTINGS_SCHEMA, DESKTOP_SETTINGS_SCHEMA,
DESKTOP_SETTINGS_STORE_KEY, DESKTOP_SETTINGS_STORE_KEY,
@ -52,6 +58,16 @@ const loaded = ref(false)
let initPromise: Promise<void> | undefined let initPromise: Promise<void> | undefined
async function loadInitial(): Promise<void> { async function loadInitial(): Promise<void> {
// 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<unknown>( const result = await Store.get<unknown>(
DESKTOP_SETTINGS_STORE_NAMESPACE, DESKTOP_SETTINGS_STORE_NAMESPACE,
DESKTOP_SETTINGS_STORE_KEY DESKTOP_SETTINGS_STORE_KEY

View file

@ -3,7 +3,12 @@ import * as E from "fp-ts/Either"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { listen, type UnlistenFn } from "@tauri-apps/api/event" 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 { import {
UPDATE_STATE_SCHEMA, UPDATE_STATE_SCHEMA,
UPDATE_STATE_STORE_KEY, UPDATE_STATE_STORE_KEY,
@ -251,6 +256,16 @@ function nextState(current: UpdateState, event: UpdateEvent): UpdateState {
} }
async function loadPersistedState(): Promise<void> { async function loadPersistedState(): Promise<void> {
// 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<PersistedUpdateState | null>( const result = await Store.get<PersistedUpdateState | null>(
UPDATE_STATE_STORE_NAMESPACE, UPDATE_STATE_STORE_NAMESPACE,
UPDATE_STATE_STORE_KEY UPDATE_STATE_STORE_KEY

View file

@ -25,21 +25,29 @@ import { diag } from "./log"
// a beforeEach guard in modules/router.ts, and survives full-page reloads // a beforeEach guard in modules/router.ts, and survives full-page reloads
// because Tauri sets it on the initial webview URL // because Tauri sets it on the initial webview URL
const orgParam = new URLSearchParams(window.location.search).get("org") 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` ? `${orgParam.replace(/[^a-zA-Z0-9]/g, "_")}.hoppscotch.store`
: `${window.location.host}.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", "--- COMMON store.ts module evaluated ---")
diag("store", "orgParam:", orgParam ?? "(none)") 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.host:", window.location.host)
diag("store", "window.location.href:", window.location.href) diag("store", "window.location.href:", window.location.href)
let cachedStorePath: string | undefined // Lazy-loaded Tauri APIs. Module-scoped so every scoped store shares
// the init step and the loaded modules. Web mode never resolves these
// These are only defined functions if in desktop mode. // because `isInitd` returns early outside desktop.
// 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.
let invoke: let invoke:
| (<T>(cmd: string, args?: Record<string, unknown>) => Promise<T>) | (<T>(cmd: string, args?: Record<string, unknown>) => Promise<T>)
| undefined | undefined
@ -102,40 +110,56 @@ export const getLogsDir = async (): Promise<string> => {
return await invoke<string>("get_logs_dir") return await invoke<string>("get_logs_dir")
} }
const getStorePath = async (): Promise<string> => { // Factory for a Store wrapper bound to a specific store file. Each
if (cachedStorePath) { // instance keeps its own resolved-path cache so two scoped stores
diag("store", "getStorePath: returning cached:", cachedStorePath) // 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<string> => {
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 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") const module = () => getModule("store")
return { return {
@ -143,9 +167,9 @@ export const Store = (() => {
init: async () => { init: async () => {
const storePath = await getStorePath() 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) 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 return result
}, },
@ -156,7 +180,11 @@ export const Store = (() => {
options?: StorageOptions options?: StorageOptions
): Promise<E.Either<StoreError, void>> => { ): Promise<E.Either<StoreError, void>> => {
const storePath = await getStorePath() 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) return module().set(storePath, namespace, key, value, options)
}, },
@ -165,7 +193,11 @@ export const Store = (() => {
key: string key: string
): Promise<E.Either<StoreError, T | undefined>> => { ): Promise<E.Either<StoreError, T | undefined>> => {
const storePath = await getStorePath() 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<T>(storePath, namespace, key) const result = await module().get<T>(storePath, namespace, key)
if (E.isRight(result)) { if (E.isRight(result)) {
const val = result.right const val = result.right
@ -177,9 +209,16 @@ export const Store = (() => {
: typeof val === "object" : typeof val === "object"
? `object(${Object.keys(val as Record<string, unknown>).length} keys)` ? `object(${Object.keys(val as Record<string, unknown>).length} keys)`
: typeof val : typeof val
diag("store", `Store.get(${namespace}, ${key}) => Right(${shape})`) diag(
"store",
`Store.get(${namespace}, ${key}) on ${staticPath} => Right(${shape})`
)
} else { } else {
diag("store", `Store.get(${namespace}, ${key}) => Left:`, result.left) diag(
"store",
`Store.get(${namespace}, ${key}) on ${staticPath} => Left:`,
result.left
)
} }
return result return result
}, },
@ -230,4 +269,16 @@ export const Store = (() => {
return extendStore(module(), storePath, namespace) return extendStore(module(), storePath, namespace)
}, },
} as const } 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)

View file

@ -111,6 +111,47 @@ const migrations: Migration[] = [
// migration can prune it once the v2 definition has stabilized. // migration can prune it once the v2 definition has stabilized.
version: 2, version: 2,
migrate: async () => { 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<unknown>(
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<Partial<LegacyPortableSettings>>( const legacyResult = await Store.get<Partial<LegacyPortableSettings>>(
STORE_NAMESPACE, STORE_NAMESPACE,
STORE_KEYS.PORTABLE_SETTINGS STORE_KEYS.PORTABLE_SETTINGS