Skip to content
Open
47 changes: 47 additions & 0 deletions src/main/ipc/settings.ipc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ipcMain, nativeTheme, BrowserWindow, shell, dialog } from "electron";
import Store from "electron-store";
import {
AppearanceConfigSchema,
type AppearanceConfig,
type Config,
type EAConfig,
type IpcResponse,
Expand Down Expand Up @@ -657,6 +659,51 @@ export function registerSettingsIpc(): void {
},
);

// Get appearance config
ipcMain.handle("appearance:get", async (): Promise<IpcResponse<AppearanceConfig>> => {
try {
const config = getConfig();
return {
success: true,
data: config.appearance ?? AppearanceConfigSchema.parse({}),
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
});

// Set appearance config — saves to store, broadcasts to renderer
ipcMain.handle(
"appearance:set",
async (_, rawAppearance: unknown): Promise<IpcResponse<void>> => {
try {
const parsed = AppearanceConfigSchema.safeParse(rawAppearance);
if (!parsed.success) {
return { success: false, error: `Invalid appearance config: ${parsed.error.message}` };
}
const appearance = parsed.data;

const currentConfig = getConfig();
getStore().set("config", { ...currentConfig, appearance });

// Broadcast to all renderer windows
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send("appearance:changed", appearance);
}

return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
);
Comment on lines +679 to +705

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing boundary validation in appearance:set

The handler trusts the renderer-supplied appearance object without parsing it through AppearanceConfigSchema. Per project guidelines ("validate at boundaries"), an invalid or extra field (e.g. an injected __proto__ key or a future incompatible preset string) would be written verbatim to the electron-store. A safeParse guard makes the boundary explicit:

Suggested change
ipcMain.handle(
"appearance:set",
async (_, appearance: AppearanceConfig): Promise<IpcResponse<void>> => {
try {
const currentConfig = getConfig();
getStore().set("config", { ...currentConfig, appearance });
// Broadcast to all renderer windows
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send("appearance:changed", appearance);
}
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
);
ipcMain.handle(
"appearance:set",
async (_, raw: unknown): Promise<IpcResponse<void>> => {
try {
const parsed = AppearanceConfigSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: "Invalid appearance config" };
}
const appearance = parsed.data;
const currentConfig = getConfig();
getStore().set("config", { ...currentConfig, appearance });
// Broadcast to all renderer windows
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send("appearance:changed", appearance);
}
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in commit 046732a — the handler now uses AppearanceConfigSchema.safeParse(rawAppearance) and returns an error on invalid config. See settings.ipc.ts:683-686.


// Test OpenClaw connection by running `openclaw health`
ipcMain.handle("settings:test-openclaw-connection", async (): Promise<IpcResponse<void>> => {
const { execFile } = await import("node:child_process");
Expand Down
16 changes: 16 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,22 @@ const api = {
},
},

// Appearance customization
appearance: {
get: (): Promise<unknown> => ipcRenderer.invoke("appearance:get"),
set: (config: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke("appearance:set", config),
onChange: (callback: (data: Record<string, unknown>) => void): void => {
ipcRenderer.on(
"appearance:changed",
(_: Electron.IpcRendererEvent, data: Record<string, unknown>) => callback(data),
);
},
removeAllListeners: (): void => {
ipcRenderer.removeAllListeners("appearance:changed");
},
},

// Auth events (token expiry, extension re-auth)
auth: {
onTokenExpired: (
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { DraftEditLearnedToast } from "./components/DraftEditLearnedToast";
import { AnalysisOverrideLearnedToast } from "./components/AnalysisOverrideLearnedToast";
import { SnoozeMenu } from "./components/SnoozeMenu";
import { FindBar } from "./components/FindBar";
import { useAppearance } from "./hooks/useAppearance";
import { registerBundledExtensions } from "./extensions";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import {
Expand Down Expand Up @@ -665,6 +666,9 @@ export default function App() {
syncProgress,
} = useAppStore();

// Apply theme CSS variables (presets, accent, vibrancy, font scale)
useAppearance();

// Initialize keyboard shortcuts
useKeyboardShortcuts({
onToggleShortcutHelp: () => setShowShortcuts((prev) => !prev),
Expand Down
217 changes: 176 additions & 41 deletions src/renderer/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import {
type McpServerConfig,
type ModelConfig,
type ModelTier,
type AppearanceConfig,
type CliToolConfig,
} from "../../shared/types";
import { THEME_PRESET_LIST, ACCENT_SWATCHES } from "../../shared/theme-presets";
import { useAppStore, type Account, type SettingsTab } from "../store";
import { reconfigurePostHog, trackEvent } from "../services/posthog";
import { SplitConfigEditor } from "./SplitConfigEditor";
Expand Down Expand Up @@ -51,6 +53,8 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) {
setUndoSendDelay,
currentAccountId,
highlightMemoryIds,
appearance,
setAppearance,
} = useAppStore();
const [isAddingAccount, setIsAddingAccount] = useState(false);
const [addAccountPhase, setAddAccountPhase] = useState("Connecting...");
Expand Down Expand Up @@ -353,6 +357,18 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) {
}
};

// Update appearance — applies locally + persists via IPC
const updateAppearance = async (patch: Partial<AppearanceConfig>) => {
const updated = { ...appearance, ...patch };
setAppearance(updated);
await window.api.appearance.set(updated);
};

// Live preview only (no IPC) — used during color picker drag
const previewAppearance = (patch: Partial<AppearanceConfig>) => {
setAppearance({ ...appearance, ...patch });
};

const handleDensityChange = async (density: InboxDensity) => {
setInboxDensity(density);
await window.api.settings.set({ inboxDensity: density });
Expand Down Expand Up @@ -852,48 +868,153 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) {
Configure how Exo generates draft replies.
</p>

{/* Appearance / Theme Toggle */}
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-600 mb-6">
<div className="mb-3">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">Appearance</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Choose your preferred color theme.
{/* Appearance */}
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-600 mb-6 space-y-5">
{/* Light / Dark / System toggle */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Mode</h3>
<div className="flex space-x-2">
{(["light", "dark", "system"] as const).map((mode) => (
<button
key={mode}
onClick={() => handleThemeChange(mode)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors capitalize ${
themePreference === mode
? "bg-blue-600 dark:bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
{mode}
</button>
))}
</div>
</div>

{/* Theme presets */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Theme</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Color palettes for surfaces and accents.
</p>
<div className="flex space-x-3">
{THEME_PRESET_LIST.map((preset) => (
<button
key={preset.id}
onClick={() => updateAppearance({ themePreset: preset.id })}
title={preset.name}
className={`flex flex-col items-center gap-1.5 group`}
>
{/* Two-tone swatch: surface + accent */}
<div
className={`w-10 h-10 rounded-full overflow-hidden border-2 transition-all ${
appearance.themePreset === preset.id
? "border-blue-500 ring-2 ring-blue-500/30 scale-110"
: "border-gray-300 dark:border-gray-600 group-hover:border-gray-400"
}`}
>
<div
className="w-full h-1/2"
style={{ backgroundColor: preset.preview.surface }}
/>
<div
className="w-full h-1/2"
style={{ backgroundColor: preset.preview.accent }}
/>
</div>
<span className="text-xs text-gray-600 dark:text-gray-400">
{preset.name}
</span>
</button>
))}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleThemeChange("light")}
data-active={themePreference === "light" ? "true" : undefined}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
themePreference === "light"
? "bg-blue-600 dark:bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
Light
</button>
<button
onClick={() => handleThemeChange("dark")}
data-active={themePreference === "dark" ? "true" : undefined}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
themePreference === "dark"
? "bg-blue-600 dark:bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
Dark
</button>
<button
onClick={() => handleThemeChange("system")}
data-active={themePreference === "system" ? "true" : undefined}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
themePreference === "system"
? "bg-blue-600 dark:bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
System
</button>

{/* Accent color picker */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
Accent Color
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Override the theme accent with a custom color.
</p>
<div className="flex items-center space-x-2">
{/* "Auto" = use preset default */}
<button
onClick={() => updateAppearance({ accentColor: null })}
title="Use theme default"
className={`w-8 h-8 rounded-full border-2 transition-all flex items-center justify-center text-xs font-medium ${
!appearance.accentColor
? "border-blue-500 ring-2 ring-blue-500/30"
: "border-gray-300 dark:border-gray-600 hover:border-gray-400"
}`}
>
<span className="text-gray-500 dark:text-gray-400">A</span>
</button>

{/* Preset accent swatches */}
{ACCENT_SWATCHES.map((swatch) => (
<button
key={swatch.hex}
onClick={() => updateAppearance({ accentColor: swatch.hex })}
title={swatch.name}
className={`w-8 h-8 rounded-full border-2 transition-all ${
appearance.accentColor === swatch.hex
? "border-blue-500 ring-2 ring-blue-500/30 scale-110"
: "border-gray-300 dark:border-gray-600 hover:border-gray-400"
}`}
style={{ backgroundColor: swatch.hex }}
/>
))}

{/* Custom color input */}
<label
title="Custom color"
className={`relative w-8 h-8 rounded-full border-2 transition-all cursor-pointer overflow-hidden ${
appearance.accentColor &&
!ACCENT_SWATCHES.some((s) => s.hex === appearance.accentColor)
? "border-blue-500 ring-2 ring-blue-500/30"
: "border-gray-300 dark:border-gray-600 hover:border-gray-400"
}`}
style={{
backgroundColor:
appearance.accentColor &&
!ACCENT_SWATCHES.some((s) => s.hex === appearance.accentColor)
? appearance.accentColor
: undefined,
}}
>
<input
type="color"
value={appearance.accentColor ?? "#2563eb"}
onInput={(e) =>
previewAppearance({
accentColor: (e.target as HTMLInputElement).value,
})
}
onChange={(e) => updateAppearance({ accentColor: e.target.value })}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
{/* Color wheel icon when no custom color is active */}
{(!appearance.accentColor ||
ACCENT_SWATCHES.some((s) => s.hex === appearance.accentColor)) && (
<div className="absolute inset-0 flex items-center justify-center">
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
</div>
)}
</label>
</div>
</div>
</div>

Expand Down Expand Up @@ -1404,7 +1525,21 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) {

{accountError && (
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300 px-4 py-3 rounded-lg mb-4">
{accountError}
<p>{accountError}</p>
{accountError.includes("Access denied") && (
<p className="mt-2 text-xs">
Add your email as a test user in{" "}
<a
href="https://console.cloud.google.com/auth/audience"
target="_blank"
rel="noreferrer"
className="underline font-medium"
>
Google Cloud Console → Audience
</a>
, then try again.
</p>
)}
</div>
)}

Expand Down
Loading