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:
parent
15d12f8ce5
commit
9861ee84ad
20 changed files with 1599 additions and 251 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
282
packages/hoppscotch-common/src/components/settings/Desktop.vue
Normal file
282
packages/hoppscotch-common/src/components/settings/Desktop.vue
Normal 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>
|
||||
164
packages/hoppscotch-common/src/composables/desktop-settings.ts
Normal file
164
packages/hoppscotch-common/src/composables/desktop-settings.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
368
packages/hoppscotch-common/src/composables/update-check.ts
Normal file
368
packages/hoppscotch-common/src/composables/update-check.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
72
packages/hoppscotch-common/src/platform/desktop-settings.ts
Normal file
72
packages/hoppscotch-common/src/platform/desktop-settings.ts
Normal 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
|
||||
}
|
||||
69
packages/hoppscotch-common/src/platform/update-state.ts
Normal file
69
packages/hoppscotch-common/src/platform/update-state.ts
Normal 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>
|
||||
|
|
@ -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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -117,4 +189,51 @@ mod tests {
|
|||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ pub fn run() {
|
|||
hopp_auth_port,
|
||||
quit_app,
|
||||
backup::check_and_backup_on_version_change,
|
||||
config::set_desktop_config,
|
||||
updater::check_for_updates,
|
||||
updater::download_and_install_update,
|
||||
updater::restart_application,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ref } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { load, download, close } from "@hoppscotch/plugin-appload"
|
||||
import { getVersion } from "@tauri-apps/api/app"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
|
|
@ -44,7 +45,7 @@ export function useAppInitialization() {
|
|||
|
||||
const saveConnectionState = async (state: ConnectionState) => {
|
||||
try {
|
||||
await persistence.setConnectionState(state)
|
||||
await persistence.connectionState.set(state)
|
||||
} catch (err) {
|
||||
console.error("Failed to save connection state:", err)
|
||||
}
|
||||
|
|
@ -246,8 +247,8 @@ export function useAppInitialization() {
|
|||
// instances. The InstanceService's detectCurrentInstanceFromHostname
|
||||
// persists the detected instance (including cloud-org) to this store,
|
||||
// so on restart the main window can resume the correct instance.
|
||||
const connectionState = await persistence.getConnectionState()
|
||||
const recentInstances = await persistence.getRecentInstances()
|
||||
const connectionState = await persistence.connectionState.get()
|
||||
const recentInstances = await persistence.recentInstances.get()
|
||||
|
||||
mainDiag(`loadRecent: connectionState=${JSON.stringify(connectionState)}`)
|
||||
mainDiag(
|
||||
|
|
@ -354,7 +355,18 @@ export function useAppInitialization() {
|
|||
}
|
||||
|
||||
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>) => {
|
||||
|
|
|
|||
120
packages/hoppscotch-desktop/src/kernel/store-resource.ts
Normal file
120
packages/hoppscotch-desktop/src/kernel/store-resource.ts
Normal 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
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +1,52 @@
|
|||
import * as E from "fp-ts/Either"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { z } from "zod"
|
||||
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 = {
|
||||
UPDATE_STATE: "updateState",
|
||||
UPDATE_STATE: UPDATE_STATE_STORE_KEY,
|
||||
CONNECTION_STATE: "connectionState",
|
||||
RECENT_INSTANCES: "recentInstances",
|
||||
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",
|
||||
DESKTOP_SETTINGS: DESKTOP_SETTINGS_STORE_KEY,
|
||||
} as const
|
||||
|
||||
export const UPDATE_STATE_SCHEMA = z.object({
|
||||
status: z.enum([
|
||||
"idle",
|
||||
"checking",
|
||||
"available",
|
||||
"not_available",
|
||||
"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({
|
||||
// Runtime validator for `Instance` values read from the store. The type
|
||||
// annotation pins the Zod output to the canonical `Instance` in common,
|
||||
// so any drift between the definition stored here and the definition
|
||||
// consumed by the webview's instance service would fail typecheck
|
||||
// rather than silently producing a mismatched runtime value.
|
||||
export const INSTANCE_SCHEMA: z.ZodType<Instance> = z.object({
|
||||
kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]),
|
||||
serverUrl: z.string(),
|
||||
displayName: z.string(),
|
||||
|
|
@ -44,38 +55,156 @@ export const INSTANCE_SCHEMA = z.object({
|
|||
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"]),
|
||||
instance: INSTANCE_SCHEMA.optional(),
|
||||
target: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export const PORTABLE_SETTINGS_SCHEMA = z.object({
|
||||
disableUpdateNotifications: z.boolean(),
|
||||
autoSkipWelcome: z.boolean(),
|
||||
})
|
||||
export type PersistedConnectionState = z.infer<
|
||||
typeof PERSISTED_CONNECTION_STATE_SCHEMA
|
||||
>
|
||||
|
||||
export type InstanceKind = z.infer<typeof INSTANCE_SCHEMA>["kind"]
|
||||
export type Instance = z.infer<typeof INSTANCE_SCHEMA>
|
||||
export type ConnectionState = z.infer<typeof CONNECTION_STATE_SCHEMA>
|
||||
// Re-exported for callers that import from this service. The canonical type
|
||||
// lives in `@hoppscotch/common/platform/desktop-settings`.
|
||||
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 {
|
||||
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[] = [
|
||||
{
|
||||
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 {
|
||||
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 {
|
||||
if (!DesktopPersistenceService.instance) {
|
||||
|
|
@ -87,176 +216,150 @@ export class DesktopPersistenceService {
|
|||
async init(): Promise<E.Either<StoreError, void>> {
|
||||
const initResult = await Store.init()
|
||||
if (E.isLeft(initResult)) {
|
||||
console.error(
|
||||
"[PersistenceService] Failed to initialize store:",
|
||||
initResult.left
|
||||
)
|
||||
Log.error(LOG_TAG, "Failed to initialize store", initResult.left)
|
||||
return initResult
|
||||
}
|
||||
await this.runMigrations()
|
||||
return initResult
|
||||
const migrationResult = await this.runMigrations()
|
||||
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>(
|
||||
STORE_NAMESPACE,
|
||||
STORE_KEYS.SCHEMA_VERSION
|
||||
)
|
||||
const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1"
|
||||
const currentVersion = perhapsVersion ?? "1"
|
||||
const targetVersion = "1"
|
||||
const rawVersion = perhapsVersion ?? "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) {
|
||||
if (migration.version > parseInt(currentVersion)) {
|
||||
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 (migration.version > parseInt(currentVersion, 10)) {
|
||||
const result = await migration.migrate()
|
||||
if (E.isLeft(result)) {
|
||||
console.error("Failed to save update state:", result.left)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getUpdateState(): Promise<UpdateState | null> {
|
||||
const result = await Store.get<UpdateState>(
|
||||
// Record the new version only when the write succeeds. A silent
|
||||
// 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_KEYS.UPDATE_STATE
|
||||
STORE_KEYS.SCHEMA_VERSION,
|
||||
targetVersion
|
||||
)
|
||||
if (E.isRight(result) && result.right) {
|
||||
return result.right
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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(versionWrite)) {
|
||||
Log.error(
|
||||
LOG_TAG,
|
||||
"Failed to persist schema version after migrations",
|
||||
versionWrite.left
|
||||
)
|
||||
if (E.isLeft(result)) {
|
||||
console.error("Failed to save connection state:", result.left)
|
||||
return versionWrite
|
||||
}
|
||||
return E.right(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async getConnectionState(): Promise<ConnectionState | null> {
|
||||
const result = await Store.get<ConnectionState>(
|
||||
STORE_NAMESPACE,
|
||||
STORE_KEYS.CONNECTION_STATE
|
||||
)
|
||||
if (E.isRight(result) && result.right) {
|
||||
return result.right
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async setRecentInstances(instances: Instance[]): Promise<void> {
|
||||
const result = await Store.set(
|
||||
STORE_NAMESPACE,
|
||||
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(
|
||||
/**
|
||||
* Adds an instance to the recent list, preserving the "most-recent-first,
|
||||
* max 10, deduplicated by kind+serverUrl" invariants. Kept as a free
|
||||
* function over the resource rather than a method on the service so the
|
||||
* data-access concern (the resource) stays separate from the business
|
||||
* rules (dedupe, sort, trim).
|
||||
*/
|
||||
export async function addRecentInstance(
|
||||
recent: StoreResource<Instance[]>,
|
||||
instance: Instance
|
||||
): Promise<void> {
|
||||
const current = await recent.get()
|
||||
const now = new Date().toISOString()
|
||||
const existingIndex = current.findIndex(
|
||||
(i) => i.kind === instance.kind && i.serverUrl === instance.serverUrl
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
instances[existingIndex] = {
|
||||
...instance,
|
||||
lastUsed: new Date().toISOString(),
|
||||
}
|
||||
} else {
|
||||
instances.unshift({ ...instance, lastUsed: new Date().toISOString() })
|
||||
}
|
||||
const merged =
|
||||
existingIndex >= 0
|
||||
? current.map((existing, index) =>
|
||||
index === existingIndex ? { ...instance, lastUsed: now } : existing
|
||||
)
|
||||
: [{ ...instance, lastUsed: now }, ...current]
|
||||
|
||||
const sortedInstances = instances
|
||||
const next = [...merged]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
|
||||
(a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
|
||||
)
|
||||
.slice(0, 10)
|
||||
|
||||
await this.setRecentInstances(sortedInstances)
|
||||
await recent.set(next)
|
||||
}
|
||||
|
||||
async removeRecentInstance(serverUrl: string): Promise<void> {
|
||||
const instances = await this.getRecentInstances()
|
||||
const filtered = instances.filter((i) => i.serverUrl !== serverUrl)
|
||||
await this.setRecentInstances(filtered)
|
||||
}
|
||||
|
||||
async setPortableSettings(settings: PortableSettings): Promise<void> {
|
||||
console.log("Setting portable settings:", settings)
|
||||
const result = await Store.set(
|
||||
STORE_NAMESPACE,
|
||||
STORE_KEYS.PORTABLE_SETTINGS,
|
||||
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
|
||||
}
|
||||
/**
|
||||
* Removes an instance from the recent list by `serverUrl`. Absent entries
|
||||
* are silently ignored.
|
||||
*/
|
||||
export async function removeRecentInstance(
|
||||
recent: StoreResource<Instance[]>,
|
||||
serverUrl: string
|
||||
): Promise<void> {
|
||||
const current = await recent.get()
|
||||
const filtered = current.filter((i) => i.serverUrl !== serverUrl)
|
||||
await recent.set(filtered)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,20 @@
|
|||
export interface RecentInstance {
|
||||
url: string
|
||||
lastUsed: string
|
||||
version?: string
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export interface StoreSchema {
|
||||
recentInstances: RecentInstance[]
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
// Re-exports of types whose canonical definitions live in common. Listed
|
||||
// here so in-package imports can keep using `~/types` without every caller
|
||||
// needing to know the precise module path in common. New types that need to
|
||||
// cross the shell/webview boundary belong in common directly.
|
||||
export {
|
||||
UpdateStatus,
|
||||
type UpdateState,
|
||||
type DownloadProgress,
|
||||
} from "@hoppscotch/common/platform/update-state"
|
||||
|
||||
// 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 {
|
||||
AVAILABLE,
|
||||
NOT_AVAILABLE,
|
||||
TIMEOUT,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
export interface UpdateState {
|
||||
status: UpdateStatus
|
||||
version?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PortableSettings {
|
||||
disableUpdateNotifications: boolean
|
||||
autoSkipWelcome: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,6 @@ import { close } from "@hoppscotch/plugin-appload"
|
|||
import { invoke } from "@tauri-apps/api/core"
|
||||
|
||||
import { Io } from "~/kernel"
|
||||
import type { PortableSettings } from "~/types"
|
||||
import {
|
||||
useAppInitialization,
|
||||
AppState,
|
||||
|
|
@ -143,9 +142,18 @@ const updaterClient = new UpdaterClient()
|
|||
const showPortableWelcome = ref(false)
|
||||
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,
|
||||
autoSkipWelcome: false,
|
||||
disableUpdateChecks: false,
|
||||
})
|
||||
|
||||
watch(
|
||||
|
|
@ -183,26 +191,22 @@ const closeApp = async () => {
|
|||
|
||||
const handlePortableWelcomeContinue = async () => {
|
||||
try {
|
||||
console.log(
|
||||
"About to save portable settings:",
|
||||
JSON.stringify(portableSettings)
|
||||
)
|
||||
|
||||
const settingsToSave: PortableSettings = {
|
||||
// Read-modify-write against the full `DesktopSettings` object so
|
||||
// unrelated fields like timeout or zoom, potentially written by the
|
||||
// webview-side settings page in the same session, are preserved.
|
||||
const current = await persistence.desktopSettings.get()
|
||||
const updated = {
|
||||
...current,
|
||||
disableUpdateNotifications: portableSettings.disableUpdateNotifications,
|
||||
autoSkipWelcome: portableSettings.autoSkipWelcome,
|
||||
}
|
||||
|
||||
console.log("Saving portable settings:", settingsToSave)
|
||||
await persistence.setPortableSettings(settingsToSave)
|
||||
|
||||
const savedSettings = await persistence.getPortableSettings()
|
||||
console.log("Verified saved settings:", savedSettings)
|
||||
await persistence.desktopSettings.set(updated)
|
||||
|
||||
showPortableWelcome.value = false
|
||||
await loadRecent()
|
||||
} catch (error) {
|
||||
console.error("Failed to save portable settings:", error)
|
||||
console.error("Failed to save desktop settings:", error)
|
||||
showPortableWelcome.value = false
|
||||
await loadRecent()
|
||||
}
|
||||
|
|
@ -211,8 +215,18 @@ const handlePortableWelcomeContinue = async () => {
|
|||
const checkForUpdatesPortable = async () => {
|
||||
console.log("Checking portable updates, current settings:", portableSettings)
|
||||
|
||||
if (portableSettings.disableUpdateNotifications) {
|
||||
console.log("Update notifications disabled for portable mode")
|
||||
// Two disable flags land in this gate for backwards compatibility. The
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
@ -238,14 +252,13 @@ const initializePortableMode = async () => {
|
|||
currentDirectory.value = "."
|
||||
}
|
||||
|
||||
const settings = await persistence.getPortableSettings()
|
||||
console.log("Loaded portable settings:", settings)
|
||||
const settings = await persistence.desktopSettings.get()
|
||||
console.log("Loaded desktop settings:", settings)
|
||||
|
||||
portableSettings.disableUpdateNotifications =
|
||||
settings.disableUpdateNotifications
|
||||
portableSettings.autoSkipWelcome = settings.autoSkipWelcome
|
||||
|
||||
console.log("Updated reactive portableSettings:", portableSettings)
|
||||
portableSettings.disableUpdateChecks = settings.disableUpdateChecks
|
||||
|
||||
await checkForUpdatesPortable()
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
type UpdateEvent,
|
||||
type DownloadProgress,
|
||||
} from "~/services/updater.client"
|
||||
import { DesktopPersistenceService } from "~/services/persistence.service"
|
||||
|
||||
import AppHeader from "./shared/AppHeader.vue"
|
||||
import LoadingState from "./shared/LoadingState.vue"
|
||||
|
|
@ -145,12 +146,24 @@ const checkForUpdates = 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()
|
||||
if (!hasUpdates) {
|
||||
await loadRecent()
|
||||
if (hasUpdates) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await loadRecent()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await updaterClient.listenToUpdates((event: UpdateEvent) => {
|
||||
switch (event.type) {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ interface Props {
|
|||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
message: "",
|
||||
progress: undefined,
|
||||
showProgress: true,
|
||||
showCancel: false,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import { InfraPlatform } from "@app/platform/infra/infra.platform"
|
|||
import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io"
|
||||
import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service"
|
||||
|
||||
import DesktopSettingsSection from "@hoppscotch/common/components/settings/Desktop.vue"
|
||||
|
||||
// Std interceptors
|
||||
import { NativeKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/native"
|
||||
import { AgentKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/agent"
|
||||
|
|
@ -142,6 +144,12 @@ async function initApp() {
|
|||
ui: {
|
||||
additionalFooterMenuItems: config.menuItems,
|
||||
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: {
|
||||
paddingLeft: headerPaddingLeft,
|
||||
paddingTop: headerPaddingTop,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@
|
|||
"@app/platform/*": ["./src/platform/*"],
|
||||
"@app/services/*": ["./src/services/*"],
|
||||
"@app/components/*": ["./src/components/*"],
|
||||
"@app/composables/*": ["./src/composables/*"],
|
||||
"@app/helpers/*": ["./src/helpers/*"],
|
||||
"@app/api/*": ["./src/api/*"],
|
||||
"@app/lib/*": ["./src/lib/*"],
|
||||
"@app/kernel/*": ["./src/kernel/*"]
|
||||
}
|
||||
},
|
||||
"types": ["vite/client", "unplugin-icons/types/vue"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export default defineConfig({
|
|||
"@app/platform": path.resolve(__dirname, "./src/platform"),
|
||||
"@app/services": path.resolve(__dirname, "./src/services"),
|
||||
"@app/components": path.resolve(__dirname, "./src/components"),
|
||||
"@app/composables": path.resolve(__dirname, "./src/composables"),
|
||||
"@app/helpers": path.resolve(__dirname, "./src/helpers"),
|
||||
"@app/api": path.resolve(__dirname, "./src/api"),
|
||||
"@app/lib": path.resolve(__dirname, "./src/lib"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue