diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index ec4284f5d1a..e478d0c6eff 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -35,6 +35,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getWindowFullscreenState, openExternal, pickFolder, setTheme, @@ -48,6 +49,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index d28c507fa7f..0d5c79cf55a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; +export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index a67b3246fb4..13e6e8d3956 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,10 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as Electron from "electron"; + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; -import { getLocalEnvironmentBootstraps } from "./window.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => { }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); }); }); + +describe("getWindowFullscreenState", () => { + it.effect("reads the current native window state", () => { + const window = { isFullScreen: () => true } as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.isTrue(yield* getWindowFullscreenState.handler()); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 94357c7a161..a4e98aaabad 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -55,6 +55,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + return Option.isSome(window) && window.value.isFullScreen(); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4b5c0d2f656..9f92cfcf2fa 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + getWindowFullscreenState: () => + ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + onWindowFullscreenStateChange: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + listener(fullscreen); + }; + + ipcRenderer.on(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 280f2109fec..492ef1ad577 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -28,7 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -46,6 +46,7 @@ const environmentInput = { } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { + const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), @@ -66,10 +67,13 @@ function makeFakeBrowserWindow() { close: vi.fn(), focus: vi.fn(), isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), @@ -88,6 +92,7 @@ function makeFakeBrowserWindow() { send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, + windowListeners, }; } @@ -338,6 +343,37 @@ describe("DesktopWindow", () => { }), ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); + if (!enterFullscreen || !leaveFullscreen) { + return yield* Effect.die("fullscreen listeners were not registered"); + } + + enterFullscreen(); + leaveFullscreen(); + assert.deepEqual(fakeWindow.send.mock.calls, [ + [WINDOW_FULLSCREEN_STATE_CHANNEL, true], + [WINDOW_FULLSCREEN_STATE_CHANNEL, false], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..c808c77e51c 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -15,7 +15,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; @@ -365,6 +365,15 @@ export const make = Effect.gen(function* () { window.setTitle(environment.displayName); }); + if (environment.platform === "darwin") { + window.on("enter-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); + }); + window.on("leave-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); + }); + } + let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; const clearDevelopmentLoadRetry = () => { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d429..455cc9199f6 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -55,11 +55,35 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { + const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; + return isMacosDesktop && typeof getWindowFullscreenState === "function" + ? getWindowFullscreenState() + : false; + }); const macosWindowControlsStyle = - isElectron && isMacPlatform(navigator.platform) + isMacosDesktop && !isWindowFullscreen ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) : undefined; + useEffect(() => { + if (!isMacosDesktop) return; + const bridge = window.desktopBridge; + if (!bridge) return; + const { getWindowFullscreenState, onWindowFullscreenStateChange } = bridge; + if ( + typeof getWindowFullscreenState !== "function" || + typeof onWindowFullscreenStateChange !== "function" + ) { + return; + } + + const unsubscribe = onWindowFullscreenStateChange(setIsWindowFullscreen); + setIsWindowFullscreen(getWindowFullscreenState()); + return unsubscribe; + }, [isMacosDesktop]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bbb4d934ca2..02f98ebee58 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -994,6 +994,8 @@ export interface DesktopBridge { ) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + getWindowFullscreenState: () => boolean; + onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise;