feat(app): add configurable global background wallpaper

This commit is contained in:
miracle
2026-09-08 11:19:24 +08:00
committed by GitHub
parent 5fb667716f
commit 39aa663ebf
28 changed files with 1240 additions and 1 deletions
+5
View File
@@ -140,6 +140,7 @@ import { Input } from "@/components/ui/input";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { HistoryEntry } from "@/lib/backend/tauri";
import { resolveDefaultAiSchema, type AiAction } from "@/lib/ai/ai";
import { useBackgroundImage } from "@/composables/useBackgroundImage";
import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChangeDialog.vue";
import { resolveWindowContext } from "@/lib/app/windowContext";
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
@@ -172,6 +173,7 @@ const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { active: appBackgroundActive, backgroundObjectUrl: appBackgroundObjectUrl, backgroundImageStyle: appBackgroundImageStyle } = useBackgroundImage(settingsStore);
const { uiFontFamilyPreview } = useUiFontFamilyPreview();
const savedSqlStore = useSavedSqlStore();
const promptTemplateStore = usePromptTemplateStore();
@@ -3386,6 +3388,9 @@ onUnmounted(() => {
<template>
<LoginPage v-if="setupRequired || (needsAuth && !authenticated)" :setup-mode="setupRequired" @authenticated="onLoginSuccess" />
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="fixed inset-0 h-screen w-screen overflow-hidden">
<div v-if="appBackgroundActive && appBackgroundObjectUrl" data-app-background class="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div class="h-full w-full" :style="appBackgroundImageStyle"></div>
</div>
<TooltipProvider :delay-duration="300">
<div class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden" :class="{ 'dbx-desktop-window-frame': drawDesktopWindowFrame }" :style="appUiFontFamilyStyle">
<AppToolbar
@@ -131,6 +131,9 @@ import {
forgetWebdavSyncSecretsPassphrase,
forgetWebdavSavedPassword,
getAppSupportInfo,
checkBackgroundImage,
clearBackgroundImage,
saveBackgroundImage,
loadMaxAgentTurns,
saveMaxAgentTurns,
loadMaxRetries,
@@ -194,6 +197,7 @@ import McpAuthorizationStepper from "@/components/settings/McpAuthorizationStepp
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
import { BACKGROUND_IMAGE_DISPLAY_MODES, BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES, defaultBackgroundImageSettings, normalizeBackgroundImageDisplayMode, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import {
editorSettingsDraftChanged,
editorSettingsDraftFromSettings,
@@ -420,6 +424,12 @@ const editTableFontFamily = ref(settingsStore.editorSettings.tableFontFamily);
const editUiFontFamily = ref(settingsStore.editorSettings.uiFontFamily);
const editUiScale = ref(settingsStore.editorSettings.uiScale);
const editTheme = ref(settingsStore.editorSettings.theme);
const editBackgroundImage = ref<BackgroundImageSettings>(cloneBackgroundImageDraft(settingsStore.editorSettings.backgroundImage));
const backgroundImageTransparencyPercent = computed(() => Math.round((1 - editBackgroundImage.value.opacity) * 100));
const backgroundImageFileMissing = ref(false);
// Set when the draft clears a configured image; the stored copy is only
// deleted once the change is actually applied.
let pendingBackgroundImageCleanup: string | null = null;
const editCustomThemes = ref<CustomTheme[]>([...settingsStore.editorSettings.customThemes]);
const editActiveCustomThemeId = ref(settingsStore.editorSettings.activeCustomThemeId);
const editDataGridTypeColorSchemes = ref<DataGridTypeColorScheme[]>(cloneDataGridTypeColorSchemes(settingsStore.editorSettings.dataGridTypeColorSchemes));
@@ -664,6 +674,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
uiFontFamily: editUiFontFamily.value,
uiScale: editUiScale.value,
theme: editTheme.value,
backgroundImage: editBackgroundImage.value,
customThemes: editCustomThemes.value,
activeCustomThemeId: editActiveCustomThemeId.value,
executeMode: editExecuteMode.value,
@@ -766,6 +777,86 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
const editEditorSettingsBase = ref<EditorSettingsDraft>(editorSettingsDraftFromSettings(settingsStore.editorSettings));
const hasEditorDraftChanges = computed(() => editorSettingsDraftChanged(currentEditorSettingsDraft(), editEditorSettingsBase.value));
// --- Background image draft state ---
function cloneBackgroundImageDraft(settings: BackgroundImageSettings): BackgroundImageSettings {
return JSON.parse(JSON.stringify(settings)) as BackgroundImageSettings;
}
async function checkBackgroundImageFileExists() {
const filePath = editBackgroundImage.value.filePath;
if (!filePath) {
backgroundImageFileMissing.value = false;
return;
}
try {
backgroundImageFileMissing.value = !(await checkBackgroundImage(filePath));
} catch {
backgroundImageFileMissing.value = true;
}
}
async function pickBackgroundImage() {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: false,
filters: [{ name: t("settings.backgroundImageFilterName"), extensions: ["png", "jpg", "jpeg", "webp", "bmp", "gif"] }],
});
if (Array.isArray(selected) || typeof selected !== "string" || !selected) return;
try {
const { stat } = await import("@tauri-apps/plugin-fs");
const metadata = await stat(selected);
if (metadata.size > BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES) {
toast(t("settings.backgroundImageTooLarge"), 4000);
return;
}
} catch {
// The Rust command re-validates; keep going so it can produce the error.
}
const info = await saveBackgroundImage(selected);
editBackgroundImage.value = {
...defaultBackgroundImageSettings(),
opacity: editBackgroundImage.value.opacity,
blur: editBackgroundImage.value.blur,
displayMode: editBackgroundImage.value.displayMode,
filePath: info.storedPath,
fileName: info.fileName,
};
backgroundImageFileMissing.value = false;
} catch (error) {
// Surface every failure (missing command in a stale binary, fs scope, copy
// errors): Tauri rejections are plain strings, so String() keeps the detail.
console.error("[dbx] background image selection failed", error);
toast(`${t("settings.backgroundImageSaveFailed")}: ${error instanceof Error ? error.message : String(error)}`, 6000);
}
}
function clearBackgroundImageDraft() {
const previousPath = editBackgroundImage.value.filePath;
editBackgroundImage.value = {
...defaultBackgroundImageSettings(),
opacity: editBackgroundImage.value.opacity,
blur: editBackgroundImage.value.blur,
displayMode: editBackgroundImage.value.displayMode,
};
backgroundImageFileMissing.value = false;
if (previousPath) pendingBackgroundImageCleanup = previousPath;
}
function updateBackgroundImageDisplayMode(mode: unknown) {
editBackgroundImage.value = { ...editBackgroundImage.value, displayMode: normalizeBackgroundImageDisplayMode(mode) };
}
// Slider shows surface transparency (100% = wallpaper most visible); the stored
// field stays surface opacity, so the value is inverted on the way in/out.
function updateBackgroundImageTransparency(transparencyPercent: number) {
editBackgroundImage.value = { ...editBackgroundImage.value, opacity: Math.min(1, Math.max(0.05, 1 - transparencyPercent / 100)) };
}
function updateBackgroundImageBlur(value: number) {
editBackgroundImage.value = { ...editBackgroundImage.value, blur: Math.min(20, Math.max(0, Math.round(value))) };
}
const snippetDialogOpen = ref(false);
const snippetEditingId = ref<string | null>(null);
const snippetForm = ref({ label: "", prefix: "", body: "" });
@@ -1077,6 +1168,8 @@ function syncEditorSettingsDraftFromStore() {
editUiFontFamily.value = settingsStore.editorSettings.uiFontFamily;
editUiScale.value = settingsStore.editorSettings.uiScale;
editTheme.value = settingsStore.editorSettings.theme;
editBackgroundImage.value = cloneBackgroundImageDraft(settingsStore.editorSettings.backgroundImage);
pendingBackgroundImageCleanup = null;
editCustomThemes.value = [...settingsStore.editorSettings.customThemes];
editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
@@ -1499,6 +1592,9 @@ function applyEditorSettingsKeysToRefs(draft: EditorSettingsDraft, keys: readonl
case "defaultTransactionMode":
editDefaultTransactionMode.value = draft.defaultTransactionMode;
break;
case "backgroundImage":
editBackgroundImage.value = cloneBackgroundImageDraft(draft.backgroundImage);
break;
default: {
// Compile-time exhaustiveness over EDITOR_SETTINGS_DRAFT_KEYS: adding a
// draft key without a ref assignment fails the build here.
@@ -1632,6 +1728,11 @@ async function persistSettings() {
await settingsStore.persistEditorSettings();
editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);
}
if (pendingBackgroundImageCleanup) {
const cleanupPath = pendingBackgroundImageCleanup;
pendingBackgroundImageCleanup = null;
void clearBackgroundImage(cleanupPath).catch(() => {});
}
const metadataCacheMaxMemoryMb = normalizeMetadataCacheMemoryMb(editMetadataCacheMaxMemoryMb.value);
await settingsStore.updateDesktopSettings({
show_tray_icon: editShowTrayIcon.value,
@@ -3757,6 +3858,7 @@ onMounted(() => {
void refreshWebDavPasswordStatus();
checkLayoutDescTruncation();
initTruncationObservers();
void checkBackgroundImageFileExists();
});
onUnmounted(() => {
@@ -6124,6 +6226,55 @@ onUnmounted(() => {
</div>
</div>
<div v-if="!isWeb" class="settings-appearance-group" data-background-image-settings>
<Label>{{ t("settings.backgroundImage") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.backgroundImageDescription") }}</p>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" class="h-8" @click="pickBackgroundImage">
{{ t("settings.backgroundImageChoose") }}
</Button>
<Button v-if="editBackgroundImage.filePath" type="button" variant="outline" size="sm" class="h-8" @click="clearBackgroundImageDraft">
{{ t("settings.backgroundImageClear") }}
</Button>
<span v-if="editBackgroundImage.fileName" class="min-w-0 truncate text-xs text-muted-foreground" :title="editBackgroundImage.fileName">
{{ editBackgroundImage.fileName }}
</span>
<span v-else class="text-xs text-muted-foreground">{{ t("settings.backgroundImageNone") }}</span>
</div>
<p v-if="backgroundImageFileMissing" class="text-xs text-destructive">
{{ t("settings.backgroundImageMissing") }}
</p>
<div class="space-y-1">
<Label for="background-image-display-mode">{{ t("settings.backgroundImageDisplayMode") }}</Label>
<Select :model-value="editBackgroundImage.displayMode" @update:model-value="updateBackgroundImageDisplayMode">
<SelectTrigger id="background-image-display-mode" class="h-8 w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="mode in BACKGROUND_IMAGE_DISPLAY_MODES" :key="mode" :value="mode">
{{ t(`settings.backgroundImageMode${mode.charAt(0).toUpperCase()}${mode.slice(1)}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<Label for="background-image-transparency" class="text-xs text-muted-foreground">{{ t("settings.backgroundImageTransparency") }}</Label>
<span class="text-xs text-muted-foreground">{{ backgroundImageTransparencyPercent }}%</span>
</div>
<input id="background-image-transparency" type="range" min="0" max="95" step="1" :value="backgroundImageTransparencyPercent" class="w-full accent-primary" @input="updateBackgroundImageTransparency(Number(($event.target as HTMLInputElement).value))" />
</div>
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<Label for="background-image-blur" class="text-xs text-muted-foreground">{{ t("settings.backgroundImageBlur") }}</Label>
<span class="text-xs text-muted-foreground">{{ editBackgroundImage.blur }}px</span>
</div>
<input id="background-image-blur" type="range" min="0" max="20" step="1" :value="editBackgroundImage.blur" class="w-full accent-primary" @input="updateBackgroundImageBlur(Number(($event.target as HTMLInputElement).value))" />
</div>
</div>
</div>
<Separator />
<div class="space-y-2">
@@ -0,0 +1,38 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const composableSource = readFileSync(new URL("../../../composables/useBackgroundImage.ts", import.meta.url), "utf8");
const globalsCss = readFileSync(new URL("../../../../src/styles/globals.css", import.meta.url), "utf8");
const queryEditorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
const welcomeScreenSource = readFileSync(new URL("../../layout/WelcomeScreen.vue", import.meta.url), "utf8");
describe("global background image wiring", () => {
it("mounts a single app-level background layer behind all content", () => {
expect(appSource).toContain("data-app-background");
expect(appSource).toMatch(/pointer-events-none fixed inset-0 -z-10/);
expect(appSource).toContain("useBackgroundImage(settingsStore)");
});
it("toggles the wallpaper class on <html> and clears CodeMirror backgrounds app-wide", () => {
expect(composableSource).toContain('BACKGROUND_IMAGE_ACTIVE_CLASS = "dbx-bg-active"');
expect(globalsCss).toContain("html.dbx-bg-active .cm-editor");
expect(globalsCss).toContain("html.dbx-bg-active .cm-gutters");
});
it("keeps card and popover surfaces opaque (readability contract)", () => {
expect(composableSource).toContain("BACKGROUND_IMAGE_SURFACE_VARS");
const appBackground = readFileSync(new URL("../../../lib/app/appBackgroundImage.ts", import.meta.url), "utf8");
expect(appBackground).toContain('"--background"');
expect(appBackground).toContain('"--sidebar"');
expect(appBackground).not.toMatch(/"--card"|"--popover"/);
});
it("removed the scoped per-area mode (editor/welcome no longer render their own layers)", () => {
for (const source of [queryEditorSource, welcomeScreenSource]) {
expect(source).not.toContain("useBackgroundImage");
expect(source).not.toContain("data-query-editor-bg");
expect(source).not.toContain("data-welcome-bg");
}
});
});
@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { reactive } from "vue";
import { BACKGROUND_IMAGE_ACTIVE_CLASS, useBackgroundImage } from "@/composables/useBackgroundImage";
import { useTheme } from "@/composables/useTheme";
import { defaultBackgroundImageSettings, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
const readBackgroundImageMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/backend/api", () => ({
readBackgroundImage: readBackgroundImageMock,
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
function settingsWith(overrides: Partial<BackgroundImageSettings>) {
const base = defaultBackgroundImageSettings();
return { editorSettings: reactive({ backgroundImage: { ...base, ...overrides } }) } as Parameters<typeof useBackgroundImage>[0];
}
describe("useBackgroundImage", () => {
beforeEach(() => {
vi.resetModules();
readBackgroundImageMock.mockReset();
(globalThis as Record<string, unknown>).__TAURI_INTERNALS__ = {};
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
});
it("reports no active background without a configured file", () => {
const bg = useBackgroundImage(settingsWith({}));
expect(bg.backgroundObjectUrl.value).toBeNull();
expect(bg.active.value).toBe(false);
expect(document.documentElement.classList.contains(BACKGROUND_IMAGE_ACTIVE_CLASS)).toBe(false);
});
it("loads the configured file into an object URL and toggles the global wallpaper class", async () => {
const createObjectURL = vi.fn(() => "blob:bg-1");
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true, writable: true });
Object.defineProperty(URL, "revokeObjectURL", { value: revokeObjectURL, configurable: true, writable: true });
readBackgroundImageMock.mockResolvedValue("aGVsbG8=");
const bg = useBackgroundImage(settingsWith({ filePath: "/data/background-image.png", fileName: "wall.png" }));
await vi.waitFor(() => expect(bg.backgroundObjectUrl.value).toBe("blob:bg-1"));
expect(bg.active.value).toBe(true);
expect(document.documentElement.classList.contains(BACKGROUND_IMAGE_ACTIVE_CLASS)).toBe(true);
});
it("falls back to no background when the file cannot be read", async () => {
readBackgroundImageMock.mockRejectedValue(new Error("gone"));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const bg = useBackgroundImage(settingsWith({ filePath: "/data/background-image.png" }));
await vi.waitFor(() => expect(readBackgroundImageMock).toHaveBeenCalled());
await Promise.resolve();
expect(bg.backgroundObjectUrl.value).toBeNull();
expect(bg.active.value).toBe(false);
expect(document.documentElement.classList.contains(BACKGROUND_IMAGE_ACTIVE_CLASS)).toBe(false);
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it("tints custom palette surface colors from the custom color settings while active", async () => {
const theme = useTheme();
const originalMode = theme.themeMode.value;
const originalPalette = theme.themePalette.value;
const originalColors = { ...theme.customUiColors.value };
theme.setThemeMode("light");
theme.setCustomUiColors({ ...originalColors, background: "#123456", sidebar: "#654321" });
theme.setThemePalette("custom");
const createObjectURL = vi.fn(() => "blob:bg-custom");
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true, writable: true });
readBackgroundImageMock.mockResolvedValue("aGVsbG8=");
try {
const bg = useBackgroundImage(settingsWith({ filePath: "/data/background-image.png", opacity: 0.5 }));
await vi.waitFor(() => expect(bg.active.value).toBe(true));
await vi.waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(18 52 86 / 0.5)");
expect(document.documentElement.style.getPropertyValue("--sidebar")).toBe("rgb(101 67 33 / 0.5)");
});
} finally {
theme.setCustomUiColors(originalColors);
theme.setThemeMode(originalMode);
theme.setThemePalette(originalPalette);
}
});
it("re-emits custom palette inline surface colors when the wallpaper is inactive", async () => {
const theme = useTheme();
const originalMode = theme.themeMode.value;
const originalPalette = theme.themePalette.value;
const originalColors = { ...theme.customUiColors.value };
theme.setThemeMode("light");
theme.setCustomUiColors({ ...originalColors, background: "#123456", sidebar: "#654321" });
theme.setThemePalette("custom");
try {
// applyCustomUiColors has just written these inline values for the custom palette.
document.documentElement.style.setProperty("--background", "rgb(18 52 86)");
document.documentElement.style.setProperty("--sidebar", "rgb(101 67 33)");
const bg = useBackgroundImage(settingsWith({}));
await vi.waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(18 52 86)");
expect(document.documentElement.style.getPropertyValue("--sidebar")).toBe("rgb(101 67 33)");
});
expect(bg.active.value).toBe(false);
} finally {
theme.setCustomUiColors(originalColors);
theme.setThemeMode(originalMode);
theme.setThemePalette(originalPalette);
}
});
});
@@ -0,0 +1,140 @@
import { computed, ref, watch, watchPostEffect, type ComputedRef, type Ref } from "vue";
import { BACKGROUND_IMAGE_SURFACE_VARS, backgroundImageStyle, backgroundImageSurfaceAlpha, surfaceColorWithAlpha, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import { APP_CUSTOM_UI_COLOR_DEFS, appCustomUiColorValue, deriveCustomUiColors } from "@/lib/app/appTheme";
import { readBackgroundImage } from "@/lib/backend/api";
import { useTheme } from "@/composables/useTheme";
import type { EditorSettings } from "@/stores/settingsStore";
type SettingsStoreLike = { editorSettings: EditorSettings };
export const BACKGROUND_IMAGE_ACTIVE_CLASS = "dbx-bg-active";
const SURFACE_VAR_NAMES = new Set<string>(BACKGROUND_IMAGE_SURFACE_VARS);
const backgroundObjectUrl = ref<string | null>(null);
let loadedFilePath: string | null = null;
let loadEpoch = 0;
let warnedOnce = false;
function base64ToBlob(base64: string): Blob {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes]);
}
async function loadBackgroundObjectUrl(settingsStore: SettingsStoreLike) {
const filePath = settingsStore.editorSettings.backgroundImage.filePath;
if (!filePath) {
loadedFilePath = filePath;
setAndRevokeObjectUrl(null);
return;
}
const epoch = ++loadEpoch;
try {
const base64 = await readBackgroundImage(filePath);
if (epoch !== loadEpoch) return;
loadedFilePath = filePath;
setAndRevokeObjectUrl(URL.createObjectURL(base64ToBlob(base64)));
} catch (error) {
if (epoch !== loadEpoch) return;
loadedFilePath = filePath;
setAndRevokeObjectUrl(null);
if (!warnedOnce) {
warnedOnce = true;
console.warn("[dbx] failed to load background image; falling back to plain surfaces", error);
}
}
}
function setAndRevokeObjectUrl(next: string | null) {
if (backgroundObjectUrl.value && backgroundObjectUrl.value !== next) {
URL.revokeObjectURL(backgroundObjectUrl.value);
}
backgroundObjectUrl.value = next;
}
export interface BackgroundImageComposable {
backgroundObjectUrl: Ref<string | null>;
backgroundSettings: ComputedRef<BackgroundImageSettings>;
active: ComputedRef<boolean>;
backgroundImageStyle: ComputedRef<Record<string, string>>;
}
export function useBackgroundImage(settingsStore: SettingsStoreLike): BackgroundImageComposable {
const backgroundSettings = computed(() => settingsStore.editorSettings.backgroundImage);
const { isDark, themePalette, activeCustomUiColors, cornerStyle } = useTheme();
void loadBackgroundObjectUrl(settingsStore);
watch(
() => backgroundSettings.value.filePath,
(filePath) => {
if (filePath === loadedFilePath) return;
void loadBackgroundObjectUrl(settingsStore);
},
);
const active = computed(() => Boolean(backgroundObjectUrl.value));
// Keeps the palette surface colors in sync while the wallpaper is active:
// clear the inline overrides first so the computed values below come from
// the freshly applied palette classes, then re-emit them with alpha. The
// theme refs read below are the reactive deps: any palette / mode / custom
// color change re-runs this effect right after applyTheme rewrote the
// class-based (and custom-palette inline) variables. Custom palettes own
// every surface variable as an inline style, so clearing them would fall
// back to the palette class colors; their base values are resolved from the
// custom color settings instead of the computed style.
const customSurfaceBaseColors = (): Map<string, string> => {
const base = new Map<string, string>();
if (themePalette.value !== "custom") return base;
const colors = activeCustomUiColors.value;
for (const def of APP_CUSTOM_UI_COLOR_DEFS) {
if (SURFACE_VAR_NAMES.has(def.varName)) {
base.set(def.varName, appCustomUiColorValue(colors[def.key]).color);
}
}
for (const [name, value] of Object.entries(deriveCustomUiColors(colors))) {
if (SURFACE_VAR_NAMES.has(name)) base.set(name, value);
}
return base;
};
watchPostEffect(() => {
if (typeof document === "undefined") return;
void isDark.value;
void themePalette.value;
void activeCustomUiColors.value;
void cornerStyle.value;
const doc = document.documentElement;
const alpha = backgroundImageSurfaceAlpha(backgroundSettings.value);
const isActive = active.value;
const customBase = customSurfaceBaseColors();
for (const varName of BACKGROUND_IMAGE_SURFACE_VARS) {
doc.style.removeProperty(varName);
}
doc.classList.toggle(BACKGROUND_IMAGE_ACTIVE_CLASS, isActive);
if (!isActive) {
// Re-emit the custom palette values the clear above removed.
for (const [varName, color] of customBase) {
doc.style.setProperty(varName, color);
}
return;
}
const computedStyle = getComputedStyle(doc);
for (const varName of BACKGROUND_IMAGE_SURFACE_VARS) {
const base = customBase.get(varName) ?? computedStyle.getPropertyValue(varName);
const tinted = surfaceColorWithAlpha(base, alpha);
if (tinted) doc.style.setProperty(varName, tinted);
}
});
return {
backgroundObjectUrl,
backgroundSettings,
active,
backgroundImageStyle: computed(() => backgroundImageStyle(backgroundSettings.value, backgroundObjectUrl.value)),
};
}
+17
View File
@@ -6592,6 +6592,23 @@ export default {
uiScaleDescription: "Scale the entire desktop UI for high-DPI displays. Changes apply immediately and are restored on next launch.",
theme: "Theme",
cornerStyle: "Corner style",
backgroundImage: "Background image",
backgroundImageDescription: "Set an image behind the whole app. Cards and dialogs stay opaque for readability.",
backgroundImageChoose: "Choose image…",
backgroundImageClear: "Clear",
backgroundImageNone: "No image set",
backgroundImageTransparency: "Transparency",
backgroundImageDisplayMode: "Display mode",
backgroundImageModeFill: "Fill",
backgroundImageModeFit: "Fit",
backgroundImageModeStretch: "Stretch",
backgroundImageModeCenter: "Center",
backgroundImageModeTile: "Tile",
backgroundImageBlur: "Blur",
backgroundImageMissing: "The image file no longer exists. Please choose it again.",
backgroundImageTooLarge: "The image exceeds the 20 MB size limit.",
backgroundImageSaveFailed: "Failed to save the background image.",
backgroundImageFilterName: "Image",
cornerStyleNone: "Square",
cornerStyleSmall: "Small",
cornerStyleLarge: "Large",
+17
View File
@@ -6183,6 +6183,23 @@ export default withEnglishFallback({
uiScaleDescription: "Escala toda la interfaz de escritorio para pantallas de alta densidad. Los cambios se aplican al instante y se restauran al volver a abrir.",
theme: "Tema",
cornerStyle: "Estilo de esquinas",
backgroundImage: "Imagen de fondo",
backgroundImageDescription: "Establece una imagen detrás de toda la aplicación. Las tarjetas y diálogos permanecen opacos para facilitar la lectura.",
backgroundImageChoose: "Elegir imagen…",
backgroundImageClear: "Quitar",
backgroundImageNone: "Sin imagen",
backgroundImageTransparency: "Transparencia",
backgroundImageDisplayMode: "Modo de visualización",
backgroundImageModeFill: "Rellenar",
backgroundImageModeFit: "Ajustar",
backgroundImageModeStretch: "Estirar",
backgroundImageModeCenter: "Centrar",
backgroundImageModeTile: "Mosaico",
backgroundImageBlur: "Desenfoque",
backgroundImageMissing: "El archivo de imagen ya no existe. Selecciónalo de nuevo.",
backgroundImageTooLarge: "La imagen supera el límite de 20 MB.",
backgroundImageSaveFailed: "No se pudo guardar la imagen de fondo.",
backgroundImageFilterName: "Imagen",
cornerStyleNone: "Sin redondeo",
cornerStyleSmall: "Pequeñas",
cornerStyleLarge: "Grandes",
+17
View File
@@ -6183,6 +6183,23 @@ export default withEnglishFallback({
uiScaleDescription: "Scala l'intera interfaccia utente desktop per display ad alta densità (High-DPI). Le modifiche si applicano immediatamente e vengono ripristinate al prossimo avvio.",
theme: "Tema",
cornerStyle: "Stile degli angoli",
backgroundImage: "Immagine di sfondo",
backgroundImageDescription: "Imposta un immagine dietro all'intera app. Le schede e le finestre di dialogo restano opache per leggibilità.",
backgroundImageChoose: "Scegli immagine…",
backgroundImageClear: "Rimuovi",
backgroundImageNone: "Nessuna immagine impostata",
backgroundImageTransparency: "Trasparenza",
backgroundImageDisplayMode: "Modalità di visualizzazione",
backgroundImageModeFill: "Riempi",
backgroundImageModeFit: "Adatta",
backgroundImageModeStretch: "Stira",
backgroundImageModeCenter: "Al centro",
backgroundImageModeTile: "Affianca",
backgroundImageBlur: "Sfocatura",
backgroundImageMissing: "Il file immagine non esiste più. Selezionalo di nuovo.",
backgroundImageTooLarge: "L'immagine supera il limite di 20 MB.",
backgroundImageSaveFailed: "Salvataggio dell'immagine di sfondo non riuscito.",
backgroundImageFilterName: "Immagine",
cornerStyleNone: "Senza arrotondamento",
cornerStyleSmall: "Piccoli",
cornerStyleLarge: "Grandi",
+17
View File
@@ -6221,6 +6221,23 @@ export default withEnglishFallback({
uiScaleDescription: "高DPIディスプレイ向けにデスクトップUI全体をスケーリングします。変更は即座に適用され、次回起動時に復元されます。",
theme: "テーマ",
cornerStyle: "角の丸み",
backgroundImage: "背景画像",
backgroundImageDescription: "アプリ全体の背景に画像を設定します。カードとダイアログは読みやすさのため不透明のままです。",
backgroundImageChoose: "画像を選択…",
backgroundImageClear: "クリア",
backgroundImageNone: "画像未設定",
backgroundImageTransparency: "透明度",
backgroundImageDisplayMode: "表示方法",
backgroundImageModeFill: "フィル",
backgroundImageModeFit: "合わせる",
backgroundImageModeStretch: "引き伸ばし",
backgroundImageModeCenter: "中央",
backgroundImageModeTile: "タイル",
backgroundImageBlur: "ぼかし",
backgroundImageMissing: "画像ファイルが見つかりません。再度選択してください。",
backgroundImageTooLarge: "画像が 20MB のサイズ制限を超えています。",
backgroundImageSaveFailed: "背景画像の保存に失敗しました。",
backgroundImageFilterName: "画像",
cornerStyleNone: "なし",
cornerStyleSmall: "小",
cornerStyleLarge: "大",
+17
View File
@@ -5918,6 +5918,23 @@ export default withEnglishFallback({
uiScaleDescription: "고해상도 디스플레이를 위해 전체 데스크톱 UI를 확장합니다. 변경 사항은 즉시 적용되며 다음 실행 시 복원됩니다.",
theme: "테마",
cornerStyle: "모서리 스타일",
backgroundImage: "배경 이미지",
backgroundImageDescription: "앱 전체 배경에 이미지를 설정합니다. 카드와 대화상자는 가독성을 위해 불투명하게 유지됩니다.",
backgroundImageChoose: "이미지 선택…",
backgroundImageClear: "지우기",
backgroundImageNone: "이미지 없음",
backgroundImageTransparency: "투명도",
backgroundImageDisplayMode: "디스플레이 모드",
backgroundImageModeFill: "채우기",
backgroundImageModeFit: "맞춤",
backgroundImageModeStretch: "늘리기",
backgroundImageModeCenter: "가운데",
backgroundImageModeTile: "타일",
backgroundImageBlur: "흐림",
backgroundImageMissing: "이미지 파일이 더 이상 존재하지 않습니다. 다시 선택해 주세요.",
backgroundImageTooLarge: "이미지가 20MB 크기 제한을 초과합니다.",
backgroundImageSaveFailed: "배경 이미지를 저장하지 못했습니다.",
backgroundImageFilterName: "이미지",
cornerStyleNone: "각형",
cornerStyleSmall: "작게",
cornerStyleLarge: "크게",
+17
View File
@@ -6185,6 +6185,23 @@ export default withEnglishFallback({
uiScaleDescription: "Dimensione toda a UI do desktop para telas de alta resolução (high-DPI). As alterações são aplicadas imediatamente e restauradas na próxima inicialização.",
theme: "Tema",
cornerStyle: "Estilo dos cantos",
backgroundImage: "Imagem de fundo",
backgroundImageDescription: "Define uma imagem atrás de todo o aplicativo. Cartões e diálogos permanecem opacos para legibilidade.",
backgroundImageChoose: "Escolher imagem…",
backgroundImageClear: "Limpar",
backgroundImageNone: "Nenhuma imagem definida",
backgroundImageTransparency: "Transparência",
backgroundImageDisplayMode: "Modo de exibição",
backgroundImageModeFill: "Preencher",
backgroundImageModeFit: "Ajustar",
backgroundImageModeStretch: "Esticar",
backgroundImageModeCenter: "Centralizar",
backgroundImageModeTile: "Lado a lado",
backgroundImageBlur: "Desfoque",
backgroundImageMissing: "O arquivo de imagem não existe mais. Selecione-o novamente.",
backgroundImageTooLarge: "A imagem excede o limite de 20 MB.",
backgroundImageSaveFailed: "Falha ao salvar a imagem de fundo.",
backgroundImageFilterName: "Imagem",
cornerStyleNone: "Sem arredondamento",
cornerStyleSmall: "Pequenos",
cornerStyleLarge: "Grandes",
+17
View File
@@ -6487,6 +6487,23 @@ export default withEnglishFallback({
uiScaleDescription: "Yüksek DPI ekranlar için tüm masaüstü arayüzünü ölçekleyin. Değişiklikler hemen uygulanır ve sonraki açılışta geri yüklenir.",
theme: "Tema",
cornerStyle: "Köşe biçemi",
backgroundImage: "Arka plan resmi",
backgroundImageDescription: "Tüm uygulamanın arkasına bir resim ayarlar. Kartlar ve iletişim kutuları okunabilirlik için opak kalır.",
backgroundImageChoose: "Resim seç…",
backgroundImageClear: "Temizle",
backgroundImageNone: "Resim ayarlanmadı",
backgroundImageTransparency: "Şeffaflık",
backgroundImageDisplayMode: "Görüntü modu",
backgroundImageModeFill: "Doldur",
backgroundImageModeFit: "Sığdır",
backgroundImageModeStretch: "Uzat",
backgroundImageModeCenter: "Ortala",
backgroundImageModeTile: "Döşe",
backgroundImageBlur: "Bulanıklık",
backgroundImageMissing: "Resim dosyası artık mevcut değil. Lütfen yeniden seçin.",
backgroundImageTooLarge: "Resim 20 MB boyut sınırını aşıyor.",
backgroundImageSaveFailed: "Arka plan resmi kaydedilemedi.",
backgroundImageFilterName: "Resim",
cornerStyleNone: "Köşeli",
cornerStyleSmall: "Küçük",
cornerStyleLarge: "Büyük",
+17
View File
@@ -6575,6 +6575,23 @@ export default withEnglishFallback({
uiScaleDescription: "按比例缩放整个桌面端界面,适合高清屏;修改后立即生效,并在下次启动时恢复。",
theme: "主题",
cornerStyle: "圆角样式",
backgroundImage: "背景图片",
backgroundImageDescription: "为整个应用设置背景图片。卡片和对话框保持不透明以保证可读性。",
backgroundImageChoose: "选择图片…",
backgroundImageClear: "清除",
backgroundImageNone: "未设置图片",
backgroundImageTransparency: "透明度",
backgroundImageDisplayMode: "显示方式",
backgroundImageModeFill: "填充",
backgroundImageModeFit: "适应",
backgroundImageModeStretch: "拉伸",
backgroundImageModeCenter: "居中",
backgroundImageModeTile: "平铺",
backgroundImageBlur: "模糊",
backgroundImageMissing: "图片文件已不存在,请重新选择。",
backgroundImageTooLarge: "图片超过 20MB 大小限制。",
backgroundImageSaveFailed: "保存背景图片失败。",
backgroundImageFilterName: "图片",
cornerStyleNone: "无圆角",
cornerStyleSmall: "小圆角",
cornerStyleLarge: "大圆角",
+17
View File
@@ -5502,6 +5502,23 @@ export default withEnglishFallback({
uiScaleDescription: "按比例縮放整個桌面端介面,適合高 DPI 螢幕;修改後立即生效,並在下次啟動時復原。",
theme: "主題",
cornerStyle: "圓角樣式",
backgroundImage: "背景图片",
backgroundImageDescription: "為整個應用設定背景圖片。卡片和對話框保持不透明以確保可讀性。",
backgroundImageChoose: "選擇圖片…",
backgroundImageClear: "清除",
backgroundImageNone: "未設定圖片",
backgroundImageTransparency: "透明度",
backgroundImageDisplayMode: "顯示方式",
backgroundImageModeFill: "填充",
backgroundImageModeFit: "適應",
backgroundImageModeStretch: "拉伸",
backgroundImageModeCenter: "置中",
backgroundImageModeTile: "平鋪",
backgroundImageBlur: "模糊",
backgroundImageMissing: "圖片檔案已不存在,請重新選擇。",
backgroundImageTooLarge: "圖片超過 20MB 大小限制。",
backgroundImageSaveFailed: "儲存背景圖片失敗。",
backgroundImageFilterName: "圖片",
cornerStyleNone: "無圓角",
cornerStyleSmall: "小圓角",
cornerStyleLarge: "大圓角",
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import {
BACKGROUND_IMAGE_FILE_EXTENSIONS,
BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES,
BACKGROUND_IMAGE_SURFACE_VARS,
backgroundImageFileExtension,
backgroundImageStyle,
backgroundImageSurfaceAlpha,
defaultBackgroundImageSettings,
normalizeBackgroundImageSettings,
parseCssColorTriplet,
surfaceColorWithAlpha,
} from "@/lib/app/appBackgroundImage";
describe("backgroundImageFileExtension", () => {
it("extracts supported extensions case-insensitively", () => {
expect(backgroundImageFileExtension("wallpaper.PNG")).toBe("png");
expect(backgroundImageFileExtension("photo.Jpeg")).toBe("jpeg");
expect(backgroundImageFileExtension("/path/to/image.webp")).toBe("webp");
});
it("returns null for unsupported or missing extensions", () => {
expect(backgroundImageFileExtension("vector.svg")).toBeNull();
expect(backgroundImageFileExtension("no-extension")).toBeNull();
expect(backgroundImageFileExtension("")).toBeNull();
});
it("exposes the dialog filter extension list", () => {
expect(BACKGROUND_IMAGE_FILE_EXTENSIONS).toContain("png");
expect(BACKGROUND_IMAGE_FILE_EXTENSIONS).toContain("gif");
});
});
describe("normalizeBackgroundImageSettings", () => {
it("falls back to defaults for missing or invalid fields", () => {
expect(normalizeBackgroundImageSettings(undefined)).toEqual(defaultBackgroundImageSettings());
expect(normalizeBackgroundImageSettings(null)).toEqual(defaultBackgroundImageSettings());
expect(normalizeBackgroundImageSettings({})).toEqual(defaultBackgroundImageSettings());
});
it("keeps valid values and clamps out-of-range numbers", () => {
const settings = normalizeBackgroundImageSettings({
filePath: "/data/background-image.png",
fileName: "wall.png",
opacity: 0.75,
blur: 8,
displayMode: "tile",
});
expect(settings).toEqual({ filePath: "/data/background-image.png", fileName: "wall.png", opacity: 0.75, blur: 8, displayMode: "tile" });
const clamped = normalizeBackgroundImageSettings({ opacity: 5, blur: -3 });
expect(clamped.opacity).toBe(1);
expect(clamped.blur).toBe(0);
const belowMin = normalizeBackgroundImageSettings({ opacity: 0.001, blur: 99 });
expect(belowMin.opacity).toBe(0.05);
expect(belowMin.blur).toBe(20);
});
it("falls back to the default display mode for unknown values", () => {
expect(normalizeBackgroundImageSettings({ displayMode: "diagonal" }).displayMode).toBe("fill");
expect(normalizeBackgroundImageSettings({ displayMode: 42 }).displayMode).toBe("fill");
expect(normalizeBackgroundImageSettings({ displayMode: "tile" }).displayMode).toBe("tile");
});
it("treats blank or non-string paths as unset", () => {
expect(normalizeBackgroundImageSettings({ filePath: " " }).filePath).toBeNull();
expect(normalizeBackgroundImageSettings({ filePath: 42 }).filePath).toBeNull();
expect(normalizeBackgroundImageSettings({ fileName: "" }).fileName).toBeNull();
});
});
describe("backgroundImageStyle", () => {
const url = "blob:http://localhost/abc";
it("emits nothing without an object URL", () => {
expect(backgroundImageStyle({ displayMode: "fill", blur: 0 }, null)).toEqual({});
});
it("paints fill mode as cover by default", () => {
expect(backgroundImageStyle({ displayMode: "fill", blur: 0 }, url)).toEqual({
backgroundImage: `url("${url}")`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
});
expect(backgroundImageStyle({ displayMode: "nonsense" as never, blur: 0 }, url).backgroundSize).toBe("cover");
});
it("maps the other display modes to their background properties", () => {
expect(backgroundImageStyle({ displayMode: "fit", blur: 0 }, url)).toMatchObject({ backgroundSize: "contain", backgroundRepeat: "no-repeat" });
expect(backgroundImageStyle({ displayMode: "stretch", blur: 0 }, url)).toMatchObject({ backgroundSize: "100% 100%" });
expect(backgroundImageStyle({ displayMode: "center", blur: 0 }, url)).not.toHaveProperty("backgroundSize");
expect(backgroundImageStyle({ displayMode: "tile", blur: 0 }, url)).toMatchObject({ backgroundRepeat: "repeat" });
});
it("adds blur and a slight scale to hide transparent blur edges", () => {
expect(backgroundImageStyle({ displayMode: "fill", blur: 12 }, url)).toMatchObject({
filter: "blur(12px)",
transform: "scale(1.06)",
});
});
});
describe("backgroundImageSurfaceAlpha", () => {
it("maps the surface-opacity setting 1:1 and clamps", () => {
expect(backgroundImageSurfaceAlpha({ opacity: 0.8 })).toBe(0.8);
expect(backgroundImageSurfaceAlpha({ opacity: 3 })).toBe(1);
expect(backgroundImageSurfaceAlpha({ opacity: -1 })).toBe(0.05);
});
it("exposes the root surface variables that go translucent", () => {
expect(BACKGROUND_IMAGE_SURFACE_VARS).toContain("--background");
expect(BACKGROUND_IMAGE_SURFACE_VARS).toContain("--sidebar");
expect(BACKGROUND_IMAGE_SURFACE_VARS).not.toContain("--card");
expect(BACKGROUND_IMAGE_SURFACE_VARS).not.toContain("--popover");
});
});
describe("parseCssColorTriplet", () => {
it("parses rgb and hex forms, dropping any alpha", () => {
expect(parseCssColorTriplet("rgb(17 18 20)")).toEqual([17, 18, 20]);
expect(parseCssColorTriplet("rgb(17, 18, 20)")).toEqual([17, 18, 20]);
expect(parseCssColorTriplet("rgb(17 18 20 / 0.8)")).toEqual([17, 18, 20]);
expect(parseCssColorTriplet("#131416")).toEqual([19, 20, 22]);
expect(parseCssColorTriplet("#fff")).toEqual([255, 255, 255]);
});
it("returns null for unparsable input", () => {
expect(parseCssColorTriplet("")).toBeNull();
expect(parseCssColorTriplet("red")).toBeNull();
expect(parseCssColorTriplet("#12345")).toBeNull();
});
});
describe("surfaceColorWithAlpha", () => {
it("re-emits a color with the requested alpha", () => {
expect(surfaceColorWithAlpha("rgb(19 20 22)", 0.8)).toBe("rgb(19 20 22 / 0.8)");
expect(surfaceColorWithAlpha("#131416", 0.5)).toBe("rgb(19 20 22 / 0.5)");
expect(surfaceColorWithAlpha("nope", 0.5)).toBeNull();
});
it("keeps wide-gamut and hsl notations instead of converting to rgb", () => {
expect(surfaceColorWithAlpha("oklch(0.19 0.004 285)", 0.43)).toBe("oklch(0.19 0.004 285 / 0.43)");
expect(surfaceColorWithAlpha("oklch(0.19 0.004 285 / 0.9)", 0.2)).toBe("oklch(0.19 0.004 285 / 0.2)");
expect(surfaceColorWithAlpha("hsl(240 10% 3.9%)", 0.6)).toBe("hsl(240 10% 3.9% / 0.6)");
expect(surfaceColorWithAlpha("hsl(240, 10%, 3.9%)", 0.6)).toBe("hsl(240, 10%, 3.9%, 0.6)");
expect(surfaceColorWithAlpha("240 10% 3.9%", 0.6)).toBe("hsl(240 10% 3.9% / 0.6)");
});
});
describe("BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES", () => {
it("is 20 MB", () => {
expect(BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES).toBe(20 * 1024 * 1024);
});
});
@@ -0,0 +1,176 @@
export const BACKGROUND_IMAGE_STORAGE_LIMIT_BYTES = 20 * 1024 * 1024;
export const BACKGROUND_IMAGE_MIN_OPACITY = 0.05;
export const BACKGROUND_IMAGE_MAX_OPACITY = 1;
// Surface opacity: how opaque the app surfaces render over the wallpaper.
// 1 = solid surfaces (no image visible), 0.05 = strongest wallpaper.
export const BACKGROUND_IMAGE_DEFAULT_OPACITY = 0.8;
export const BACKGROUND_IMAGE_MAX_BLUR_PX = 20;
export const BACKGROUND_IMAGE_DEFAULT_BLUR_PX = 0;
export const BACKGROUND_IMAGE_FILE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "bmp", "gif"] as const;
/** Classic desktop-wallpaper presets; default "fill" crops to cover the window. */
export const BACKGROUND_IMAGE_DISPLAY_MODES = ["fill", "fit", "stretch", "center", "tile"] as const;
export type BackgroundImageDisplayMode = (typeof BACKGROUND_IMAGE_DISPLAY_MODES)[number];
export const BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE: BackgroundImageDisplayMode = "fill";
export type BackgroundImageSettings = {
filePath: string | null;
fileName: string | null;
opacity: number;
blur: number;
displayMode: BackgroundImageDisplayMode;
};
export function defaultBackgroundImageSettings(): BackgroundImageSettings {
return {
filePath: null,
fileName: null,
opacity: BACKGROUND_IMAGE_DEFAULT_OPACITY,
blur: BACKGROUND_IMAGE_DEFAULT_BLUR_PX,
displayMode: BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE,
};
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
function normalizeOptionalText(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeBackgroundImageDisplayMode(value: unknown): BackgroundImageDisplayMode {
return (BACKGROUND_IMAGE_DISPLAY_MODES as readonly string[]).includes(value as string) ? (value as BackgroundImageDisplayMode) : BACKGROUND_IMAGE_DEFAULT_DISPLAY_MODE;
}
export function normalizeBackgroundImageSettings(value: unknown): BackgroundImageSettings {
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
return {
filePath: normalizeOptionalText(source.filePath),
fileName: normalizeOptionalText(source.fileName),
opacity: clampNumber(source.opacity, BACKGROUND_IMAGE_MIN_OPACITY, BACKGROUND_IMAGE_MAX_OPACITY, BACKGROUND_IMAGE_DEFAULT_OPACITY),
blur: clampNumber(source.blur, 0, BACKGROUND_IMAGE_MAX_BLUR_PX, BACKGROUND_IMAGE_DEFAULT_BLUR_PX),
displayMode: normalizeBackgroundImageDisplayMode(source.displayMode),
};
}
/** Extension (lowercase, no dot) for dialog filters and stored-copy naming; null when the name has none. */
export function backgroundImageFileExtension(fileName: string): string | null {
const match = /\.([a-z0-9]+)$/i.exec(fileName.trim());
if (!match) return null;
const extension = match[1].toLowerCase();
return (BACKGROUND_IMAGE_FILE_EXTENSIONS as readonly string[]).includes(extension) ? extension : null;
}
/**
* Paint style for the wallpaper layer. Modes follow the classic desktop
* wallpaper presets: fill/fit/stretch scale the image, center paints it at
* its natural size, tile repeats it. Blur always scales the layer slightly
* so blurred edges never reveal transparent margins.
*/
export function backgroundImageStyle(settings: Pick<BackgroundImageSettings, "displayMode" | "blur">, objectUrl: string | null): Record<string, string> {
if (!objectUrl) return {};
const style: Record<string, string> = { backgroundImage: `url("${objectUrl}")` };
switch (settings.displayMode) {
case "fit":
style.backgroundSize = "contain";
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
case "stretch":
style.backgroundSize = "100% 100%";
style.backgroundRepeat = "no-repeat";
break;
case "center":
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
case "tile":
style.backgroundRepeat = "repeat";
break;
case "fill":
default:
style.backgroundSize = "cover";
style.backgroundPosition = "center";
style.backgroundRepeat = "no-repeat";
break;
}
if (settings.blur > 0) {
style.filter = `blur(${settings.blur}px)`;
style.transform = "scale(1.06)";
}
return style;
}
/**
* Root surface variables that become translucent while a wallpaper is active.
* Card/popover/dialog surfaces stay opaque on purpose: floating panels keep
* their text readable regardless of what the wallpaper shows behind them.
*/
export const BACKGROUND_IMAGE_SURFACE_VARS = ["--background", "--sidebar", "--muted", "--secondary", "--accent", "--dbx-chrome", "--dbx-chrome-muted", "--dbx-content", "--dbx-editor-toolbar", "--dbx-gutter", "--dbx-sidebar-header"] as const;
/** Alpha for the surface variables derived from the surface-opacity setting. */
export function backgroundImageSurfaceAlpha(settings: Pick<BackgroundImageSettings, "opacity">): number {
return clampNumber(settings.opacity, BACKGROUND_IMAGE_MIN_OPACITY, BACKGROUND_IMAGE_MAX_OPACITY, BACKGROUND_IMAGE_DEFAULT_OPACITY);
}
/** Parses "#rgb", "#rrggbb", "rgb(r g b)", "rgb(r, g, b)" and "rgb(r g b / a)" into an rgb triplet; null when unparsable. */
export function parseCssColorTriplet(value: string): [number, number, number] | null {
const trimmed = value.trim();
if (!trimmed) return null;
const rgbMatch = /^rgba?\((\d+)\s*,?\s+(\d+)\s*,?\s+(\d+)(?:\s*\/\s*[\d.]+)?\)$/i.exec(trimmed);
if (rgbMatch) return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])];
const hex = trimmed.startsWith("#") ? trimmed.slice(1) : null;
if (hex && (hex.length === 3 || hex.length === 6)) {
const expand =
hex.length === 3
? hex
.split("")
.map((char) => char + char)
.join("")
: hex;
return [parseInt(expand.slice(0, 2), 16), parseInt(expand.slice(2, 4), 16), parseInt(expand.slice(4, 6), 16)];
}
return null;
}
/**
* Re-emit a surface color with the given alpha, e.g. "rgb(17 18 20 / 0.8)".
* Keeps the source notation when possible: wide-gamut palettes declare their
* tokens in oklch (see the @supports block in globals.css) and converting them
* to rgb would clip them; hsl stays hsl, oklch stays oklch, hex/rgb become rgb.
* Also accepts raw "H S% L%" channel triplets as used by shadcn-style themes.
*/
export function surfaceColorWithAlpha(color: string, alpha: number): string | null {
const trimmed = color.trim();
if (!trimmed) return null;
const fnMatch = /^([a-zA-Z-]+)\(([^()]+)\)$/.exec(trimmed);
if (fnMatch) {
const fn = fnMatch[1].toLowerCase();
const inner = fnMatch[2].trim();
if (fn === "rgb" || fn === "rgba") {
const triplet = parseCssColorTriplet(trimmed);
return triplet ? `rgb(${triplet[0]} ${triplet[1]} ${triplet[2]} / ${alpha})` : null;
}
if (inner.includes(",")) {
const parts = inner.split(",").map((part) => part.trim());
if (parts.length !== 3 || parts.some((part) => !part)) return null;
return `${fn}(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
}
const base = inner.split("/")[0].trim();
if (!base) return null;
return `${fn}(${base} / ${alpha})`;
}
if (trimmed.startsWith("#")) {
const triplet = parseCssColorTriplet(trimmed);
return triplet ? `rgb(${triplet[0]} ${triplet[1]} ${triplet[2]} / ${alpha})` : null;
}
if (/^[\d.]+%?\s+[\d.]+%?\s+[\d.]+%?$/.test(trimmed)) {
return `hsl(${trimmed} / ${alpha})`;
}
return null;
}
+4
View File
@@ -326,6 +326,10 @@ export const loadPinnedTreeNodeIds = forward("loadPinnedTreeNodeIds");
export const savePinnedTreeNodeIds = forward("savePinnedTreeNodeIds");
export const loadEditorSettings = forward("loadEditorSettings");
export const saveEditorSettings = forward("saveEditorSettings");
export const saveBackgroundImage = forward("saveBackgroundImage");
export const clearBackgroundImage = forward("clearBackgroundImage");
export const readBackgroundImage = forward("readBackgroundImage");
export const checkBackgroundImage = forward("checkBackgroundImage");
export const loadOpenTabsState = forward("loadOpenTabsState");
export const saveOpenTabsState = forward("saveOpenTabsState");
export const saveDetachedTabHandoff = forward("saveDetachedTabHandoff");
+22
View File
@@ -1881,6 +1881,28 @@ export async function saveEditorSettings(settings: unknown): Promise<void> {
await saveBrowserAppState("editor_settings", settings);
}
// Background images are desktop-only: the web backend has no local data dir.
export interface BackgroundImageInfo {
storedPath: string;
fileName: string;
}
export async function saveBackgroundImage(_sourcePath: string): Promise<BackgroundImageInfo> {
throw new Error("Background images are only supported in the desktop app");
}
export async function clearBackgroundImage(_storedPath: string): Promise<void> {
throw new Error("Background images are only supported in the desktop app");
}
export async function readBackgroundImage(_storedPath: string): Promise<string> {
throw new Error("Background images are only supported in the desktop app");
}
export async function checkBackgroundImage(_storedPath: string): Promise<boolean> {
throw new Error("Background images are only supported in the desktop app");
}
export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null> {
const value = await loadBrowserAppState("open_tabs");
if (!value || typeof value !== "object") return null;
+21
View File
@@ -740,6 +740,27 @@ export async function saveEditorSettings(settings: unknown): Promise<void> {
return invoke("save_editor_settings", { settings });
}
export interface BackgroundImageInfo {
storedPath: string;
fileName: string;
}
export async function saveBackgroundImage(sourcePath: string): Promise<BackgroundImageInfo> {
return invoke("save_background_image", { sourcePath });
}
export async function clearBackgroundImage(storedPath: string): Promise<void> {
return invoke("clear_background_image", { storedPath });
}
export async function readBackgroundImage(storedPath: string): Promise<string> {
return invoke("read_background_image", { storedPath });
}
export async function checkBackgroundImage(storedPath: string): Promise<boolean> {
return invoke("check_background_image", { storedPath });
}
export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null> {
return invoke("load_open_tabs_state");
}
@@ -179,6 +179,18 @@ describe("settingsTransfer", () => {
expect(transferCategoryForKey("notARealSetting")).toBeUndefined();
});
it("round-trips the wallpaper settings under the appearance category", () => {
const settings: EditorSettings = {
...DEFAULT_EDITOR_SETTINGS,
backgroundImage: { ...DEFAULT_EDITOR_SETTINGS.backgroundImage, filePath: "/data/background-image.png", fileName: "wall.png", opacity: 0.42 },
};
const result = parseSettingsTransferFile(serializeSettingsTransfer(settings));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.editorSettings.backgroundImage).toEqual(settings.backgroundImage);
expect(transferCategoryForKey("backgroundImage")).toBe("appearance");
});
it("collects categories in display order without duplicates", () => {
expect(collectTransferCategories(["snippets", "theme", "shortcuts", "fontFamily"])).toEqual(["appearance", "shortcuts", "snippets"]);
});
@@ -1,4 +1,5 @@
import type { EditorSettings } from "@/stores/settingsStore";
import { normalizeBackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
import { normalizeCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy";
@@ -11,6 +12,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"uiFontFamily",
"uiScale",
"theme",
"backgroundImage",
"customThemes",
"activeCustomThemeId",
"executeMode",
@@ -131,6 +133,7 @@ function normalizedDraftValue(key: EditorSettingsDraftKey, value: unknown): unkn
if (key === "queryResultMaxRows") return normalizeQueryResultMaxRowsDraft(value);
if (key === "completionTriggerMode") return normalizeCompletionTriggerMode(value);
if (key === "redisKeyTemplates") return normalizeRedisKeyTemplates(value);
if (key === "backgroundImage") return normalizeBackgroundImageSettings(value);
return value;
}
@@ -71,7 +71,7 @@ export function serializeSettingsTransfer(settings: EditorSettings, meta: Settin
const SETTINGS_TRANSFER_CATEGORY_ORDER: readonly SettingsTransferCategoryId[] = ["appearance", "editor", "formatter", "navigation", "data", "shortcuts", "snippets", "other"];
const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readonly EditorSettingsDraftKey[]> = {
appearance: ["fontFamily", "fontSize", "tableFontFamily", "uiFontFamily", "uiScale", "theme", "customThemes", "activeCustomThemeId", "toolbarItems", "updateNotificationsEnabled"],
appearance: ["fontFamily", "fontSize", "tableFontFamily", "uiFontFamily", "uiScale", "theme", "customThemes", "activeCustomThemeId", "backgroundImage", "toolbarItems", "updateNotificationsEnabled"],
editor: [
"executeMode",
"defaultTransactionMode",
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_EDITOR_SETTINGS, normalizeEditorSettings } from "@/stores/settingsStore";
import { defaultBackgroundImageSettings } from "@/lib/app/appBackgroundImage";
describe("normalizeEditorSettings backgroundImage", () => {
it("merges default background image settings for legacy configs missing the field", () => {
const normalized = normalizeEditorSettings({ fontFamily: "'Fira Code', monospace" } as never);
expect(normalized.backgroundImage).toEqual(defaultBackgroundImageSettings());
expect(normalized.fontFamily).toBe("'Fira Code', monospace");
});
it("keeps configured background image values and clamps invalid ones", () => {
const normalized = normalizeEditorSettings({
backgroundImage: { filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.4, blur: 6, displayMode: "tile" },
} as never);
expect(normalized.backgroundImage).toEqual({ filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.4, blur: 6, displayMode: "tile" });
const clamped = normalizeEditorSettings({ backgroundImage: { opacity: 12, blur: -1 } } as never);
expect(clamped.backgroundImage.opacity).toBe(1);
expect(clamped.backgroundImage.blur).toBe(0);
});
it("drops unknown fields from stored settings (showOn* from the removed scoped mode)", () => {
const normalized = normalizeEditorSettings({
backgroundImage: { filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.5, blur: 0, showOnWelcome: true, showOnEditor: true },
} as never);
expect(normalized.backgroundImage).toEqual({ filePath: "/data/background-image.jpg", fileName: "a.jpg", opacity: 0.5, blur: 0, displayMode: "fill" });
});
it("uses default settings for a brand-new store baseline", () => {
expect(DEFAULT_EDITOR_SETTINGS.backgroundImage).toEqual(defaultBackgroundImageSettings());
});
});
+5
View File
@@ -2,6 +2,7 @@ import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { aiConfigToItem, generateId, getConfigKey } from "@/lib/ai/aiConfigList";
import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
import { defaultBackgroundImageSettings, normalizeBackgroundImageSettings, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage";
import * as api from "@/lib/backend/api";
import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
@@ -724,6 +725,7 @@ export interface EditorSettings {
uiFontFamily: string;
uiScale: number;
theme: EditorTheme;
backgroundImage: BackgroundImageSettings;
customThemeColors: CustomThemeColors;
customThemes: CustomTheme[];
activeCustomThemeId: string;
@@ -956,6 +958,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
uiFontFamily: DEFAULT_UI_FONT_FAMILY,
uiScale: 1,
theme: "app",
backgroundImage: defaultBackgroundImageSettings(),
customThemeColors: { ...DEFAULT_CUSTOM_THEME_COLORS },
customThemes: [...DEFAULT_CUSTOM_THEMES],
activeCustomThemeId: "default",
@@ -1586,6 +1589,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
clickTableNavigationTarget: normalizeClickTableNavigationTarget(settings.clickTableNavigationTarget),
completionTriggerMode: normalizeCompletionTriggerMode(settings.completionTriggerMode),
defaultTransactionMode: normalizeDefaultTransactionMode(settings.defaultTransactionMode),
backgroundImage: normalizeBackgroundImageSettings(settings.backgroundImage),
};
}
@@ -2089,6 +2093,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.fontSize !== undefined) editorSettings.value.fontSize = partial.fontSize;
if (partial.uiFontFamily !== undefined) editorSettings.value.uiFontFamily = normalizeFontFamily(partial.uiFontFamily, DEFAULT_EDITOR_SETTINGS.uiFontFamily);
if (partial.uiScale !== undefined) editorSettings.value.uiScale = normalizeUiScale(partial.uiScale);
if (partial.backgroundImage !== undefined) editorSettings.value.backgroundImage = normalizeBackgroundImageSettings(partial.backgroundImage);
if (partial.theme !== undefined) editorSettings.value.theme = partial.theme;
if (partial.customThemeColors !== undefined) {
editorSettings.value.customThemeColors = {
+10
View File
@@ -2762,3 +2762,13 @@ html.theme-vscode .app-tab-scroll.wrap-mode .app-tab-pill[data-active-tab="false
background-color: color-mix(in oklab, var(--destructive) 30%, transparent);
}
}
/* Wallpaper mode (html.dbx-bg-active is toggled by useBackgroundImage):
CodeMirror themes paint opaque editor backgrounds via EditorView.theme, so
clear them app-wide to let the background image show through. Active line,
selection, and search-highlight layers keep their own translucent colors
and stay readable on top. */
html.dbx-bg-active .cm-editor,
html.dbx-bg-active .cm-gutters {
background-color: transparent !important;
}
+191
View File
@@ -0,0 +1,191 @@
use std::path::{Path, PathBuf};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
const BACKGROUND_IMAGE_BASE_NAME: &str = "background-image";
pub(crate) const BACKGROUND_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024;
const SUPPORTED_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "webp", "bmp", "gif"];
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundImageInfo {
pub stored_path: String,
pub file_name: String,
}
fn background_image_extension(source: &Path) -> Result<String, String> {
let extension = source
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
.ok_or_else(|| "unsupported background image: missing file extension".to_string())?;
if SUPPORTED_EXTENSIONS.contains(&extension.as_str()) {
Ok(extension)
} else {
Err(format!("unsupported background image extension: {extension} (allowed: png, jpg, jpeg, webp, bmp, gif)"))
}
}
fn background_image_path(data_dir: &Path, extension: &str) -> PathBuf {
data_dir.join(format!("{BACKGROUND_IMAGE_BASE_NAME}.{extension}"))
}
fn is_background_image_path(path: &Path) -> bool {
path.file_name().and_then(|value| value.to_str()).is_some_and(|name| name.starts_with(BACKGROUND_IMAGE_BASE_NAME))
}
fn remove_stale_background_images(data_dir: &Path, keep_extension: Option<&str>) {
for extension in SUPPORTED_EXTENSIONS {
if Some(extension) == keep_extension {
continue;
}
let candidate = background_image_path(data_dir, extension);
if candidate.exists() {
if let Err(error) = std::fs::remove_file(&candidate) {
log::warn!("Failed to remove stale background image {}: {error}", candidate.display());
}
}
}
}
fn copy_background_image_into(data_dir: &Path, source: &Path, extension: &str) -> Result<PathBuf, String> {
let metadata =
std::fs::metadata(source).map_err(|error| format!("failed to read background image metadata: {error}"))?;
if metadata.len() > BACKGROUND_IMAGE_MAX_BYTES {
return Err("background image exceeds the 20 MB size limit".to_string());
}
let target = background_image_path(data_dir, extension);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|error| format!("failed to create data dir: {error}"))?;
}
std::fs::copy(source, &target).map_err(|error| format!("failed to copy background image: {error}"))?;
remove_stale_background_images(data_dir, Some(extension));
Ok(target)
}
/// Copies the picked image into the resolved data dir so the background
/// survives the original file being moved or deleted. The data dir must be
/// resolved here (portable mode / DBX_DATA_DIR override) — the frontend only
/// knows the default app data dir.
#[tauri::command]
pub async fn save_background_image(app: AppHandle, source_path: String) -> Result<BackgroundImageInfo, String> {
let source = PathBuf::from(&source_path);
let extension = background_image_extension(&source)?;
let file_name = source.file_name().and_then(|value| value.to_str()).unwrap_or("background image").to_string();
let default_data_dir = app.path().app_data_dir().map_err(|error| error.to_string())?;
let data_dir = crate::data_dir::resolve_data_dir_with_mode(default_data_dir).data_dir;
let stored_path =
tauri::async_runtime::spawn_blocking(move || copy_background_image_into(&data_dir, &source, &extension))
.await
.map_err(|error| format!("background image copy task failed: {error}"))??;
Ok(BackgroundImageInfo { stored_path: stored_path.to_string_lossy().to_string(), file_name })
}
#[tauri::command]
pub async fn clear_background_image(stored_path: String) -> Result<(), String> {
let path = PathBuf::from(&stored_path);
// The stored path always points at <data_dir>/background-image.<ext>; the
// file-name guard keeps a corrupted config from deleting arbitrary files.
if !is_background_image_path(&path) {
return Err("refusing to clear unexpected background image path".to_string());
}
if path.exists() {
std::fs::remove_file(&path).map_err(|error| format!("failed to remove background image: {error}"))?;
}
Ok(())
}
/// Returns the stored copy's bytes as base64. Reads go through an app command
/// because the fs plugin's granted scopes don't reliably cover the resolved
/// data dir (fs:allow-read-file ships without path scopes).
#[tauri::command]
pub async fn read_background_image(stored_path: String) -> Result<String, String> {
let path = PathBuf::from(&stored_path);
if !is_background_image_path(&path) {
return Err("refusing to read unexpected background image path".to_string());
}
tauri::async_runtime::spawn_blocking(move || {
let bytes = std::fs::read(&path).map_err(|error| format!("failed to read background image: {error}"))?;
Ok(BASE64_STANDARD.encode(bytes))
})
.await
.map_err(|error| format!("background image read task failed: {error}"))?
}
/// Existence probe for the settings dialog's stale-file hint.
#[tauri::command]
pub async fn check_background_image(stored_path: String) -> Result<bool, String> {
let path = PathBuf::from(&stored_path);
if !is_background_image_path(&path) {
return Ok(false);
}
Ok(path.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!("dbx-tauri-{label}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn background_image_extension_accepts_supported_and_rejects_others() {
assert_eq!(background_image_extension(Path::new("/tmp/wall.png")).unwrap(), "png");
assert_eq!(background_image_extension(Path::new("C:\\pic.JPG")).unwrap(), "jpg");
assert!(background_image_extension(Path::new("no-extension")).is_err());
assert!(background_image_extension(Path::new("vector.svg")).is_err());
}
#[test]
fn copy_keeps_current_extension_and_cleans_stale_copies() {
let temp = TempDir::new("background-image-copy");
let data_dir = temp.0.join("data");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::write(background_image_path(&data_dir, "png"), b"old").unwrap();
let source = temp.0.join("fresh.jpg");
std::fs::write(&source, b"new").unwrap();
let stored = copy_background_image_into(&data_dir, &source, "jpg").unwrap();
assert_eq!(stored, background_image_path(&data_dir, "jpg"));
assert!(stored.exists());
assert!(!background_image_path(&data_dir, "png").exists());
}
#[test]
fn copy_rejects_oversized_images() {
let temp = TempDir::new("background-image-size");
let source = temp.0.join("big.png");
std::fs::write(&source, vec![0u8; BACKGROUND_IMAGE_MAX_BYTES as usize + 1]).unwrap();
let error = copy_background_image_into(&temp.0, &source, "png").unwrap_err();
assert!(error.contains("20 MB"));
assert!(!background_image_path(&temp.0, "png").exists());
}
#[test]
fn clear_guard_rejects_unexpected_file_names() {
assert!(is_background_image_path(Path::new("/data/background-image.png")));
assert!(!is_background_image_path(Path::new("/data/important.db")));
assert!(!is_background_image_path(Path::new("/data/dbx.db")));
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod agents;
pub mod ai;
pub mod ai_multi_config;
pub mod app_settings;
pub mod background_image;
pub mod cloud_sync;
pub mod config_cmd;
pub mod connection;
+4
View File
@@ -1736,6 +1736,10 @@ pub fn run() {
commands::app_settings::save_pinned_tree_node_ids,
commands::app_settings::load_mcp_global_policy,
commands::app_settings::save_mcp_global_policy,
commands::background_image::save_background_image,
commands::background_image::clear_background_image,
commands::background_image::read_background_image,
commands::background_image::check_background_image,
commands::mcp_http_server::load_mcp_http_server_settings,
commands::mcp_http_server::save_mcp_http_server_settings,
commands::mcp_http_server::mcp_http_server_status,