diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json
index a6e89a0e..2a73e6e4 100644
--- a/packages/hoppscotch-common/locales/en.json
+++ b/packages/hoppscotch-common/locales/en.json
@@ -1288,7 +1288,12 @@
"dark_mode": "Dark",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
+ "desktop": "Desktop",
+ "desktop_description": "Preferences that apply only to the Hoppscotch desktop app.",
+ "desktop_updates": "Updates",
"disable_encode_mode_tooltip": "Never encode the parameters in the request",
+ "disable_update_checks": "Disable automatic update checks",
+ "disable_update_checks_description": "Skip the update check at app startup. Use the button above to check on demand.",
"enable_encode_mode_tooltip": "Always encode the parameters in the request",
"enter_otp": "Enter Agent's code",
"expand_navigation": "Expand navigation",
@@ -1346,6 +1351,15 @@
"telemetry_helps_us": "Telemetry helps us to personalize our operations and deliver the best experience to you.",
"theme": "Theme",
"theme_description": "Customize your application theme.",
+ "update_check_description": "Manually check for a new version and install it. Works regardless of the toggle below.",
+ "update_check_now": "Check for updates",
+ "update_checking": "Checking\u2026",
+ "update_download_version": "Download v{version}",
+ "update_downloading": "Downloading\u2026",
+ "update_downloading_percent": "Downloading {percent}%",
+ "update_installing": "Installing\u2026",
+ "update_restart_now": "Restart to apply update",
+ "update_up_to_date": "Up to date",
"use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
"user": "User",
"verified_email": "Verified email",
diff --git a/packages/hoppscotch-common/locales/pt-br.json b/packages/hoppscotch-common/locales/pt-br.json
index 6acc4693..309d62b0 100644
--- a/packages/hoppscotch-common/locales/pt-br.json
+++ b/packages/hoppscotch-common/locales/pt-br.json
@@ -766,7 +766,11 @@
"dark_mode": "Escuro",
"delete_account": "Excluir conta",
"delete_account_description": "Ao deletar sua conta, todos os seus dados serão permanentemente excluídos. Esta ação não pode ser desfeita.",
+ "desktop": "Desktop",
+ "desktop_description": "Preferências específicas do aplicativo desktop Hoppscotch.",
+ "desktop_updates": "Atualizações",
"disable_encode_mode_tooltip": "Nunca codificar os parâmetros na requisição",
+ "disable_update_checks": "Desativar verificação automática de atualizações",
"enable_encode_mode_tooltip": "Sempre codificar os parâmetros na requisição",
"expand_navigation": "Expandir navegação",
"experiments": "Experimentos",
diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts
index 0a108a93..5cf1795b 100644
--- a/packages/hoppscotch-common/src/components.d.ts
+++ b/packages/hoppscotch-common/src/components.d.ts
@@ -306,6 +306,7 @@ declare module 'vue' {
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
SettingsAgentSubtitle: typeof import('./components/settings/AgentSubtitle.vue')['default']
+ SettingsDesktop: typeof import('./components/settings/Desktop.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default']
SettingsInterceptorErrorPlaceholder: typeof import('./components/settings/InterceptorErrorPlaceholder.vue')['default']
diff --git a/packages/hoppscotch-common/src/components/settings/Desktop.vue b/packages/hoppscotch-common/src/components/settings/Desktop.vue
new file mode 100644
index 00000000..659dae2f
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/settings/Desktop.vue
@@ -0,0 +1,282 @@
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/composables/desktop-settings.ts b/packages/hoppscotch-common/src/composables/desktop-settings.ts
new file mode 100644
index 00000000..effc69c9
--- /dev/null
+++ b/packages/hoppscotch-common/src/composables/desktop-settings.ts
@@ -0,0 +1,164 @@
+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 = (
+ key: K,
+ value: DesktopSettings[K]
+) => Promise
+
+// Singleton state, initialized on first `useDesktopSettings()` call.
+const settings = reactive(DESKTOP_SETTINGS_SCHEMA.parse({}))
+const loaded = ref(false)
+let initPromise: Promise | undefined
+
+async function loadInitial(): Promise {
+ const result = await Store.get(
+ 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 {
+ 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
+ /** True once the initial store read has completed. */
+ loaded: Readonly
+ /** 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,
+ loaded: readonly(loaded),
+ update,
+ }
+}
diff --git a/packages/hoppscotch-common/src/composables/update-check.ts b/packages/hoppscotch-common/src/composables/update-check.ts
new file mode 100644
index 00000000..8b223b86
--- /dev/null
+++ b/packages/hoppscotch-common/src/composables/update-check.ts
@@ -0,0 +1,368 @@
+import { ref, readonly, type Ref } from "vue"
+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"
+import {
+ UPDATE_STATE_SCHEMA,
+ UPDATE_STATE_STORE_KEY,
+ UPDATE_STATE_STORE_NAMESPACE,
+ type DownloadProgress as WireDownloadProgress,
+ type UpdateState as PersistedUpdateState,
+} from "~/platform/update-state"
+import { Log } from "~/kernel/log"
+
+const LOG_TAG = "useUpdateCheck"
+
+/**
+ * Webview-side accessor for the desktop updater.
+ *
+ * Wraps the Tauri updater commands (`check_for_updates`,
+ * `download_and_install_update`, `restart_application`, `cancel_update`)
+ * and the `updater-event` Tauri channel into a single reactive accessor.
+ *
+ * State is modelled as a discriminated union where each variant carries
+ * exactly the fields that variant needs (the `available` variant carries
+ * `latestVersion`, the `downloading` variant carries `progress`, and so
+ * on). Impossible combinations ("available without a version", "not
+ * downloading but progress is set") are unrepresentable by construction,
+ * and callers narrow through `state.kind`.
+ *
+ * State transitions are owned by a single pure `applyEvent` function
+ * driven by the `updater-event` channel. Action wrappers (`check`,
+ * `download`, `restart`, `cancel`) await initialization before invoking
+ * so the listener is guaranteed to be subscribed before any command
+ * fires, and rely on the event stream for the transitions rather than
+ * mutating state themselves. This removes the "fast path + event" drift
+ * that made two paths responsible for updating the same refs.
+ *
+ * Module-level singleton: every caller gets the same reactive state so
+ * any consumer (settings page, portable welcome, startup flow) sees the
+ * same value.
+ */
+
+// Download progress with a derived `percentage`. The wire form from
+// Rust and the persisted form only carry `downloaded` and optional
+// `total`. The `percentage` is computed on top so the UI has a
+// ready-to-bind field.
+export interface DownloadProgress extends WireDownloadProgress {
+ percentage: number
+}
+
+// Response from the `check_for_updates` Tauri command. Used only to
+// invoke the command. Actual state transitions arrive on the event
+// channel.
+interface UpdateInfo {
+ available: boolean
+ currentVersion: string
+ latestVersion?: string
+ releaseNotes?: string
+}
+
+// Tauri event payload variants. Must match the `UpdateEvent` tagged union in
+// `hoppscotch-desktop/src/services/updater.client.ts`. Centralizing this
+// type into common would remove the duplication, but the event channel is
+// a Rust-to-webview wire contract that currently lives in the shell, so
+// keeping the mirror here scoped to this composable is acceptable until
+// that contract gets its own shared module.
+type UpdateEvent =
+ | { type: "CheckStarted" }
+ | { type: "CheckCompleted"; info: UpdateInfo }
+ | { type: "CheckFailed"; error: string }
+ | { type: "DownloadStarted"; totalBytes?: number }
+ // The Rust-emitted payload only carries `downloaded` and optional
+ // `total`. The reducer derives `percentage` and the persisted
+ // `DownloadProgress` form below extends with that derived field.
+ | { type: "DownloadProgress"; progress: WireDownloadProgress }
+ | { type: "DownloadCompleted" }
+ | { type: "InstallStarted" }
+ | { type: "InstallCompleted" }
+ | { type: "RestartRequired" }
+ | { type: "UpdateCancelled" }
+ | { type: "Error"; message: string }
+
+// The composable's internal state. Each variant carries exactly the
+// fields that variant needs. `currentVersion` rides along with any
+// post-check variant so the UI can display "currently on vX" context
+// regardless of whether an update was found.
+export type UpdateState =
+ | { kind: "idle" }
+ | { kind: "checking" }
+ | {
+ kind: "available"
+ currentVersion: string
+ latestVersion: string
+ }
+ | { kind: "not_available"; currentVersion: string }
+ | { kind: "downloading"; progress: DownloadProgress }
+ | { kind: "installing" }
+ | { kind: "ready_to_restart" }
+ | { kind: "error"; message: string }
+
+// String-literal helper for consumers that want to compare without
+// destructuring `state.kind` directly. `UpdateState["kind"]` gives the
+// same union at the type level.
+export const UpdateKind = {
+ IDLE: "idle",
+ CHECKING: "checking",
+ AVAILABLE: "available",
+ NOT_AVAILABLE: "not_available",
+ DOWNLOADING: "downloading",
+ INSTALLING: "installing",
+ READY_TO_RESTART: "ready_to_restart",
+ ERROR: "error",
+} as const satisfies Record
+
+// Singleton state.
+const state = ref({ kind: "idle" })
+let initPromise: Promise | undefined
+let unlistenFn: UnlistenFn | undefined
+
+function percentageOf(downloaded: number, total: number | undefined): number {
+ if (!total || total <= 0) return 0
+ return (downloaded / total) * 100
+}
+
+/**
+ * Derives the composable's internal `UpdateState` from the flat
+ * persisted form. The persisted form is a wire contract with Rust and
+ * older shell code, and translating on read keeps that contract
+ * unchanged while the composable gets the richer internal type.
+ */
+function fromPersisted(
+ persisted: PersistedUpdateState | null | undefined
+): UpdateState {
+ if (!persisted) return { kind: "idle" }
+
+ switch (persisted.status) {
+ case "idle":
+ return { kind: "idle" }
+ case "checking":
+ return { kind: "checking" }
+ case "available":
+ // The persisted form is optional on `version`. If the writer
+ // omitted it, fall back to idle rather than fabricating a version.
+ return persisted.version
+ ? {
+ kind: "available",
+ currentVersion: "",
+ latestVersion: persisted.version,
+ }
+ : { kind: "idle" }
+ case "not_available":
+ return { kind: "not_available", currentVersion: "" }
+ case "downloading": {
+ const downloaded = persisted.progress?.downloaded ?? 0
+ const total = persisted.progress?.total
+ return {
+ kind: "downloading",
+ progress: {
+ downloaded,
+ total,
+ percentage: percentageOf(downloaded, total),
+ },
+ }
+ }
+ case "installing":
+ return { kind: "installing" }
+ case "ready_to_restart":
+ return { kind: "ready_to_restart" }
+ case "error":
+ return { kind: "error", message: persisted.message ?? "Unknown error" }
+ }
+}
+
+/**
+ * Pure reducer from current state + incoming event to next state. Kept
+ * pure (no ref access, no side effects) so it can be exercised in
+ * isolation and so the full transition table is readable at a glance.
+ */
+function nextState(current: UpdateState, event: UpdateEvent): UpdateState {
+ switch (event.type) {
+ case "CheckStarted":
+ return { kind: "checking" }
+
+ case "CheckCompleted":
+ if (event.info.available && event.info.latestVersion) {
+ return {
+ kind: "available",
+ currentVersion: event.info.currentVersion,
+ latestVersion: event.info.latestVersion,
+ }
+ }
+ return {
+ kind: "not_available",
+ currentVersion: event.info.currentVersion,
+ }
+
+ case "CheckFailed":
+ return { kind: "error", message: event.error }
+
+ case "DownloadStarted":
+ return {
+ kind: "downloading",
+ progress: {
+ downloaded: 0,
+ total: event.totalBytes,
+ percentage: 0,
+ },
+ }
+
+ case "DownloadProgress":
+ // The wire form has no `percentage`. Without computing it
+ // here, `Math.round(progress.percentage)` in the view runs on
+ // `undefined` and the button label renders "Downloading NaN%"
+ // for every progress tick. `DownloadStarted` above takes the
+ // same approach.
+ return {
+ kind: "downloading",
+ progress: {
+ downloaded: event.progress.downloaded,
+ total: event.progress.total,
+ percentage: percentageOf(
+ event.progress.downloaded,
+ event.progress.total
+ ),
+ },
+ }
+
+ case "DownloadCompleted":
+ return { kind: "installing" }
+
+ case "InstallStarted":
+ return { kind: "installing" }
+
+ case "InstallCompleted":
+ // Install is a short step that transitions straight into awaiting a
+ // restart. The `RestartRequired` event follows and flips the state,
+ // so keep the current state here rather than double-transitioning.
+ return current
+
+ case "RestartRequired":
+ return { kind: "ready_to_restart" }
+
+ case "UpdateCancelled":
+ return { kind: "idle" }
+
+ case "Error":
+ return { kind: "error", message: event.message }
+ }
+}
+
+async function loadPersistedState(): Promise {
+ const result = await Store.get(
+ UPDATE_STATE_STORE_NAMESPACE,
+ UPDATE_STATE_STORE_KEY
+ )
+ if (E.isRight(result) && result.right) {
+ const parsed = UPDATE_STATE_SCHEMA.safeParse(result.right)
+ if (parsed.success) {
+ state.value = fromPersisted(parsed.data)
+ }
+ }
+}
+
+async function subscribeToEvents(): Promise {
+ if (unlistenFn) return
+ unlistenFn = await listen("updater-event", (event) => {
+ state.value = nextState(state.value, event.payload)
+ })
+}
+
+async function ensureInitialized(): Promise {
+ if (!initPromise) {
+ initPromise = (async () => {
+ await loadPersistedState()
+ await subscribeToEvents()
+ })().catch((err) => {
+ Log.error(LOG_TAG, "Initialization failed", err)
+ initPromise = undefined
+ throw err
+ })
+ }
+ await initPromise
+}
+
+// Action wrappers. Each awaits initialization so the event listener is
+// guaranteed subscribed before the Tauri command runs, then invokes the
+// command. State transitions arrive via the event channel, so the
+// wrappers do not mutate `state` on success. On `invoke` failure they
+// feed a synthetic "failed" event through the same reducer so the
+// transition path stays uniform.
+async function check(): Promise {
+ await ensureInitialized()
+ try {
+ await invoke("check_for_updates", { showNativeDialog: false })
+ } catch (err) {
+ state.value = nextState(state.value, {
+ type: "CheckFailed",
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+}
+
+async function download(): Promise {
+ await ensureInitialized()
+ try {
+ await invoke("download_and_install_update")
+ } catch (err) {
+ state.value = nextState(state.value, {
+ type: "Error",
+ message: err instanceof Error ? err.message : String(err),
+ })
+ }
+}
+
+async function restart(): Promise {
+ await ensureInitialized()
+ try {
+ await invoke("restart_application")
+ } catch (err) {
+ state.value = nextState(state.value, {
+ type: "Error",
+ message: err instanceof Error ? err.message : String(err),
+ })
+ }
+}
+
+async function cancel(): Promise {
+ await ensureInitialized()
+ try {
+ await invoke("cancel_update")
+ // State advances to `idle` via the `updater-event` channel. The
+ // Rust updater emits `UpdateCancelled` on success, so the
+ // subscribed listener applies the transition. Applying it here
+ // as well would produce two `idle` transitions per cancel, which
+ // is harmless today but would double-fire any future side effect
+ // added to the `UpdateCancelled` case in `nextState`.
+ } catch (err) {
+ state.value = nextState(state.value, {
+ type: "Error",
+ message: err instanceof Error ? err.message : String(err),
+ })
+ }
+}
+
+export function useUpdateCheck(): {
+ state: Readonly>
+ check: () => Promise
+ download: () => Promise
+ restart: () => Promise
+ cancel: () => Promise
+} {
+ // Fire-and-forget initialization so the composable returns synchronously.
+ // Actions await initialization internally before invoking commands, so
+ // race-with-subscription is not possible through the action path. A
+ // consumer that reads `state.value` immediately sees `idle`, which is
+ // the correct default for a fresh mount.
+ void ensureInitialized()
+
+ return {
+ state: readonly(state),
+ check,
+ download,
+ restart,
+ cancel,
+ }
+}
diff --git a/packages/hoppscotch-common/src/platform/desktop-settings.ts b/packages/hoppscotch-common/src/platform/desktop-settings.ts
new file mode 100644
index 00000000..696a86a4
--- /dev/null
+++ b/packages/hoppscotch-common/src/platform/desktop-settings.ts
@@ -0,0 +1,72 @@
+import { z } from "zod"
+
+/**
+ * Shared schema and types for the Tauri desktop app's user settings.
+ *
+ * The settings live in `tauri-plugin-store` under namespace
+ * `DESKTOP_SETTINGS_STORE_NAMESPACE`, key `DESKTOP_SETTINGS_STORE_KEY`.
+ * Both the Tauri shell (`hoppscotch-desktop`) and the webview
+ * (`hoppscotch-selfhost-web`, loaded via appload) read and write
+ * through the same namespace and key.
+ *
+ * The schema is the contract between the two sides. A change here
+ * without coordinating both sides leaves the shell and the webview
+ * disagreeing about what is in the store. This module lives in
+ * `hoppscotch-common` because both packages already depend on common
+ * and neither depends on the other directly, so common is the only
+ * place a shared definition can live.
+ *
+ * Every field has a Zod `.default()` that preserves the pre-settings-epic
+ * behavior, so a partial read (missing keys, corrupt blob, fresh install)
+ * parses cleanly into a fully-populated object. Use `parseDesktopSettings()`
+ * to read raw values safely.
+ */
+
+// Store coordinates. Both sides must use these constants, never string
+// literals, so a rename is a single edit and a typo is a compile error.
+export const DESKTOP_SETTINGS_STORE_NAMESPACE = "hoppscotch-desktop.v1"
+export const DESKTOP_SETTINGS_STORE_KEY = "desktopSettings"
+
+// Fields are grouped by the area of the app they affect. Defaults
+// preserve today's hardcoded behavior so any field not yet bound to a
+// control in the settings UI ships without a visible change for existing
+// users.
+export const DESKTOP_SETTINGS_SCHEMA = z.object({
+ // Migrated from the legacy portable-only `PortableSettings`. A future
+ // epic ticket promotes `disableUpdateNotifications` to a user-facing
+ // control on all builds. For now it stays portable-only.
+ disableUpdateNotifications: z.boolean().default(false),
+ autoSkipWelcome: z.boolean().default(false),
+
+ // Connection and startup behavior. The `connectionTimeoutMs` default
+ // matches `API_TIMEOUT_SECS` in `config.rs`. User-facing controls for
+ // these fields are future scope.
+ connectionTimeoutMs: z.number().int().positive().default(30_000),
+ autoReconnectLastInstance: z.boolean().default(true),
+
+ // Update-pipeline controls. `disable*` polarity matches the existing
+ // `disableUpdateNotifications` field so all three update-related
+ // booleans read uniformly, and the on-by-default framing ("Disable X"
+ // with default false) nudges users toward keeping the update flow
+ // active. `disableUpdateChecks` is bound to a toggle in the current
+ // settings UI. `disableUpdateDownloads` is future scope.
+ disableUpdateChecks: z.boolean().default(false),
+ disableUpdateDownloads: z.boolean().default(false),
+
+ // Display and UX. User-facing zoom control is future scope.
+ zoomLevel: z.number().positive().default(1.0),
+})
+
+export type DesktopSettings = z.infer
+
+/**
+ * Parses a raw value into `DesktopSettings`, falling back to full defaults
+ * on any validation failure. Never throws.
+ */
+export const parseDesktopSettings = (raw: unknown): DesktopSettings => {
+ const parsed = DESKTOP_SETTINGS_SCHEMA.safeParse(raw ?? {})
+ if (!parsed.success) {
+ return DESKTOP_SETTINGS_SCHEMA.parse({})
+ }
+ return parsed.data
+}
diff --git a/packages/hoppscotch-common/src/platform/update-state.ts b/packages/hoppscotch-common/src/platform/update-state.ts
new file mode 100644
index 00000000..6235b95b
--- /dev/null
+++ b/packages/hoppscotch-common/src/platform/update-state.ts
@@ -0,0 +1,69 @@
+import { z } from "zod"
+
+/**
+ * Shared schema and types for the desktop app's auto-updater state.
+ *
+ * The updater state is written to `tauri-plugin-store` by the Tauri shell
+ * (`hoppscotch-desktop/src/utils/updater.ts`) and read by both the shell's
+ * persistence service and the webview's settings page. This module is the
+ * single source of truth for the definition that crosses all three
+ * boundaries (Rust, shell JS, webview JS via the store file).
+ *
+ * Persisted form is deliberately flat (status + optional fields). The
+ * webview's `useUpdateCheck` composable derives a discriminated union
+ * over this flat form for its internal state, but the wire format that
+ * hits disk stays simple so existing Rust writers continue to work
+ * unchanged.
+ */
+
+// Store coordinates. Both the shell persistence service and the webview
+// composable reference these constants rather than string literals.
+export const UPDATE_STATE_STORE_NAMESPACE = "hoppscotch-desktop.v1"
+export const UPDATE_STATE_STORE_KEY = "updateState"
+
+// `UpdateStatus` as a `const` object rather than a TS `enum` so:
+// 1. The values are plain string literals, so they cross the store
+// boundary as JSON without extra conversion.
+// 2. The inferred union type (`"idle" | "checking" | ...`) narrows
+// cleanly in switch statements and matches Zod's `z.enum` output.
+// 3. It imports zero-cost into the webview bundle, where TS enums can
+// produce runtime objects that tree-shaking sometimes fails to drop.
+export const UpdateStatus = {
+ IDLE: "idle",
+ CHECKING: "checking",
+ AVAILABLE: "available",
+ NOT_AVAILABLE: "not_available",
+ DOWNLOADING: "downloading",
+ INSTALLING: "installing",
+ READY_TO_RESTART: "ready_to_restart",
+ ERROR: "error",
+} as const
+
+export type UpdateStatus = (typeof UpdateStatus)[keyof typeof UpdateStatus]
+
+export const UPDATE_STATUS_SCHEMA = z.enum([
+ UpdateStatus.IDLE,
+ UpdateStatus.CHECKING,
+ UpdateStatus.AVAILABLE,
+ UpdateStatus.NOT_AVAILABLE,
+ UpdateStatus.DOWNLOADING,
+ UpdateStatus.INSTALLING,
+ UpdateStatus.READY_TO_RESTART,
+ UpdateStatus.ERROR,
+])
+
+export const DOWNLOAD_PROGRESS_SCHEMA = z.object({
+ downloaded: z.number(),
+ total: z.number().optional(),
+})
+
+export type DownloadProgress = z.infer
+
+export const UPDATE_STATE_SCHEMA = z.object({
+ status: UPDATE_STATUS_SCHEMA,
+ version: z.string().optional(),
+ message: z.string().optional(),
+ progress: DOWNLOAD_PROGRESS_SCHEMA.optional(),
+})
+
+export type UpdateState = z.infer
diff --git a/packages/hoppscotch-desktop/src-tauri/src/config.rs b/packages/hoppscotch-desktop/src-tauri/src/config.rs
index 2a60ee9d..c3b7aa48 100644
--- a/packages/hoppscotch-desktop/src-tauri/src/config.rs
+++ b/packages/hoppscotch-desktop/src-tauri/src/config.rs
@@ -1,9 +1,15 @@
-use std::{fs, path::PathBuf, time::Duration};
+use std::{fs, path::PathBuf, sync::Mutex, time::Duration};
+use serde::Deserialize;
use tauri_plugin_appload::{ApiConfig, CacheConfig, Config, StorageConfig, VendorConfig};
use crate::{error::HoppError, path};
+// Appload plugin configuration. These constants are baked into the plugin
+// config at startup via `HoppApploadConfig::build()`, before the webview
+// exists, so they cannot be overridden by runtime user settings. A future
+// user-facing connection timeout override will need a separate mechanism,
+// either a startup-time store file read or a deferred appload init.
const API_SERVER_URL: &str = "http://localhost:3200";
const API_TIMEOUT_SECS: u64 = 30;
const CACHE_MAX_SIZE_MB: usize = 1000;
@@ -66,6 +72,72 @@ impl HoppApploadConfig {
}
}
+// Webview-pushed runtime settings bridge.
+//
+// The webview persists user settings (timeout, zoom, auto-reconnect, and so
+// on) via `tauri-plugin-store`. The Tauri shell needs live access to some
+// of those values, for example `connectionTimeoutMs` for the appload HTTP
+// client. Rather than having Rust read the store file directly, which would
+// couple this code to the plugin's on-disk format, the webview pushes the
+// current settings to Rust via `set_desktop_config` at init and on change.
+//
+// The IPC plumbing is wired end-to-end but no Rust code reads
+// `DESKTOP_CONFIG` yet. Consumers such as the appload connection timeout
+// are future scope.
+//
+// The struct deliberately only deserializes fields Rust actually consumes.
+// TS sends the full `DESKTOP_SETTINGS_SCHEMA` payload and serde drops the
+// rest. Adding a new Rust consumer means adding a field here, not changing
+// the IPC contract.
+
+/// Subset of the webview-side `DesktopSettings` that Rust services consume.
+///
+/// Field names are snake_case with `rename_all = "camelCase"` so they line
+/// up with what the TS store produces from `DESKTOP_SETTINGS_SCHEMA`.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DesktopConfig {
+ /// Timeout (ms) for outbound HTTP requests in the appload client and
+ /// related connection paths. Mirrors `API_TIMEOUT_SECS` when the value
+ /// is 30_000.
+ pub connection_timeout_ms: u64,
+}
+
+/// Live copy of the most recent settings pushed from the webview.
+///
+/// `None` means the webview has not called `set_desktop_config` yet, which
+/// is the case during the early Tauri startup path before the window loads
+/// and for the whole of the pre-webview `PortableHome` and `StandardHome`
+/// flow. Consumers must treat `None` as "no override, use the compile-time
+/// default".
+static DESKTOP_CONFIG: Mutex