Skip to content

Commit 3bda56c

Browse files
pmaxhoganclaude
andcommitted
fix(ui): settings error handling, dark titlebar, and activity labels
Fixes found while driving the live dev build against a real Drive backend: settings (HIGH): a plausible out-of-range value (e.g. 100 concurrent uploads, a 10s scan interval) BRICKED the Rules tab. The settings store set `error = String(e)` on the structured IPC error, which stringifies to the literal "[object Object]", and the template's v-else-if chain replaced the ENTIRE form with it - unrecoverable until an app restart, plus an unhandled promise rejection. - store now stores a stable SPEC s24 CODE (toErrorCode), never String(e) - the Rules form stays mounted; a rejected patch shows as an inline, localized banner instead of replacing the form - numeric fields are clamped to the backend ranges (concurrency 1..32, scan 30..604800, deep-verify 3600..31536000, bandwidth 1..100000, hook 1..86400) BEFORE patching, so a typed out-of-range value is corrected in place and never round-trips to a backend rejection; input min/max aligned to match - all Rules commits route through a wrapper that swallows the (now-rare) rejection after the store records it, killing the unhandled-rejection Vue warning error localization: AddSourceWizard surfaced IPC failures via `String(e)` too (same "[object Object]"/raw-English risk); it now uses the toErrorCode + t(`errors.${code}.long`) pattern like the rest of the app. dark titlebar: the native window titlebar + border inherited the user's Windows ACCENT color (a per-machine teal/blue that clashed with the theme). Force a dark caption + neutral border + immersive dark mode via DwmSetWindowAttribute before the first show, so the chrome always matches Driven's dark theme. Win10 ignores the color attrs (graceful no-op). activity labels: deep_verify_done / update_applied rendered as raw snake_case in the Activity table + filter while upload_done showed "Uploaded"; add the missing activity.events.* labels so every backend event type is humanized. All verified live in a cargo tauri dev build (CDP): 100 concurrent uploads clamps to 32 with the form intact and no console errors; the titlebar is dark; the event filter reads "Deep verify complete" / "App updated". ui suite 207 + vue-tsc + eslint + prettier green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZQh3ZfwtZsM6c5qnTuWZP
1 parent 0057c3a commit 3bda56c

9 files changed

Lines changed: 304 additions & 62 deletions

File tree

src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ windows-sys = { version = "0.61", features = [
119119
# `CreateFileW` (and the other handle-open APIs) take a `SECURITY_ATTRIBUTES`
120120
# pointer and are gated behind `Win32_Security` in windows-sys 0.59.
121121
"Win32_Security",
122+
# DwmSetWindowAttribute: force a dark titlebar/border so the native window
123+
# chrome matches Driven's dark theme instead of the user's Windows accent.
124+
"Win32_Graphics_Dwm",
122125
] }
123126

124127
[dev-dependencies]

src-tauri/src/lib.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,59 @@ fn state_db_path(app: &tauri::AppHandle) -> anyhow::Result<PathBuf> {
8888
Ok(config_dir.join("driven").join("state.db"))
8989
}
9090

91+
/// Force a dark titlebar + window border on Windows so the native chrome matches
92+
/// Driven's dark theme. Without this the caption + border inherit the user's
93+
/// Windows ACCENT color ("show accent color on title bars and window borders"),
94+
/// which renders the chrome in an arbitrary per-machine color that clashes with
95+
/// the teal/dark UI. We set immersive dark mode (light caption text/buttons) plus
96+
/// a near-black caption and a subtle neutral border via the DWM window attributes.
97+
/// Win10 ignores the color attributes (graceful no-op); Win11 honors them. Applied
98+
/// before the first `show()` so there is no flash of accent-colored chrome.
99+
#[cfg(windows)]
100+
fn apply_dark_titlebar(window: &tauri::WebviewWindow) {
101+
use std::mem::size_of;
102+
use windows_sys::Win32::Foundation::HWND;
103+
use windows_sys::Win32::Graphics::Dwm::DwmSetWindowAttribute;
104+
105+
// DWMWINDOWATTRIBUTE values (windows-sys takes the attribute as a u32).
106+
// Spelled out as literals so we depend only on DwmSetWindowAttribute, not the
107+
// constant set.
108+
const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20;
109+
const DWMWA_BORDER_COLOR: u32 = 34;
110+
const DWMWA_CAPTION_COLOR: u32 = 35;
111+
112+
let hwnd = match window.hwnd() {
113+
Ok(h) => h.0 as isize as HWND,
114+
Err(_) => return,
115+
};
116+
// COLORREF byte order is 0x00BBGGRR.
117+
let caption: u32 = 0x000b_0909; // #09090b (zinc-950) - the app's dark surface
118+
let border: u32 = 0x0046_3f3f; // #3f3f46 (zinc-700) - a subtle neutral edge
119+
let dark: i32 = 1; // BOOL TRUE -> light caption text + window buttons
120+
// SAFETY: hwnd is a live top-level window handle for the duration of the call;
121+
// each pointer references a stack value valid across the synchronous call.
122+
unsafe {
123+
DwmSetWindowAttribute(
124+
hwnd,
125+
DWMWA_USE_IMMERSIVE_DARK_MODE,
126+
(&dark as *const i32).cast(),
127+
size_of::<i32>() as u32,
128+
);
129+
DwmSetWindowAttribute(
130+
hwnd,
131+
DWMWA_CAPTION_COLOR,
132+
(&caption as *const u32).cast(),
133+
size_of::<u32>() as u32,
134+
);
135+
DwmSetWindowAttribute(
136+
hwnd,
137+
DWMWA_BORDER_COLOR,
138+
(&border as *const u32).cast(),
139+
size_of::<u32>() as u32,
140+
);
141+
}
142+
}
143+
91144
/// Show + focus the main window (a normal launch, a tray/dock click, or a
92145
/// second-launch surface). No-op if the window is not present.
93146
fn show_main_window(app: &tauri::AppHandle) {
@@ -461,6 +514,13 @@ pub fn run() {
461514
}
462515
}
463516

517+
// Force a dark titlebar/border (Windows) BEFORE the first show so the
518+
// native chrome never flashes the user's clashing Windows accent color.
519+
#[cfg(windows)]
520+
if let Some(window) = app.get_webview_window(MAIN_WINDOW) {
521+
apply_dark_titlebar(&window);
522+
}
523+
464524
// SPEC s13 / s20: the main window is declared hidden in
465525
// tauri.conf.json. Show it for a normal launch; keep it hidden
466526
// (tray-only) when started with --minimized (e.g. from autostart

ui/src/__tests__/activity-event-label.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ describe("activityEventLabel (R1-P2-3)", () => {
2222
expect(label("scan_done")).toBe("Scan complete");
2323
expect(label("paused")).toBe("Paused");
2424
expect(label("local.unicode_collision")).toBe("Name collision");
25+
// Every backend-emitted event type must be humanized - not shown as the raw
26+
// snake_case code (the inconsistency: deep_verify_done / update_applied used
27+
// to render raw in the table + filter while upload_done showed "Uploaded").
28+
expect(label("deep_verify_done")).toBe("Deep verify complete");
29+
expect(label("update_applied")).toBe("App updated");
2530
});
2631

2732
it("falls back to errors.<code>.short for error/skip code event types", () => {

ui/src/__tests__/settings-components.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,61 @@ describe("Settings Rules tab", () => {
761761
});
762762
});
763763

764+
it("clamps out-of-range numeric inputs to the backend range before patching", async () => {
765+
// Regression: a plausible out-of-range value (100 concurrent uploads, a 10s
766+
// scan interval) must be clamped client-side so it never round-trips to a
767+
// backend rejection - that rejection used to brick the entire Rules form.
768+
invokeMock.mockImplementation((cmd: string, args: unknown) => {
769+
if (cmd === "get_settings") return Promise.resolve(makeSettings());
770+
if (cmd === "update_settings") {
771+
const patch = (args as { patch: Record<string, unknown> }).patch;
772+
return Promise.resolve(makeSettings(patch as Partial<SettingsDto>));
773+
}
774+
return Promise.resolve(undefined);
775+
});
776+
const wrapper = mount(Settings, { props: { tab: "rules" }, global: globalMountOptions });
777+
await flushPromises();
778+
const nums = wrapper.get('[data-testid="rules-form"]').findAll('input[type="number"]');
779+
// Order in the form: [bandwidth, concurrent, scan, deepVerify, hook].
780+
await nums[1].setValue("100"); // concurrent uploads, backend max 32
781+
await nums[1].trigger("change");
782+
await flushPromises();
783+
expect(invokeMock).toHaveBeenCalledWith("update_settings", {
784+
patch: { global: { defaultConcurrentUploads: 32 } },
785+
});
786+
await nums[2].setValue("10"); // scan interval, backend min 30
787+
await nums[2].trigger("change");
788+
await flushPromises();
789+
expect(invokeMock).toHaveBeenCalledWith("update_settings", {
790+
patch: { global: { scanIntervalSecs: 30 } },
791+
});
792+
});
793+
794+
it("keeps the Rules form visible with a localized banner when a patch is rejected", async () => {
795+
// Regression: a rejected patch must NOT replace the whole form with the raw
796+
// error ("[object Object]") and brick the page. The form stays mounted and an
797+
// inline, localized error banner appears so the user can correct the value.
798+
invokeMock.mockImplementation((cmd: string) => {
799+
if (cmd === "get_settings") return Promise.resolve(makeSettings());
800+
if (cmd === "update_settings")
801+
return Promise.reject({ code: "internal.invalid_input", message: "out of range" });
802+
return Promise.resolve(undefined);
803+
});
804+
const wrapper = mount(Settings, { props: { tab: "rules" }, global: globalMountOptions });
805+
await flushPromises();
806+
// Any commit that patches: toggle "pause on battery".
807+
const battery = wrapper.get('[data-testid="rules-form"]').findAll('input[type="checkbox"]')[0];
808+
await battery.setValue(false);
809+
await battery.trigger("change");
810+
await flushPromises();
811+
// The form is STILL mounted (not bricked) ...
812+
expect(wrapper.find('[data-testid="rules-form"]').exists()).toBe(true);
813+
// ... and a localized banner shows the error - never "[object Object]".
814+
const banner = wrapper.get('[data-testid="rules-error"]');
815+
expect(banner.text().length).toBeGreaterThan(0);
816+
expect(banner.text()).not.toContain("[object Object]");
817+
});
818+
764819
it("changes the Windows VSS mode when the windows settings group is present", async () => {
765820
invokeMock.mockImplementation((cmd: string, args: unknown) => {
766821
if (cmd === "get_settings") return Promise.resolve(makeSettings());

ui/src/__tests__/settings-stores.test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,23 @@ describe("settings store", () => {
275275
expect(store.settings?.global.skipOnBattery).toBe(false);
276276
});
277277

278-
it("patch surfaces the error and rethrows", async () => {
278+
it("patch surfaces the stable error CODE (not String(e)) and rethrows", async () => {
279+
// Regression: the store must store the SPEC s24 code via toErrorCode, never
280+
// String(e) - a structured `{ code, message }` error stringifies to the
281+
// literal "[object Object]", which used to render straight into the Rules tab.
279282
const store = useSettingsStore();
280-
invokeMock.mockRejectedValueOnce(new Error("write failed"));
281-
await expect(store.patch({ updater: { channel: "dev" } })).rejects.toThrow("write failed");
282-
expect(store.error).toContain("write failed");
283+
invokeMock.mockRejectedValueOnce({ code: "internal.invalid_input", message: "out of range" });
284+
await expect(store.patch({ updater: { channel: "dev" } })).rejects.toMatchObject({
285+
code: "internal.invalid_input",
286+
});
287+
expect(store.errorCode).toBe("internal.invalid_input");
288+
});
289+
290+
it("patch falls back to internal.bug for a code-less error", async () => {
291+
const store = useSettingsStore();
292+
invokeMock.mockRejectedValueOnce(new Error("boom"));
293+
await expect(store.patch({ updater: { channel: "dev" } })).rejects.toThrow("boom");
294+
expect(store.errorCode).toBe("internal.bug");
283295
});
284296

285297
it("setTelemetryEnabled calls set_telemetry_enabled and updates the snapshot (R2-P1-1)", async () => {
@@ -303,10 +315,10 @@ describe("settings store", () => {
303315
});
304316
});
305317

306-
it("setTelemetryEnabled surfaces the error and rethrows", async () => {
318+
it("setTelemetryEnabled surfaces the error code and rethrows", async () => {
307319
const store = useSettingsStore();
308-
invokeMock.mockRejectedValueOnce(new Error("toggle failed"));
309-
await expect(store.setTelemetryEnabled(false)).rejects.toThrow("toggle failed");
310-
expect(store.error).toContain("toggle failed");
320+
invokeMock.mockRejectedValueOnce({ code: "internal.bug", message: "toggle failed" });
321+
await expect(store.setTelemetryEnabled(false)).rejects.toMatchObject({ code: "internal.bug" });
322+
expect(store.errorCode).toBe("internal.bug");
311323
});
312324
});

ui/src/components/AddSourceWizard.vue

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,13 @@ const pendingRecoveryAck = ref(false);
8282
const preview = ref<ExclusionPreview | null>(null);
8383
const previewLoading = ref(false);
8484
const submitting = ref(false);
85-
const errorMessage = ref<string | null>(null);
86-
// R8-P2-1: the recovery reveal/ack error on the reveal step as a stable SPEC s24
87-
// CODE (not a raw String(e), which renders a Tauri structured error as
88-
// `[object Object]` and can leak backend English). The reveal step localizes it
89-
// via t(`errors.${code}.long`).
85+
// The wizard's general error as a stable SPEC s24 CODE (not a raw String(e),
86+
// which renders a Tauri structured `{ code, message }` error as the literal
87+
// "[object Object]" and can leak backend English). The template localizes it via
88+
// t(`errors.${code}.long`).
89+
const errorCode = ref<string | null>(null);
90+
// R8-P2-1: the recovery reveal/ack error on the reveal step, same stable-code
91+
// treatment, localized on the reveal step.
9092
const revealErrorCode = ref<string | null>(null);
9193
9294
const includePatterns = computed(() => splitPatterns(includePatternsText.value));
@@ -146,7 +148,7 @@ function reset(): void {
146148
createdSource.value = null;
147149
pendingRecoveryAck.value = false;
148150
preview.value = null;
149-
errorMessage.value = null;
151+
errorCode.value = null;
150152
revealErrorCode.value = null;
151153
submitting.value = false;
152154
}
@@ -156,7 +158,7 @@ function close(): void {
156158
}
157159
158160
async function chooseLocalFolder(): Promise<void> {
159-
errorMessage.value = null;
161+
errorCode.value = null;
160162
try {
161163
// C1: the BACKEND owns the folder dialog and returns { path, token }. We
162164
// never accept a typed path - only this dialog result + its token.
@@ -170,15 +172,15 @@ async function chooseLocalFolder(): Promise<void> {
170172
171173
/** Surface a Drive-picker failure on the wizard's shared error line. */
172174
function onDrivePickerError(e: unknown): void {
173-
errorMessage.value = String(e);
175+
errorCode.value = toErrorCode(e);
174176
}
175177
176178
async function loadPreview(): Promise<void> {
177179
// R1-P1-2: preview by the backend-minted dialog TOKEN (not a raw path). The
178180
// token is peeked non-consumingly, so add_source still gets its single use.
179181
if (localPathToken.value === null) return;
180182
previewLoading.value = true;
181-
errorMessage.value = null;
183+
errorCode.value = null;
182184
try {
183185
preview.value = await ipc.previewExclusions({
184186
localPathToken: localPathToken.value,
@@ -187,7 +189,7 @@ async function loadPreview(): Promise<void> {
187189
excludePatterns: excludePatterns.value,
188190
});
189191
} catch (e) {
190-
errorMessage.value = String(e);
192+
errorCode.value = toErrorCode(e);
191193
} finally {
192194
previewLoading.value = false;
193195
}
@@ -217,7 +219,7 @@ async function confirm(): Promise<void> {
217219
return;
218220
}
219221
submitting.value = true;
220-
errorMessage.value = null;
222+
errorCode.value = null;
221223
try {
222224
const displayName = localPath.value.split(/[\\/]/).filter(Boolean).pop();
223225
const result = await sources.add({
@@ -250,7 +252,7 @@ async function confirm(): Promise<void> {
250252
close();
251253
}
252254
} catch (e) {
253-
errorMessage.value = String(e);
255+
errorCode.value = toErrorCode(e);
254256
} finally {
255257
submitting.value = false;
256258
}
@@ -497,8 +499,8 @@ defineExpose({ start });
497499
</p>
498500
</div>
499501

500-
<p v-if="errorMessage" class="text-sm text-red-600">
501-
{{ errorMessage }}
502+
<p v-if="errorCode" class="text-sm text-red-600" role="alert">
503+
{{ t(`errors.${errorCode}.long`) }}
502504
</p>
503505

504506
<div class="flex justify-between gap-2">

ui/src/locales/en-US.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@
313313
"upload_done": "Uploaded",
314314
"trash_done": "Removed",
315315
"scan_done": "Scan complete",
316+
"deep_verify_done": "Deep verify complete",
317+
"update_applied": "App updated",
316318
"paused": "Paused",
317319
"error": "Error",
318320
"local.unicode_collision": "Name collision",

ui/src/stores/settings.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defineStore } from "pinia";
22
import { ref } from "vue";
33

44
import * as ipc from "../ipc/commands";
5+
import { toErrorCode } from "../ipc/errors";
56
import type { SettingsDto, SettingsPatch } from "../ipc/types";
67

78
// Settings store (SPEC s11.6, s22; DESIGN s8.2 Rules + About tabs). Holds the
@@ -10,29 +11,36 @@ import type { SettingsDto, SettingsPatch } from "../ipc/types";
1011
// snapshot with the authoritative result the backend returns (so derived /
1112
// clamped values - e.g. an out-of-range concurrent-uploads override - reflect
1213
// what was actually stored).
14+
//
15+
// Errors are stored as the stable SPEC s24 CODE (via toErrorCode), never
16+
// `String(e)`: a Tauri structured `{ code, message }` error stringifies to the
17+
// literal "[object Object]", which a previous version rendered straight into the
18+
// Rules tab - so a rejected value showed "[object Object]" AND (via the template's
19+
// v-else-if chain) hid the entire form until an app restart. The view localizes
20+
// the code via t(`errors.${code}.long`) and keeps the form visible.
1321
export const useSettingsStore = defineStore("settings", () => {
1422
const settings = ref<SettingsDto | null>(null);
1523
const loading = ref(false);
16-
const error = ref<string | null>(null);
24+
const errorCode = ref<string | null>(null);
1725

1826
async function refresh(): Promise<void> {
1927
loading.value = true;
20-
error.value = null;
28+
errorCode.value = null;
2129
try {
2230
settings.value = await ipc.getSettings();
2331
} catch (e) {
24-
error.value = String(e);
32+
errorCode.value = toErrorCode(e);
2533
} finally {
2634
loading.value = false;
2735
}
2836
}
2937

3038
async function patch(p: SettingsPatch): Promise<void> {
31-
error.value = null;
39+
errorCode.value = null;
3240
try {
3341
settings.value = await ipc.updateSettings(p);
3442
} catch (e) {
35-
error.value = String(e);
43+
errorCode.value = toErrorCode(e);
3644
throw e;
3745
}
3846
}
@@ -45,7 +53,7 @@ export const useSettingsStore = defineStore("settings", () => {
4553
// backend also routes update_settings' telemetry branch through the same
4654
// cancel-preserving path, so either route is safe; this is the explicit one.)
4755
async function setTelemetryEnabled(enabled: boolean): Promise<void> {
48-
error.value = null;
56+
errorCode.value = null;
4957
try {
5058
await ipc.setTelemetryEnabled(enabled);
5159
if (settings.value) {
@@ -54,10 +62,10 @@ export const useSettingsStore = defineStore("settings", () => {
5462
await refresh();
5563
}
5664
} catch (e) {
57-
error.value = String(e);
65+
errorCode.value = toErrorCode(e);
5866
throw e;
5967
}
6068
}
6169

62-
return { settings, loading, error, refresh, patch, setTelemetryEnabled };
70+
return { settings, loading, errorCode, refresh, patch, setTelemetryEnabled };
6371
});

0 commit comments

Comments
 (0)