Merge branch 'feature/desktop-daemon-settings'
This commit is contained in:
@@ -5,15 +5,33 @@ const asyncStorageMock = vi.hoisted(() => ({
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
const electronRuntimeState = vi.hoisted(() => ({
|
||||
isElectron: false,
|
||||
}));
|
||||
|
||||
const desktopSettingsMock = vi.hoisted(() => ({
|
||||
loadDesktopSettings: vi.fn<() => Promise<unknown>>(),
|
||||
migrateLegacyDesktopSettings: vi.fn<(_: unknown) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/host", () => ({
|
||||
isElectronRuntime: () => electronRuntimeState.isElectron,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => desktopSettingsMock);
|
||||
|
||||
describe("use-settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
electronRuntimeState.isElectron = false;
|
||||
desktopSettingsMock.loadDesktopSettings.mockReset();
|
||||
desktopSettingsMock.migrateLegacyDesktopSettings.mockReset();
|
||||
});
|
||||
|
||||
it("defaults built-in daemon management to enabled when storage is empty", async () => {
|
||||
@@ -26,7 +44,7 @@ describe("use-settings", () => {
|
||||
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify(mod.DEFAULT_APP_SETTINGS),
|
||||
JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,7 +68,7 @@ describe("use-settings", () => {
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("loads persisted built-in daemon management state", async () => {
|
||||
it("ignores renderer-owned daemon management state outside Electron", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
@@ -66,14 +84,13 @@ describe("use-settings", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads persisted beta release channel", async () => {
|
||||
it("ignores renderer-owned release channel outside Electron", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
@@ -86,6 +103,89 @@ describe("use-settings", () => {
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result.releaseChannel).toBe("beta");
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("keeps legacy AsyncStorage migration for client settings only", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return null;
|
||||
}
|
||||
if (key === "@paseo:settings") {
|
||||
return JSON.stringify({
|
||||
theme: "dark",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadAppSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
});
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates legacy desktop-owned settings through Electron before reading effective settings", async () => {
|
||||
electronRuntimeState.isElectron = true;
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
desktopSettingsMock.migrateLegacyDesktopSettings.mockResolvedValue();
|
||||
desktopSettingsMock.loadDesktopSettings.mockResolvedValue({
|
||||
releaseChannel: "beta",
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(desktopSettingsMock.migrateLegacyDesktopSettings).toHaveBeenCalledWith({
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips desktop IPC when loading effective settings outside Electron", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
theme: "light",
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,66 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
loadDesktopSettings,
|
||||
migrateLegacyDesktopSettings,
|
||||
useDesktopSettings,
|
||||
} from "@/desktop/settings/desktop-settings";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
manageBuiltInDaemon: boolean;
|
||||
sendBehavior: SendBehavior;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
manageBuiltInDaemon: boolean;
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
settings: AppSettings;
|
||||
isLoading: boolean;
|
||||
error: unknown | null;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<Settings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAppSettings(): UseAppSettingsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: APP_SETTINGS_QUERY_KEY,
|
||||
queryFn: loadSettingsFromStorage,
|
||||
queryFn: loadAppSettingsFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
@@ -49,7 +69,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
try {
|
||||
const prev =
|
||||
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
|
||||
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
@@ -63,7 +83,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
try {
|
||||
const next = { ...DEFAULT_APP_SETTINGS };
|
||||
const next = { ...DEFAULT_CLIENT_SETTINGS };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
@@ -73,7 +93,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
settings: data ?? DEFAULT_APP_SETTINGS,
|
||||
settings: data ?? DEFAULT_CLIENT_SETTINGS,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
updateSettings,
|
||||
@@ -81,51 +101,172 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const appSettings = useAppSettings();
|
||||
const desktopSettings = useDesktopSettings();
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<Settings>) => {
|
||||
const appUpdates: Partial<AppSettings> = {};
|
||||
if (updates.theme !== undefined) {
|
||||
appUpdates.theme = updates.theme;
|
||||
}
|
||||
if (updates.sendBehavior !== undefined) {
|
||||
appUpdates.sendBehavior = updates.sendBehavior;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
}
|
||||
|
||||
if (isElectronRuntime()) {
|
||||
const desktopUpdates: Parameters<typeof desktopSettings.updateSettings>[0] = {};
|
||||
if (updates.manageBuiltInDaemon !== undefined) {
|
||||
desktopUpdates.daemon = {
|
||||
manageBuiltInDaemon: updates.manageBuiltInDaemon,
|
||||
};
|
||||
}
|
||||
if (updates.releaseChannel !== undefined) {
|
||||
desktopUpdates.releaseChannel = updates.releaseChannel;
|
||||
}
|
||||
if (Object.keys(desktopUpdates).length > 0) {
|
||||
promises.push(desktopSettings.updateSettings(desktopUpdates));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
[appSettings, desktopSettings],
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
const resets: Promise<void>[] = [appSettings.resetSettings()];
|
||||
if (isElectronRuntime()) {
|
||||
resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS));
|
||||
}
|
||||
await Promise.all(resets);
|
||||
}, [appSettings, desktopSettings]);
|
||||
|
||||
return {
|
||||
settings: {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings.settings,
|
||||
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.settings.releaseChannel,
|
||||
},
|
||||
isLoading: appSettings.isLoading || desktopSettings.isLoading,
|
||||
error: appSettings.error ?? desktopSettings.error,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
|
||||
parsed.theme = DEFAULT_APP_SETTINGS.theme;
|
||||
}
|
||||
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
|
||||
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
|
||||
}
|
||||
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) };
|
||||
}
|
||||
|
||||
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacyStored) {
|
||||
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
|
||||
const next = {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
...pickAppSettingsFromLegacy(legacyParsed),
|
||||
} satisfies AppSettings;
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS));
|
||||
return DEFAULT_APP_SETTINGS;
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS));
|
||||
return DEFAULT_CLIENT_SETTINGS;
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSettingsFromStorage(): Promise<Settings> {
|
||||
const legacyDesktopSettings = isElectronRuntime()
|
||||
? await loadLegacyDesktopSettingsFromStorage()
|
||||
: null;
|
||||
const appSettings = await loadAppSettingsFromStorage();
|
||||
|
||||
if (!isElectronRuntime()) {
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
};
|
||||
}
|
||||
|
||||
if (legacyDesktopSettings) {
|
||||
await migrateLegacyDesktopSettings(legacyDesktopSettings);
|
||||
}
|
||||
|
||||
const desktopSettings = await loadDesktopSettings();
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.releaseChannel,
|
||||
};
|
||||
}
|
||||
|
||||
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme as AppSettings["theme"];
|
||||
}
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
||||
}
|
||||
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
|
||||
result.releaseChannel = legacy.releaseChannel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useSettings = useAppSettings;
|
||||
async function loadLegacyDesktopSettingsFromStorage(): Promise<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} | null> {
|
||||
const stored = await loadRendererSettingsPayload();
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: {
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} = {};
|
||||
|
||||
if (typeof stored.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
||||
}
|
||||
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
|
||||
result.releaseChannel = stored.releaseChannel;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
async function loadRendererSettingsPayload(): Promise<Record<string, unknown> | null> {
|
||||
const current = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (current) {
|
||||
return JSON.parse(current) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (!legacy) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(legacy) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user