feat(desktop): settings phase 0 - infra and update check (#6172)

Co-authored-by: VicenzoMF <81040684+VicenzoMF@users.noreply.github.com>
This commit is contained in:
Shreyas 2026-04-28 00:36:06 +05:30 committed by GitHub
parent 15d12f8ce5
commit 9861ee84ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1599 additions and 251 deletions

View file

@ -1288,7 +1288,12 @@
"dark_mode": "Dark", "dark_mode": "Dark",
"delete_account": "Delete account", "delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", "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_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", "enable_encode_mode_tooltip": "Always encode the parameters in the request",
"enter_otp": "Enter Agent's code", "enter_otp": "Enter Agent's code",
"expand_navigation": "Expand navigation", "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.", "telemetry_helps_us": "Telemetry helps us to personalize our operations and deliver the best experience to you.",
"theme": "Theme", "theme": "Theme",
"theme_description": "Customize your application 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", "use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
"user": "User", "user": "User",
"verified_email": "Verified email", "verified_email": "Verified email",

View file

@ -766,7 +766,11 @@
"dark_mode": "Escuro", "dark_mode": "Escuro",
"delete_account": "Excluir conta", "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.", "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_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", "enable_encode_mode_tooltip": "Sempre codificar os parâmetros na requisição",
"expand_navigation": "Expandir navegação", "expand_navigation": "Expandir navegação",
"experiments": "Experimentos", "experiments": "Experimentos",

View file

@ -306,6 +306,7 @@ declare module 'vue' {
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default'] RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsAgent: typeof import('./components/settings/Agent.vue')['default'] SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
SettingsAgentSubtitle: typeof import('./components/settings/AgentSubtitle.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'] SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default'] SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default']
SettingsInterceptorErrorPlaceholder: typeof import('./components/settings/InterceptorErrorPlaceholder.vue')['default'] SettingsInterceptorErrorPlaceholder: typeof import('./components/settings/InterceptorErrorPlaceholder.vue')['default']

View file

@ -0,0 +1,282 @@
<template>
<!-- Three-column grid identical to the shared settings page's other
sections (General / Theme / Kernel interceptor), so this block falls
into the same visual rhythm when it renders. -->
<div class="md:grid md:grid-cols-3 md:gap-4">
<div class="p-8 md:col-span-1">
<h3 class="heading">
{{ t("settings.desktop") }}
</h3>
<p class="my-1 text-secondaryLight">
{{ t("settings.desktop_description") }}
</p>
</div>
<div class="space-y-8 p-8 md:col-span-2">
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.desktop_updates") }}
</h4>
<!-- Manual update check control. The button's label, icon,
disabled-ness, and click behavior all come from one view
descriptor computed from the update state, so adding or
renaming a state means touching one `case` instead of five
parallel switches. A fixed `min-width` holds the button
size stable across label changes. Download progress rides
inline in the label rather than in a separate progress bar,
so the control stays the same size across every state. -->
<div class="mt-4">
<div class="flex items-center space-x-3">
<HoppButtonSecondary
class="!min-w-[15rem] !justify-start"
:icon="view.icon"
:label="view.label"
:disabled="view.disabled"
outline
@click="view.action"
/>
<HoppButtonSecondary
v-if="view.showCancel"
:label="t('action.cancel')"
outline
@click="updateCheck.cancel()"
/>
</div>
<div class="mt-3 min-h-[1.25rem]">
<Transition name="helper-fade" mode="out-in">
<p :key="helperText" :class="helperTextClasses">
{{ helperText }}
</p>
</Transition>
</div>
</div>
<!-- Auto-check toggle with its own description. The inner
`flex items-center` wrapper matches the convention used by
other toggles on the shared settings page (see
`settings/Native.vue`), so the toggle sits at the same left
edge as the check button above instead of stretching to fill
the `flex-col` parent. -->
<div class="mt-6">
<div class="flex items-center">
<HoppSmartToggle
:on="desktopSettings.settings.disableUpdateChecks"
@change="toggleDisableUpdateChecks"
>
{{ t("settings.disable_update_checks") }}
</HoppSmartToggle>
</div>
<p class="mt-3 text-xs text-secondaryLight">
{{ t("settings.disable_update_checks_description") }}
</p>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch, type Component } from "vue"
import { HoppButtonSecondary, HoppSmartToggle } from "@hoppscotch/ui"
import { useI18n } from "~/composables/i18n"
import IconLucideDownload from "~icons/lucide/download"
import IconLucideRefreshCw from "~icons/lucide/refresh-cw"
import IconLucideLoader from "~icons/lucide/loader"
import IconLucideCheckCircle from "~icons/lucide/check-circle"
import IconLucideAlertCircle from "~icons/lucide/alert-circle"
import { useDesktopSettings } from "~/composables/desktop-settings"
import { useUpdateCheck } from "~/composables/update-check"
// The shared settings page iterates `additionalSettingsSections`
// with `:key="item.id"`. Without an explicit `id` on the component
// options, Vue would use `undefined` as the key, which causes
// keyed-list warnings at runtime and means the renderer loses its
// cross-update identity for this node (so a re-registration by any
// consumer would force a full remount instead of a reconcile).
// Registering a stable `id` closes that.
defineOptions({
name: "DesktopSettingsSection",
id: "desktop-settings",
})
const t = useI18n()
const desktopSettings = useDesktopSettings()
const updateCheck = useUpdateCheck()
// Composed view descriptor for the manual-check button. Every field the
// template binds comes from the same function so adding, renaming, or
// deleting an update state is a single-case edit. The alternative of
// parallel computeds for `label`, `icon`, `disabled`, and click handler
// spreads the behavior for each state across four functions and leaves
// no single place to read what state X renders as.
type ButtonView = {
label: string
icon: Component
disabled: boolean
showCancel: boolean
action: () => Promise<void> | void
}
const noop = (): void => undefined
const view = computed<ButtonView>(() => {
const s = updateCheck.state.value
switch (s.kind) {
case "idle":
return {
label: t("settings.update_check_now"),
icon: IconLucideRefreshCw,
disabled: false,
showCancel: false,
action: updateCheck.check,
}
case "checking":
return {
label: t("settings.update_checking"),
icon: IconLucideLoader,
disabled: true,
showCancel: false,
action: noop,
}
case "available":
return {
label: t("settings.update_download_version", {
version: s.latestVersion,
}),
icon: IconLucideDownload,
disabled: false,
showCancel: false,
action: updateCheck.download,
}
case "not_available":
return {
label: t("settings.update_check_now"),
icon: IconLucideCheckCircle,
disabled: false,
showCancel: false,
action: updateCheck.check,
}
case "downloading":
return {
label: t("settings.update_downloading_percent", {
percent: Math.round(s.progress.percentage),
}),
icon: IconLucideLoader,
disabled: true,
showCancel: true,
action: noop,
}
case "installing":
return {
label: t("settings.update_installing"),
icon: IconLucideLoader,
disabled: true,
showCancel: true,
action: noop,
}
case "ready_to_restart":
return {
label: t("settings.update_restart_now"),
icon: IconLucideRefreshCw,
disabled: false,
showCancel: false,
action: updateCheck.restart,
}
case "error":
return {
label: t("settings.update_check_now"),
icon: IconLucideAlertCircle,
disabled: false,
showCancel: false,
action: updateCheck.check,
}
default: {
// Exhaustiveness check. TypeScript narrows `s` to `never` here if
// every variant of `UpdateState["kind"]` is handled above, so
// adding a new variant without a matching `case` fails to compile.
// The throw also satisfies eslint's `vue/return-in-computed-property`,
// which does not do TS-level exhaustiveness analysis.
const unreachable: never = s
throw new Error(`Unhandled update state: ${JSON.stringify(unreachable)}`)
}
}
})
// Helper text below the button shows the baseline description by default.
// Transient status feedback ("Up to date", error message) appears on entry
// into `not_available` or `error` and fades back to the description after
// a linger, so the description stays visible most of the time and the
// status feedback reads as transient confirmation rather than permanent
// replacement. The `<Transition>` wrapping the `<p>` fades between
// content changes.
const TRANSIENT_FEEDBACK_MS = {
notAvailable: 4_000,
error: 6_000,
} as const
const showTransientFeedback = ref(false)
let feedbackTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => updateCheck.state.value.kind,
(kind) => {
if (kind !== "not_available" && kind !== "error") return
showTransientFeedback.value = true
if (feedbackTimer) clearTimeout(feedbackTimer)
feedbackTimer = setTimeout(
() => {
showTransientFeedback.value = false
},
kind === "error"
? TRANSIENT_FEEDBACK_MS.error
: TRANSIENT_FEEDBACK_MS.notAvailable
)
}
)
onBeforeUnmount(() => {
if (feedbackTimer) clearTimeout(feedbackTimer)
})
const helperText = computed(() => {
if (showTransientFeedback.value) {
const s = updateCheck.state.value
if (s.kind === "error") {
return s.message
}
if (s.kind === "not_available") {
return t("settings.update_up_to_date")
}
}
return t("settings.update_check_description")
})
const helperTextClasses = computed(() => {
const base = "text-xs"
return showTransientFeedback.value && updateCheck.state.value.kind === "error"
? `${base} text-red-500`
: `${base} text-secondaryLight`
})
async function toggleDisableUpdateChecks(): Promise<void> {
await desktopSettings.update(
"disableUpdateChecks",
!desktopSettings.settings.disableUpdateChecks
)
}
</script>
<style scoped>
.helper-fade-enter-active,
.helper-fade-leave-active {
transition: opacity 0.3s ease;
}
.helper-fade-enter-from,
.helper-fade-leave-to {
opacity: 0;
}
</style>

View file

@ -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 = <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,
}
}

View file

@ -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<string, UpdateState["kind"]>
// Singleton state.
const state = ref<UpdateState>({ kind: "idle" })
let initPromise: Promise<void> | 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<void> {
const result = await Store.get<PersistedUpdateState | null>(
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<void> {
if (unlistenFn) return
unlistenFn = await listen<UpdateEvent>("updater-event", (event) => {
state.value = nextState(state.value, event.payload)
})
}
async function ensureInitialized(): Promise<void> {
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<void> {
await ensureInitialized()
try {
await invoke<UpdateInfo>("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<void> {
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<void> {
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<void> {
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<Ref<UpdateState>>
check: () => Promise<void>
download: () => Promise<void>
restart: () => Promise<void>
cancel: () => Promise<void>
} {
// 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,
}
}

View file

@ -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<typeof DESKTOP_SETTINGS_SCHEMA>
/**
* 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
}

View file

@ -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<typeof DOWNLOAD_PROGRESS_SCHEMA>
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<typeof UPDATE_STATE_SCHEMA>

View file

@ -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 tauri_plugin_appload::{ApiConfig, CacheConfig, Config, StorageConfig, VendorConfig};
use crate::{error::HoppError, path}; 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_SERVER_URL: &str = "http://localhost:3200";
const API_TIMEOUT_SECS: u64 = 30; const API_TIMEOUT_SECS: u64 = 30;
const CACHE_MAX_SIZE_MB: usize = 1000; 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<Option<DesktopConfig>> = Mutex::new(None);
/// Returns a clone of the most recent settings pushed from the webview, or
/// `None` if nothing has been pushed yet.
///
/// Cloning keeps the lock scope short, which is cheap because
/// `DesktopConfig` is a small POD struct.
#[allow(dead_code)] // no Rust consumers yet, see module doc above.
pub fn current_desktop_config() -> Option<DesktopConfig> {
DESKTOP_CONFIG
.lock()
.ok()
.and_then(|guard| guard.clone())
}
/// Tauri command invoked by the webview on init and whenever settings
/// change. Overwrites any previously-pushed config and is idempotent on
/// identical input.
#[tauri::command]
pub fn set_desktop_config(config: DesktopConfig) -> Result<(), String> {
tracing::debug!(?config, "Received desktop config from webview");
let mut guard = DESKTOP_CONFIG
.lock()
.map_err(|e| format!("DESKTOP_CONFIG mutex poisoned: {}", e))?;
*guard = Some(config);
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -117,4 +189,51 @@ mod tests {
assert!(!config.config_dir.as_os_str().is_empty()); assert!(!config.config_dir.as_os_str().is_empty());
} }
// The roundtrip and overwrite assertions stay in one test because
// `DESKTOP_CONFIG` is process-wide shared state and cargo runs tests
// in parallel by default. Splitting them into two `#[test]` functions
// would race for the global mutex and produce flaky assertions
// depending on schedule. The other tests in this module exercise
// `DesktopConfig` deserialization in isolation and never touch
// `DESKTOP_CONFIG`, so they are safe to run alongside this one.
#[test]
fn set_desktop_config_roundtrip_and_overwrite() {
let result = set_desktop_config(DesktopConfig {
connection_timeout_ms: 45_000,
});
assert!(result.is_ok());
assert_eq!(
current_desktop_config().unwrap().connection_timeout_ms,
45_000
);
set_desktop_config(DesktopConfig {
connection_timeout_ms: 90_000,
})
.unwrap();
assert_eq!(
current_desktop_config().unwrap().connection_timeout_ms,
90_000
);
}
#[test]
fn desktop_config_deserializes_from_camel_case() {
let json = r#"{"connectionTimeoutMs": 60000}"#;
let cfg: DesktopConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.connection_timeout_ms, 60_000);
}
#[test]
fn desktop_config_deserialize_ignores_extra_fields() {
// TS pushes the full `DESKTOP_SETTINGS_SCHEMA` so extras must drop.
let json = r#"{
"connectionTimeoutMs": 30000,
"disableUpdateNotifications": true,
"zoomLevel": 1.25
}"#;
let cfg: DesktopConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.connection_timeout_ms, 30_000);
}
} }

View file

@ -248,6 +248,7 @@ pub fn run() {
hopp_auth_port, hopp_auth_port,
quit_app, quit_app,
backup::check_and_backup_on_version_change, backup::check_and_backup_on_version_change,
config::set_desktop_config,
updater::check_for_updates, updater::check_for_updates,
updater::download_and_install_update, updater::download_and_install_update,
updater::restart_application, updater::restart_application,

View file

@ -1,4 +1,5 @@
import { ref } from "vue" import { ref } from "vue"
import * as E from "fp-ts/Either"
import { load, download, close } from "@hoppscotch/plugin-appload" import { load, download, close } from "@hoppscotch/plugin-appload"
import { getVersion } from "@tauri-apps/api/app" import { getVersion } from "@tauri-apps/api/app"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
@ -44,7 +45,7 @@ export function useAppInitialization() {
const saveConnectionState = async (state: ConnectionState) => { const saveConnectionState = async (state: ConnectionState) => {
try { try {
await persistence.setConnectionState(state) await persistence.connectionState.set(state)
} catch (err) { } catch (err) {
console.error("Failed to save connection state:", err) console.error("Failed to save connection state:", err)
} }
@ -246,8 +247,8 @@ export function useAppInitialization() {
// instances. The InstanceService's detectCurrentInstanceFromHostname // instances. The InstanceService's detectCurrentInstanceFromHostname
// persists the detected instance (including cloud-org) to this store, // persists the detected instance (including cloud-org) to this store,
// so on restart the main window can resume the correct instance. // so on restart the main window can resume the correct instance.
const connectionState = await persistence.getConnectionState() const connectionState = await persistence.connectionState.get()
const recentInstances = await persistence.getRecentInstances() const recentInstances = await persistence.recentInstances.get()
mainDiag(`loadRecent: connectionState=${JSON.stringify(connectionState)}`) mainDiag(`loadRecent: connectionState=${JSON.stringify(connectionState)}`)
mainDiag( mainDiag(
@ -354,7 +355,18 @@ export function useAppInitialization() {
} }
statusMessage.value = "Initializing stores..." statusMessage.value = "Initializing stores..."
await persistence.init() // `init` returns `Either<StoreError, void>` so callers can decide
// how to surface a failure. Branching to a thrown Error here lets
// the surrounding `initialize()` try/catch route the failure into
// `error.value` for the UI, the same way every other startup
// failure is reported, instead of letting init silently complete
// and leave the app running on defaults with no Rust sync.
const initResult = await persistence.init()
if (E.isLeft(initResult)) {
throw new Error(
`Persistence init failed: ${initResult.left.kind}: ${initResult.left.message}`
)
}
} }
const initialize = async (customLogic?: () => Promise<void>) => { const initialize = async (customLogic?: () => Promise<void>) => {

View file

@ -0,0 +1,120 @@
import * as E from "fp-ts/Either"
import type { z } from "zod"
import { Log } from "@hoppscotch/common/kernel/log"
import { Store } from "~/kernel/store"
const LOG_TAG = "store-resource"
/**
* A single schema-validated, namespaced, persistent value in the shared
* store.
*
* The persistence service holds several of these (desktop settings,
* update state, connection state, recent instances) which previously
* existed as bespoke `get*` / `set*` / `watch*` method pairs on the
* service class. Each pair was ~20 lines of near-identical plumbing
* that wrapped `Store.get` with a parse and a default fallback,
* wrapped `Store.set` with validation and a throw on failure, and
* wrapped `Store.watch` with an undefined filter and a parse on every
* incoming value. Extracting the pattern to a factory cuts the
* service down to a thin declarative layer where each resource is
* four lines.
*
* A resource is an ordinary value that can be passed around and composed.
* Compound operations (for example "add an instance to the recent list"
* which reads, transforms, and writes) become free functions over a
* resource rather than methods on a god class, which separates the data
* access concern (this factory) from the business rules (the free
* functions).
*/
export interface StoreResource<T> {
/**
* Reads the current value from the store. Falls back to `defaults()` on
* any read error, missing key, or schema validation failure, so callers
* always receive a valid `T`.
*/
get(): Promise<T>
/**
* Writes a new value after validating through the schema. Throws on
* validation failure or store write failure. Callers that want silent
* best-effort semantics should wrap the call themselves.
*/
set(value: T): Promise<void>
/**
* Subscribes to external writes. The handler receives the parsed value
* whenever any writer (this process or another) updates the key.
* Resolves to an unsubscribe function.
*/
watch(handler: (value: T) => void): Promise<() => void>
}
// Input type is deliberately `any` so this works with schemas whose parse
// input differs from output, most commonly `z.object` schemas that carry
// `.default()` on each field (input has optional fields, output has them
// required). Constraining input to `T` would reject every such schema.
export function createStoreResource<T>(
namespace: string,
key: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: z.ZodType<T, z.ZodTypeDef, any>,
defaults: () => T
): StoreResource<T> {
return {
async get(): Promise<T> {
const result = await Store.get<unknown>(namespace, key)
if (E.isLeft(result) || result.right === undefined) {
return defaults()
}
const parsed = schema.safeParse(result.right)
if (!parsed.success) {
Log.warn(
LOG_TAG,
`${namespace}/${key} failed schema validation, falling back to defaults`,
parsed.error
)
return defaults()
}
return parsed.data
},
async set(value: T): Promise<void> {
const validated = schema.parse(value)
const result = await Store.set(namespace, key, validated)
if (E.isLeft(result)) {
// `StoreError` is a tagged union with `kind` and `message`.
// Interpolating the object directly stringifies to
// `[object Object]`, which is useless in logs and throws, so
// format it explicitly here.
const err = result.left
throw new Error(
`Failed to persist ${namespace}/${key}: ${err.kind}: ${err.message}`
)
}
},
async watch(handler: (value: T) => void): Promise<() => void> {
const emitter = await Store.watch(namespace, key)
return emitter.on("change", ({ value }: { value?: unknown }) => {
if (value === undefined) return
const parsed = schema.safeParse(value)
if (parsed.success) {
handler(parsed.data)
return
}
// Mirrors the parse-failure log in `get()`. Without this, an
// external writer with a schema mismatch (for example a shell
// and webview temporarily out of sync after a migration) would
// stop delivering updates with no observable signal.
Log.warn(
LOG_TAG,
`${namespace}/${key} watch received invalid value, skipping`,
parsed.error
)
})
},
}
}

View file

@ -1,41 +1,52 @@
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { invoke } from "@tauri-apps/api/core"
import { z } from "zod" import { z } from "zod"
import { StoreError } from "@hoppscotch/kernel" import { StoreError } from "@hoppscotch/kernel"
import { Store } from "~/kernel/store"
import { UpdateState, PortableSettings } from "~/types"
export const STORE_NAMESPACE = "hoppscotch-desktop.v1" import {
DESKTOP_SETTINGS_SCHEMA,
DESKTOP_SETTINGS_STORE_KEY,
DESKTOP_SETTINGS_STORE_NAMESPACE,
type DesktopSettings,
} from "@hoppscotch/common/platform/desktop-settings"
import {
UPDATE_STATE_SCHEMA,
UPDATE_STATE_STORE_KEY,
} from "@hoppscotch/common/platform/update-state"
import type { Instance } from "@hoppscotch/common/platform/instance"
import { Log } from "@hoppscotch/common/kernel/log"
import { Store } from "~/kernel/store"
import {
createStoreResource,
type StoreResource,
} from "~/kernel/store-resource"
import type { UpdateState } from "~/types"
const LOG_TAG = "persistence"
// Shared namespace for every desktop-local store resource. Individual keys
// live in `STORE_KEYS` below. Exported for the small handful of callers
// that still touch the store directly.
export const STORE_NAMESPACE = DESKTOP_SETTINGS_STORE_NAMESPACE
export const STORE_KEYS = { export const STORE_KEYS = {
UPDATE_STATE: "updateState", UPDATE_STATE: UPDATE_STATE_STORE_KEY,
CONNECTION_STATE: "connectionState", CONNECTION_STATE: "connectionState",
RECENT_INSTANCES: "recentInstances", RECENT_INSTANCES: "recentInstances",
SCHEMA_VERSION: "schema_version", SCHEMA_VERSION: "schema_version",
// Legacy key. Written by portable builds in schema v1. Read only by the
// v1 to v2 migration. All other code uses `DESKTOP_SETTINGS`.
PORTABLE_SETTINGS: "portableSettings", PORTABLE_SETTINGS: "portableSettings",
DESKTOP_SETTINGS: DESKTOP_SETTINGS_STORE_KEY,
} as const } as const
export const UPDATE_STATE_SCHEMA = z.object({ // Runtime validator for `Instance` values read from the store. The type
status: z.enum([ // annotation pins the Zod output to the canonical `Instance` in common,
"idle", // so any drift between the definition stored here and the definition
"checking", // consumed by the webview's instance service would fail typecheck
"available", // rather than silently producing a mismatched runtime value.
"not_available", export const INSTANCE_SCHEMA: z.ZodType<Instance> = z.object({
"downloading",
"installing",
"ready_to_restart",
"error",
]),
version: z.string().optional(),
message: z.string().optional(),
progress: z
.object({
downloaded: z.number(),
total: z.number().optional(),
})
.optional(),
})
export const INSTANCE_SCHEMA = z.object({
kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]), kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]),
serverUrl: z.string(), serverUrl: z.string(),
displayName: z.string(), displayName: z.string(),
@ -44,38 +55,156 @@ export const INSTANCE_SCHEMA = z.object({
bundleName: z.string().optional(), bundleName: z.string().optional(),
}) })
export const CONNECTION_STATE_SCHEMA = z.object({ // Runtime definition of the persisted connection state. NOTE: the
// canonical `ConnectionState` type in
// `@hoppscotch/common/platform/instance.ts` is a discriminated union,
// while the persisted form here is flat with optional fields per
// variant. The two are semi-compatible (the union serializes cleanly
// into this flat form), but the reader loses type narrowing. A later
// migration can switch the persisted form to a discriminated union so
// common's type becomes the single source of truth end-to-end.
export const PERSISTED_CONNECTION_STATE_SCHEMA = z.object({
status: z.enum(["idle", "connecting", "connected", "error"]), status: z.enum(["idle", "connecting", "connected", "error"]),
instance: INSTANCE_SCHEMA.optional(), instance: INSTANCE_SCHEMA.optional(),
target: z.string().optional(), target: z.string().optional(),
message: z.string().optional(), message: z.string().optional(),
}) })
export const PORTABLE_SETTINGS_SCHEMA = z.object({ export type PersistedConnectionState = z.infer<
disableUpdateNotifications: z.boolean(), typeof PERSISTED_CONNECTION_STATE_SCHEMA
autoSkipWelcome: z.boolean(), >
})
export type InstanceKind = z.infer<typeof INSTANCE_SCHEMA>["kind"] // Re-exported for callers that import from this service. The canonical type
export type Instance = z.infer<typeof INSTANCE_SCHEMA> // lives in `@hoppscotch/common/platform/desktop-settings`.
export type ConnectionState = z.infer<typeof CONNECTION_STATE_SCHEMA> export type { DesktopSettings }
// Legacy `PortableSettings` interface. Kept as a local type (not
// exported, not re-exported from `~/types`) because the v2 migration
// is the only reader and no new code should produce this form. Once
// the migration is retired this type can be dropped entirely.
interface LegacyPortableSettings {
disableUpdateNotifications: boolean
autoSkipWelcome: boolean
}
interface Migration { interface Migration {
version: number version: number
migrate: () => Promise<void> // Each migration returns its result as an `Either` so the surrounding
// `runMigrations` loop, and in turn `init`, can propagate the
// `StoreError` contract without falling back to throws.
migrate: () => Promise<E.Either<StoreError, void>>
} }
const migrations: Migration[] = [ const migrations: Migration[] = [
{ {
version: 1, version: 1,
migrate: async () => {}, migrate: async () => E.right(undefined),
},
{
// v1 to v2. Introduces `DesktopSettings` as the single source of truth
// for all desktop builds. Portable users had `portableSettings` with two
// fields, standard-build users had nothing. Both land in `desktopSettings`
// with full defaults for any field not carried over.
//
// Legacy `portableSettings` is intentionally left in place. The key
// is cheap to keep, it preserves a rollback path, and a later
// migration can prune it once the v2 definition has stabilized.
version: 2,
migrate: async () => {
const legacyResult = await Store.get<Partial<LegacyPortableSettings>>(
STORE_NAMESPACE,
STORE_KEYS.PORTABLE_SETTINGS
)
const legacy =
E.isRight(legacyResult) && legacyResult.right
? legacyResult.right
: undefined
// Use `safeParse` with a defaults fallback. `Store.get` returns
// the raw JSON value cast to the generic without validation, so
// a corrupted legacy entry (for example a stringified boolean
// left behind by an older build) would throw from `.parse` and
// abort the full persistence init. Falling back to a fresh
// defaults parse keeps the migration progressing on bad data.
const parsed = DESKTOP_SETTINGS_SCHEMA.safeParse({
disableUpdateNotifications: legacy?.disableUpdateNotifications,
autoSkipWelcome: legacy?.autoSkipWelcome,
})
const migrated = parsed.success
? parsed.data
: DESKTOP_SETTINGS_SCHEMA.parse({})
const writeResult = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.DESKTOP_SETTINGS,
migrated
)
if (E.isLeft(writeResult)) {
// Return Left so `runMigrations` aborts before recording the
// new `SCHEMA_VERSION`. Swallowing here would let the loop
// bump the version despite the failed write, and the next
// launch would treat the data as already migrated and never
// retry.
Log.error(
LOG_TAG,
"v2 migration failed to write desktopSettings",
writeResult.left
)
return writeResult
}
return E.right(undefined)
},
}, },
] ]
/**
* Facade over the desktop-local persistent store.
*
* Each persistent value is exposed as a `StoreResource<T>`, which
* carries a uniform `{ get, set, watch }` API validated through a Zod
* schema. The service itself is a thin orchestrator. It runs
* schema-version migrations on init, subscribes once to the
* desktop-settings resource so the Rust mailbox stays in sync with any
* writer, and exposes the resources as readonly members.
*
* Callers move from `persistence.setFoo(value)` to `persistence.foo.set(value)`
* and likewise for `get` / `watch`. Compound operations (e.g. adding to the
* recent-instances list) live as free functions over the resource, below.
*/
export class DesktopPersistenceService { export class DesktopPersistenceService {
private static instance: DesktopPersistenceService private static instance: DesktopPersistenceService
private constructor() {} readonly desktopSettings: StoreResource<DesktopSettings>
readonly updateState: StoreResource<UpdateState | null>
readonly connectionState: StoreResource<PersistedConnectionState | null>
readonly recentInstances: StoreResource<Instance[]>
private constructor() {
this.desktopSettings = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.DESKTOP_SETTINGS,
DESKTOP_SETTINGS_SCHEMA,
() => DESKTOP_SETTINGS_SCHEMA.parse({})
)
this.updateState = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.UPDATE_STATE,
UPDATE_STATE_SCHEMA.nullable(),
() => null
)
this.connectionState = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.CONNECTION_STATE,
PERSISTED_CONNECTION_STATE_SCHEMA.nullable(),
() => null
)
this.recentInstances = createStoreResource(
STORE_NAMESPACE,
STORE_KEYS.RECENT_INSTANCES,
z.array(INSTANCE_SCHEMA),
() => []
)
}
public static getInstance(): DesktopPersistenceService { public static getInstance(): DesktopPersistenceService {
if (!DesktopPersistenceService.instance) { if (!DesktopPersistenceService.instance) {
@ -87,176 +216,150 @@ export class DesktopPersistenceService {
async init(): Promise<E.Either<StoreError, void>> { async init(): Promise<E.Either<StoreError, void>> {
const initResult = await Store.init() const initResult = await Store.init()
if (E.isLeft(initResult)) { if (E.isLeft(initResult)) {
console.error( Log.error(LOG_TAG, "Failed to initialize store", initResult.left)
"[PersistenceService] Failed to initialize store:",
initResult.left
)
return initResult return initResult
} }
await this.runMigrations() const migrationResult = await this.runMigrations()
return initResult if (E.isLeft(migrationResult)) {
return migrationResult
}
await this.setupRustSync()
return E.right(undefined)
} }
private async runMigrations() { /**
* Keep the Rust-side `DESKTOP_CONFIG` mailbox (see
* `src-tauri/src/config.rs`) in sync with the desktop settings resource.
*
* Two triggers. An initial push so Rust has the current value
* before any consumer reads it. A subscription to the resource's
* `watch` so every subsequent write from any writer (this shell,
* the webview, another process) gets mirrored. The write-side code
* path stays pure persistence, and the sync is a cross-cutting
* concern handled here.
*
* Both paths swallow failures. Rust already has a compile-time default
* for every field Rust cares about, so a failed sync degrades to
* "Rust reads stale value" rather than a user-visible error.
*/
private async setupRustSync(): Promise<void> {
try {
const initial = await this.desktopSettings.get()
await invoke("set_desktop_config", { config: initial })
} catch (err) {
Log.warn(LOG_TAG, "Initial DesktopSettings sync to Rust failed", err)
}
try {
await this.desktopSettings.watch((settings) => {
invoke("set_desktop_config", { config: settings }).catch((err) => {
Log.warn(LOG_TAG, "DesktopSettings sync to Rust failed", err)
})
})
} catch (err) {
Log.warn(
LOG_TAG,
"Failed to subscribe to DesktopSettings for Rust sync",
err
)
}
}
private async runMigrations(): Promise<E.Either<StoreError, void>> {
const versionResult = await Store.get<string>( const versionResult = await Store.get<string>(
STORE_NAMESPACE, STORE_NAMESPACE,
STORE_KEYS.SCHEMA_VERSION STORE_KEYS.SCHEMA_VERSION
) )
const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1" const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1"
const currentVersion = perhapsVersion ?? "1" const rawVersion = perhapsVersion ?? "1"
const targetVersion = "1" // Coerce a corrupted or non-numeric stored value to the lowest known
// version. Without this, `parseInt("v2")` or `parseInt("")` would
// return NaN, every `migration.version > NaN` check below would
// evaluate to false, the loop would skip every migration, and the
// code would still write `SCHEMA_VERSION = "2"` at the end. That
// would mark migrations complete without ever running them.
// Falling back to "1" instead reruns every migration from scratch,
// which is safe because each migration is idempotent on a fresh
// store and a real fresh install lands here through the same path.
const parsedVersion = parseInt(rawVersion, 10)
const currentVersion = Number.isNaN(parsedVersion) ? "1" : rawVersion
const targetVersion = "2"
if (currentVersion === targetVersion) {
return E.right(undefined)
}
if (currentVersion !== targetVersion) {
for (const migration of migrations) { for (const migration of migrations) {
if (migration.version > parseInt(currentVersion)) { if (migration.version > parseInt(currentVersion, 10)) {
await migration.migrate() const result = await migration.migrate()
}
}
await Store.set(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION, targetVersion)
}
}
async setUpdateState(state: UpdateState): Promise<void> {
const result = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.UPDATE_STATE,
state
)
if (E.isLeft(result)) { if (E.isLeft(result)) {
console.error("Failed to save update state:", result.left) return result
}
} }
} }
async getUpdateState(): Promise<UpdateState | null> { // Record the new version only when the write succeeds. A silent
const result = await Store.get<UpdateState>( // failure here would leave the stored version stale, and the next
// launch would re-run every migration on already-migrated data.
const versionWrite = await Store.set(
STORE_NAMESPACE, STORE_NAMESPACE,
STORE_KEYS.UPDATE_STATE STORE_KEYS.SCHEMA_VERSION,
targetVersion
) )
if (E.isRight(result) && result.right) { if (E.isLeft(versionWrite)) {
return result.right Log.error(
} LOG_TAG,
return null "Failed to persist schema version after migrations",
} versionWrite.left
async watchUpdateState(
handler: (state: UpdateState) => void
): Promise<() => void> {
const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.UPDATE_STATE)
return watcher.on("change", ({ value }: { value?: unknown }) => {
if (value) {
handler(value as UpdateState)
}
})
}
async setConnectionState(state: ConnectionState): Promise<void> {
const result = await Store.set(
STORE_NAMESPACE,
STORE_KEYS.CONNECTION_STATE,
state
) )
if (E.isLeft(result)) { return versionWrite
console.error("Failed to save connection state:", result.left) }
return E.right(undefined)
} }
} }
async getConnectionState(): Promise<ConnectionState | null> { /**
const result = await Store.get<ConnectionState>( * Adds an instance to the recent list, preserving the "most-recent-first,
STORE_NAMESPACE, * max 10, deduplicated by kind+serverUrl" invariants. Kept as a free
STORE_KEYS.CONNECTION_STATE * function over the resource rather than a method on the service so the
) * data-access concern (the resource) stays separate from the business
if (E.isRight(result) && result.right) { * rules (dedupe, sort, trim).
return result.right */
} export async function addRecentInstance(
return null recent: StoreResource<Instance[]>,
} instance: Instance
): Promise<void> {
async setRecentInstances(instances: Instance[]): Promise<void> { const current = await recent.get()
const result = await Store.set( const now = new Date().toISOString()
STORE_NAMESPACE, const existingIndex = current.findIndex(
STORE_KEYS.RECENT_INSTANCES,
instances
)
if (E.isLeft(result)) {
console.error("Failed to save recent instances:", result.left)
}
}
async getRecentInstances(): Promise<Instance[]> {
const result = await Store.get<Instance[]>(
STORE_NAMESPACE,
STORE_KEYS.RECENT_INSTANCES
)
if (E.isRight(result) && result.right) {
return result.right
}
return []
}
async addRecentInstance(instance: Instance): Promise<void> {
const instances = await this.getRecentInstances()
const existingIndex = instances.findIndex(
(i) => i.kind === instance.kind && i.serverUrl === instance.serverUrl (i) => i.kind === instance.kind && i.serverUrl === instance.serverUrl
) )
if (existingIndex >= 0) { const merged =
instances[existingIndex] = { existingIndex >= 0
...instance, ? current.map((existing, index) =>
lastUsed: new Date().toISOString(), index === existingIndex ? { ...instance, lastUsed: now } : existing
} )
} else { : [{ ...instance, lastUsed: now }, ...current]
instances.unshift({ ...instance, lastUsed: new Date().toISOString() })
}
const sortedInstances = instances const next = [...merged]
.sort( .sort(
(a, b) => (a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
) )
.slice(0, 10) .slice(0, 10)
await this.setRecentInstances(sortedInstances) await recent.set(next)
} }
async removeRecentInstance(serverUrl: string): Promise<void> { /**
const instances = await this.getRecentInstances() * Removes an instance from the recent list by `serverUrl`. Absent entries
const filtered = instances.filter((i) => i.serverUrl !== serverUrl) * are silently ignored.
await this.setRecentInstances(filtered) */
} export async function removeRecentInstance(
recent: StoreResource<Instance[]>,
async setPortableSettings(settings: PortableSettings): Promise<void> { serverUrl: string
console.log("Setting portable settings:", settings) ): Promise<void> {
const result = await Store.set( const current = await recent.get()
STORE_NAMESPACE, const filtered = current.filter((i) => i.serverUrl !== serverUrl)
STORE_KEYS.PORTABLE_SETTINGS, await recent.set(filtered)
settings
)
if (E.isLeft(result)) {
console.error("Failed to save portable settings:", result.left)
throw new Error(`Failed to save portable settings: ${result.left}`)
} else {
console.log("Successfully saved portable settings")
}
}
async getPortableSettings(): Promise<PortableSettings> {
const result = await Store.get<PortableSettings>(
STORE_NAMESPACE,
STORE_KEYS.PORTABLE_SETTINGS
)
const defaultSettings = {
disableUpdateNotifications: false,
autoSkipWelcome: false,
}
if (E.isRight(result) && result.right) {
console.log("Loaded portable settings from store:", result.right)
return result.right
}
console.log("No portable settings found, using defaults:", defaultSettings)
return defaultSettings
}
} }

View file

@ -1,39 +1,20 @@
export interface RecentInstance { // Re-exports of types whose canonical definitions live in common. Listed
url: string // here so in-package imports can keep using `~/types` without every caller
lastUsed: string // needing to know the precise module path in common. New types that need to
version?: string // cross the shell/webview boundary belong in common directly.
pinned: boolean export {
} UpdateStatus,
type UpdateState,
export interface StoreSchema { type DownloadProgress,
recentInstances: RecentInstance[] } from "@hoppscotch/common/platform/update-state"
}
export enum UpdateStatus {
IDLE = "idle",
CHECKING = "checking",
AVAILABLE = "available",
NOT_AVAILABLE = "not_available",
DOWNLOADING = "downloading",
INSTALLING = "installing",
READY_TO_RESTART = "ready_to_restart",
ERROR = "error",
}
// Not to be confused with `UpdateStatus`. `CheckResult` is the outcome of a
// single call to the updater's `checkForUpdates`, where `UpdateStatus` is
// the full state machine covering checking, downloading, installing, and
// restart. Only `checkForUpdates` returns this.
export enum CheckResult { export enum CheckResult {
AVAILABLE, AVAILABLE,
NOT_AVAILABLE, NOT_AVAILABLE,
TIMEOUT, TIMEOUT,
ERROR, ERROR,
} }
export interface UpdateState {
status: UpdateStatus
version?: string
message?: string
}
export interface PortableSettings {
disableUpdateNotifications: boolean
autoSkipWelcome: boolean
}

View file

@ -116,7 +116,6 @@ import { close } from "@hoppscotch/plugin-appload"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { Io } from "~/kernel" import { Io } from "~/kernel"
import type { PortableSettings } from "~/types"
import { import {
useAppInitialization, useAppInitialization,
AppState, AppState,
@ -143,9 +142,18 @@ const updaterClient = new UpdaterClient()
const showPortableWelcome = ref(false) const showPortableWelcome = ref(false)
const currentDirectory = ref(".") const currentDirectory = ref(".")
const portableSettings = reactive<PortableSettings>({ // Fields mirrored locally for the portable welcome screen's UI and the
// startup update gate. The welcome screen only lets the user toggle
// `disableUpdateNotifications` and `autoSkipWelcome`, but the update gate
// also reads `disableUpdateChecks` so a user who set that via the settings
// page on a prior session sees the gate respected on next startup. The full
// desktop settings object is loaded and merged in
// `handlePortableWelcomeContinue` so other fields like timeout or zoom,
// written elsewhere, survive intact.
const portableSettings = reactive({
disableUpdateNotifications: false, disableUpdateNotifications: false,
autoSkipWelcome: false, autoSkipWelcome: false,
disableUpdateChecks: false,
}) })
watch( watch(
@ -183,26 +191,22 @@ const closeApp = async () => {
const handlePortableWelcomeContinue = async () => { const handlePortableWelcomeContinue = async () => {
try { try {
console.log( // Read-modify-write against the full `DesktopSettings` object so
"About to save portable settings:", // unrelated fields like timeout or zoom, potentially written by the
JSON.stringify(portableSettings) // webview-side settings page in the same session, are preserved.
) const current = await persistence.desktopSettings.get()
const updated = {
const settingsToSave: PortableSettings = { ...current,
disableUpdateNotifications: portableSettings.disableUpdateNotifications, disableUpdateNotifications: portableSettings.disableUpdateNotifications,
autoSkipWelcome: portableSettings.autoSkipWelcome, autoSkipWelcome: portableSettings.autoSkipWelcome,
} }
console.log("Saving portable settings:", settingsToSave) await persistence.desktopSettings.set(updated)
await persistence.setPortableSettings(settingsToSave)
const savedSettings = await persistence.getPortableSettings()
console.log("Verified saved settings:", savedSettings)
showPortableWelcome.value = false showPortableWelcome.value = false
await loadRecent() await loadRecent()
} catch (error) { } catch (error) {
console.error("Failed to save portable settings:", error) console.error("Failed to save desktop settings:", error)
showPortableWelcome.value = false showPortableWelcome.value = false
await loadRecent() await loadRecent()
} }
@ -211,8 +215,18 @@ const handlePortableWelcomeContinue = async () => {
const checkForUpdatesPortable = async () => { const checkForUpdatesPortable = async () => {
console.log("Checking portable updates, current settings:", portableSettings) console.log("Checking portable updates, current settings:", portableSettings)
if (portableSettings.disableUpdateNotifications) { // Two disable flags land in this gate for backwards compatibility. The
console.log("Update notifications disabled for portable mode") // legacy `disableUpdateNotifications` was originally documented as
// controlling only notifications but was wired up to skip the whole check
// in portable mode. The new `disableUpdateChecks` is the explicit
// opt-out that matches the settings-page toggle. Either flag being true
// skips the startup check, so users upgrading from a prior version keep
// their original behavior and users who set the new flag see it honored.
if (
portableSettings.disableUpdateNotifications ||
portableSettings.disableUpdateChecks
) {
console.log("Automatic update check disabled for portable mode")
return return
} }
@ -238,14 +252,13 @@ const initializePortableMode = async () => {
currentDirectory.value = "." currentDirectory.value = "."
} }
const settings = await persistence.getPortableSettings() const settings = await persistence.desktopSettings.get()
console.log("Loaded portable settings:", settings) console.log("Loaded desktop settings:", settings)
portableSettings.disableUpdateNotifications = portableSettings.disableUpdateNotifications =
settings.disableUpdateNotifications settings.disableUpdateNotifications
portableSettings.autoSkipWelcome = settings.autoSkipWelcome portableSettings.autoSkipWelcome = settings.autoSkipWelcome
portableSettings.disableUpdateChecks = settings.disableUpdateChecks
console.log("Updated reactive portableSettings:", portableSettings)
await checkForUpdatesPortable() await checkForUpdatesPortable()

View file

@ -50,6 +50,7 @@ import {
type UpdateEvent, type UpdateEvent,
type DownloadProgress, type DownloadProgress,
} from "~/services/updater.client" } from "~/services/updater.client"
import { DesktopPersistenceService } from "~/services/persistence.service"
import AppHeader from "./shared/AppHeader.vue" import AppHeader from "./shared/AppHeader.vue"
import LoadingState from "./shared/LoadingState.vue" import LoadingState from "./shared/LoadingState.vue"
@ -145,12 +146,24 @@ const checkForUpdates = async () => {
} }
const initializeStandardMode = async () => { const initializeStandardMode = async () => {
// The settings page's `disableUpdateChecks` toggle governs the automatic
// startup check, not the manual "Check for updates" button in settings.
// Reading the setting here lets air-gapped and enterprise-network users
// skip the 5-second timeout retry on every launch. They can still trigger
// a check on demand from the settings page whenever they want.
const persistence = DesktopPersistenceService.getInstance()
const settings = await persistence.desktopSettings.get()
if (!settings.disableUpdateChecks) {
const hasUpdates = await checkForUpdates() const hasUpdates = await checkForUpdates()
if (!hasUpdates) { if (hasUpdates) {
await loadRecent() return
} }
} }
await loadRecent()
}
onMounted(async () => { onMounted(async () => {
await updaterClient.listenToUpdates((event: UpdateEvent) => { await updaterClient.listenToUpdates((event: UpdateEvent) => {
switch (event.type) { switch (event.type) {

View file

@ -88,6 +88,7 @@ interface Props {
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
message: "", message: "",
progress: undefined,
showProgress: true, showProgress: true,
showCancel: false, showCancel: false,
}) })

View file

@ -27,6 +27,8 @@ import { InfraPlatform } from "@app/platform/infra/infra.platform"
import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io" import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io"
import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service" import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service"
import DesktopSettingsSection from "@hoppscotch/common/components/settings/Desktop.vue"
// Std interceptors // Std interceptors
import { NativeKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/native" import { NativeKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/native"
import { AgentKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/agent" import { AgentKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/agent"
@ -142,6 +144,12 @@ async function initApp() {
ui: { ui: {
additionalFooterMenuItems: config.menuItems, additionalFooterMenuItems: config.menuItems,
additionalSupportOptionsMenuItems: config.supportItems, additionalSupportOptionsMenuItems: config.supportItems,
// Desktop-only. Renders the "Desktop" block in the shared settings
// page. The component lives in common so every shell that builds a
// Tauri desktop target can register it the same way. Web builds pass
// `undefined` here and the settings page renders without the block.
additionalSettingsSections:
platform === "desktop" ? [DesktopSettingsSection] : undefined,
appHeader: { appHeader: {
paddingLeft: headerPaddingLeft, paddingLeft: headerPaddingLeft,
paddingTop: headerPaddingTop, paddingTop: headerPaddingTop,

View file

@ -18,11 +18,13 @@
"@app/platform/*": ["./src/platform/*"], "@app/platform/*": ["./src/platform/*"],
"@app/services/*": ["./src/services/*"], "@app/services/*": ["./src/services/*"],
"@app/components/*": ["./src/components/*"], "@app/components/*": ["./src/components/*"],
"@app/composables/*": ["./src/composables/*"],
"@app/helpers/*": ["./src/helpers/*"], "@app/helpers/*": ["./src/helpers/*"],
"@app/api/*": ["./src/api/*"], "@app/api/*": ["./src/api/*"],
"@app/lib/*": ["./src/lib/*"], "@app/lib/*": ["./src/lib/*"],
"@app/kernel/*": ["./src/kernel/*"] "@app/kernel/*": ["./src/kernel/*"]
} },
"types": ["vite/client", "unplugin-icons/types/vue"]
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]

View file

@ -86,6 +86,7 @@ export default defineConfig({
"@app/platform": path.resolve(__dirname, "./src/platform"), "@app/platform": path.resolve(__dirname, "./src/platform"),
"@app/services": path.resolve(__dirname, "./src/services"), "@app/services": path.resolve(__dirname, "./src/services"),
"@app/components": path.resolve(__dirname, "./src/components"), "@app/components": path.resolve(__dirname, "./src/components"),
"@app/composables": path.resolve(__dirname, "./src/composables"),
"@app/helpers": path.resolve(__dirname, "./src/helpers"), "@app/helpers": path.resolve(__dirname, "./src/helpers"),
"@app/api": path.resolve(__dirname, "./src/api"), "@app/api": path.resolve(__dirname, "./src/api"),
"@app/lib": path.resolve(__dirname, "./src/lib"), "@app/lib": path.resolve(__dirname, "./src/lib"),