api-client/packages/hoppscotch-common/src/composables/desktop-settings.ts
Shreyas 9861ee84ad
feat(desktop): settings phase 0 - infra and update check (#6172)
Co-authored-by: VicenzoMF <81040684+VicenzoMF@users.noreply.github.com>
2026-04-28 00:36:06 +05:30

164 lines
5.8 KiB
TypeScript

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"
import {
DESKTOP_SETTINGS_SCHEMA,
DESKTOP_SETTINGS_STORE_KEY,
DESKTOP_SETTINGS_STORE_NAMESPACE,
parseDesktopSettings,
type DesktopSettings,
} from "~/platform/desktop-settings"
import { Log } from "~/kernel/log"
const LOG_TAG = "useDesktopSettings"
/**
* Webview-side accessor for the desktop-app user settings.
*
* Reads and writes through `tauri-plugin-store` under the same namespace
* and key as the Tauri shell's persistence service, and mirrors every
* webview-originated write into the Rust-side `DESKTOP_CONFIG` mailbox
* via the `set_desktop_config` Tauri command.
*
* Why the webview handles its own Rust sync rather than relying on the
* shell's watch-based sync: the shell window closes once `appload` loads
* this webview bundle, which tears down the shell's persistence service
* and its watch subscription. Writes made after that point have no shell
* listener to forward them, so the webview owns the sync for its own
* lifetime. The shell's sync handles initial prime at app startup plus
* any shell-originated writes (the portable welcome screen) during its
* short pre-webview life.
*
* `update(key, value)` is transactional: the reactive object is mutated
* first so callers see an optimistic update, but a persist failure rolls
* the reactive back to its previous value and rethrows, so in-memory
* state never drifts from what's in the store.
*
* Module-level singleton: every caller shares the same reactive object
* so the settings section and any other consumer bound to these values
* stay coherent.
*/
type UpdateFn = <K extends keyof DesktopSettings>(
key: K,
value: DesktopSettings[K]
) => Promise<void>
// Singleton state, initialized on first `useDesktopSettings()` call.
const settings = reactive<DesktopSettings>(DESKTOP_SETTINGS_SCHEMA.parse({}))
const loaded = ref(false)
let initPromise: Promise<void> | undefined
async function loadInitial(): Promise<void> {
const result = await Store.get<unknown>(
DESKTOP_SETTINGS_STORE_NAMESPACE,
DESKTOP_SETTINGS_STORE_KEY
)
const raw = E.isRight(result) ? result.right : undefined
Object.assign(settings, parseDesktopSettings(raw))
loaded.value = true
// Subscribe to external writes (for example the Tauri shell's portable
// welcome screen) so the reactive object stays current. One subscription
// per process is enough because the reactive object is a module-level
// singleton.
try {
const emitter = await Store.watch(
DESKTOP_SETTINGS_STORE_NAMESPACE,
DESKTOP_SETTINGS_STORE_KEY
)
emitter.on("change", ({ value }: { value?: unknown }) => {
if (value !== undefined) {
Object.assign(settings, parseDesktopSettings(value))
}
})
} catch (err) {
Log.warn(LOG_TAG, "Failed to subscribe to store", err)
}
}
async function persist(): Promise<void> {
const validated = DESKTOP_SETTINGS_SCHEMA.parse(settings)
const writeResult = await Store.set(
DESKTOP_SETTINGS_STORE_NAMESPACE,
DESKTOP_SETTINGS_STORE_KEY,
validated
)
if (E.isLeft(writeResult)) {
// `StoreError` is a tagged union. Formatting `kind` and `message`
// explicitly keeps the thrown error readable. A plain
// `${writeResult.left}` interpolation stringifies to
// `[object Object]` and hides the actual cause from stack traces.
const err = writeResult.left
Log.error(LOG_TAG, "Failed to write desktopSettings", err)
throw new Error(
`Failed to write desktopSettings: ${err.kind}: ${err.message}`
)
}
// Mirror to Rust. Non-fatal on failure because Rust falls back to
// its compile-time defaults when the mailbox is empty, so a missed
// sync degrades to "Rust reads an older value" rather than rejecting
// the write the user already committed to.
try {
await invoke("set_desktop_config", { config: validated })
} catch (err) {
Log.warn(LOG_TAG, "Failed to push DesktopSettings to Rust", err)
}
}
export function useDesktopSettings(): {
/** Reactive settings object. Read-only externally, bind via refs in templates. */
settings: Readonly<DesktopSettings>
/** True once the initial store read has completed. */
loaded: Readonly<typeof loaded>
/** Updates a single setting and persists immediately, rolling back on failure. */
update: UpdateFn
} {
if (!initPromise) {
initPromise = loadInitial().catch((err) => {
Log.error(LOG_TAG, "Initial load failed", err)
// Swallow so repeat calls retry on next `update()`.
initPromise = undefined
throw err
})
}
const update: UpdateFn = async (key, value) => {
// Wait for the initial load before mutating. Without this, a
// user clicking a toggle immediately after mount could interleave
// with `loadInitial()`: the optimistic mutation and persist would
// land first, and then `loadInitial()` would resolve and call
// `Object.assign(settings, ...)` with the old on-disk value,
// overwriting the user's change in memory.
if (initPromise) {
try {
await initPromise
} catch {
// Load failed. The caller's `update` will still attempt a
// persist below, which is the right behaviour: the user
// wants their change saved even if the initial read failed.
}
}
const previous = settings[key]
settings[key] = value
try {
await persist()
} catch (err) {
// Restore the reactive state so the in-memory view reflects what's
// actually in the store. Without this, a failed persist leaves the
// settings object holding a value the next app start will not find.
settings[key] = previous
throw err
}
}
return {
settings: readonly(settings) as Readonly<DesktopSettings>,
loaded: readonly(loaded),
update,
}
}