authorgravatar for ishansachu1@gmail.comIshan <ishansachu1@gmail.com> 2026-07-16 13:04:33+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-16 09:34:33+02:00
log0418301de79bb7b6f82305236325cb9811615e9e
tree909f568bf7ccd6fb6b69db0526255c29d0283c3b
parent5e8b2c800ecd98e23adbdbfc8970f4687ac8254b
signature Signed by PGP key B5690EEEBB952194

Fix macOS fullscreen titlebar spacing (#4019)


9 files changed, 124 insertions(+), 6 deletions(-)

apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 getAppBranding,35 getAppBranding,
36 getLocalEnvironmentBootstraps,36 getLocalEnvironmentBootstraps,
37 getLocalEnvironmentBearerToken,37 getLocalEnvironmentBearerToken,
38 getWindowFullscreenState,
38 openExternal,39 openExternal,
39 pickFolder,40 pickFolder,
40 setTheme,41 setTheme,
...@@ -48,6 +49,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -48,6 +49,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
48 yield* PreviewIpc.installPreviewEventForwarding();49 yield* PreviewIpc.installPreviewEventForwarding();
4950
50 yield* ipc.handleSync(getAppBranding);51 yield* ipc.handleSync(getAppBranding);
52 yield* ipc.handleSync(getWindowFullscreenState);
51 yield* ipc.handleSync(getLocalEnvironmentBootstraps);53 yield* ipc.handleSync(getLocalEnvironmentBootstraps);
52 yield* ipc.handle(getLocalEnvironmentBearerToken);54 yield* ipc.handle(getLocalEnvironmentBearerToken);
5355
apps/desktop/src/ipc/channels.ts+2
...@@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme";...@@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme";
4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6export const MENU_ACTION_CHANNEL = "desktop:menu-action";6export const MENU_ACTION_CHANNEL = "desktop:menu-action";
7export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
8export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
7export const UPDATE_STATE_CHANNEL = "desktop:update-state";9export const UPDATE_STATE_CHANNEL = "desktop:update-state";
8export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";10export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
9export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";11export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
apps/desktop/src/ipc/methods/window.test.ts+21-1
...@@ -1,10 +1,14 @@...@@ -1,10 +1,14 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
45
6import type * as Electron from "electron";
7
5import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";8import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
6import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";9import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
7import { getLocalEnvironmentBootstraps } from "./window.ts";10import * as ElectronWindow from "../../electron/ElectronWindow.ts";
11import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts";
812
9const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {13const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
10 executablePath: "wsl.exe",14 executablePath: "wsl.exe",
...@@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => {...@@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => {
126 }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance])));130 }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance])));
127 });131 });
128});132});
133
134describe("getWindowFullscreenState", () => {
135 it.effect("reads the current native window state", () => {
136 const window = { isFullScreen: () => true } as Electron.BrowserWindow;
137
138 return Effect.gen(function* () {
139 assert.isTrue(yield* getWindowFullscreenState.handler());
140 }).pipe(
141 Effect.provide(
142 Layer.mock(ElectronWindow.ElectronWindow)({
143 currentMainOrFirst: Effect.succeed(Option.some(window)),
144 }),
145 ),
146 );
147 });
148});
apps/desktop/src/ipc/methods/window.ts+10
...@@ -55,6 +55,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({...@@ -55,6 +55,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
55 }),55 }),
56});56});
5757
58export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
59 channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
60 result: Schema.Boolean,
61 handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () {
62 const electronWindow = yield* ElectronWindow.ElectronWindow;
63 const window = yield* electronWindow.currentMainOrFirst;
64 return Option.isSome(window) && window.value.isFullScreen();
65 }),
66});
67
58export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({68export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
59 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL,69 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL,
60 result: Schema.Array(DesktopEnvironmentBootstrapSchema),70 result: Schema.Array(DesktopEnvironmentBootstrapSchema),
apps/desktop/src/preload.ts+13
...@@ -116,6 +116,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -116,6 +116,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {
116 ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);116 ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
117 };117 };
118 },118 },
119 getWindowFullscreenState: () =>
120 ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
121 onWindowFullscreenStateChange: (listener) => {
122 const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => {
123 if (typeof fullscreen !== "boolean") return;
124 listener(fullscreen);
125 };
126
127 ipcRenderer.on(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener);
128 return () => {
129 ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener);
130 };
131 },
119 getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL),132 getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL),
120 setUpdateChannel: (channel) =>133 setUpdateChannel: (channel) =>
121 ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel),134 ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel),
apps/desktop/src/window/DesktopWindow.test.ts+38-2
...@@ -28,7 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts";...@@ -28,7 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts";
28import * as ElectronShell from "../electron/ElectronShell.ts";28import * as ElectronShell from "../electron/ElectronShell.ts";
29import * as ElectronTheme from "../electron/ElectronTheme.ts";29import * as ElectronTheme from "../electron/ElectronTheme.ts";
30import * as ElectronWindow from "../electron/ElectronWindow.ts";30import * as ElectronWindow from "../electron/ElectronWindow.ts";
31import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";31import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
32import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";32import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
33import * as DesktopWindow from "./DesktopWindow.ts";33import * as DesktopWindow from "./DesktopWindow.ts";
34import * as PreviewManager from "../preview/Manager.ts";34import * as PreviewManager from "../preview/Manager.ts";
...@@ -46,6 +46,7 @@ const environmentInput = {...@@ -46,6 +46,7 @@ const environmentInput = {
46} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;46} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
4747
48function makeFakeBrowserWindow() {48function makeFakeBrowserWindow() {
49 const windowListeners = new Map<string, (...args: readonly unknown[]) => void>();
49 const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();50 const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();
50 const webContents = {51 const webContents = {
51 copyImageAt: vi.fn(),52 copyImageAt: vi.fn(),
...@@ -66,10 +67,13 @@ function makeFakeBrowserWindow() {...@@ -66,10 +67,13 @@ function makeFakeBrowserWindow() {
66 close: vi.fn(),67 close: vi.fn(),
67 focus: vi.fn(),68 focus: vi.fn(),
68 isDestroyed: vi.fn(() => false),69 isDestroyed: vi.fn(() => false),
70 isFullScreen: vi.fn(() => false),
69 isMinimized: vi.fn(() => false),71 isMinimized: vi.fn(() => false),
70 isVisible: vi.fn(() => true),72 isVisible: vi.fn(() => true),
71 loadURL: vi.fn(() => Promise.resolve()),73 loadURL: vi.fn(() => Promise.resolve()),
72 on: vi.fn(),74 on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
75 windowListeners.set(eventName, listener);
76 }),
73 once: vi.fn(),77 once: vi.fn(),
74 restore: vi.fn(),78 restore: vi.fn(),
75 setBackgroundColor: vi.fn(),79 setBackgroundColor: vi.fn(),
...@@ -88,6 +92,7 @@ function makeFakeBrowserWindow() {...@@ -88,6 +92,7 @@ function makeFakeBrowserWindow() {
88 send: webContents.send,92 send: webContents.send,
89 setAutoHideCursor: window.setAutoHideCursor,93 setAutoHideCursor: window.setAutoHideCursor,
90 webContentsListeners,94 webContentsListeners,
95 windowListeners,
91 };96 };
92}97}
9398
...@@ -338,6 +343,37 @@ describe("DesktopWindow", () => {...@@ -338,6 +343,37 @@ describe("DesktopWindow", () => {
338 }),343 }),
339 );344 );
340345
346 it.effect("publishes native macOS fullscreen changes to the renderer", () =>
347 Effect.gen(function* () {
348 const fakeWindow = makeFakeBrowserWindow();
349 const createCount = yield* Ref.make(0);
350 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
351 const layer = makeTestLayer({
352 window: fakeWindow.window,
353 createCount,
354 mainWindow,
355 });
356
357 yield* Effect.gen(function* () {
358 const desktopWindow = yield* DesktopWindow.DesktopWindow;
359 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
360
361 const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen");
362 const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen");
363 if (!enterFullscreen || !leaveFullscreen) {
364 return yield* Effect.die("fullscreen listeners were not registered");
365 }
366
367 enterFullscreen();
368 leaveFullscreen();
369 assert.deepEqual(fakeWindow.send.mock.calls, [
370 [WINDOW_FULLSCREEN_STATE_CHANNEL, true],
371 [WINDOW_FULLSCREEN_STATE_CHANNEL, false],
372 ]);
373 }).pipe(Effect.provide(layer));
374 }),
375 );
376
341 it.effect("recovers when the development renderer is temporarily unreachable", () =>377 it.effect("recovers when the development renderer is temporarily unreachable", () =>
342 Effect.gen(function* () {378 Effect.gen(function* () {
343 const fakeWindow = makeFakeBrowserWindow();379 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+10-1
...@@ -15,7 +15,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts";...@@ -15,7 +15,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
15import * as ElectronShell from "../electron/ElectronShell.ts";15import * as ElectronShell from "../electron/ElectronShell.ts";
16import * as ElectronTheme from "../electron/ElectronTheme.ts";16import * as ElectronTheme from "../electron/ElectronTheme.ts";
17import * as ElectronWindow from "../electron/ElectronWindow.ts";17import * as ElectronWindow from "../electron/ElectronWindow.ts";
18import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";18import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
19import * as PreviewManager from "../preview/Manager.ts";19import * as PreviewManager from "../preview/Manager.ts";
2020
21const TITLEBAR_HEIGHT = 40;21const TITLEBAR_HEIGHT = 40;
...@@ -365,6 +365,15 @@ export const make = Effect.gen(function* () {...@@ -365,6 +365,15 @@ export const make = Effect.gen(function* () {
365 window.setTitle(environment.displayName);365 window.setTitle(environment.displayName);
366 });366 });
367367
368 if (environment.platform === "darwin") {
369 window.on("enter-full-screen", () => {
370 window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true);
371 });
372 window.on("leave-full-screen", () => {
373 window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false);
374 });
375 }
376
368 let developmentLoadRetryIndex = 0;377 let developmentLoadRetryIndex = 0;
369 let developmentLoadRetryFiber: Fiber.Fiber<void, never> | undefined;378 let developmentLoadRetryFiber: Fiber.Fiber<void, never> | undefined;
370 const clearDevelopmentLoadRetry = () => {379 const clearDevelopmentLoadRetry = () => {
apps/web/src/components/AppSidebarLayout.tsx+26-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useEffect, type CSSProperties, type ReactNode } from "react";2import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
3import { useNavigate } from "@tanstack/react-router";3import { useNavigate } from "@tanstack/react-router";
44
5import { isElectron } from "../env";5import { isElectron } from "../env";
...@@ -55,11 +55,35 @@ function SidebarControl() {...@@ -55,11 +55,35 @@ function SidebarControl() {
5555
56export function AppSidebarLayout({ children }: { children: ReactNode }) {56export function AppSidebarLayout({ children }: { children: ReactNode }) {
57 const navigate = useNavigate();57 const navigate = useNavigate();
58 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
59 const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => {
60 const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState;
61 return isMacosDesktop && typeof getWindowFullscreenState === "function"
62 ? getWindowFullscreenState()
63 : false;
64 });
58 const macosWindowControlsStyle =65 const macosWindowControlsStyle =
59 isElectron && isMacPlatform(navigator.platform)66 isMacosDesktop && !isWindowFullscreen
60 ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)67 ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)
61 : undefined;68 : undefined;
6269
70 useEffect(() => {
71 if (!isMacosDesktop) return;
72 const bridge = window.desktopBridge;
73 if (!bridge) return;
74 const { getWindowFullscreenState, onWindowFullscreenStateChange } = bridge;
75 if (
76 typeof getWindowFullscreenState !== "function" ||
77 typeof onWindowFullscreenStateChange !== "function"
78 ) {
79 return;
80 }
81
82 const unsubscribe = onWindowFullscreenStateChange(setIsWindowFullscreen);
83 setIsWindowFullscreen(getWindowFullscreenState());
84 return unsubscribe;
85 }, [isMacosDesktop]);
86
63 useEffect(() => {87 useEffect(() => {
64 const onMenuAction = window.desktopBridge?.onMenuAction;88 const onMenuAction = window.desktopBridge?.onMenuAction;
65 if (typeof onMenuAction !== "function") {89 if (typeof onMenuAction !== "function") {
packages/contracts/src/ipc.ts+2
...@@ -994,6 +994,8 @@ export interface DesktopBridge {...@@ -994,6 +994,8 @@ export interface DesktopBridge {
994 ) => Promise<T | null>;994 ) => Promise<T | null>;
995 openExternal: (url: string) => Promise<boolean>;995 openExternal: (url: string) => Promise<boolean>;
996 onMenuAction: (listener: (action: string) => void) => () => void;996 onMenuAction: (listener: (action: string) => void) => () => void;
997 getWindowFullscreenState: () => boolean;
998 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
997 getUpdateState: () => Promise<DesktopUpdateState>;999 getUpdateState: () => Promise<DesktopUpdateState>;
998 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;1000 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;
999 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;1001 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;