fix(desktop): preserve explicit Linux theme

This commit is contained in:
onenewcode 2026-07-25 18:40:43 +08:00 committed by GitHub
parent 9889ce5a44
commit 96c56d9e4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 122 additions and 3 deletions

View File

@ -0,0 +1,110 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let mediaQueryChangeListener: ((event: MediaQueryListEvent) => void) | undefined;
let mediaQueryMatches = false;
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
});
const setTheme = vi.fn(async () => {});
function installLocalStorageStub() {
const store = new Map<string, string>();
const storage = {
get length() {
return store.size;
},
clear: vi.fn(() => store.clear()),
getItem: vi.fn((key: string) => store.get(key) ?? null),
key: vi.fn((index: number) => [...store.keys()][index] ?? null),
removeItem: vi.fn((key: string) => store.delete(key)),
setItem: vi.fn((key: string, value: string) => store.set(key, String(value))),
} as Storage;
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
function installBrowserStubs() {
const mediaQuery = {
get matches() {
return mediaQueryMatches;
},
addEventListener: vi.fn((event: string, listener: (event: MediaQueryListEvent) => void) => {
if (event === "change") mediaQueryChangeListener = listener;
}),
removeEventListener: vi.fn(),
};
vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (X11; Linux x86_64)" });
vi.stubGlobal("requestAnimationFrame", requestAnimationFrameMock);
Object.defineProperty(window, "matchMedia", { configurable: true, value: vi.fn(() => mediaQuery) });
}
async function loadTheme(mode: "light" | "dark" | "system" = "system") {
window.localStorage.setItem("dbx-theme", mode);
const { useTheme } = await import("@/composables/useTheme");
return useTheme();
}
async function flushDynamicImport() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("useTheme on Linux", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorageStub();
window.localStorage.clear();
document.documentElement.className = "";
document.documentElement.style.colorScheme = "";
mediaQueryChangeListener = undefined;
mediaQueryMatches = false;
installBrowserStubs();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.doMock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ setTheme }) }));
});
afterEach(() => {
vi.doUnmock("@/lib/backend/tauriRuntime");
vi.doUnmock("@tauri-apps/api/window");
vi.unstubAllGlobals();
});
it("uses the Linux system preference after a fresh startup without overwriting GTK", async () => {
mediaQueryMatches = true;
const theme = await loadTheme();
theme.applyTheme();
expect(theme.isDark.value).toBe(true);
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(setTheme).not.toHaveBeenCalled();
});
it("continues to apply system preference changes without writing the native theme", async () => {
const theme = await loadTheme();
theme.applyTheme();
mediaQueryChangeListener?.({ matches: true } as MediaQueryListEvent);
expect(theme.isDark.value).toBe(true);
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(setTheme).not.toHaveBeenCalled();
});
it("still writes explicit dark and light choices to the native Linux window theme", async () => {
const theme = await loadTheme("dark");
expect(theme.themeMode.value).toBe("dark");
theme.applyTheme();
await flushDynamicImport();
expect(setTheme).toHaveBeenLastCalledWith("dark");
theme.setThemeMode("light");
await flushDynamicImport();
expect(setTheme).toHaveBeenLastCalledWith("light");
});
});

View File

@ -18,6 +18,10 @@ import {
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
function isLinuxTauriRuntime() {
return isTauriRuntime() && typeof navigator !== "undefined" && /linux/i.test(navigator.userAgent);
}
const savedThemeMode = safeLocalStorageGet(APP_THEME_STORAGE_KEY);
const themeMode = ref<AppThemeMode>(normalizeAppThemeMode(savedThemeMode));
const savedThemePalette = safeLocalStorageGet(APP_THEME_PALETTE_STORAGE_KEY);
@ -67,19 +71,24 @@ function applyTheme() {
// force reflow so the class toggle takes effect before re-enabling transitions
doc.offsetHeight; // eslint-disable-line @typescript-eslint/no-unused-expressions
requestAnimationFrame(() => doc.classList.remove("disable-transitions"));
if (!isTauriRuntime()) return;
const tauriTheme = getTauriThemeForMode(themeMode.value);
// Tauri owns the Linux GTK preference. Calling setTheme(null) here forces it
// to light, so only skip the native system-theme write and keep explicit modes.
if (isLinuxTauriRuntime() && tauriTheme == null) return;
if (cachedTauriWindow) {
cachedTauriWindow
.getCurrentWindow()
.setTheme(getTauriThemeForMode(themeMode.value))
.setTheme(tauriTheme)
.catch(() => {});
} else {
import("@tauri-apps/api/window").then((mod) => {
cachedTauriWindow = mod;
mod
.getCurrentWindow()
.setTheme(getTauriThemeForMode(themeMode.value))
.setTheme(tauriTheme)
.catch(() => {});
});
}