From b0812458deb2e2e2a47fb82e253768b2a6237a1e Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sun, 15 Mar 2026 03:00:13 +0900 Subject: [PATCH 1/2] fix: defer onboarding-time restarts until setup is complete - queue automatic app relaunches while onboarding is still required - restart after onboarding finishes so system-audio and storage changes still apply - add focused tests for the relaunch coordinator --- apps/desktop/src/onboarding/final.tsx | 4 + .../src/onboarding/folder-location.tsx | 22 +++++- .../general/storage/use-storage-wizard.ts | 4 +- .../src/shared/hooks/usePermissions.ts | 32 +++++--- .../src/store/tinybase/store/save.test.ts | 79 +++++++++++++++++++ apps/desktop/src/store/tinybase/store/save.ts | 53 +++++++++++++ 6 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/store/tinybase/store/save.test.ts diff --git a/apps/desktop/src/onboarding/final.tsx b/apps/desktop/src/onboarding/final.tsx index 2b296c1ee0..30cfabbd1b 100644 --- a/apps/desktop/src/onboarding/final.tsx +++ b/apps/desktop/src/onboarding/final.tsx @@ -6,6 +6,7 @@ import { commands as sfxCommands } from "@hypr/plugin-sfx"; import { OnboardingButton } from "./shared"; +import { flushAutomaticRelaunch } from "~/store/tinybase/store/save"; import { commands } from "~/types/tauri.gen"; const SOCIALS = [ @@ -61,5 +62,8 @@ export async function finishOnboarding(onContinue?: () => void) { await commands.setOnboardingNeeded(false).catch(console.error); await new Promise((resolve) => setTimeout(resolve, 100)); await analyticsCommands.event({ event: "onboarding_completed" }); + if (await flushAutomaticRelaunch()) { + return; + } onContinue?.(); } diff --git a/apps/desktop/src/onboarding/folder-location.tsx b/apps/desktop/src/onboarding/folder-location.tsx index bb9e020f57..4bfe5dbc91 100644 --- a/apps/desktop/src/onboarding/folder-location.tsx +++ b/apps/desktop/src/onboarding/folder-location.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { homeDir } from "@tauri-apps/api/path"; -import { open as selectFolder } from "@tauri-apps/plugin-dialog"; +import { message, open as selectFolder } from "@tauri-apps/plugin-dialog"; import { FolderIcon } from "lucide-react"; import { commands as openerCommands } from "@hypr/plugin-opener2"; @@ -8,7 +8,21 @@ import { commands as settingsCommands } from "@hypr/plugin-settings"; import { ObsidianVaultList } from "~/settings/general/storage/obsidian-vault-list"; import { displayPath } from "~/settings/general/storage/path-utils"; -import { relaunch } from "~/store/tinybase/store/save"; +import { scheduleAutomaticRelaunch } from "~/store/tinybase/store/save"; + +async function handleStorageUpdate() { + const restartStatus = await scheduleAutomaticRelaunch(); + + if (restartStatus === "deferred") { + void message( + "The app will restart after onboarding to apply your storage changes", + { + kind: "info", + title: "Storage Updated", + }, + ); + } +} export function FolderLocationSection({ onContinue, @@ -52,7 +66,7 @@ export function FolderLocationSection({ }, onSuccess: async () => { queryClient.invalidateQueries({ queryKey: ["vault-base-path"] }); - await relaunch(); + await handleStorageUpdate(); }, }); @@ -65,7 +79,7 @@ export function FolderLocationSection({ }, onSuccess: async () => { queryClient.invalidateQueries({ queryKey: ["vault-base-path"] }); - await relaunch(); + await handleStorageUpdate(); }, }); diff --git a/apps/desktop/src/settings/general/storage/use-storage-wizard.ts b/apps/desktop/src/settings/general/storage/use-storage-wizard.ts index 9545fc7e46..3d8ef41e0f 100644 --- a/apps/desktop/src/settings/general/storage/use-storage-wizard.ts +++ b/apps/desktop/src/settings/general/storage/use-storage-wizard.ts @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { commands as settingsCommands } from "@hypr/plugin-settings"; -import { relaunch } from "~/store/tinybase/store/save"; +import { scheduleAutomaticRelaunch } from "~/store/tinybase/store/save"; export function useChangeContentPathWizard({ open, @@ -51,7 +51,7 @@ export function useChangeContentPathWizard({ }, onSuccess: async () => { onSuccess(); - await relaunch(); + await scheduleAutomaticRelaunch(); }, }); diff --git a/apps/desktop/src/shared/hooks/usePermissions.ts b/apps/desktop/src/shared/hooks/usePermissions.ts index 246f887764..5465c04b94 100644 --- a/apps/desktop/src/shared/hooks/usePermissions.ts +++ b/apps/desktop/src/shared/hooks/usePermissions.ts @@ -7,7 +7,21 @@ import { type PermissionStatus, } from "@hypr/plugin-permissions"; -import { relaunch } from "~/store/tinybase/store/save"; +import { scheduleAutomaticRelaunch } from "~/store/tinybase/store/save"; + +async function handleSystemAudioPermissionSuccess() { + const restartStatus = await scheduleAutomaticRelaunch(2000); + + void message( + restartStatus === "deferred" + ? "The app will restart after onboarding to apply the changes" + : "The app will now restart to apply the changes", + { + kind: "info", + title: "System Audio Status Changed", + }, + ); +} export function usePermission(type: Permission) { const status = useQuery({ @@ -24,13 +38,9 @@ export function usePermission(type: Permission) { const requestMutation = useMutation({ mutationFn: () => permissionsCommands.requestPermission(type), - onSuccess: () => { + onSuccess: async () => { if (type === "systemAudio") { - void message("The app will now restart to apply the changes", { - kind: "info", - title: "System Audio Status Changed", - }); - setTimeout(() => relaunch(), 2000); + await handleSystemAudioPermissionSuccess(); return; } setTimeout(() => status.refetch(), 1000); @@ -112,12 +122,8 @@ export function usePermissions() { const systemAudioPermission = useMutation({ mutationFn: () => permissionsCommands.requestPermission("systemAudio"), - onSuccess: () => { - void message("The app will now restart to apply the changes", { - kind: "info", - title: "System Audio Status Changed", - }); - setTimeout(() => relaunch(), 2000); + onSuccess: async () => { + await handleSystemAudioPermissionSuccess(); }, onError: console.error, }); diff --git a/apps/desktop/src/store/tinybase/store/save.test.ts b/apps/desktop/src/store/tinybase/store/save.test.ts new file mode 100644 index 0000000000..11cde8a220 --- /dev/null +++ b/apps/desktop/src/store/tinybase/store/save.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const saveMock = vi.fn<() => Promise>().mockResolvedValue(undefined); +const relaunchMock = vi.fn<() => Promise>().mockResolvedValue(undefined); +const getOnboardingNeededMock = vi + .fn<() => Promise<{ status: "ok"; data: boolean }>>() + .mockResolvedValue({ status: "ok", data: false }); + +vi.mock("@hypr/plugin-store2", () => ({ + commands: { + save: saveMock, + }, +})); + +vi.mock("@tauri-apps/plugin-process", () => ({ + relaunch: relaunchMock, +})); + +vi.mock("~/types/tauri.gen", () => ({ + commands: { + getOnboardingNeeded: getOnboardingNeededMock, + }, +})); + +describe("automatic relaunch", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + vi.clearAllMocks(); + getOnboardingNeededMock.mockResolvedValue({ status: "ok", data: false }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + test("schedules an immediate relaunch when onboarding is already done", async () => { + const { scheduleAutomaticRelaunch } = await import("./save"); + + await expect(scheduleAutomaticRelaunch(2000)).resolves.toBe("scheduled"); + + expect(saveMock).not.toHaveBeenCalled(); + expect(relaunchMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2000); + + expect(saveMock).toHaveBeenCalledTimes(1); + expect(relaunchMock).toHaveBeenCalledTimes(1); + }); + + test("defers relaunch while onboarding is still required", async () => { + getOnboardingNeededMock.mockResolvedValue({ status: "ok", data: true }); + const { scheduleAutomaticRelaunch } = await import("./save"); + + await expect(scheduleAutomaticRelaunch()).resolves.toBe("deferred"); + + expect(saveMock).not.toHaveBeenCalled(); + expect(relaunchMock).not.toHaveBeenCalled(); + }); + + test("flushes a deferred relaunch after onboarding completes", async () => { + getOnboardingNeededMock.mockResolvedValueOnce({ + status: "ok", + data: true, + }); + const { flushAutomaticRelaunch, scheduleAutomaticRelaunch } = + await import("./save"); + + await scheduleAutomaticRelaunch(); + + getOnboardingNeededMock.mockResolvedValue({ status: "ok", data: false }); + + await expect(flushAutomaticRelaunch()).resolves.toBe(true); + + expect(saveMock).toHaveBeenCalledTimes(1); + expect(relaunchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/store/tinybase/store/save.ts b/apps/desktop/src/store/tinybase/store/save.ts index 7a82bb67fe..27418f21b4 100644 --- a/apps/desktop/src/store/tinybase/store/save.ts +++ b/apps/desktop/src/store/tinybase/store/save.ts @@ -2,7 +2,11 @@ import { relaunch as tauriRelaunch } from "@tauri-apps/plugin-process"; import { commands as store2Commands } from "@hypr/plugin-store2"; +import { commands } from "~/types/tauri.gen"; + const saveHandlers = new Map Promise>(); +let pendingAutomaticRelaunch = false; +let automaticRelaunchTimeout: ReturnType | null = null; export function registerSaveHandler(id: string, handler: () => Promise) { saveHandlers.set(id, handler); @@ -22,3 +26,52 @@ export async function relaunch(): Promise { await save(); await tauriRelaunch(); } + +async function getOnboardingNeeded() { + const result = await commands.getOnboardingNeeded().catch(() => null); + if (result?.status !== "ok") { + return false; + } + return result.data; +} + +export async function scheduleAutomaticRelaunch( + delayMs = 0, +): Promise<"scheduled" | "deferred"> { + if (await getOnboardingNeeded()) { + pendingAutomaticRelaunch = true; + return "deferred"; + } + + if (automaticRelaunchTimeout) { + return "scheduled"; + } + + automaticRelaunchTimeout = setTimeout(() => { + automaticRelaunchTimeout = null; + void relaunch().catch(console.error); + }, delayMs); + + return "scheduled"; +} + +export async function flushAutomaticRelaunch(): Promise { + if (!pendingAutomaticRelaunch || (await getOnboardingNeeded())) { + return false; + } + + pendingAutomaticRelaunch = false; + + if (automaticRelaunchTimeout) { + clearTimeout(automaticRelaunchTimeout); + automaticRelaunchTimeout = null; + } + + try { + await relaunch(); + return true; + } catch (error) { + pendingAutomaticRelaunch = true; + throw error; + } +} From a54d18b202c72de350d820ee971b682cbc049503 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sun, 15 Mar 2026 15:14:06 +0900 Subject: [PATCH 2/2] fix: defer system audio restart prompt until onboarding completion - delay the system audio restart modal until onboarding finishes - mark system audio as authorized locally after a successful request - keep the deferred restart flow intact by showing the modal right before relaunch --- apps/desktop/src/onboarding/final.tsx | 8 ++++ .../src/shared/hooks/usePermissions.ts | 41 ++++++++++++++----- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/onboarding/final.tsx b/apps/desktop/src/onboarding/final.tsx index 30cfabbd1b..e95e3e210d 100644 --- a/apps/desktop/src/onboarding/final.tsx +++ b/apps/desktop/src/onboarding/final.tsx @@ -1,4 +1,5 @@ import { Icon } from "@iconify-icon/react"; +import { message } from "@tauri-apps/plugin-dialog"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { commands as openerCommands } from "@hypr/plugin-opener2"; @@ -6,6 +7,7 @@ import { commands as sfxCommands } from "@hypr/plugin-sfx"; import { OnboardingButton } from "./shared"; +import { consumePendingSystemAudioStatusChangedMessage } from "~/shared/hooks/usePermissions"; import { flushAutomaticRelaunch } from "~/store/tinybase/store/save"; import { commands } from "~/types/tauri.gen"; @@ -62,6 +64,12 @@ export async function finishOnboarding(onContinue?: () => void) { await commands.setOnboardingNeeded(false).catch(console.error); await new Promise((resolve) => setTimeout(resolve, 100)); await analyticsCommands.event({ event: "onboarding_completed" }); + if (consumePendingSystemAudioStatusChangedMessage()) { + await message("The app will now restart to apply the changes", { + kind: "info", + title: "System Audio Status Changed", + }); + } if (await flushAutomaticRelaunch()) { return; } diff --git a/apps/desktop/src/shared/hooks/usePermissions.ts b/apps/desktop/src/shared/hooks/usePermissions.ts index 5465c04b94..79d3f61e75 100644 --- a/apps/desktop/src/shared/hooks/usePermissions.ts +++ b/apps/desktop/src/shared/hooks/usePermissions.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { message } from "@tauri-apps/plugin-dialog"; +import { useState } from "react"; import { type Permission, @@ -9,21 +10,31 @@ import { import { scheduleAutomaticRelaunch } from "~/store/tinybase/store/save"; +let pendingSystemAudioStatusChangedMessage = false; + +export function consumePendingSystemAudioStatusChangedMessage() { + const pending = pendingSystemAudioStatusChangedMessage; + pendingSystemAudioStatusChangedMessage = false; + return pending; +} + async function handleSystemAudioPermissionSuccess() { const restartStatus = await scheduleAutomaticRelaunch(2000); - void message( - restartStatus === "deferred" - ? "The app will restart after onboarding to apply the changes" - : "The app will now restart to apply the changes", - { - kind: "info", - title: "System Audio Status Changed", - }, - ); + if (restartStatus === "deferred") { + pendingSystemAudioStatusChangedMessage = true; + return; + } + + void message("The app will now restart to apply the changes", { + kind: "info", + title: "System Audio Status Changed", + }); } export function usePermission(type: Permission) { + const [optimisticStatus, setOptimisticStatus] = + useState(null); const status = useQuery({ queryKey: [`${type}Permission`], queryFn: () => permissionsCommands.checkPermission(type), @@ -40,9 +51,12 @@ export function usePermission(type: Permission) { mutationFn: () => permissionsCommands.requestPermission(type), onSuccess: async () => { if (type === "systemAudio") { + setOptimisticStatus("authorized"); + setTimeout(() => void status.refetch(), 1000); await handleSystemAudioPermissionSuccess(); return; } + setOptimisticStatus(null); setTimeout(() => status.refetch(), 1000); }, }); @@ -50,6 +64,7 @@ export function usePermission(type: Permission) { const resetMutation = useMutation({ mutationFn: () => permissionsCommands.resetPermission(type), onSuccess: () => { + setOptimisticStatus(null); setTimeout(() => status.refetch(), 1000); }, }); @@ -68,7 +83,13 @@ export function usePermission(type: Permission) { resetMutation.mutate(); }; - return { status: status.data, isPending, open, request, reset }; + return { + status: optimisticStatus ?? status.data, + isPending, + open, + request, + reset, + }; } export function usePermissions() {