import type { StorageOptions, StoreError, StoreEvents, StoreEventEmitter, ScopedStore, } from "@hoppscotch/kernel" import { extendStore } from "@hoppscotch/kernel" import * as E from "fp-ts/Either" import { getModule } from "." import { getKernelMode } from "@hoppscotch/kernel" import { diag } from "./log" // on desktop, org webviews share the same app:// origin as the main webview // (to keep Tauri IPC working). the org context is passed as a query param // (?org=test-org.hoppscotch.io) instead. we include it in the store path so // each org gets its own store file on disk, preserving per-org isolation for // auth tokens, settings, collections, etc. // // the org param is the raw host (e.g. "test-org.hoppscotch.io") so we // sanitize it the same way Tauri sanitizes window labels: replace all // non-alphanumeric chars with underscores. this produces the same filename // as the old per-hostname approach (test_org_hoppscotch_io.hoppscotch.store) // the ?org= query param is preserved across Vue Router navigations by // 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 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", "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) // 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 let join: ((...paths: string[]) => Promise) | undefined // Single init promise to avoid multiple imports and race conditions let initPromise: Promise | undefined const isInitd = async () => { if (getKernelMode() !== "desktop") return if (!initPromise) { initPromise = Promise.all([ import("@tauri-apps/api/core").then((module) => { invoke = module.invoke }), import("@tauri-apps/api/path").then((module) => { join = module.join }), ]).then(() => {}) } await initPromise } export const getConfigDir = async (): Promise => { await isInitd() if (!invoke) throw new Error("getConfigDir is only available in desktop mode") return await invoke("get_config_dir") } export const getBackupDir = async (): Promise => { await isInitd() if (!invoke) throw new Error("getBackupDir is only available in desktop mode") return await invoke("get_backup_dir") } export const getLatestDir = async (): Promise => { await isInitd() if (!invoke) throw new Error("getLatestDir is only available in desktop mode") return await invoke("get_latest_dir") } export const getStoreDir = async (): Promise => { await isInitd() if (!invoke) throw new Error("getStoreDir is only available in desktop mode") return await invoke("get_store_dir") } export const getInstanceDir = async (): Promise => { await isInitd() if (!invoke) throw new Error("getInstanceDir is only available in desktop mode") return await invoke("get_instance_dir") } export const getLogsDir = async (): Promise => { await isInitd() if (!invoke) throw new Error("getLogsDir is only available in desktop mode") return await invoke("get_logs_dir") } // 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 } const module = () => getModule("store") return { capabilities: () => module().capabilities, init: async () => { const storePath = await getStorePath() diag("store", `Store.init(${staticPath}) called with path:`, storePath) const result = await module().init(storePath) diag("store", `Store.init(${staticPath}) completed for path:`, storePath) return result }, set: async ( namespace: string, key: string, value: unknown, options?: StorageOptions ): Promise> => { const storePath = await getStorePath() diag( "store", `Store.set(${namespace}, ${key}) on ${staticPath}:`, storePath ) return module().set(storePath, namespace, key, value, options) }, get: async ( namespace: string, key: string ): Promise> => { const storePath = await getStorePath() 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 const shape = val === undefined ? "undefined" : val === null ? "null" : typeof val === "object" ? `object(${Object.keys(val as Record).length} keys)` : typeof val diag( "store", `Store.get(${namespace}, ${key}) on ${staticPath} => Right(${shape})` ) } else { diag( "store", `Store.get(${namespace}, ${key}) on ${staticPath} => Left:`, result.left ) } return result }, remove: async ( namespace: string, key: string ): Promise> => { const storePath = await getStorePath() return module().remove(storePath, namespace, key) }, clear: async (namespace?: string): Promise> => { const storePath = await getStorePath() return module().clear(storePath, namespace) }, has: async ( namespace: string, key: string ): Promise> => { const storePath = await getStorePath() return module().has(storePath, namespace, key) }, listNamespaces: async (): Promise> => { const storePath = await getStorePath() return module().listNamespaces(storePath) }, listKeys: async ( namespace: string ): Promise> => { const storePath = await getStorePath() return module().listKeys(storePath, namespace) }, watch: async ( namespace: string, key: string ): Promise> => { const storePath = await getStorePath() return module().watch(storePath, namespace, key) }, extend: async (namespace: string): Promise => { const storePath = await getStorePath() 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)