authorgravatar for bessaadkhalil@gmail.comkronk0s <bessaadkhalil@gmail.com> 2026-10-12 12:26:16+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 04:26:16-07:00
log5753207d53362608f281738eda3a9faf4e13b6be
tree375db027ee193d37692d57f2399dc0bb5916e2a9
parent58d253231d83f669a2cdfefa5fe479751d22c582
signature Signed by PGP key B5690EEEBB952194

fix(desktop): avoid Hyprland 0.48–0.50 Wayland color-manager startup crashes (#18566)

Disable WaylandWpColorManagerV1 before Electron is ready only on native Wayland Hyprland sessions running a stock 0.48.0-0.50.1 release, which advertise FEATURE_SET_PRIMARIES but reject set_primaries (hyprwm/Hyprland#9444; fixed in 0.51.0 by hyprwm/Hyprland#11499). Any probe failure or explicit feature override keeps the default. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 299 insertions(+), 1 deletions(-)

apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts+149
...@@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest";...@@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest";
5import {5import {
6 resolveEarlyLinuxElectronOptions,6 resolveEarlyLinuxElectronOptions,
7 resolveEarlyLinuxPasswordStorePreference,7 resolveEarlyLinuxPasswordStorePreference,
8 resolveLinuxDisableFeaturesSwitch,
8} from "./DesktopEarlyElectronStartup.ts";9} from "./DesktopEarlyElectronStartup.ts";
910
10describe("DesktopEarlyElectronStartup", () => {11describe("DesktopEarlyElectronStartup", () => {
...@@ -120,4 +121,152 @@ describe("DesktopEarlyElectronStartup", () => {...@@ -120,4 +121,152 @@ describe("DesktopEarlyElectronStartup", () => {
120121
121 assert.equal(preference, "gnome-libsecret");122 assert.equal(preference, "gnome-libsecret");
122 });123 });
124
125 describe("resolveLinuxDisableFeaturesSwitch", () => {
126 const hyprland = { XDG_CURRENT_DESKTOP: "Hyprland", XDG_SESSION_TYPE: "wayland" };
127 const releaseCommits: Record<string, string> = {
128 "0.48.0": "5ee35f914f921e5696030698e74fb5566a804768",
129 "0.48.1": "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc",
130 "0.49.0": "9958d297641b5c84dcff93f9039d80a5ad37ab00",
131 "0.50.0": "c4a4c341568944bd4fb9cd503558b2de602c0213",
132 "0.50.1": "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc",
133 };
134 // Shape of `hyprctl version -j` from a release tarball, trimmed to the fields we read.
135 const hyprctlVersion =
136 (version: string, overrides: Record<string, unknown> = {}) =>
137 () =>
138 JSON.stringify({
139 commit: releaseCommits[version] ?? "0123456789abcdef0123456789abcdef01234567",
140 version,
141 dirty: false,
142 tag: `v${version}`,
143 flags: [],
144 ...overrides,
145 });
146 const brokenHyprland = {
147 env: hyprland,
148 ozonePlatform: "wayland",
149 enableFeatures: null,
150 disableFeatures: null,
151 readHyprctlVersionJson: hyprctlVersion("0.48.1"),
152 };
153
154 it.each(["0.48.0", "0.48.1", "0.49.0", "0.50.0", "0.50.1"])(
155 "disables the color manager on Hyprland %s",
156 (version) => {
157 assert.equal(
158 resolveLinuxDisableFeaturesSwitch({
159 ...brokenHyprland,
160 readHyprctlVersionJson: hyprctlVersion(version),
161 }),
162 "WaylandWpColorManagerV1",
163 );
164 },
165 );
166
167 it.each([
168 { label: "case-insensitive desktop tokens", env: { XDG_CURRENT_DESKTOP: "foo:HYPRLAND" } },
169 {
170 label: "XDG_SESSION_DESKTOP when XDG_CURRENT_DESKTOP is unset",
171 env: { XDG_SESSION_DESKTOP: "hyprland" },
172 },
173 ])("detects Hyprland via $label", ({ env }) => {
174 assert.equal(
175 resolveLinuxDisableFeaturesSwitch({ ...brokenHyprland, env }),
176 "WaylandWpColorManagerV1",
177 );
178 });
179
180 it("keeps existing disabled features", () => {
181 assert.equal(
182 resolveLinuxDisableFeaturesSwitch({
183 ...brokenHyprland,
184 disableFeatures: "Vulkan,WaylandWpColorManagerV1Extra",
185 }),
186 "Vulkan,WaylandWpColorManagerV1Extra,WaylandWpColorManagerV1",
187 );
188 });
189
190 it.each([
191 { label: "Hyprland 0.47.2", readHyprctlVersionJson: hyprctlVersion("0.47.2") },
192 { label: "Hyprland 0.51.0", readHyprctlVersionJson: hyprctlVersion("0.51.0") },
193 { label: "Hyprland 0.56.2", readHyprctlVersionJson: hyprctlVersion("0.56.2") },
194 { label: "an unrecognized version", readHyprctlVersionJson: hyprctlVersion("1.0.0") },
195 {
196 label: "a malformed version",
197 readHyprctlVersionJson: hyprctlVersion("0.48.not-a-version"),
198 },
199 { label: "a two-part version", readHyprctlVersionJson: hyprctlVersion("0.48") },
200 { label: "a prerelease version", readHyprctlVersionJson: hyprctlVersion("0.49.0-rc1") },
201 {
202 label: "a development build after an affected release",
203 readHyprctlVersionJson: hyprctlVersion("0.50.1", { tag: "v0.50.1-87-g4e8875b5" }),
204 },
205 {
206 label: "a later Nix build reporting an affected release tag",
207 readHyprctlVersionJson: hyprctlVersion("0.50.1", {
208 commit: "4e8875b5e9700c81ca4e169dc7b85bb5b3c8cb7a",
209 }),
210 },
211 {
212 label: "an affected version with another release's commit",
213 readHyprctlVersionJson: hyprctlVersion("0.48.0", { commit: releaseCommits["0.48.1"] }),
214 },
215 { label: "an unknown patch release", readHyprctlVersionJson: hyprctlVersion("0.50.2") },
216 {
217 label: "missing commit metadata",
218 readHyprctlVersionJson: () =>
219 JSON.stringify({ version: "0.48.1", tag: "v0.48.1", dirty: false }),
220 },
221 {
222 label: "a build without git metadata",
223 readHyprctlVersionJson: hyprctlVersion("0.48.1", { tag: "" }),
224 },
225 {
226 label: "a dirty build",
227 readHyprctlVersionJson: hyprctlVersion("0.48.1", { dirty: true }),
228 },
229 {
230 label: "output without build metadata",
231 readHyprctlVersionJson: () => JSON.stringify({ version: "0.48.1" }),
232 },
233 { label: "unparseable hyprctl output", readHyprctlVersionJson: () => "ok" },
234 {
235 label: "a missing hyprctl",
236 readHyprctlVersionJson: () => {
237 throw new Error("spawnSync hyprctl ENOENT");
238 },
239 },
240 ])("preserves color management for $label", (overrides) => {
241 assert.isNull(resolveLinuxDisableFeaturesSwitch({ ...brokenHyprland, ...overrides }));
242 });
243
244 it.each([
245 {
246 label: "a stale Hyprland signature in KDE",
247 env: { XDG_CURRENT_DESKTOP: "KDE", HYPRLAND_INSTANCE_SIGNATURE: "abc_123" },
248 },
249 {
250 label: "resolved X11 in a Wayland session",
251 ozonePlatform: "x11",
252 env: { ...hyprland, WAYLAND_DISPLAY: "wayland-1" },
253 },
254 { label: "a missing platform", ozonePlatform: null },
255 { label: "an unrelated platform", ozonePlatform: "headless" },
256 { label: "already disabled features", disableFeatures: "Vulkan, WaylandWpColorManagerV1" },
257 { label: "an explicitly enabled feature", enableFeatures: "Vulkan,WaylandWpColorManagerV1" },
258 {
259 label: "a feature enabled with trial parameters",
260 enableFeatures: "*WaylandWpColorManagerV1<Study.Group:mode/strict",
261 },
262 ])("skips the Hyprland probe for $label", (overrides) => {
263 assert.isNull(
264 resolveLinuxDisableFeaturesSwitch({
265 ...brokenHyprland,
266 ...overrides,
267 readHyprctlVersionJson: () => assert.fail("hyprctl should not run"),
268 }),
269 );
270 });
271 });
123});272});
apps/desktop/src/app/DesktopEarlyElectronStartup.ts+72
...@@ -81,6 +81,78 @@ export function resolveEarlyLinuxPasswordStorePreference(...@@ -81,6 +81,78 @@ export function resolveEarlyLinuxPasswordStorePreference(
81 }81 }
82}82}
8383
84const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1";
85
86// Chromium feature entries look like `*Name<Trial.Group:param/value`.
87const featureListNames = (value: string | null): ReadonlyArray<string> =>
88 (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []);
89
90// A stale HYPRLAND_INSTANCE_SIGNATURE is often inherited into other desktops, so only
91// trust the session's desktop names.
92const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean =>
93 (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "")
94 .split(":")
95 .some((name) => name.trim().toLowerCase() === "hyprland");
96
97const HyprctlVersionJson = fromLenientJson(
98 Schema.Struct({
99 version: Schema.String,
100 tag: Schema.String,
101 commit: Schema.String,
102 dirty: Schema.Boolean,
103 }),
104);
105const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson);
106
107// Hyprland 0.48.0 added wp-color-manager-v1 (hyprwm/Hyprland#9444) advertising
108// FEATURE_SET_PRIMARIES while rejecting set_primaries outside debug mode, which Chromium
109// treats as a fatal protocol error. 0.51.0 accepts them (hyprwm/Hyprland#11499).
110// Only stock builds of these releases count. Matching the commit as well as the tag
111// keeps later development builds that still report an old VERSION and tag (Nix
112// synthesizes `v${VERSION}`) on the default.
113const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap<string, string> = new Map([
114 ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"],
115 ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"],
116 ["0.49.0", "9958d297641b5c84dcff93f9039d80a5ad37ab00"],
117 ["0.50.0", "c4a4c341568944bd4fb9cd503558b2de602c0213"],
118 ["0.50.1", "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc"],
119]);
120
121const hasBrokenColorManager = (readHyprctlVersionJson: () => string): boolean => {
122 try {
123 const { version, tag, commit, dirty } = decodeHyprctlVersionJson(readHyprctlVersionJson());
124 return (
125 AFFECTED_HYPRLAND_RELEASE_COMMITS.get(version) === commit && tag === `v${version}` && !dirty
126 );
127 } catch {
128 return false;
129 }
130};
131
132/**
133 * Returns the merged `--disable-features` value to apply before Electron is ready,
134 * or null to leave the command line alone. `ozonePlatform` is the resolved
135 * `--ozone-platform` value; Electron fills it in before app code runs.
136 *
137 * Color management stays on unless `hyprctl version -j` identifies a stock Hyprland
138 * release known to kill the native Wayland connection right after the first window opens.
139 */
140export function resolveLinuxDisableFeaturesSwitch(input: {
141 readonly env: NodeJS.ProcessEnv;
142 readonly ozonePlatform: string | null;
143 readonly enableFeatures: string | null;
144 readonly disableFeatures: string | null;
145 readonly readHyprctlVersionJson: () => string;
146}): string | null {
147 if (input.ozonePlatform !== "wayland" || !isHyprlandSession(input.env)) return null;
148 if (featureListNames(input.enableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null;
149 if (featureListNames(input.disableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null;
150 if (!hasBrokenColorManager(input.readHyprctlVersionJson)) return null;
151 return input.disableFeatures === null
152 ? WAYLAND_COLOR_MANAGER_FEATURE
153 : `${input.disableFeatures},${WAYLAND_COLOR_MANAGER_FEATURE}`;
154}
155
84export function resolveEarlyLinuxElectronOptions(156export function resolveEarlyLinuxElectronOptions(
85 input: EarlyLinuxElectronOptionsInput,157 input: EarlyLinuxElectronOptionsInput,
86): EarlyLinuxElectronOptions {158): EarlyLinuxElectronOptions {
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+58
...@@ -14,6 +14,7 @@ const {...@@ -14,6 +14,7 @@ const {
14 mkdirSyncMock,14 mkdirSyncMock,
15 writeFileSyncMock,15 writeFileSyncMock,
16 copyFileSyncMock,16 copyFileSyncMock,
17 execFileSyncMock,
17} = vi.hoisted(() => ({18} = vi.hoisted(() => ({
18 appendSwitchMock: vi.fn(),19 appendSwitchMock: vi.fn(),
19 getSwitchValueMock: vi.fn(),20 getSwitchValueMock: vi.fn(),
...@@ -23,6 +24,7 @@ const {...@@ -23,6 +24,7 @@ const {
23 mkdirSyncMock: vi.fn(),24 mkdirSyncMock: vi.fn(),
24 writeFileSyncMock: vi.fn(),25 writeFileSyncMock: vi.fn(),
25 copyFileSyncMock: vi.fn(),26 copyFileSyncMock: vi.fn(),
27 execFileSyncMock: vi.fn(),
26}));28}));
2729
28vi.mock("electron", () => ({30vi.mock("electron", () => ({
...@@ -49,6 +51,10 @@ vi.mock("node:fs", () => ({...@@ -49,6 +51,10 @@ vi.mock("node:fs", () => ({
49 copyFileSync: copyFileSyncMock,51 copyFileSync: copyFileSyncMock,
50}));52}));
5153
54vi.mock("node:child_process", () => ({
55 execFileSync: execFileSyncMock,
56}));
57
52import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";58import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
5359
54describe("DesktopPreReadyPlatform", () => {60describe("DesktopPreReadyPlatform", () => {
...@@ -61,6 +67,7 @@ describe("DesktopPreReadyPlatform", () => {...@@ -61,6 +67,7 @@ describe("DesktopPreReadyPlatform", () => {
61 mkdirSyncMock.mockReset();67 mkdirSyncMock.mockReset();
62 writeFileSyncMock.mockReset();68 writeFileSyncMock.mockReset();
63 copyFileSyncMock.mockReset();69 copyFileSyncMock.mockReset();
70 execFileSyncMock.mockReset();
64 });71 });
6572
66 it.effect("preserves an explicit Linux password-store switch", () => {73 it.effect("preserves an explicit Linux password-store switch", () => {
...@@ -81,6 +88,57 @@ describe("DesktopPreReadyPlatform", () => {...@@ -81,6 +88,57 @@ describe("DesktopPreReadyPlatform", () => {
81 );88 );
82 });89 });
8390
91 it.effect.each([
92 {
93 label: "merges the color-manager fallback into disabled features on Hyprland 0.48.1",
94 switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" },
95 hyprlandVersion: "0.48.1",
96 hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc",
97 expected: [["disable-features", "Vulkan,WaylandWpColorManagerV1"]],
98 },
99 {
100 label: "preserves color management on Hyprland 0.51.0",
101 switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" },
102 hyprlandVersion: "0.51.0",
103 hyprlandCommit: "0123456789abcdef0123456789abcdef01234567",
104 expected: [],
105 },
106 {
107 label: "preserves color management when Electron resolved X11",
108 switches: { "ozone-platform": "x11", "disable-features": "Vulkan" },
109 hyprlandVersion: "0.48.1",
110 hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc",
111 expected: [],
112 },
113 ])("$label", ({ switches, hyprlandVersion, hyprlandCommit, expected }) => {
114 vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland");
115 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
116 const commandLine = new Map<string, string>(Object.entries(switches));
117 hasSwitchMock.mockImplementation((switchName: string) => commandLine.has(switchName));
118 getSwitchValueMock.mockImplementation(
119 (switchName: string) => commandLine.get(switchName) ?? "",
120 );
121 execFileSyncMock.mockReturnValue(
122 JSON.stringify({
123 commit: hyprlandCommit,
124 version: hyprlandVersion,
125 dirty: false,
126 tag: `v${hyprlandVersion}`,
127 }),
128 );
129
130 return DesktopPreReadyPlatform.make.pipe(
131 Effect.provideService(HostProcess.Platform, "linux"),
132 Effect.map(() => {
133 const featureSwitches = appendSwitchMock.mock.calls.filter(([name]) =>
134 ["disable-features", "enable-features", "ozone-platform"].includes(name),
135 );
136 assert.deepEqual(featureSwitches, expected);
137 }),
138 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
139 );
140 });
141
84 it.effect.each([142 it.effect.each([
85 { previousEntry: undefined, label: "missing" },143 { previousEntry: undefined, label: "missing" },
86 { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" },144 { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" },
apps/desktop/src/app/DesktopPreReadyPlatform.ts+20-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available.1// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings, prepares the Linux desktop entry, and asks Hyprland for its version synchronously before app services are available.
2import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";3import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";4import * as NodePath from "node:path";
4import * as Context from "effect/Context";5import * as Context from "effect/Context";
...@@ -115,6 +116,24 @@ export const make = Effect.gen(function* () {...@@ -115,6 +116,24 @@ export const make = Effect.gen(function* () {
115 if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) {116 if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) {
116 Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore);117 Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore);
117 }118 }
119 // Chromium keeps only the last --disable-features value, so pass the merged list.
120 const disableFeatures = DesktopEarlyElectronStartup.resolveLinuxDisableFeaturesSwitch({
121 env: process.env,
122 ozonePlatform: readCommandLineSwitchValue(Electron.app.commandLine, "ozone-platform"),
123 enableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "enable-features"),
124 disableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "disable-features"),
125 // Runs only on Hyprland Wayland sessions without an explicit feature override.
126 readHyprctlVersionJson: () =>
127 NodeChildProcess.execFileSync("hyprctl", ["version", "-j"], {
128 encoding: "utf8",
129 stdio: ["ignore", "pipe", "ignore"],
130 timeout: 1000,
131 maxBuffer: 64 * 1024,
132 }),
133 });
134 if (disableFeatures !== null) {
135 Electron.app.commandLine.appendSwitch("disable-features", disableFeatures);
136 }
118 }137 }
119138
120 return { linux, linuxPasswordStoreCommandLine };139 return { linux, linuxPasswordStoreCommandLine };