Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function layerElectronApp(

const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
source: Effect.succeed("system" as const),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
});
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,14 @@ export const make = DesktopLifecycle.of({
const context = yield* Effect.context<DesktopLifecycleRegistrationServices>();
const runEffect = Effect.runPromiseWith(context);
const quit: QuitState = { phase: "running", waitingSinceNanos: 0n };
// Restore the persisted theme before the first window is created so the
// initial window background and title bar match the saved preference.
yield* electronTheme.setSource(yield* electronTheme.source).pipe(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Effect.catchCause((cause) =>
logLifecycleError("failed to restore the persisted theme", { cause }),
),
Effect.withSpan("desktop.lifecycle.themeRestore"),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High app/DesktopLifecycle.ts:251

Malformed userData/theme.json aborts lifecycle.register, preventing the desktop app from starting instead of falling back to the system theme. electronTheme.source is yielded before Effect.catchCause is attached, so its read failure bypasses recovery; wrap both the read and setSource in the caught effect.

Suggested change
yield* electronTheme.setSource(yield* electronTheme.source).pipe(
Effect.catchCause((cause) =>
logLifecycleError("failed to restore the persisted theme", { cause }),
),
Effect.withSpan("desktop.lifecycle.themeRestore"),
);
yield* Effect.gen(function* () {
yield* electronTheme.setSource(yield* electronTheme.source);
}).pipe(
Effect.catchCause((cause) =>
logLifecycleError("failed to restore the persisted theme", { cause }),
),
Effect.withSpan("desktop.lifecycle.themeRestore"),
);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopLifecycle.ts around lines 251-256:

Malformed `userData/theme.json` aborts `lifecycle.register`, preventing the desktop app from starting instead of falling back to the system theme. `electronTheme.source` is yielded before `Effect.catchCause` is attached, so its read failure bypasses recovery; wrap both the read and `setSource` in the caught effect.

yield* electronTheme.onUpdated(() => {
void runEffect(
desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/src/electron/ElectronTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { beforeEach, vi } from "vite-plus/test";

const { nativeTheme, storeValues } = vi.hoisted(() => {
const listeners = new Map<string, Set<() => void>>();
const nativeTheme = {
themeSource: "system",
shouldUseDarkColors: false,
on: (event: string, listener: () => void) => {
const set = listeners.get(event) ?? new Set<() => void>();
set.add(listener);
listeners.set(event, set);
},
removeListener: (event: string, listener: () => void) => {
listeners.get(event)?.delete(listener);
},
emitUpdated: () => {
for (const listener of listeners.get("updated") ?? []) listener();
},
updatedListenerCount: () => listeners.get("updated")?.size ?? 0,
};
return { nativeTheme, storeValues: new Map<string, unknown>() };
});

vi.mock("electron", () => ({ nativeTheme }));

vi.mock("electron-store", () => ({
default: class FakeStore {
defaults: Record<string, unknown>;
constructor(options?: { defaults?: Record<string, unknown> }) {
this.defaults = options?.defaults ?? {};
}
get(key: string) {
return storeValues.has(key) ? storeValues.get(key) : this.defaults[key];
}
set(key: string, value: unknown) {
storeValues.set(key, value);
}
},
}));

import * as ElectronTheme from "./ElectronTheme.ts";

describe("ElectronTheme", () => {
beforeEach(() => {
storeValues.clear();
nativeTheme.themeSource = "system";
nativeTheme.shouldUseDarkColors = false;
});

it.effect("defaults the persisted source to system", () =>
Effect.gen(function* () {
const theme = yield* ElectronTheme.ElectronTheme;
assert.equal(yield* theme.source, "system");
}).pipe(Effect.provide(ElectronTheme.layer)),
);

it.effect("falls back to system for an unrecognized persisted value", () =>
Effect.gen(function* () {
storeValues.set("theme", "neon");
const theme = yield* ElectronTheme.ElectronTheme;
assert.equal(yield* theme.source, "system");
}).pipe(Effect.provide(ElectronTheme.layer)),
);

it.effect("setSource updates nativeTheme and persists the choice", () =>
Effect.gen(function* () {
const theme = yield* ElectronTheme.ElectronTheme;
yield* theme.setSource("dark");
assert.equal(nativeTheme.themeSource, "dark");
assert.equal(storeValues.get("theme"), "dark");
assert.equal(yield* theme.source, "dark");
}).pipe(Effect.provide(ElectronTheme.layer)),
);

it.effect("onUpdated forwards native theme updates until the scope closes", () =>
Effect.gen(function* () {
const theme = yield* ElectronTheme.ElectronTheme;
let calls = 0;
yield* Effect.scoped(
Effect.gen(function* () {
yield* theme.onUpdated(() => {
calls += 1;
});
nativeTheme.emitUpdated();
assert.equal(calls, 1);
}),
);
nativeTheme.emitUpdated();
assert.equal(calls, 1);
assert.equal(nativeTheme.updatedListenerCount(), 0);
}).pipe(Effect.provide(ElectronTheme.layer)),
);
});
24 changes: 24 additions & 0 deletions apps/desktop/src/electron/ElectronTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";

import * as Electron from "electron";
import Store from "electron-store";

export class ElectronThemeSetSourceError extends Schema.TaggedError<ElectronThemeSetSourceError>()(
"ElectronThemeSetSourceError",
Expand All @@ -19,10 +20,28 @@ export class ElectronThemeSetSourceError extends Schema.TaggedError<ElectronThem
}
}

interface ThemeStoreShape {
readonly theme: DesktopTheme;
}

const isDesktopTheme = Schema.is(DesktopThemeSchema);

// electron-store resolves app paths when constructed, so the store is built
// on first use instead of when this module is imported.
let themeStore: Store<ThemeStoreShape> | null = null;
const getThemeStore = (): Store<ThemeStoreShape> => {
themeStore ??= new Store<ThemeStoreShape>({
name: "theme",
defaults: { theme: "system" },
});
return themeStore;
};

export class ElectronTheme extends Context.Service<
ElectronTheme,
{
readonly shouldUseDarkColors: Effect.Effect<boolean>;
readonly source: Effect.Effect<DesktopTheme>;
readonly setSource: (theme: DesktopTheme) => Effect.Effect<void, ElectronThemeSetSourceError>;
readonly onUpdated: (listener: () => void) => Effect.Effect<void, never, Scope.Scope>;
}
Expand All @@ -31,10 +50,15 @@ export class ElectronTheme extends Context.Service<
/** @public */
export const make = ElectronTheme.of({
shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors),
source: Effect.sync(() => {
const stored: unknown = getThemeStore().get("theme");
return isDesktopTheme(stored) ? stored : "system";
}),
setSource: Effect.fn("ElectronTheme.setSource")(function (theme) {
return Effect.try({
try: () => {
Electron.nativeTheme.themeSource = theme;
getThemeStore().set("theme", theme);
},
catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }),
});
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ const layerElectronMenu = Layer.succeed(ElectronMenu.ElectronMenu, {

const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
source: Effect.succeed("system"),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
} satisfies ElectronTheme.ElectronTheme["Service"]);
Expand Down
Loading