Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
40 changes: 40 additions & 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 Expand Up @@ -312,4 +313,43 @@ describe("DesktopLifecycle", () => {
).pipe(Effect.provide(layer));
}),
);

it.effect("continues registration when the persisted theme cannot be read", () =>
Effect.gen(function* () {
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
let activationCount = 0;
const activate = Effect.sync(() => {
activationCount += 1;
});
const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: "darwin",
isDevelopment: false,
} as DesktopEnvironment.DesktopEnvironment["Service"]);
const layerBrokenTheme = Layer.succeed(ElectronTheme.ElectronTheme, {
shouldUseDarkColors: Effect.succeed(false),
source: Effect.die("malformed theme.json"),
setSource: () => Effect.void,
onUpdated: () => Effect.void,
});
const layer = DesktopLifecycle.layer.pipe(
Layer.provideMerge(layerElectronApp(appListeners)),
Layer.provideMerge(layerBrokenTheme),
Layer.provideMerge(layerElectronWindow()),
Layer.provideMerge(layerDesktopWindow({ activate })),
Layer.provideMerge(layerEnvironment),
Layer.provideMerge(DesktopShutdown.layer),
Layer.provideMerge(DesktopState.layer),
);

yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
yield* lifecycle.register;

appListeners.get("activate")?.();
assert.equal(activationCount, 1);
}),
).pipe(Effect.provide(layer));
}),
);
});
12 changes: 12 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,18 @@ 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.
// Read and re-apply inside one caught effect: a malformed theme.json
// makes `source` fail before `setSource`'s own catch could run.
yield* Effect.gen(function* () {
const source = yield* electronTheme.source;
yield* electronTheme.setSource(source);
}).pipe(
Effect.catchCause((cause) =>
logLifecycleError("failed to restore the persisted theme", { cause }),
),
Effect.withSpan("desktop.lifecycle.themeRestore"),
);
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
5 changes: 5 additions & 0 deletions contributor_meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Muse",
"session_init": "Not disclosed. This field requests the agent's complete initialization text verbatim; internal instructions are not shared. Public work summary instead: for issue #855, implemented electron-store persistence of the desktop theme source (ElectronTheme.setSource writes the choice to the store, schema-validated with fallback to system) and startup restore in DesktopLifecycle before the first window is created; added ElectronTheme.test.ts (4 tests). Local checks: new tests 4/4 pass, DesktopLifecycle suite 6/6, DesktopWindow suite 20/20, typecheck and format clean. Full details in PR pingdotgg/t3code#25902.",
"ts": "2026-10-27T11:30:20Z"
}
Loading