| author | |
| committer | |
| log | 5753207d53362608f281738eda3a9faf4e13b6be |
| tree | 375db027ee193d37692d57f2399dc0bb5916e2a9 |
| parent | 58d253231d83f669a2cdfefa5fe479751d22c582 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 5 | import { |
| 6 | 6 | resolveEarlyLinuxElectronOptions, |
| 7 | 7 | resolveEarlyLinuxPasswordStorePreference, |
| 8 | resolveLinuxDisableFeaturesSwitch, | |
| 8 | 9 | } from "./DesktopEarlyElectronStartup.ts"; |
| 9 | 10 | |
| 10 | 11 | describe("DesktopEarlyElectronStartup", () => { |
| ... | ... | @@ -120,4 +121,152 @@ describe("DesktopEarlyElectronStartup", () => { |
| 120 | 121 | |
| 121 | 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 | 81 | } |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; | |
| 85 | ||
| 86 | // Chromium feature entries look like `*Name<Trial.Group:param/value`. | |
| 87 | const 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. | |
| 92 | const 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 | ||
| 97 | const HyprctlVersionJson = fromLenientJson( | |
| 98 | Schema.Struct({ | |
| 99 | version: Schema.String, | |
| 100 | tag: Schema.String, | |
| 101 | commit: Schema.String, | |
| 102 | dirty: Schema.Boolean, | |
| 103 | }), | |
| 104 | ); | |
| 105 | const 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. | |
| 113 | const 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 | ||
| 121 | const 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 | */ | |
| 140 | export 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 | ||
| 84 | 156 | export function resolveEarlyLinuxElectronOptions( |
| 85 | 157 | input: EarlyLinuxElectronOptionsInput, |
| 86 | 158 | ): EarlyLinuxElectronOptions { |
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+58| ... | ... | @@ -14,6 +14,7 @@ const { |
| 14 | 14 | mkdirSyncMock, |
| 15 | 15 | writeFileSyncMock, |
| 16 | 16 | copyFileSyncMock, |
| 17 | execFileSyncMock, | |
| 17 | 18 | } = vi.hoisted(() => ({ |
| 18 | 19 | appendSwitchMock: vi.fn(), |
| 19 | 20 | getSwitchValueMock: vi.fn(), |
| ... | ... | @@ -23,6 +24,7 @@ const { |
| 23 | 24 | mkdirSyncMock: vi.fn(), |
| 24 | 25 | writeFileSyncMock: vi.fn(), |
| 25 | 26 | copyFileSyncMock: vi.fn(), |
| 27 | execFileSyncMock: vi.fn(), | |
| 26 | 28 | })); |
| 27 | 29 | |
| 28 | 30 | vi.mock("electron", () => ({ |
| ... | ... | @@ -49,6 +51,10 @@ vi.mock("node:fs", () => ({ |
| 49 | 51 | copyFileSync: copyFileSyncMock, |
| 50 | 52 | })); |
| 51 | 53 | |
| 54 | vi.mock("node:child_process", () => ({ | |
| 55 | execFileSync: execFileSyncMock, | |
| 56 | })); | |
| 57 | ||
| 52 | 58 | import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; |
| 53 | 59 | |
| 54 | 60 | describe("DesktopPreReadyPlatform", () => { |
| ... | ... | @@ -61,6 +67,7 @@ describe("DesktopPreReadyPlatform", () => { |
| 61 | 67 | mkdirSyncMock.mockReset(); |
| 62 | 68 | writeFileSyncMock.mockReset(); |
| 63 | 69 | copyFileSyncMock.mockReset(); |
| 70 | execFileSyncMock.mockReset(); | |
| 64 | 71 | }); |
| 65 | 72 | |
| 66 | 73 | it.effect("preserves an explicit Linux password-store switch", () => { |
| ... | ... | @@ -81,6 +88,57 @@ describe("DesktopPreReadyPlatform", () => { |
| 81 | 88 | ); |
| 82 | 89 | }); |
| 83 | 90 | |
| 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 | 142 | it.effect.each([ |
| 85 | 143 | { previousEntry: undefined, label: "missing" }, |
| 86 | 144 | { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, |
apps/desktop/src/app/DesktopPreReadyPlatform.ts+20-1| ... | ... | @@ -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. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 2 | 3 | import * as NodeFS from "node:fs"; |
| 3 | 4 | import * as NodePath from "node:path"; |
| 4 | 5 | import * as Context from "effect/Context"; |
| ... | ... | @@ -115,6 +116,24 @@ export const make = Effect.gen(function* () { |
| 115 | 116 | if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { |
| 116 | 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 | } |
| 119 | 138 | |
| 120 | 139 | return { linux, linuxPasswordStoreCommandLine }; |