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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>) => {
|
||||||
|
|
|
||||||
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 * 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) {
|
if (currentVersion === targetVersion) {
|
||||||
for (const migration of migrations) {
|
return E.right(undefined)
|
||||||
if (migration.version > parseInt(currentVersion)) {
|
}
|
||||||
await migration.migrate()
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (migration.version > parseInt(currentVersion, 10)) {
|
||||||
|
const result = await migration.migrate()
|
||||||
|
if (E.isLeft(result)) {
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Store.set(STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION, targetVersion)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async setUpdateState(state: UpdateState): Promise<void> {
|
// Record the new version only when the write succeeds. A silent
|
||||||
const result = await Store.set(
|
// 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,
|
||||||
state
|
targetVersion
|
||||||
)
|
)
|
||||||
if (E.isLeft(result)) {
|
if (E.isLeft(versionWrite)) {
|
||||||
console.error("Failed to save update state:", result.left)
|
Log.error(
|
||||||
}
|
LOG_TAG,
|
||||||
}
|
"Failed to persist schema version after migrations",
|
||||||
|
versionWrite.left
|
||||||
async getUpdateState(): Promise<UpdateState | null> {
|
|
||||||
const result = await Store.get<UpdateState>(
|
|
||||||
STORE_NAMESPACE,
|
|
||||||
STORE_KEYS.UPDATE_STATE
|
|
||||||
)
|
|
||||||
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(result)) {
|
|
||||||
console.error("Failed to save connection state:", result.left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
(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 sortedInstances = instances
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
|
|
||||||
)
|
)
|
||||||
.slice(0, 10)
|
return versionWrite
|
||||||
|
|
||||||
await this.setRecentInstances(sortedInstances)
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
return E.right(undefined)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
)
|
||||||
|
|
||||||
|
const merged =
|
||||||
|
existingIndex >= 0
|
||||||
|
? current.map((existing, index) =>
|
||||||
|
index === existingIndex ? { ...instance, lastUsed: now } : existing
|
||||||
|
)
|
||||||
|
: [{ ...instance, lastUsed: now }, ...current]
|
||||||
|
|
||||||
|
const next = [...merged]
|
||||||
|
.sort(
|
||||||
|
(a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
await recent.set(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
// 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,10 +146,22 @@ const checkForUpdates = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const initializeStandardMode = async () => {
|
const initializeStandardMode = async () => {
|
||||||
const hasUpdates = await checkForUpdates()
|
// The settings page's `disableUpdateChecks` toggle governs the automatic
|
||||||
if (!hasUpdates) {
|
// startup check, not the manual "Check for updates" button in settings.
|
||||||
await loadRecent()
|
// 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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadRecent()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ interface Props {
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
message: "",
|
message: "",
|
||||||
|
progress: undefined,
|
||||||
showProgress: true,
|
showProgress: true,
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" }]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue