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
58 changes: 58 additions & 0 deletions apps/desktop/src/electron/ElectronWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,27 @@ const TestLayer = ElectronWindow.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
);

const WindowsTestLayer = ElectronWindow.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "win32")),
);

function makeBrowserWindow(input: { readonly id: number; readonly destroyed: boolean }) {
return {
id: input.id,
isDestroyed: vi.fn(() => input.destroyed),
} as unknown as Electron.BrowserWindow;
}

function makeAppearanceWindow(id: number) {
const window = {
id,
isDestroyed: vi.fn(() => false),
setBackgroundColor: vi.fn(),
setTitleBarOverlay: vi.fn(),
};
return window as unknown as Electron.BrowserWindow;
}

describe("ElectronWindow", () => {
beforeEach(() => {
appFocusMock.mockReset();
Expand Down Expand Up @@ -127,6 +141,50 @@ describe("ElectronWindow", () => {
}).pipe(Effect.provide(TestLayer)),
);

it.effect("preserves resolved overlay capability through a mixed appearance pass", () =>
Effect.gen(function* () {
const nativeWindow = makeAppearanceWindow(11);
const overlayWindow = makeAppearanceWindow(12);
browserWindowMock
.mockImplementationOnce(function NativeWindowMock() {
return nativeWindow;
})
.mockImplementationOnce(function OverlayWindowMock() {
return overlayWindow;
});

const electronWindow = yield* ElectronWindow.ElectronWindow;
yield* electronWindow.create({ frame: true });
yield* electronWindow.create({
titleBarStyle: "hidden",
titleBarOverlay: {
color: "#01000000",
height: 40,
symbolColor: "#f8fafc",
},
});
getAllWindowsMock.mockReturnValue([nativeWindow, overlayWindow]);

yield* electronWindow.syncAllAppearance((window) =>
Effect.sync(() => {
window.setBackgroundColor("#0a0a0a");
if (ElectronWindow.getAppearanceCapabilities(window)?.titleBarOverlay === true) {
window.setTitleBarOverlay({
color: "#01000000",
height: 40,
symbolColor: "#f8fafc",
});
}
}),
);

assert.deepEqual(vi.mocked(nativeWindow.setBackgroundColor).mock.calls, [["#0a0a0a"]]);
assert.equal(vi.mocked(nativeWindow.setTitleBarOverlay).mock.calls.length, 0);
assert.deepEqual(vi.mocked(overlayWindow.setBackgroundColor).mock.calls, [["#0a0a0a"]]);
assert.equal(vi.mocked(overlayWindow.setTitleBarOverlay).mock.calls.length, 1);
}).pipe(Effect.provide(WindowsTestLayer)),
);

it.effect("preserves window enumeration failures as structured defects", () =>
Effect.gen(function* () {
const cause = new Error("window enumeration failed");
Expand Down
93 changes: 64 additions & 29 deletions apps/desktop/src/electron/ElectronWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import * as Schema from "effect/Schema";

import * as Electron from "electron";

import { resolveLinuxX11WindowFrameOptions } from "./LinuxWindowFrame.ts";

const ElectronWindowCreateOptions = Schema.Struct({
title: Schema.NullOr(Schema.String),
width: Schema.NullOr(Schema.Number),
Expand Down Expand Up @@ -41,6 +43,21 @@ const ElectronWindowOperation = Schema.Literals([
"destroy-window",
]);

export interface ElectronWindowAppearanceCapabilities {
readonly titleBarOverlay: boolean;
}

const appearanceCapabilities = new WeakMap<
Electron.BrowserWindow,
ElectronWindowAppearanceCapabilities
>();

export function getAppearanceCapabilities(
window: Electron.BrowserWindow,
): ElectronWindowAppearanceCapabilities | undefined {
return appearanceCapabilities.get(window);
}

export class ElectronWindowCreateError extends Schema.TaggedErrorClass<ElectronWindowCreateError>()(
"ElectronWindowCreateError",
{
Expand Down Expand Up @@ -164,35 +181,53 @@ export const make = Effect.gen(function* () {
});

return ElectronWindow.of({
create: (options) => {
const webPreferences = options.webPreferences;
const diagnosticOptions = {
title: options.title ?? null,
width: options.width ?? null,
height: options.height ?? null,
minWidth: options.minWidth ?? null,
minHeight: options.minHeight ?? null,
show: options.show ?? null,
modal: options.modal ?? null,
frame: options.frame ?? null,
transparent: options.transparent ?? null,
backgroundColor: options.backgroundColor ?? null,
webPreferences: {
preload: webPreferences?.preload ?? null,
partition: webPreferences?.partition ?? null,
backgroundThrottling: webPreferences?.backgroundThrottling ?? null,
sandbox: webPreferences?.sandbox ?? null,
contextIsolation: webPreferences?.contextIsolation ?? null,
nodeIntegration: webPreferences?.nodeIntegration ?? null,
webviewTag: webPreferences?.webviewTag ?? null,
},
} satisfies typeof ElectronWindowCreateOptions.Type;
create: (options) =>
Effect.promise(() =>
resolveLinuxX11WindowFrameOptions({
options,
platform,
env: process.env,
ozonePlatform: Electron.app.commandLine?.getSwitchValue("ozone-platform") ?? null,
}),
).pipe(
Effect.flatMap((resolvedOptions) => {
const webPreferences = resolvedOptions.webPreferences;
const diagnosticOptions = {
title: resolvedOptions.title ?? null,
width: resolvedOptions.width ?? null,
height: resolvedOptions.height ?? null,
minWidth: resolvedOptions.minWidth ?? null,
minHeight: resolvedOptions.minHeight ?? null,
show: resolvedOptions.show ?? null,
modal: resolvedOptions.modal ?? null,
frame: resolvedOptions.frame ?? null,
transparent: resolvedOptions.transparent ?? null,
backgroundColor: resolvedOptions.backgroundColor ?? null,
webPreferences: {
preload: webPreferences?.preload ?? null,
partition: webPreferences?.partition ?? null,
backgroundThrottling: webPreferences?.backgroundThrottling ?? null,
sandbox: webPreferences?.sandbox ?? null,
contextIsolation: webPreferences?.contextIsolation ?? null,
nodeIntegration: webPreferences?.nodeIntegration ?? null,
webviewTag: webPreferences?.webviewTag ?? null,
},
} satisfies typeof ElectronWindowCreateOptions.Type;

return Effect.try({
try: () => new Electron.BrowserWindow(options),
catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }),
});
},
return Effect.try({
try: () => {
const window = new Electron.BrowserWindow(resolvedOptions);
appearanceCapabilities.set(window, {
titleBarOverlay:
resolvedOptions.titleBarOverlay === true ||
typeof resolvedOptions.titleBarOverlay === "object",
});
return window;
},
catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }),
});
}),
),
main: liveMain,
currentMainOrFirst,
focusedMainOrFirst,
Expand Down Expand Up @@ -294,4 +329,4 @@ export const make = Effect.gen(function* () {
});
});

export const layer = Layer.effect(ElectronWindow, make);
export const layer = Layer.effect(ElectronWindow, make);
159 changes: 159 additions & 0 deletions apps/desktop/src/electron/LinuxWindowFrame.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { assert, describe, it } from "@effect/vitest";
import type * as Electron from "electron";
import { vi } from "vite-plus/test";

import {
isLinuxX11Session,
readX11WmSupportedHints,
resolveLinuxX11WindowFrameOptions,
} from "./LinuxWindowFrame.ts";

const hiddenTitleBarOptions = {
width: 1100,
height: 780,
titleBarStyle: "hidden",
titleBarOverlay: {
color: "#01000000",
height: 40,
symbolColor: "#1f2937",
},
} satisfies Electron.BrowserWindowConstructorOptions;

describe("LinuxWindowFrame", () => {
it("resolves X11 from the Electron backend before desktop session environment", () => {
const x11Environment = {
DISPLAY: ":0",
WAYLAND_DISPLAY: undefined,
XDG_SESSION_TYPE: "x11",
};
const waylandEnvironment = {
DISPLAY: ":0",
WAYLAND_DISPLAY: "wayland-0",
XDG_SESSION_TYPE: "wayland",
};

assert.isTrue(isLinuxX11Session("linux", x11Environment));
assert.isTrue(
isLinuxX11Session("linux", {
DISPLAY: ":0",
WAYLAND_DISPLAY: undefined,
XDG_SESSION_TYPE: undefined,
}),
);
assert.isFalse(isLinuxX11Session("linux", waylandEnvironment));
assert.isTrue(isLinuxX11Session("linux", waylandEnvironment, "x11"));
assert.isFalse(isLinuxX11Session("linux", x11Environment, "wayland"));
assert.isFalse(isLinuxX11Session("darwin", x11Environment, "x11"));
});

it("enforces a hard deadline when xprop never completes", async () => {
vi.useFakeTimers();
try {
const kill = vi.fn(() => true);
const resultPromise = readX11WmSupportedHints({}, () => ({ kill }));

await vi.advanceTimersByTimeAsync(250);

assert.isNull(await resultPromise);
assert.deepEqual(kill.mock.calls, [["SIGKILL"]]);
} finally {
vi.useRealTimers();
}
});

it("falls back to native decorations when an X11 WM lacks GTK frame extents", async () => {
const readWmSupportedHints = vi.fn(() =>
"_NET_SUPPORTED(ATOM) = _NET_ACTIVE_WINDOW, _NET_WM_STATE",
);

const resolved = await resolveLinuxX11WindowFrameOptions({
options: hiddenTitleBarOptions,
platform: "linux",
env: { DISPLAY: ":0", WAYLAND_DISPLAY: undefined, XDG_SESSION_TYPE: "x11" },
readWmSupportedHints,
});

assert.deepEqual(resolved, {
width: 1100,
height: 780,
frame: true,
});
assert.equal(readWmSupportedHints.mock.calls.length, 1);
});

it("does not accept similarly named GTK frame extent atoms", async () => {
const resolved = await resolveLinuxX11WindowFrameOptions({
options: hiddenTitleBarOptions,
platform: "linux",
env: { DISPLAY: ":0", WAYLAND_DISPLAY: undefined, XDG_SESSION_TYPE: "x11" },
readWmSupportedHints: () =>
"_NET_SUPPORTED(ATOM) = _NET_ACTIVE_WINDOW, _GTK_FRAME_EXTENTS_V2, _NET_WM_STATE",
});

assert.deepEqual(resolved, {
width: 1100,
height: 780,
frame: true,
});
});

it("falls back when Electron is explicitly forced to X11 from Wayland", async () => {
const resolved = await resolveLinuxX11WindowFrameOptions({
options: hiddenTitleBarOptions,
platform: "linux",
env: { DISPLAY: ":1", WAYLAND_DISPLAY: "wayland-0", XDG_SESSION_TYPE: "wayland" },
ozonePlatform: "x11",
readWmSupportedHints: () => "_NET_SUPPORTED(ATOM) = _NET_ACTIVE_WINDOW",
});

assert.deepEqual(resolved, {
width: 1100,
height: 780,
frame: true,
});
});

it("keeps client decorations when the X11 WM supports GTK frame extents", async () => {
const resolved = await resolveLinuxX11WindowFrameOptions({
options: hiddenTitleBarOptions,
platform: "linux",
env: { DISPLAY: ":0", WAYLAND_DISPLAY: undefined, XDG_SESSION_TYPE: "x11" },
readWmSupportedHints: () =>
"_NET_SUPPORTED(ATOM) = _NET_ACTIVE_WINDOW, _GTK_FRAME_EXTENTS, _NET_WM_STATE",
});

assert.strictEqual(resolved, hiddenTitleBarOptions);
});

it("fails open when X11 support cannot be inspected", async () => {
const resolved = await resolveLinuxX11WindowFrameOptions({
options: hiddenTitleBarOptions,
platform: "linux",
env: { DISPLAY: ":0", WAYLAND_DISPLAY: undefined, XDG_SESSION_TYPE: "x11" },
readWmSupportedHints: () => null,
});

assert.strictEqual(resolved, hiddenTitleBarOptions);
});

it("does not probe or alter Wayland and non-hidden windows", async () => {
const readWmSupportedHints = vi.fn(() => "");

const wayland = await resolveLinuxX11WindowFrameOptions({
options: hiddenTitleBarOptions,
platform: "linux",
env: { DISPLAY: ":1", WAYLAND_DISPLAY: "wayland-0", XDG_SESSION_TYPE: "wayland" },
readWmSupportedHints,
});
const native = await resolveLinuxX11WindowFrameOptions({
options: { width: 800, height: 600, frame: true },
platform: "linux",
env: { DISPLAY: ":0", WAYLAND_DISPLAY: undefined, XDG_SESSION_TYPE: "x11" },
readWmSupportedHints,
});

assert.strictEqual(wayland, hiddenTitleBarOptions);
assert.deepEqual(native, { width: 800, height: 600, frame: true });
assert.equal(readWmSupportedHints.mock.calls.length, 0);
});
});
Loading
Loading