diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index 70441702e016b1f670affa8eb3f5fd8c9cfa4440..b311a9e25ed5ad3eeddec7ea71844c0879078112 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; import { resolveEarlyLinuxElectronOptions, resolveEarlyLinuxPasswordStorePreference, + resolveLinuxDisableFeaturesSwitch, } from "./DesktopEarlyElectronStartup.ts"; describe("DesktopEarlyElectronStartup", () => { @@ -120,4 +121,152 @@ describe("DesktopEarlyElectronStartup", () => { assert.equal(preference, "gnome-libsecret"); }); + + describe("resolveLinuxDisableFeaturesSwitch", () => { + const hyprland = { XDG_CURRENT_DESKTOP: "Hyprland", XDG_SESSION_TYPE: "wayland" }; + const releaseCommits: Record = { + "0.48.0": "5ee35f914f921e5696030698e74fb5566a804768", + "0.48.1": "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", + "0.49.0": "9958d297641b5c84dcff93f9039d80a5ad37ab00", + "0.50.0": "c4a4c341568944bd4fb9cd503558b2de602c0213", + "0.50.1": "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc", + }; + // Shape of `hyprctl version -j` from a release tarball, trimmed to the fields we read. + const hyprctlVersion = + (version: string, overrides: Record = {}) => + () => + JSON.stringify({ + commit: releaseCommits[version] ?? "0123456789abcdef0123456789abcdef01234567", + version, + dirty: false, + tag: `v${version}`, + flags: [], + ...overrides, + }); + const brokenHyprland = { + env: hyprland, + ozonePlatform: "wayland", + enableFeatures: null, + disableFeatures: null, + readHyprctlVersionJson: hyprctlVersion("0.48.1"), + }; + + it.each(["0.48.0", "0.48.1", "0.49.0", "0.50.0", "0.50.1"])( + "disables the color manager on Hyprland %s", + (version) => { + assert.equal( + resolveLinuxDisableFeaturesSwitch({ + ...brokenHyprland, + readHyprctlVersionJson: hyprctlVersion(version), + }), + "WaylandWpColorManagerV1", + ); + }, + ); + + it.each([ + { label: "case-insensitive desktop tokens", env: { XDG_CURRENT_DESKTOP: "foo:HYPRLAND" } }, + { + label: "XDG_SESSION_DESKTOP when XDG_CURRENT_DESKTOP is unset", + env: { XDG_SESSION_DESKTOP: "hyprland" }, + }, + ])("detects Hyprland via $label", ({ env }) => { + assert.equal( + resolveLinuxDisableFeaturesSwitch({ ...brokenHyprland, env }), + "WaylandWpColorManagerV1", + ); + }); + + it("keeps existing disabled features", () => { + assert.equal( + resolveLinuxDisableFeaturesSwitch({ + ...brokenHyprland, + disableFeatures: "Vulkan,WaylandWpColorManagerV1Extra", + }), + "Vulkan,WaylandWpColorManagerV1Extra,WaylandWpColorManagerV1", + ); + }); + + it.each([ + { label: "Hyprland 0.47.2", readHyprctlVersionJson: hyprctlVersion("0.47.2") }, + { label: "Hyprland 0.51.0", readHyprctlVersionJson: hyprctlVersion("0.51.0") }, + { label: "Hyprland 0.56.2", readHyprctlVersionJson: hyprctlVersion("0.56.2") }, + { label: "an unrecognized version", readHyprctlVersionJson: hyprctlVersion("1.0.0") }, + { + label: "a malformed version", + readHyprctlVersionJson: hyprctlVersion("0.48.not-a-version"), + }, + { label: "a two-part version", readHyprctlVersionJson: hyprctlVersion("0.48") }, + { label: "a prerelease version", readHyprctlVersionJson: hyprctlVersion("0.49.0-rc1") }, + { + label: "a development build after an affected release", + readHyprctlVersionJson: hyprctlVersion("0.50.1", { tag: "v0.50.1-87-g4e8875b5" }), + }, + { + label: "a later Nix build reporting an affected release tag", + readHyprctlVersionJson: hyprctlVersion("0.50.1", { + commit: "4e8875b5e9700c81ca4e169dc7b85bb5b3c8cb7a", + }), + }, + { + label: "an affected version with another release's commit", + readHyprctlVersionJson: hyprctlVersion("0.48.0", { commit: releaseCommits["0.48.1"] }), + }, + { label: "an unknown patch release", readHyprctlVersionJson: hyprctlVersion("0.50.2") }, + { + label: "missing commit metadata", + readHyprctlVersionJson: () => + JSON.stringify({ version: "0.48.1", tag: "v0.48.1", dirty: false }), + }, + { + label: "a build without git metadata", + readHyprctlVersionJson: hyprctlVersion("0.48.1", { tag: "" }), + }, + { + label: "a dirty build", + readHyprctlVersionJson: hyprctlVersion("0.48.1", { dirty: true }), + }, + { + label: "output without build metadata", + readHyprctlVersionJson: () => JSON.stringify({ version: "0.48.1" }), + }, + { label: "unparseable hyprctl output", readHyprctlVersionJson: () => "ok" }, + { + label: "a missing hyprctl", + readHyprctlVersionJson: () => { + throw new Error("spawnSync hyprctl ENOENT"); + }, + }, + ])("preserves color management for $label", (overrides) => { + assert.isNull(resolveLinuxDisableFeaturesSwitch({ ...brokenHyprland, ...overrides })); + }); + + it.each([ + { + label: "a stale Hyprland signature in KDE", + env: { XDG_CURRENT_DESKTOP: "KDE", HYPRLAND_INSTANCE_SIGNATURE: "abc_123" }, + }, + { + label: "resolved X11 in a Wayland session", + ozonePlatform: "x11", + env: { ...hyprland, WAYLAND_DISPLAY: "wayland-1" }, + }, + { label: "a missing platform", ozonePlatform: null }, + { label: "an unrelated platform", ozonePlatform: "headless" }, + { label: "already disabled features", disableFeatures: "Vulkan, WaylandWpColorManagerV1" }, + { label: "an explicitly enabled feature", enableFeatures: "Vulkan,WaylandWpColorManagerV1" }, + { + label: "a feature enabled with trial parameters", + enableFeatures: "*WaylandWpColorManagerV1 { + assert.isNull( + resolveLinuxDisableFeaturesSwitch({ + ...brokenHyprland, + ...overrides, + readHyprctlVersionJson: () => assert.fail("hyprctl should not run"), + }), + ); + }); + }); }); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 95922bdfdca88f54dd319db571b8d1a68d9cbb9f..c4adf8f3c75e6d58c6856e21a83625dba3b4170d 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -81,6 +81,78 @@ export function resolveEarlyLinuxPasswordStorePreference( } } +const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; + +// Chromium feature entries look like `*Name => + (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []); + +// A stale HYPRLAND_INSTANCE_SIGNATURE is often inherited into other desktops, so only +// trust the session's desktop names. +const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean => + (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "") + .split(":") + .some((name) => name.trim().toLowerCase() === "hyprland"); + +const HyprctlVersionJson = fromLenientJson( + Schema.Struct({ + version: Schema.String, + tag: Schema.String, + commit: Schema.String, + dirty: Schema.Boolean, + }), +); +const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson); + +// Hyprland 0.48.0 added wp-color-manager-v1 (hyprwm/Hyprland#9444) advertising +// FEATURE_SET_PRIMARIES while rejecting set_primaries outside debug mode, which Chromium +// treats as a fatal protocol error. 0.51.0 accepts them (hyprwm/Hyprland#11499). +// Only stock builds of these releases count. Matching the commit as well as the tag +// keeps later development builds that still report an old VERSION and tag (Nix +// synthesizes `v${VERSION}`) on the default. +const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap = new Map([ + ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"], + ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"], + ["0.49.0", "9958d297641b5c84dcff93f9039d80a5ad37ab00"], + ["0.50.0", "c4a4c341568944bd4fb9cd503558b2de602c0213"], + ["0.50.1", "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc"], +]); + +const hasBrokenColorManager = (readHyprctlVersionJson: () => string): boolean => { + try { + const { version, tag, commit, dirty } = decodeHyprctlVersionJson(readHyprctlVersionJson()); + return ( + AFFECTED_HYPRLAND_RELEASE_COMMITS.get(version) === commit && tag === `v${version}` && !dirty + ); + } catch { + return false; + } +}; + +/** + * Returns the merged `--disable-features` value to apply before Electron is ready, + * or null to leave the command line alone. `ozonePlatform` is the resolved + * `--ozone-platform` value; Electron fills it in before app code runs. + * + * Color management stays on unless `hyprctl version -j` identifies a stock Hyprland + * release known to kill the native Wayland connection right after the first window opens. + */ +export function resolveLinuxDisableFeaturesSwitch(input: { + readonly env: NodeJS.ProcessEnv; + readonly ozonePlatform: string | null; + readonly enableFeatures: string | null; + readonly disableFeatures: string | null; + readonly readHyprctlVersionJson: () => string; +}): string | null { + if (input.ozonePlatform !== "wayland" || !isHyprlandSession(input.env)) return null; + if (featureListNames(input.enableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null; + if (featureListNames(input.disableFeatures).includes(WAYLAND_COLOR_MANAGER_FEATURE)) return null; + if (!hasBrokenColorManager(input.readHyprctlVersionJson)) return null; + return input.disableFeatures === null + ? WAYLAND_COLOR_MANAGER_FEATURE + : `${input.disableFeatures},${WAYLAND_COLOR_MANAGER_FEATURE}`; +} + export function resolveEarlyLinuxElectronOptions( input: EarlyLinuxElectronOptionsInput, ): EarlyLinuxElectronOptions { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index ecdc94d8254e519958ce55a86249704422f4004c..29fa706698e0191eb7920e073e12af00fd5f908c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -14,6 +14,7 @@ const { mkdirSyncMock, writeFileSyncMock, copyFileSyncMock, + execFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -23,6 +24,7 @@ const { mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), copyFileSyncMock: vi.fn(), + execFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ @@ -49,6 +51,10 @@ vi.mock("node:fs", () => ({ copyFileSync: copyFileSyncMock, })); +vi.mock("node:child_process", () => ({ + execFileSync: execFileSyncMock, +})); + import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; describe("DesktopPreReadyPlatform", () => { @@ -61,6 +67,7 @@ describe("DesktopPreReadyPlatform", () => { mkdirSyncMock.mockReset(); writeFileSyncMock.mockReset(); copyFileSyncMock.mockReset(); + execFileSyncMock.mockReset(); }); it.effect("preserves an explicit Linux password-store switch", () => { @@ -81,6 +88,57 @@ describe("DesktopPreReadyPlatform", () => { ); }); + it.effect.each([ + { + label: "merges the color-manager fallback into disabled features on Hyprland 0.48.1", + switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" }, + hyprlandVersion: "0.48.1", + hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", + expected: [["disable-features", "Vulkan,WaylandWpColorManagerV1"]], + }, + { + label: "preserves color management on Hyprland 0.51.0", + switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" }, + hyprlandVersion: "0.51.0", + hyprlandCommit: "0123456789abcdef0123456789abcdef01234567", + expected: [], + }, + { + label: "preserves color management when Electron resolved X11", + switches: { "ozone-platform": "x11", "disable-features": "Vulkan" }, + hyprlandVersion: "0.48.1", + hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", + expected: [], + }, + ])("$label", ({ switches, hyprlandVersion, hyprlandCommit, expected }) => { + vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland"); + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + const commandLine = new Map(Object.entries(switches)); + hasSwitchMock.mockImplementation((switchName: string) => commandLine.has(switchName)); + getSwitchValueMock.mockImplementation( + (switchName: string) => commandLine.get(switchName) ?? "", + ); + execFileSyncMock.mockReturnValue( + JSON.stringify({ + commit: hyprlandCommit, + version: hyprlandVersion, + dirty: false, + tag: `v${hyprlandVersion}`, + }), + ); + + return DesktopPreReadyPlatform.make.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.map(() => { + const featureSwitches = appendSwitchMock.mock.calls.filter(([name]) => + ["disable-features", "enable-features", "ozone-platform"].includes(name), + ); + assert.deepEqual(featureSwitches, expected); + }), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); + }); + it.effect.each([ { previousEntry: undefined, label: "missing" }, { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 378eb58f160c85be6c48b17018667d1606042f5b..b021ae02e826cb72cc0f0994cd367cd0d39cad0f 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available. +// @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. +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as Context from "effect/Context"; @@ -115,6 +116,24 @@ export const make = Effect.gen(function* () { if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); } + // Chromium keeps only the last --disable-features value, so pass the merged list. + const disableFeatures = DesktopEarlyElectronStartup.resolveLinuxDisableFeaturesSwitch({ + env: process.env, + ozonePlatform: readCommandLineSwitchValue(Electron.app.commandLine, "ozone-platform"), + enableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "enable-features"), + disableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "disable-features"), + // Runs only on Hyprland Wayland sessions without an explicit feature override. + readHyprctlVersionJson: () => + NodeChildProcess.execFileSync("hyprctl", ["version", "-j"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1000, + maxBuffer: 64 * 1024, + }), + }); + if (disableFeatures !== null) { + Electron.app.commandLine.appendSwitch("disable-features", disableFeatures); + } } return { linux, linuxPasswordStoreCommandLine };