diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index a6e89a0e..2a73e6e4 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -1288,7 +1288,12 @@ "dark_mode": "Dark", "delete_account": "Delete account", "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", + "desktop": "Desktop", + "desktop_description": "Preferences that apply only to the Hoppscotch desktop app.", + "desktop_updates": "Updates", "disable_encode_mode_tooltip": "Never encode the parameters in the request", + "disable_update_checks": "Disable automatic update checks", + "disable_update_checks_description": "Skip the update check at app startup. Use the button above to check on demand.", "enable_encode_mode_tooltip": "Always encode the parameters in the request", "enter_otp": "Enter Agent's code", "expand_navigation": "Expand navigation", @@ -1346,6 +1351,15 @@ "telemetry_helps_us": "Telemetry helps us to personalize our operations and deliver the best experience to you.", "theme": "Theme", "theme_description": "Customize your application theme.", + "update_check_description": "Manually check for a new version and install it. Works regardless of the toggle below.", + "update_check_now": "Check for updates", + "update_checking": "Checking\u2026", + "update_download_version": "Download v{version}", + "update_downloading": "Downloading\u2026", + "update_downloading_percent": "Downloading {percent}%", + "update_installing": "Installing\u2026", + "update_restart_now": "Restart to apply update", + "update_up_to_date": "Up to date", "use_experimental_url_bar": "Use experimental URL bar with environment highlighting", "user": "User", "verified_email": "Verified email", diff --git a/packages/hoppscotch-common/locales/pt-br.json b/packages/hoppscotch-common/locales/pt-br.json index 6acc4693..309d62b0 100644 --- a/packages/hoppscotch-common/locales/pt-br.json +++ b/packages/hoppscotch-common/locales/pt-br.json @@ -766,7 +766,11 @@ "dark_mode": "Escuro", "delete_account": "Excluir conta", "delete_account_description": "Ao deletar sua conta, todos os seus dados serão permanentemente excluídos. Esta ação não pode ser desfeita.", + "desktop": "Desktop", + "desktop_description": "Preferências específicas do aplicativo desktop Hoppscotch.", + "desktop_updates": "Atualizações", "disable_encode_mode_tooltip": "Nunca codificar os parâmetros na requisição", + "disable_update_checks": "Desativar verificação automática de atualizações", "enable_encode_mode_tooltip": "Sempre codificar os parâmetros na requisição", "expand_navigation": "Expandir navegação", "experiments": "Experimentos", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 0a108a93..5cf1795b 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -306,6 +306,7 @@ declare module 'vue' { RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default'] SettingsAgent: typeof import('./components/settings/Agent.vue')['default'] SettingsAgentSubtitle: typeof import('./components/settings/AgentSubtitle.vue')['default'] + SettingsDesktop: typeof import('./components/settings/Desktop.vue')['default'] SettingsExtension: typeof import('./components/settings/Extension.vue')['default'] SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default'] SettingsInterceptorErrorPlaceholder: typeof import('./components/settings/InterceptorErrorPlaceholder.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/settings/Desktop.vue b/packages/hoppscotch-common/src/components/settings/Desktop.vue new file mode 100644 index 00000000..659dae2f --- /dev/null +++ b/packages/hoppscotch-common/src/components/settings/Desktop.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/packages/hoppscotch-common/src/composables/desktop-settings.ts b/packages/hoppscotch-common/src/composables/desktop-settings.ts new file mode 100644 index 00000000..effc69c9 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/desktop-settings.ts @@ -0,0 +1,164 @@ +import { reactive, ref, readonly } from "vue" +import * as E from "fp-ts/Either" +import { invoke } from "@tauri-apps/api/core" + +import { Store } from "~/kernel/store" +import { + DESKTOP_SETTINGS_SCHEMA, + DESKTOP_SETTINGS_STORE_KEY, + DESKTOP_SETTINGS_STORE_NAMESPACE, + parseDesktopSettings, + type DesktopSettings, +} from "~/platform/desktop-settings" +import { Log } from "~/kernel/log" + +const LOG_TAG = "useDesktopSettings" + +/** + * Webview-side accessor for the desktop-app user settings. + * + * Reads and writes through `tauri-plugin-store` under the same namespace + * and key as the Tauri shell's persistence service, and mirrors every + * webview-originated write into the Rust-side `DESKTOP_CONFIG` mailbox + * via the `set_desktop_config` Tauri command. + * + * Why the webview handles its own Rust sync rather than relying on the + * shell's watch-based sync: the shell window closes once `appload` loads + * this webview bundle, which tears down the shell's persistence service + * and its watch subscription. Writes made after that point have no shell + * listener to forward them, so the webview owns the sync for its own + * lifetime. The shell's sync handles initial prime at app startup plus + * any shell-originated writes (the portable welcome screen) during its + * short pre-webview life. + * + * `update(key, value)` is transactional: the reactive object is mutated + * first so callers see an optimistic update, but a persist failure rolls + * the reactive back to its previous value and rethrows, so in-memory + * state never drifts from what's in the store. + * + * Module-level singleton: every caller shares the same reactive object + * so the settings section and any other consumer bound to these values + * stay coherent. + */ + +type UpdateFn = ( + key: K, + value: DesktopSettings[K] +) => Promise + +// Singleton state, initialized on first `useDesktopSettings()` call. +const settings = reactive(DESKTOP_SETTINGS_SCHEMA.parse({})) +const loaded = ref(false) +let initPromise: Promise | undefined + +async function loadInitial(): Promise { + const result = await Store.get( + DESKTOP_SETTINGS_STORE_NAMESPACE, + DESKTOP_SETTINGS_STORE_KEY + ) + const raw = E.isRight(result) ? result.right : undefined + Object.assign(settings, parseDesktopSettings(raw)) + loaded.value = true + + // Subscribe to external writes (for example the Tauri shell's portable + // welcome screen) so the reactive object stays current. One subscription + // per process is enough because the reactive object is a module-level + // singleton. + try { + const emitter = await Store.watch( + DESKTOP_SETTINGS_STORE_NAMESPACE, + DESKTOP_SETTINGS_STORE_KEY + ) + emitter.on("change", ({ value }: { value?: unknown }) => { + if (value !== undefined) { + Object.assign(settings, parseDesktopSettings(value)) + } + }) + } catch (err) { + Log.warn(LOG_TAG, "Failed to subscribe to store", err) + } +} + +async function persist(): Promise { + const validated = DESKTOP_SETTINGS_SCHEMA.parse(settings) + const writeResult = await Store.set( + DESKTOP_SETTINGS_STORE_NAMESPACE, + DESKTOP_SETTINGS_STORE_KEY, + validated + ) + if (E.isLeft(writeResult)) { + // `StoreError` is a tagged union. Formatting `kind` and `message` + // explicitly keeps the thrown error readable. A plain + // `${writeResult.left}` interpolation stringifies to + // `[object Object]` and hides the actual cause from stack traces. + const err = writeResult.left + Log.error(LOG_TAG, "Failed to write desktopSettings", err) + throw new Error( + `Failed to write desktopSettings: ${err.kind}: ${err.message}` + ) + } + + // Mirror to Rust. Non-fatal on failure because Rust falls back to + // its compile-time defaults when the mailbox is empty, so a missed + // sync degrades to "Rust reads an older value" rather than rejecting + // the write the user already committed to. + try { + await invoke("set_desktop_config", { config: validated }) + } catch (err) { + Log.warn(LOG_TAG, "Failed to push DesktopSettings to Rust", err) + } +} + +export function useDesktopSettings(): { + /** Reactive settings object. Read-only externally, bind via refs in templates. */ + settings: Readonly + /** True once the initial store read has completed. */ + loaded: Readonly + /** Updates a single setting and persists immediately, rolling back on failure. */ + update: UpdateFn +} { + if (!initPromise) { + initPromise = loadInitial().catch((err) => { + Log.error(LOG_TAG, "Initial load failed", err) + // Swallow so repeat calls retry on next `update()`. + initPromise = undefined + throw err + }) + } + + const update: UpdateFn = async (key, value) => { + // Wait for the initial load before mutating. Without this, a + // user clicking a toggle immediately after mount could interleave + // with `loadInitial()`: the optimistic mutation and persist would + // land first, and then `loadInitial()` would resolve and call + // `Object.assign(settings, ...)` with the old on-disk value, + // overwriting the user's change in memory. + if (initPromise) { + try { + await initPromise + } catch { + // Load failed. The caller's `update` will still attempt a + // persist below, which is the right behaviour: the user + // wants their change saved even if the initial read failed. + } + } + + const previous = settings[key] + settings[key] = value + try { + await persist() + } catch (err) { + // Restore the reactive state so the in-memory view reflects what's + // actually in the store. Without this, a failed persist leaves the + // settings object holding a value the next app start will not find. + settings[key] = previous + throw err + } + } + + return { + settings: readonly(settings) as Readonly, + loaded: readonly(loaded), + update, + } +} diff --git a/packages/hoppscotch-common/src/composables/update-check.ts b/packages/hoppscotch-common/src/composables/update-check.ts new file mode 100644 index 00000000..8b223b86 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/update-check.ts @@ -0,0 +1,368 @@ +import { ref, readonly, type Ref } from "vue" +import * as E from "fp-ts/Either" +import { invoke } from "@tauri-apps/api/core" +import { listen, type UnlistenFn } from "@tauri-apps/api/event" + +import { Store } from "~/kernel/store" +import { + UPDATE_STATE_SCHEMA, + UPDATE_STATE_STORE_KEY, + UPDATE_STATE_STORE_NAMESPACE, + type DownloadProgress as WireDownloadProgress, + type UpdateState as PersistedUpdateState, +} from "~/platform/update-state" +import { Log } from "~/kernel/log" + +const LOG_TAG = "useUpdateCheck" + +/** + * Webview-side accessor for the desktop updater. + * + * Wraps the Tauri updater commands (`check_for_updates`, + * `download_and_install_update`, `restart_application`, `cancel_update`) + * and the `updater-event` Tauri channel into a single reactive accessor. + * + * State is modelled as a discriminated union where each variant carries + * exactly the fields that variant needs (the `available` variant carries + * `latestVersion`, the `downloading` variant carries `progress`, and so + * on). Impossible combinations ("available without a version", "not + * downloading but progress is set") are unrepresentable by construction, + * and callers narrow through `state.kind`. + * + * State transitions are owned by a single pure `applyEvent` function + * driven by the `updater-event` channel. Action wrappers (`check`, + * `download`, `restart`, `cancel`) await initialization before invoking + * so the listener is guaranteed to be subscribed before any command + * fires, and rely on the event stream for the transitions rather than + * mutating state themselves. This removes the "fast path + event" drift + * that made two paths responsible for updating the same refs. + * + * Module-level singleton: every caller gets the same reactive state so + * any consumer (settings page, portable welcome, startup flow) sees the + * same value. + */ + +// Download progress with a derived `percentage`. The wire form from +// Rust and the persisted form only carry `downloaded` and optional +// `total`. The `percentage` is computed on top so the UI has a +// ready-to-bind field. +export interface DownloadProgress extends WireDownloadProgress { + percentage: number +} + +// Response from the `check_for_updates` Tauri command. Used only to +// invoke the command. Actual state transitions arrive on the event +// channel. +interface UpdateInfo { + available: boolean + currentVersion: string + latestVersion?: string + releaseNotes?: string +} + +// Tauri event payload variants. Must match the `UpdateEvent` tagged union in +// `hoppscotch-desktop/src/services/updater.client.ts`. Centralizing this +// type into common would remove the duplication, but the event channel is +// a Rust-to-webview wire contract that currently lives in the shell, so +// keeping the mirror here scoped to this composable is acceptable until +// that contract gets its own shared module. +type UpdateEvent = + | { type: "CheckStarted" } + | { type: "CheckCompleted"; info: UpdateInfo } + | { type: "CheckFailed"; error: string } + | { type: "DownloadStarted"; totalBytes?: number } + // The Rust-emitted payload only carries `downloaded` and optional + // `total`. The reducer derives `percentage` and the persisted + // `DownloadProgress` form below extends with that derived field. + | { type: "DownloadProgress"; progress: WireDownloadProgress } + | { type: "DownloadCompleted" } + | { type: "InstallStarted" } + | { type: "InstallCompleted" } + | { type: "RestartRequired" } + | { type: "UpdateCancelled" } + | { type: "Error"; message: string } + +// The composable's internal state. Each variant carries exactly the +// fields that variant needs. `currentVersion` rides along with any +// post-check variant so the UI can display "currently on vX" context +// regardless of whether an update was found. +export type UpdateState = + | { kind: "idle" } + | { kind: "checking" } + | { + kind: "available" + currentVersion: string + latestVersion: string + } + | { kind: "not_available"; currentVersion: string } + | { kind: "downloading"; progress: DownloadProgress } + | { kind: "installing" } + | { kind: "ready_to_restart" } + | { kind: "error"; message: string } + +// String-literal helper for consumers that want to compare without +// destructuring `state.kind` directly. `UpdateState["kind"]` gives the +// same union at the type level. +export const UpdateKind = { + IDLE: "idle", + CHECKING: "checking", + AVAILABLE: "available", + NOT_AVAILABLE: "not_available", + DOWNLOADING: "downloading", + INSTALLING: "installing", + READY_TO_RESTART: "ready_to_restart", + ERROR: "error", +} as const satisfies Record + +// Singleton state. +const state = ref({ kind: "idle" }) +let initPromise: Promise | undefined +let unlistenFn: UnlistenFn | undefined + +function percentageOf(downloaded: number, total: number | undefined): number { + if (!total || total <= 0) return 0 + return (downloaded / total) * 100 +} + +/** + * Derives the composable's internal `UpdateState` from the flat + * persisted form. The persisted form is a wire contract with Rust and + * older shell code, and translating on read keeps that contract + * unchanged while the composable gets the richer internal type. + */ +function fromPersisted( + persisted: PersistedUpdateState | null | undefined +): UpdateState { + if (!persisted) return { kind: "idle" } + + switch (persisted.status) { + case "idle": + return { kind: "idle" } + case "checking": + return { kind: "checking" } + case "available": + // The persisted form is optional on `version`. If the writer + // omitted it, fall back to idle rather than fabricating a version. + return persisted.version + ? { + kind: "available", + currentVersion: "", + latestVersion: persisted.version, + } + : { kind: "idle" } + case "not_available": + return { kind: "not_available", currentVersion: "" } + case "downloading": { + const downloaded = persisted.progress?.downloaded ?? 0 + const total = persisted.progress?.total + return { + kind: "downloading", + progress: { + downloaded, + total, + percentage: percentageOf(downloaded, total), + }, + } + } + case "installing": + return { kind: "installing" } + case "ready_to_restart": + return { kind: "ready_to_restart" } + case "error": + return { kind: "error", message: persisted.message ?? "Unknown error" } + } +} + +/** + * Pure reducer from current state + incoming event to next state. Kept + * pure (no ref access, no side effects) so it can be exercised in + * isolation and so the full transition table is readable at a glance. + */ +function nextState(current: UpdateState, event: UpdateEvent): UpdateState { + switch (event.type) { + case "CheckStarted": + return { kind: "checking" } + + case "CheckCompleted": + if (event.info.available && event.info.latestVersion) { + return { + kind: "available", + currentVersion: event.info.currentVersion, + latestVersion: event.info.latestVersion, + } + } + return { + kind: "not_available", + currentVersion: event.info.currentVersion, + } + + case "CheckFailed": + return { kind: "error", message: event.error } + + case "DownloadStarted": + return { + kind: "downloading", + progress: { + downloaded: 0, + total: event.totalBytes, + percentage: 0, + }, + } + + case "DownloadProgress": + // The wire form has no `percentage`. Without computing it + // here, `Math.round(progress.percentage)` in the view runs on + // `undefined` and the button label renders "Downloading NaN%" + // for every progress tick. `DownloadStarted` above takes the + // same approach. + return { + kind: "downloading", + progress: { + downloaded: event.progress.downloaded, + total: event.progress.total, + percentage: percentageOf( + event.progress.downloaded, + event.progress.total + ), + }, + } + + case "DownloadCompleted": + return { kind: "installing" } + + case "InstallStarted": + return { kind: "installing" } + + case "InstallCompleted": + // Install is a short step that transitions straight into awaiting a + // restart. The `RestartRequired` event follows and flips the state, + // so keep the current state here rather than double-transitioning. + return current + + case "RestartRequired": + return { kind: "ready_to_restart" } + + case "UpdateCancelled": + return { kind: "idle" } + + case "Error": + return { kind: "error", message: event.message } + } +} + +async function loadPersistedState(): Promise { + const result = await Store.get( + UPDATE_STATE_STORE_NAMESPACE, + UPDATE_STATE_STORE_KEY + ) + if (E.isRight(result) && result.right) { + const parsed = UPDATE_STATE_SCHEMA.safeParse(result.right) + if (parsed.success) { + state.value = fromPersisted(parsed.data) + } + } +} + +async function subscribeToEvents(): Promise { + if (unlistenFn) return + unlistenFn = await listen("updater-event", (event) => { + state.value = nextState(state.value, event.payload) + }) +} + +async function ensureInitialized(): Promise { + if (!initPromise) { + initPromise = (async () => { + await loadPersistedState() + await subscribeToEvents() + })().catch((err) => { + Log.error(LOG_TAG, "Initialization failed", err) + initPromise = undefined + throw err + }) + } + await initPromise +} + +// Action wrappers. Each awaits initialization so the event listener is +// guaranteed subscribed before the Tauri command runs, then invokes the +// command. State transitions arrive via the event channel, so the +// wrappers do not mutate `state` on success. On `invoke` failure they +// feed a synthetic "failed" event through the same reducer so the +// transition path stays uniform. +async function check(): Promise { + await ensureInitialized() + try { + await invoke("check_for_updates", { showNativeDialog: false }) + } catch (err) { + state.value = nextState(state.value, { + type: "CheckFailed", + error: err instanceof Error ? err.message : String(err), + }) + } +} + +async function download(): Promise { + await ensureInitialized() + try { + await invoke("download_and_install_update") + } catch (err) { + state.value = nextState(state.value, { + type: "Error", + message: err instanceof Error ? err.message : String(err), + }) + } +} + +async function restart(): Promise { + await ensureInitialized() + try { + await invoke("restart_application") + } catch (err) { + state.value = nextState(state.value, { + type: "Error", + message: err instanceof Error ? err.message : String(err), + }) + } +} + +async function cancel(): Promise { + await ensureInitialized() + try { + await invoke("cancel_update") + // State advances to `idle` via the `updater-event` channel. The + // Rust updater emits `UpdateCancelled` on success, so the + // subscribed listener applies the transition. Applying it here + // as well would produce two `idle` transitions per cancel, which + // is harmless today but would double-fire any future side effect + // added to the `UpdateCancelled` case in `nextState`. + } catch (err) { + state.value = nextState(state.value, { + type: "Error", + message: err instanceof Error ? err.message : String(err), + }) + } +} + +export function useUpdateCheck(): { + state: Readonly> + check: () => Promise + download: () => Promise + restart: () => Promise + cancel: () => Promise +} { + // Fire-and-forget initialization so the composable returns synchronously. + // Actions await initialization internally before invoking commands, so + // race-with-subscription is not possible through the action path. A + // consumer that reads `state.value` immediately sees `idle`, which is + // the correct default for a fresh mount. + void ensureInitialized() + + return { + state: readonly(state), + check, + download, + restart, + cancel, + } +} diff --git a/packages/hoppscotch-common/src/platform/desktop-settings.ts b/packages/hoppscotch-common/src/platform/desktop-settings.ts new file mode 100644 index 00000000..696a86a4 --- /dev/null +++ b/packages/hoppscotch-common/src/platform/desktop-settings.ts @@ -0,0 +1,72 @@ +import { z } from "zod" + +/** + * Shared schema and types for the Tauri desktop app's user settings. + * + * The settings live in `tauri-plugin-store` under namespace + * `DESKTOP_SETTINGS_STORE_NAMESPACE`, key `DESKTOP_SETTINGS_STORE_KEY`. + * Both the Tauri shell (`hoppscotch-desktop`) and the webview + * (`hoppscotch-selfhost-web`, loaded via appload) read and write + * through the same namespace and key. + * + * The schema is the contract between the two sides. A change here + * without coordinating both sides leaves the shell and the webview + * disagreeing about what is in the store. This module lives in + * `hoppscotch-common` because both packages already depend on common + * and neither depends on the other directly, so common is the only + * place a shared definition can live. + * + * Every field has a Zod `.default()` that preserves the pre-settings-epic + * behavior, so a partial read (missing keys, corrupt blob, fresh install) + * parses cleanly into a fully-populated object. Use `parseDesktopSettings()` + * to read raw values safely. + */ + +// Store coordinates. Both sides must use these constants, never string +// literals, so a rename is a single edit and a typo is a compile error. +export const DESKTOP_SETTINGS_STORE_NAMESPACE = "hoppscotch-desktop.v1" +export const DESKTOP_SETTINGS_STORE_KEY = "desktopSettings" + +// Fields are grouped by the area of the app they affect. Defaults +// preserve today's hardcoded behavior so any field not yet bound to a +// control in the settings UI ships without a visible change for existing +// users. +export const DESKTOP_SETTINGS_SCHEMA = z.object({ + // Migrated from the legacy portable-only `PortableSettings`. A future + // epic ticket promotes `disableUpdateNotifications` to a user-facing + // control on all builds. For now it stays portable-only. + disableUpdateNotifications: z.boolean().default(false), + autoSkipWelcome: z.boolean().default(false), + + // Connection and startup behavior. The `connectionTimeoutMs` default + // matches `API_TIMEOUT_SECS` in `config.rs`. User-facing controls for + // these fields are future scope. + connectionTimeoutMs: z.number().int().positive().default(30_000), + autoReconnectLastInstance: z.boolean().default(true), + + // Update-pipeline controls. `disable*` polarity matches the existing + // `disableUpdateNotifications` field so all three update-related + // booleans read uniformly, and the on-by-default framing ("Disable X" + // with default false) nudges users toward keeping the update flow + // active. `disableUpdateChecks` is bound to a toggle in the current + // settings UI. `disableUpdateDownloads` is future scope. + disableUpdateChecks: z.boolean().default(false), + disableUpdateDownloads: z.boolean().default(false), + + // Display and UX. User-facing zoom control is future scope. + zoomLevel: z.number().positive().default(1.0), +}) + +export type DesktopSettings = z.infer + +/** + * Parses a raw value into `DesktopSettings`, falling back to full defaults + * on any validation failure. Never throws. + */ +export const parseDesktopSettings = (raw: unknown): DesktopSettings => { + const parsed = DESKTOP_SETTINGS_SCHEMA.safeParse(raw ?? {}) + if (!parsed.success) { + return DESKTOP_SETTINGS_SCHEMA.parse({}) + } + return parsed.data +} diff --git a/packages/hoppscotch-common/src/platform/update-state.ts b/packages/hoppscotch-common/src/platform/update-state.ts new file mode 100644 index 00000000..6235b95b --- /dev/null +++ b/packages/hoppscotch-common/src/platform/update-state.ts @@ -0,0 +1,69 @@ +import { z } from "zod" + +/** + * Shared schema and types for the desktop app's auto-updater state. + * + * The updater state is written to `tauri-plugin-store` by the Tauri shell + * (`hoppscotch-desktop/src/utils/updater.ts`) and read by both the shell's + * persistence service and the webview's settings page. This module is the + * single source of truth for the definition that crosses all three + * boundaries (Rust, shell JS, webview JS via the store file). + * + * Persisted form is deliberately flat (status + optional fields). The + * webview's `useUpdateCheck` composable derives a discriminated union + * over this flat form for its internal state, but the wire format that + * hits disk stays simple so existing Rust writers continue to work + * unchanged. + */ + +// Store coordinates. Both the shell persistence service and the webview +// composable reference these constants rather than string literals. +export const UPDATE_STATE_STORE_NAMESPACE = "hoppscotch-desktop.v1" +export const UPDATE_STATE_STORE_KEY = "updateState" + +// `UpdateStatus` as a `const` object rather than a TS `enum` so: +// 1. The values are plain string literals, so they cross the store +// boundary as JSON without extra conversion. +// 2. The inferred union type (`"idle" | "checking" | ...`) narrows +// cleanly in switch statements and matches Zod's `z.enum` output. +// 3. It imports zero-cost into the webview bundle, where TS enums can +// produce runtime objects that tree-shaking sometimes fails to drop. +export const UpdateStatus = { + IDLE: "idle", + CHECKING: "checking", + AVAILABLE: "available", + NOT_AVAILABLE: "not_available", + DOWNLOADING: "downloading", + INSTALLING: "installing", + READY_TO_RESTART: "ready_to_restart", + ERROR: "error", +} as const + +export type UpdateStatus = (typeof UpdateStatus)[keyof typeof UpdateStatus] + +export const UPDATE_STATUS_SCHEMA = z.enum([ + UpdateStatus.IDLE, + UpdateStatus.CHECKING, + UpdateStatus.AVAILABLE, + UpdateStatus.NOT_AVAILABLE, + UpdateStatus.DOWNLOADING, + UpdateStatus.INSTALLING, + UpdateStatus.READY_TO_RESTART, + UpdateStatus.ERROR, +]) + +export const DOWNLOAD_PROGRESS_SCHEMA = z.object({ + downloaded: z.number(), + total: z.number().optional(), +}) + +export type DownloadProgress = z.infer + +export const UPDATE_STATE_SCHEMA = z.object({ + status: UPDATE_STATUS_SCHEMA, + version: z.string().optional(), + message: z.string().optional(), + progress: DOWNLOAD_PROGRESS_SCHEMA.optional(), +}) + +export type UpdateState = z.infer diff --git a/packages/hoppscotch-desktop/src-tauri/src/config.rs b/packages/hoppscotch-desktop/src-tauri/src/config.rs index 2a60ee9d..c3b7aa48 100644 --- a/packages/hoppscotch-desktop/src-tauri/src/config.rs +++ b/packages/hoppscotch-desktop/src-tauri/src/config.rs @@ -1,9 +1,15 @@ -use std::{fs, path::PathBuf, time::Duration}; +use std::{fs, path::PathBuf, sync::Mutex, time::Duration}; +use serde::Deserialize; use tauri_plugin_appload::{ApiConfig, CacheConfig, Config, StorageConfig, VendorConfig}; use crate::{error::HoppError, path}; +// Appload plugin configuration. These constants are baked into the plugin +// config at startup via `HoppApploadConfig::build()`, before the webview +// exists, so they cannot be overridden by runtime user settings. A future +// user-facing connection timeout override will need a separate mechanism, +// either a startup-time store file read or a deferred appload init. const API_SERVER_URL: &str = "http://localhost:3200"; const API_TIMEOUT_SECS: u64 = 30; const CACHE_MAX_SIZE_MB: usize = 1000; @@ -66,6 +72,72 @@ impl HoppApploadConfig { } } +// Webview-pushed runtime settings bridge. +// +// The webview persists user settings (timeout, zoom, auto-reconnect, and so +// on) via `tauri-plugin-store`. The Tauri shell needs live access to some +// of those values, for example `connectionTimeoutMs` for the appload HTTP +// client. Rather than having Rust read the store file directly, which would +// couple this code to the plugin's on-disk format, the webview pushes the +// current settings to Rust via `set_desktop_config` at init and on change. +// +// The IPC plumbing is wired end-to-end but no Rust code reads +// `DESKTOP_CONFIG` yet. Consumers such as the appload connection timeout +// are future scope. +// +// The struct deliberately only deserializes fields Rust actually consumes. +// TS sends the full `DESKTOP_SETTINGS_SCHEMA` payload and serde drops the +// rest. Adding a new Rust consumer means adding a field here, not changing +// the IPC contract. + +/// Subset of the webview-side `DesktopSettings` that Rust services consume. +/// +/// Field names are snake_case with `rename_all = "camelCase"` so they line +/// up with what the TS store produces from `DESKTOP_SETTINGS_SCHEMA`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopConfig { + /// Timeout (ms) for outbound HTTP requests in the appload client and + /// related connection paths. Mirrors `API_TIMEOUT_SECS` when the value + /// is 30_000. + pub connection_timeout_ms: u64, +} + +/// Live copy of the most recent settings pushed from the webview. +/// +/// `None` means the webview has not called `set_desktop_config` yet, which +/// is the case during the early Tauri startup path before the window loads +/// and for the whole of the pre-webview `PortableHome` and `StandardHome` +/// flow. Consumers must treat `None` as "no override, use the compile-time +/// default". +static DESKTOP_CONFIG: Mutex> = 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 { + DESKTOP_CONFIG + .lock() + .ok() + .and_then(|guard| guard.clone()) +} + +/// Tauri command invoked by the webview on init and whenever settings +/// change. Overwrites any previously-pushed config and is idempotent on +/// identical input. +#[tauri::command] +pub fn set_desktop_config(config: DesktopConfig) -> Result<(), String> { + tracing::debug!(?config, "Received desktop config from webview"); + let mut guard = DESKTOP_CONFIG + .lock() + .map_err(|e| format!("DESKTOP_CONFIG mutex poisoned: {}", e))?; + *guard = Some(config); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -117,4 +189,51 @@ mod tests { assert!(!config.config_dir.as_os_str().is_empty()); } + + // The roundtrip and overwrite assertions stay in one test because + // `DESKTOP_CONFIG` is process-wide shared state and cargo runs tests + // in parallel by default. Splitting them into two `#[test]` functions + // would race for the global mutex and produce flaky assertions + // depending on schedule. The other tests in this module exercise + // `DesktopConfig` deserialization in isolation and never touch + // `DESKTOP_CONFIG`, so they are safe to run alongside this one. + #[test] + fn set_desktop_config_roundtrip_and_overwrite() { + let result = set_desktop_config(DesktopConfig { + connection_timeout_ms: 45_000, + }); + assert!(result.is_ok()); + assert_eq!( + current_desktop_config().unwrap().connection_timeout_ms, + 45_000 + ); + + set_desktop_config(DesktopConfig { + connection_timeout_ms: 90_000, + }) + .unwrap(); + assert_eq!( + current_desktop_config().unwrap().connection_timeout_ms, + 90_000 + ); + } + + #[test] + fn desktop_config_deserializes_from_camel_case() { + let json = r#"{"connectionTimeoutMs": 60000}"#; + let cfg: DesktopConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.connection_timeout_ms, 60_000); + } + + #[test] + fn desktop_config_deserialize_ignores_extra_fields() { + // TS pushes the full `DESKTOP_SETTINGS_SCHEMA` so extras must drop. + let json = r#"{ + "connectionTimeoutMs": 30000, + "disableUpdateNotifications": true, + "zoomLevel": 1.25 + }"#; + let cfg: DesktopConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.connection_timeout_ms, 30_000); + } } diff --git a/packages/hoppscotch-desktop/src-tauri/src/lib.rs b/packages/hoppscotch-desktop/src-tauri/src/lib.rs index 62c1f8f7..db89d88f 100644 --- a/packages/hoppscotch-desktop/src-tauri/src/lib.rs +++ b/packages/hoppscotch-desktop/src-tauri/src/lib.rs @@ -248,6 +248,7 @@ pub fn run() { hopp_auth_port, quit_app, backup::check_and_backup_on_version_change, + config::set_desktop_config, updater::check_for_updates, updater::download_and_install_update, updater::restart_application, diff --git a/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts b/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts index e09fde23..addfa159 100644 --- a/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts +++ b/packages/hoppscotch-desktop/src/composables/useAppInitialization.ts @@ -1,4 +1,5 @@ import { ref } from "vue" +import * as E from "fp-ts/Either" import { load, download, close } from "@hoppscotch/plugin-appload" import { getVersion } from "@tauri-apps/api/app" import { invoke } from "@tauri-apps/api/core" @@ -44,7 +45,7 @@ export function useAppInitialization() { const saveConnectionState = async (state: ConnectionState) => { try { - await persistence.setConnectionState(state) + await persistence.connectionState.set(state) } catch (err) { console.error("Failed to save connection state:", err) } @@ -246,8 +247,8 @@ export function useAppInitialization() { // instances. The InstanceService's detectCurrentInstanceFromHostname // persists the detected instance (including cloud-org) to this store, // so on restart the main window can resume the correct instance. - const connectionState = await persistence.getConnectionState() - const recentInstances = await persistence.getRecentInstances() + const connectionState = await persistence.connectionState.get() + const recentInstances = await persistence.recentInstances.get() mainDiag(`loadRecent: connectionState=${JSON.stringify(connectionState)}`) mainDiag( @@ -354,7 +355,18 @@ export function useAppInitialization() { } statusMessage.value = "Initializing stores..." - await persistence.init() + // `init` returns `Either` 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) => { diff --git a/packages/hoppscotch-desktop/src/kernel/store-resource.ts b/packages/hoppscotch-desktop/src/kernel/store-resource.ts new file mode 100644 index 00000000..4bbcec3a --- /dev/null +++ b/packages/hoppscotch-desktop/src/kernel/store-resource.ts @@ -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 { + /** + * 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 + + /** + * 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 + + /** + * 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( + namespace: string, + key: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schema: z.ZodType, + defaults: () => T +): StoreResource { + return { + async get(): Promise { + const result = await Store.get(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 { + 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 + ) + }) + }, + } +} diff --git a/packages/hoppscotch-desktop/src/services/persistence.service.ts b/packages/hoppscotch-desktop/src/services/persistence.service.ts index 025e7b14..44d54bcb 100644 --- a/packages/hoppscotch-desktop/src/services/persistence.service.ts +++ b/packages/hoppscotch-desktop/src/services/persistence.service.ts @@ -1,41 +1,52 @@ import * as E from "fp-ts/Either" +import { invoke } from "@tauri-apps/api/core" import { z } from "zod" import { StoreError } from "@hoppscotch/kernel" -import { Store } from "~/kernel/store" -import { UpdateState, PortableSettings } from "~/types" -export const STORE_NAMESPACE = "hoppscotch-desktop.v1" +import { + DESKTOP_SETTINGS_SCHEMA, + DESKTOP_SETTINGS_STORE_KEY, + DESKTOP_SETTINGS_STORE_NAMESPACE, + type DesktopSettings, +} from "@hoppscotch/common/platform/desktop-settings" +import { + UPDATE_STATE_SCHEMA, + UPDATE_STATE_STORE_KEY, +} from "@hoppscotch/common/platform/update-state" +import type { Instance } from "@hoppscotch/common/platform/instance" +import { Log } from "@hoppscotch/common/kernel/log" + +import { Store } from "~/kernel/store" +import { + createStoreResource, + type StoreResource, +} from "~/kernel/store-resource" +import type { UpdateState } from "~/types" + +const LOG_TAG = "persistence" + +// Shared namespace for every desktop-local store resource. Individual keys +// live in `STORE_KEYS` below. Exported for the small handful of callers +// that still touch the store directly. +export const STORE_NAMESPACE = DESKTOP_SETTINGS_STORE_NAMESPACE export const STORE_KEYS = { - UPDATE_STATE: "updateState", + UPDATE_STATE: UPDATE_STATE_STORE_KEY, CONNECTION_STATE: "connectionState", RECENT_INSTANCES: "recentInstances", SCHEMA_VERSION: "schema_version", + // Legacy key. Written by portable builds in schema v1. Read only by the + // v1 to v2 migration. All other code uses `DESKTOP_SETTINGS`. PORTABLE_SETTINGS: "portableSettings", + DESKTOP_SETTINGS: DESKTOP_SETTINGS_STORE_KEY, } as const -export const UPDATE_STATE_SCHEMA = z.object({ - status: z.enum([ - "idle", - "checking", - "available", - "not_available", - "downloading", - "installing", - "ready_to_restart", - "error", - ]), - version: z.string().optional(), - message: z.string().optional(), - progress: z - .object({ - downloaded: z.number(), - total: z.number().optional(), - }) - .optional(), -}) - -export const INSTANCE_SCHEMA = z.object({ +// Runtime validator for `Instance` values read from the store. The type +// annotation pins the Zod output to the canonical `Instance` in common, +// so any drift between the definition stored here and the definition +// consumed by the webview's instance service would fail typecheck +// rather than silently producing a mismatched runtime value. +export const INSTANCE_SCHEMA: z.ZodType = z.object({ kind: z.enum(["on-prem", "cloud", "cloud-org", "vendored"]), serverUrl: z.string(), displayName: z.string(), @@ -44,38 +55,156 @@ export const INSTANCE_SCHEMA = z.object({ bundleName: z.string().optional(), }) -export const CONNECTION_STATE_SCHEMA = z.object({ +// Runtime definition of the persisted connection state. NOTE: the +// canonical `ConnectionState` type in +// `@hoppscotch/common/platform/instance.ts` is a discriminated union, +// while the persisted form here is flat with optional fields per +// variant. The two are semi-compatible (the union serializes cleanly +// into this flat form), but the reader loses type narrowing. A later +// migration can switch the persisted form to a discriminated union so +// common's type becomes the single source of truth end-to-end. +export const PERSISTED_CONNECTION_STATE_SCHEMA = z.object({ status: z.enum(["idle", "connecting", "connected", "error"]), instance: INSTANCE_SCHEMA.optional(), target: z.string().optional(), message: z.string().optional(), }) -export const PORTABLE_SETTINGS_SCHEMA = z.object({ - disableUpdateNotifications: z.boolean(), - autoSkipWelcome: z.boolean(), -}) +export type PersistedConnectionState = z.infer< + typeof PERSISTED_CONNECTION_STATE_SCHEMA +> -export type InstanceKind = z.infer["kind"] -export type Instance = z.infer -export type ConnectionState = z.infer +// Re-exported for callers that import from this service. The canonical type +// lives in `@hoppscotch/common/platform/desktop-settings`. +export type { DesktopSettings } + +// Legacy `PortableSettings` interface. Kept as a local type (not +// exported, not re-exported from `~/types`) because the v2 migration +// is the only reader and no new code should produce this form. Once +// the migration is retired this type can be dropped entirely. +interface LegacyPortableSettings { + disableUpdateNotifications: boolean + autoSkipWelcome: boolean +} interface Migration { version: number - migrate: () => Promise + // 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> } const migrations: Migration[] = [ { version: 1, - migrate: async () => {}, + migrate: async () => E.right(undefined), + }, + { + // v1 to v2. Introduces `DesktopSettings` as the single source of truth + // for all desktop builds. Portable users had `portableSettings` with two + // fields, standard-build users had nothing. Both land in `desktopSettings` + // with full defaults for any field not carried over. + // + // Legacy `portableSettings` is intentionally left in place. The key + // is cheap to keep, it preserves a rollback path, and a later + // migration can prune it once the v2 definition has stabilized. + version: 2, + migrate: async () => { + const legacyResult = await Store.get>( + 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`, which + * carries a uniform `{ get, set, watch }` API validated through a Zod + * schema. The service itself is a thin orchestrator. It runs + * schema-version migrations on init, subscribes once to the + * desktop-settings resource so the Rust mailbox stays in sync with any + * writer, and exposes the resources as readonly members. + * + * Callers move from `persistence.setFoo(value)` to `persistence.foo.set(value)` + * and likewise for `get` / `watch`. Compound operations (e.g. adding to the + * recent-instances list) live as free functions over the resource, below. + */ export class DesktopPersistenceService { private static instance: DesktopPersistenceService - private constructor() {} + readonly desktopSettings: StoreResource + readonly updateState: StoreResource + readonly connectionState: StoreResource + readonly recentInstances: StoreResource + + private constructor() { + this.desktopSettings = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.DESKTOP_SETTINGS, + DESKTOP_SETTINGS_SCHEMA, + () => DESKTOP_SETTINGS_SCHEMA.parse({}) + ) + this.updateState = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.UPDATE_STATE, + UPDATE_STATE_SCHEMA.nullable(), + () => null + ) + this.connectionState = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.CONNECTION_STATE, + PERSISTED_CONNECTION_STATE_SCHEMA.nullable(), + () => null + ) + this.recentInstances = createStoreResource( + STORE_NAMESPACE, + STORE_KEYS.RECENT_INSTANCES, + z.array(INSTANCE_SCHEMA), + () => [] + ) + } public static getInstance(): DesktopPersistenceService { if (!DesktopPersistenceService.instance) { @@ -87,176 +216,150 @@ export class DesktopPersistenceService { async init(): Promise> { const initResult = await Store.init() if (E.isLeft(initResult)) { - console.error( - "[PersistenceService] Failed to initialize store:", - initResult.left - ) + Log.error(LOG_TAG, "Failed to initialize store", initResult.left) return initResult } - await this.runMigrations() - return initResult + const migrationResult = await this.runMigrations() + if (E.isLeft(migrationResult)) { + return migrationResult + } + await this.setupRustSync() + return E.right(undefined) } - private async runMigrations() { + /** + * Keep the Rust-side `DESKTOP_CONFIG` mailbox (see + * `src-tauri/src/config.rs`) in sync with the desktop settings resource. + * + * Two triggers. An initial push so Rust has the current value + * before any consumer reads it. A subscription to the resource's + * `watch` so every subsequent write from any writer (this shell, + * the webview, another process) gets mirrored. The write-side code + * path stays pure persistence, and the sync is a cross-cutting + * concern handled here. + * + * Both paths swallow failures. Rust already has a compile-time default + * for every field Rust cares about, so a failed sync degrades to + * "Rust reads stale value" rather than a user-visible error. + */ + private async setupRustSync(): Promise { + 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> { const versionResult = await Store.get( STORE_NAMESPACE, STORE_KEYS.SCHEMA_VERSION ) const perhapsVersion = E.isRight(versionResult) ? versionResult.right : "1" - const currentVersion = perhapsVersion ?? "1" - const targetVersion = "1" + const rawVersion = perhapsVersion ?? "1" + // Coerce a corrupted or non-numeric stored value to the lowest known + // version. Without this, `parseInt("v2")` or `parseInt("")` would + // return NaN, every `migration.version > NaN` check below would + // evaluate to false, the loop would skip every migration, and the + // code would still write `SCHEMA_VERSION = "2"` at the end. That + // would mark migrations complete without ever running them. + // Falling back to "1" instead reruns every migration from scratch, + // which is safe because each migration is idempotent on a fresh + // store and a real fresh install lands here through the same path. + const parsedVersion = parseInt(rawVersion, 10) + const currentVersion = Number.isNaN(parsedVersion) ? "1" : rawVersion + const targetVersion = "2" - if (currentVersion !== targetVersion) { - for (const migration of migrations) { - if (migration.version > parseInt(currentVersion)) { - await migration.migrate() + if (currentVersion === targetVersion) { + return E.right(undefined) + } + + 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 { - const result = await Store.set( + // Record the new version only when the write succeeds. A silent + // failure here would leave the stored version stale, and the next + // launch would re-run every migration on already-migrated data. + const versionWrite = await Store.set( STORE_NAMESPACE, - STORE_KEYS.UPDATE_STATE, - state + STORE_KEYS.SCHEMA_VERSION, + targetVersion ) - if (E.isLeft(result)) { - console.error("Failed to save update state:", result.left) - } - } - - async getUpdateState(): Promise { - const result = await Store.get( - 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 { - 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 { - const result = await Store.get( - STORE_NAMESPACE, - STORE_KEYS.CONNECTION_STATE - ) - if (E.isRight(result) && result.right) { - return result.right - } - return null - } - - async setRecentInstances(instances: Instance[]): Promise { - 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 { - const result = await Store.get( - STORE_NAMESPACE, - STORE_KEYS.RECENT_INSTANCES - ) - if (E.isRight(result) && result.right) { - return result.right - } - return [] - } - - async addRecentInstance(instance: Instance): Promise { - 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() + if (E.isLeft(versionWrite)) { + Log.error( + LOG_TAG, + "Failed to persist schema version after migrations", + versionWrite.left ) - .slice(0, 10) - - await this.setRecentInstances(sortedInstances) - } - - async removeRecentInstance(serverUrl: string): Promise { - const instances = await this.getRecentInstances() - const filtered = instances.filter((i) => i.serverUrl !== serverUrl) - await this.setRecentInstances(filtered) - } - - async setPortableSettings(settings: PortableSettings): Promise { - 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 versionWrite } - } - - async getPortableSettings(): Promise { - const result = await Store.get( - 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 + return E.right(undefined) } } + +/** + * 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 +): Promise { + 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, + serverUrl: string +): Promise { + const current = await recent.get() + const filtered = current.filter((i) => i.serverUrl !== serverUrl) + await recent.set(filtered) +} diff --git a/packages/hoppscotch-desktop/src/types/index.ts b/packages/hoppscotch-desktop/src/types/index.ts index 2f44a6d7..73945441 100644 --- a/packages/hoppscotch-desktop/src/types/index.ts +++ b/packages/hoppscotch-desktop/src/types/index.ts @@ -1,39 +1,20 @@ -export interface RecentInstance { - url: string - lastUsed: string - version?: string - pinned: boolean -} - -export interface StoreSchema { - recentInstances: RecentInstance[] -} - -export enum UpdateStatus { - IDLE = "idle", - CHECKING = "checking", - AVAILABLE = "available", - NOT_AVAILABLE = "not_available", - DOWNLOADING = "downloading", - INSTALLING = "installing", - READY_TO_RESTART = "ready_to_restart", - ERROR = "error", -} +// Re-exports of types whose canonical definitions live in common. Listed +// here so in-package imports can keep using `~/types` without every caller +// needing to know the precise module path in common. New types that need to +// cross the shell/webview boundary belong in common directly. +export { + UpdateStatus, + type UpdateState, + type DownloadProgress, +} from "@hoppscotch/common/platform/update-state" +// Not to be confused with `UpdateStatus`. `CheckResult` is the outcome of a +// single call to the updater's `checkForUpdates`, where `UpdateStatus` is +// the full state machine covering checking, downloading, installing, and +// restart. Only `checkForUpdates` returns this. export enum CheckResult { AVAILABLE, NOT_AVAILABLE, TIMEOUT, ERROR, } - -export interface UpdateState { - status: UpdateStatus - version?: string - message?: string -} - -export interface PortableSettings { - disableUpdateNotifications: boolean - autoSkipWelcome: boolean -} diff --git a/packages/hoppscotch-desktop/src/views/PortableHome.vue b/packages/hoppscotch-desktop/src/views/PortableHome.vue index fa9910cb..83aadeda 100644 --- a/packages/hoppscotch-desktop/src/views/PortableHome.vue +++ b/packages/hoppscotch-desktop/src/views/PortableHome.vue @@ -116,7 +116,6 @@ import { close } from "@hoppscotch/plugin-appload" import { invoke } from "@tauri-apps/api/core" import { Io } from "~/kernel" -import type { PortableSettings } from "~/types" import { useAppInitialization, AppState, @@ -143,9 +142,18 @@ const updaterClient = new UpdaterClient() const showPortableWelcome = ref(false) const currentDirectory = ref(".") -const portableSettings = reactive({ +// Fields mirrored locally for the portable welcome screen's UI and the +// startup update gate. The welcome screen only lets the user toggle +// `disableUpdateNotifications` and `autoSkipWelcome`, but the update gate +// also reads `disableUpdateChecks` so a user who set that via the settings +// page on a prior session sees the gate respected on next startup. The full +// desktop settings object is loaded and merged in +// `handlePortableWelcomeContinue` so other fields like timeout or zoom, +// written elsewhere, survive intact. +const portableSettings = reactive({ disableUpdateNotifications: false, autoSkipWelcome: false, + disableUpdateChecks: false, }) watch( @@ -183,26 +191,22 @@ const closeApp = async () => { const handlePortableWelcomeContinue = async () => { try { - console.log( - "About to save portable settings:", - JSON.stringify(portableSettings) - ) - - const settingsToSave: PortableSettings = { + // Read-modify-write against the full `DesktopSettings` object so + // unrelated fields like timeout or zoom, potentially written by the + // webview-side settings page in the same session, are preserved. + const current = await persistence.desktopSettings.get() + const updated = { + ...current, disableUpdateNotifications: portableSettings.disableUpdateNotifications, autoSkipWelcome: portableSettings.autoSkipWelcome, } - console.log("Saving portable settings:", settingsToSave) - await persistence.setPortableSettings(settingsToSave) - - const savedSettings = await persistence.getPortableSettings() - console.log("Verified saved settings:", savedSettings) + await persistence.desktopSettings.set(updated) showPortableWelcome.value = false await loadRecent() } catch (error) { - console.error("Failed to save portable settings:", error) + console.error("Failed to save desktop settings:", error) showPortableWelcome.value = false await loadRecent() } @@ -211,8 +215,18 @@ const handlePortableWelcomeContinue = async () => { const checkForUpdatesPortable = async () => { console.log("Checking portable updates, current settings:", portableSettings) - if (portableSettings.disableUpdateNotifications) { - console.log("Update notifications disabled for portable mode") + // Two disable flags land in this gate for backwards compatibility. The + // legacy `disableUpdateNotifications` was originally documented as + // controlling only notifications but was wired up to skip the whole check + // in portable mode. The new `disableUpdateChecks` is the explicit + // opt-out that matches the settings-page toggle. Either flag being true + // skips the startup check, so users upgrading from a prior version keep + // their original behavior and users who set the new flag see it honored. + if ( + portableSettings.disableUpdateNotifications || + portableSettings.disableUpdateChecks + ) { + console.log("Automatic update check disabled for portable mode") return } @@ -238,14 +252,13 @@ const initializePortableMode = async () => { currentDirectory.value = "." } - const settings = await persistence.getPortableSettings() - console.log("Loaded portable settings:", settings) + const settings = await persistence.desktopSettings.get() + console.log("Loaded desktop settings:", settings) portableSettings.disableUpdateNotifications = settings.disableUpdateNotifications portableSettings.autoSkipWelcome = settings.autoSkipWelcome - - console.log("Updated reactive portableSettings:", portableSettings) + portableSettings.disableUpdateChecks = settings.disableUpdateChecks await checkForUpdatesPortable() diff --git a/packages/hoppscotch-desktop/src/views/StandardHome.vue b/packages/hoppscotch-desktop/src/views/StandardHome.vue index e0a89e3a..f43068f4 100644 --- a/packages/hoppscotch-desktop/src/views/StandardHome.vue +++ b/packages/hoppscotch-desktop/src/views/StandardHome.vue @@ -50,6 +50,7 @@ import { type UpdateEvent, type DownloadProgress, } from "~/services/updater.client" +import { DesktopPersistenceService } from "~/services/persistence.service" import AppHeader from "./shared/AppHeader.vue" import LoadingState from "./shared/LoadingState.vue" @@ -145,10 +146,22 @@ const checkForUpdates = async () => { } const initializeStandardMode = async () => { - const hasUpdates = await checkForUpdates() - if (!hasUpdates) { - await loadRecent() + // The settings page's `disableUpdateChecks` toggle governs the automatic + // startup check, not the manual "Check for updates" button in settings. + // Reading the setting here lets air-gapped and enterprise-network users + // skip the 5-second timeout retry on every launch. They can still trigger + // a check on demand from the settings page whenever they want. + const persistence = DesktopPersistenceService.getInstance() + const settings = await persistence.desktopSettings.get() + + if (!settings.disableUpdateChecks) { + const hasUpdates = await checkForUpdates() + if (hasUpdates) { + return + } } + + await loadRecent() } onMounted(async () => { diff --git a/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue b/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue index 71d47852..1359145f 100644 --- a/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue +++ b/packages/hoppscotch-desktop/src/views/shared/UpdateFlow.vue @@ -88,6 +88,7 @@ interface Props { withDefaults(defineProps(), { message: "", + progress: undefined, showProgress: true, showCancel: false, }) diff --git a/packages/hoppscotch-selfhost-web/src/main.ts b/packages/hoppscotch-selfhost-web/src/main.ts index 9cb82be0..b03f7ee5 100644 --- a/packages/hoppscotch-selfhost-web/src/main.ts +++ b/packages/hoppscotch-selfhost-web/src/main.ts @@ -27,6 +27,8 @@ import { InfraPlatform } from "@app/platform/infra/infra.platform" import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io" import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service" +import DesktopSettingsSection from "@hoppscotch/common/components/settings/Desktop.vue" + // Std interceptors import { NativeKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/native" import { AgentKernelInterceptorService } from "@hoppscotch/common/platform/std/kernel-interceptors/agent" @@ -142,6 +144,12 @@ async function initApp() { ui: { additionalFooterMenuItems: config.menuItems, additionalSupportOptionsMenuItems: config.supportItems, + // Desktop-only. Renders the "Desktop" block in the shared settings + // page. The component lives in common so every shell that builds a + // Tauri desktop target can register it the same way. Web builds pass + // `undefined` here and the settings page renders without the block. + additionalSettingsSections: + platform === "desktop" ? [DesktopSettingsSection] : undefined, appHeader: { paddingLeft: headerPaddingLeft, paddingTop: headerPaddingTop, diff --git a/packages/hoppscotch-selfhost-web/tsconfig.json b/packages/hoppscotch-selfhost-web/tsconfig.json index d5681338..d50b6fcb 100644 --- a/packages/hoppscotch-selfhost-web/tsconfig.json +++ b/packages/hoppscotch-selfhost-web/tsconfig.json @@ -18,11 +18,13 @@ "@app/platform/*": ["./src/platform/*"], "@app/services/*": ["./src/services/*"], "@app/components/*": ["./src/components/*"], + "@app/composables/*": ["./src/composables/*"], "@app/helpers/*": ["./src/helpers/*"], "@app/api/*": ["./src/api/*"], "@app/lib/*": ["./src/lib/*"], "@app/kernel/*": ["./src/kernel/*"] - } + }, + "types": ["vite/client", "unplugin-icons/types/vue"] }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/packages/hoppscotch-selfhost-web/vite.config.ts b/packages/hoppscotch-selfhost-web/vite.config.ts index 6b600eff..96fbceba 100644 --- a/packages/hoppscotch-selfhost-web/vite.config.ts +++ b/packages/hoppscotch-selfhost-web/vite.config.ts @@ -86,6 +86,7 @@ export default defineConfig({ "@app/platform": path.resolve(__dirname, "./src/platform"), "@app/services": path.resolve(__dirname, "./src/services"), "@app/components": path.resolve(__dirname, "./src/components"), + "@app/composables": path.resolve(__dirname, "./src/composables"), "@app/helpers": path.resolve(__dirname, "./src/helpers"), "@app/api": path.resolve(__dirname, "./src/api"), "@app/lib": path.resolve(__dirname, "./src/lib"),