Skip to content

Commit 0e250cc

Browse files
eboyeclaude
andcommitted
fix(desktop): follow live system light/dark changes on Linux
Electron's nativeTheme reads the desktop color scheme once on Linux and then ignores the XDG settings portal (electron/electron#48736), so the System appearance stayed on whatever scheme was active at launch. Read org.freedesktop.appearance color-scheme from the portal, follow SettingChanged, and resolve the System appearance to an explicit light or dark theme source while it is selected. Refs #19119 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 171f8d8 commit 0e250cc

4 files changed

Lines changed: 307 additions & 19 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { assert, describe, it } from "@effect/vitest";
2+
import * as Effect from "effect/Effect";
3+
import { vi } from "vite-plus/test";
4+
5+
import type { LinuxColorScheme } from "./LinuxColorScheme.ts";
6+
7+
const { nativeTheme } = vi.hoisted(() => ({
8+
nativeTheme: { themeSource: "system" as "system" | "light" | "dark" },
9+
}));
10+
11+
vi.mock("electron", () => ({ nativeTheme }));
12+
13+
import * as ElectronTheme from "./ElectronTheme.ts";
14+
15+
function setup() {
16+
nativeTheme.themeSource = "system";
17+
const portal: { report: (scheme: LinuxColorScheme) => void; stopped: boolean } = {
18+
report: () => undefined,
19+
stopped: false,
20+
};
21+
const watch = (onChange: (scheme: LinuxColorScheme) => void) => {
22+
portal.report = onChange;
23+
return () => {
24+
portal.stopped = true;
25+
};
26+
};
27+
return { portal, watch };
28+
}
29+
30+
describe("ElectronTheme", () => {
31+
it.effect("resolves the system appearance from the desktop color scheme as it changes", () =>
32+
Effect.gen(function* () {
33+
const { portal, watch } = setup();
34+
const theme = yield* ElectronTheme.makeWith(watch);
35+
yield* theme.setSource("system");
36+
37+
portal.report("dark");
38+
assert.equal(nativeTheme.themeSource, "dark");
39+
portal.report("light");
40+
assert.equal(nativeTheme.themeSource, "light");
41+
portal.report(null);
42+
assert.equal(nativeTheme.themeSource, "system");
43+
}).pipe(Effect.scoped),
44+
);
45+
46+
it.effect("keeps an explicit appearance over the desktop color scheme", () =>
47+
Effect.gen(function* () {
48+
const { portal, watch } = setup();
49+
const theme = yield* ElectronTheme.makeWith(watch);
50+
portal.report("dark");
51+
52+
yield* theme.setSource("light");
53+
portal.report("dark");
54+
assert.equal(nativeTheme.themeSource, "light");
55+
56+
yield* theme.setSource("system");
57+
assert.equal(nativeTheme.themeSource, "dark");
58+
}).pipe(Effect.scoped),
59+
);
60+
61+
it.effect("stops following the desktop when the layer is released", () =>
62+
Effect.gen(function* () {
63+
const { portal, watch } = setup();
64+
yield* ElectronTheme.makeWith(watch).pipe(Effect.scoped);
65+
assert.isTrue(portal.stopped);
66+
}),
67+
);
68+
69+
it.effect("leaves the system appearance to Electron when the portal is unavailable", () =>
70+
Effect.gen(function* () {
71+
setup();
72+
const theme = yield* ElectronTheme.makeWith(() => {
73+
throw new Error("no session bus");
74+
});
75+
yield* theme.setSource("system");
76+
assert.equal(nativeTheme.themeSource, "system");
77+
}).pipe(Effect.scoped),
78+
);
79+
});
Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts";
2+
import * as HostProcess from "@t3tools/shared/HostProcess";
23
import * as Context from "effect/Context";
34
import * as Effect from "effect/Effect";
45
import * as Layer from "effect/Layer";
@@ -7,6 +8,8 @@ import * as Scope from "effect/Scope";
78

89
import * as Electron from "electron";
910

11+
import { watchLinuxColorScheme, type LinuxColorScheme } from "./LinuxColorScheme.ts";
12+
1013
export class ElectronThemeSetSourceError extends Schema.TaggedError<ElectronThemeSetSourceError>()(
1114
"ElectronThemeSetSourceError",
1215
{
@@ -28,28 +31,64 @@ export class ElectronTheme extends Context.Service<
2831
}
2932
>()("@t3tools/desktop/electron/ElectronTheme") {}
3033

31-
/** @public Service construction is part of the canonical Effect module API. */
32-
export const make = ElectronTheme.of({
33-
shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors),
34-
setSource: (theme) =>
35-
Effect.try({
36-
try: () => {
37-
Electron.nativeTheme.themeSource = theme;
38-
},
39-
catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }),
40-
}),
41-
onUpdated: (listener) =>
42-
Effect.acquireRelease(
43-
Effect.suspend(() => {
44-
Electron.nativeTheme.on("updated", listener);
45-
return Effect.void;
34+
type WatchSystemColorScheme = (onChange: (scheme: LinuxColorScheme) => void) => () => void;
35+
36+
export const makeWith = Effect.fn("ElectronTheme.make")(function* (
37+
watchSystemColorScheme: WatchSystemColorScheme | undefined,
38+
) {
39+
let source: DesktopTheme = Electron.nativeTheme.themeSource;
40+
let systemScheme: LinuxColorScheme = null;
41+
// On Linux, Electron reads the desktop color scheme once and then ignores the settings
42+
// portal (electron/electron#48736), so "system" is resolved from the portal directly.
43+
const apply = () => {
44+
Electron.nativeTheme.themeSource = source === "system" ? (systemScheme ?? source) : source;
45+
};
46+
47+
if (watchSystemColorScheme) {
48+
yield* Effect.acquireRelease(
49+
Effect.sync(() => {
50+
try {
51+
return watchSystemColorScheme((scheme) => {
52+
systemScheme = scheme;
53+
apply();
54+
});
55+
} catch {
56+
return undefined;
57+
}
4658
}),
47-
() =>
59+
(stop) => Effect.sync(() => stop?.()),
60+
);
61+
}
62+
63+
return ElectronTheme.of({
64+
shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors),
65+
setSource: (theme) =>
66+
Effect.try({
67+
try: () => {
68+
source = theme;
69+
apply();
70+
},
71+
catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }),
72+
}),
73+
onUpdated: (listener) =>
74+
Effect.acquireRelease(
4875
Effect.suspend(() => {
49-
Electron.nativeTheme.removeListener("updated", listener);
76+
Electron.nativeTheme.on("updated", listener);
5077
return Effect.void;
5178
}),
52-
),
79+
() =>
80+
Effect.suspend(() => {
81+
Electron.nativeTheme.removeListener("updated", listener);
82+
return Effect.void;
83+
}),
84+
),
85+
});
86+
});
87+
88+
/** @public Service construction is part of the canonical Effect module API. */
89+
export const make = Effect.gen(function* () {
90+
const platform = yield* HostProcess.Platform;
91+
return yield* makeWith(platform === "linux" ? watchLinuxColorScheme : undefined);
5392
});
5493

55-
export const layer = Layer.succeed(ElectronTheme, make);
94+
export const layer = Layer.effect(ElectronTheme, make);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// @effect-diagnostics nodeBuiltinImport:off -- Event-driven fake of the portal transport.
2+
import * as NodeEvents from "node:events";
3+
import { DBusError, Message, MessageType, Variant, type MessageBus } from "dbus-next";
4+
import { expect, it, vi } from "vite-plus/test";
5+
6+
import { watchLinuxColorScheme, type LinuxColorScheme } from "./LinuxColorScheme.ts";
7+
8+
const PATH = "/org/freedesktop/portal/desktop";
9+
const SETTINGS = "org.freedesktop.portal.Settings";
10+
11+
class FakeBus extends NodeEvents.EventEmitter {
12+
calls: Message[] = [];
13+
disconnect = vi.fn();
14+
readonly settings: { readOne?: unknown; read?: unknown };
15+
constructor(settings: { readOne?: unknown; read?: unknown }) {
16+
super();
17+
this.settings = settings;
18+
}
19+
20+
async call(message: Message) {
21+
this.calls.push(message);
22+
if (message.member === "AddMatch")
23+
return new Message({ type: MessageType.METHOD_RETURN, replySerial: "1" });
24+
const value = message.member === "ReadOne" ? this.settings.readOne : this.settings.read;
25+
if (value === undefined) throw new DBusError("org.freedesktop.DBus.Error.UnknownMethod", "");
26+
return new Message({
27+
type: MessageType.METHOD_RETURN,
28+
replySerial: "1",
29+
signature: "v",
30+
body: [value],
31+
});
32+
}
33+
34+
changed(namespace: string, key: string, value: number) {
35+
this.emit(
36+
"message",
37+
new Message({
38+
type: MessageType.SIGNAL,
39+
path: PATH,
40+
interface: SETTINGS,
41+
member: "SettingChanged",
42+
body: [namespace, key, new Variant("u", value)],
43+
}),
44+
);
45+
}
46+
}
47+
48+
async function watch(bus: FakeBus) {
49+
const schemes: LinuxColorScheme[] = [];
50+
const read = Promise.withResolvers<void>();
51+
const stop = watchLinuxColorScheme(
52+
(scheme) => {
53+
schemes.push(scheme);
54+
read.resolve();
55+
},
56+
bus as unknown as MessageBus,
57+
);
58+
await read.promise;
59+
return { schemes, stop };
60+
}
61+
62+
it("reads the current color scheme and follows portal changes", async () => {
63+
const bus = new FakeBus({ readOne: new Variant("u", 1) });
64+
const { schemes, stop } = await watch(bus);
65+
66+
bus.changed("org.freedesktop.appearance", "color-scheme", 2);
67+
bus.changed("org.freedesktop.appearance", "accent-color", 1);
68+
bus.changed("org.gnome.desktop.interface", "color-scheme", 1);
69+
bus.changed("org.freedesktop.appearance", "color-scheme", 1);
70+
// GNOME's light mode reports "no preference".
71+
bus.changed("org.freedesktop.appearance", "color-scheme", 0);
72+
expect(schemes).toEqual(["dark", "light", "dark", "light"]);
73+
74+
stop();
75+
bus.changed("org.freedesktop.appearance", "color-scheme", 1);
76+
expect(schemes).toEqual(["dark", "light", "dark", "light"]);
77+
expect(bus.disconnect).toHaveBeenCalledOnce();
78+
});
79+
80+
it("falls back to the nested Read reply on portals without ReadOne", async () => {
81+
const bus = new FakeBus({ read: new Variant("v", new Variant("u", 2)) });
82+
const { schemes } = await watch(bus);
83+
84+
expect(schemes).toEqual(["light"]);
85+
expect(bus.calls.map((call) => call.member)).toEqual(["AddMatch", "ReadOne", "Read"]);
86+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Message, MessageType, Variant, sessionBus, type MessageBus } from "dbus-next";
2+
3+
const PORTAL = "org.freedesktop.portal.Desktop";
4+
const PATH = "/org/freedesktop/portal/desktop";
5+
const SETTINGS = "org.freedesktop.portal.Settings";
6+
const DBUS = "org.freedesktop.DBus";
7+
const NAMESPACE = "org.freedesktop.appearance";
8+
const KEY = "color-scheme";
9+
10+
export type LinuxColorScheme = "dark" | "light" | null;
11+
12+
// The portal reports 1 for prefer-dark, 2 for prefer-light and 0 for no preference. GNOME's
13+
// light mode is 0, and the spec asks apps to use their default then, so only 1 is dark.
14+
// The deprecated Read method nests the value in a second variant.
15+
function decodeColorScheme(value: unknown): LinuxColorScheme {
16+
let scheme = value;
17+
while (scheme instanceof Variant) scheme = scheme.value;
18+
if (typeof scheme !== "number") return null;
19+
return scheme === 1 ? "dark" : "light";
20+
}
21+
22+
const settingsCall = (member: "ReadOne" | "Read") =>
23+
new Message({
24+
destination: PORTAL,
25+
path: PATH,
26+
interface: SETTINGS,
27+
member,
28+
signature: "ss",
29+
body: [NAMESPACE, KEY],
30+
});
31+
32+
/**
33+
* Follows the desktop color scheme through the XDG settings portal. Without a session bus or
34+
* portal it reports nothing, so Chromium's own resolution stays in charge.
35+
*/
36+
export function watchLinuxColorScheme(
37+
onChange: (scheme: LinuxColorScheme) => void,
38+
bus: MessageBus = sessionBus(),
39+
): () => void {
40+
let closed = false;
41+
const report = (value: unknown) => {
42+
if (!closed) onChange(decodeColorScheme(value));
43+
};
44+
const onMessage = (message: Message) => {
45+
if (
46+
message.type !== MessageType.SIGNAL ||
47+
message.path !== PATH ||
48+
message.interface !== SETTINGS ||
49+
message.member !== "SettingChanged"
50+
)
51+
return;
52+
const [namespace, key, value] = message.body;
53+
if (namespace === NAMESPACE && key === KEY) report(value);
54+
};
55+
const ignore = () => undefined;
56+
57+
bus.on("error", ignore);
58+
bus.on("message", onMessage);
59+
void (async () => {
60+
await bus.call(
61+
new Message({
62+
destination: DBUS,
63+
path: "/org/freedesktop/DBus",
64+
interface: DBUS,
65+
member: "AddMatch",
66+
signature: "s",
67+
body: [
68+
`type='signal',interface='${SETTINGS}',member='SettingChanged',path='${PATH}',arg0='${NAMESPACE}',arg1='${KEY}'`,
69+
],
70+
}),
71+
);
72+
// ReadOne needs Settings portal version 2; older portals only offer Read.
73+
const reply = await bus
74+
.call(settingsCall("ReadOne"))
75+
.catch(() => bus.call(settingsCall("Read")));
76+
if (reply) report(reply.body[0]);
77+
})().catch(ignore);
78+
79+
return () => {
80+
closed = true;
81+
bus.removeListener("message", onMessage);
82+
bus.disconnect();
83+
};
84+
}

0 commit comments

Comments
 (0)