| author | |
| committer | |
| log | 0418301de79bb7b6f82305236325cb9811615e9e |
| tree | 909f568bf7ccd6fb6b69db0526255c29d0283c3b |
| parent | 5e8b2c800ecd98e23adbdbfc8970f4687ac8254b |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 124 insertions(+), 6 deletions(-)
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -35,6 +35,7 @@ import { |
| 35 | 35 | getAppBranding, |
| 36 | 36 | getLocalEnvironmentBootstraps, |
| 37 | 37 | getLocalEnvironmentBearerToken, |
| 38 | getWindowFullscreenState, | |
| 38 | 39 | openExternal, |
| 39 | 40 | pickFolder, |
| 40 | 41 | setTheme, |
| ... | ... | @@ -48,6 +49,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 48 | 49 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 49 | 50 | |
| 50 | 51 | yield* ipc.handleSync(getAppBranding); |
| 52 | yield* ipc.handleSync(getWindowFullscreenState); | |
| 51 | 53 | yield* ipc.handleSync(getLocalEnvironmentBootstraps); |
| 52 | 54 | yield* ipc.handle(getLocalEnvironmentBearerToken); |
| 53 | 55 |
apps/desktop/src/ipc/channels.ts+2| ... | ... | @@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | 6 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 7 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; | |
| 8 | export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; | |
| 7 | 9 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 8 | 10 | export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 9 | 11 | export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; |
apps/desktop/src/ipc/methods/window.test.ts+21-1| ... | ... | @@ -1,10 +1,14 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | |
| 3 | 4 | import * as Option from "effect/Option"; |
| 4 | 5 | |
| 6 | import type * as Electron from "electron"; | |
| 7 | ||
| 5 | 8 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; |
| 6 | 9 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 7 | import { getLocalEnvironmentBootstraps } from "./window.ts"; | |
| 10 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | |
| 11 | import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; | |
| 8 | 12 | |
| 9 | 13 | const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 10 | 14 | executablePath: "wsl.exe", |
| ... | ... | @@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => { |
| 126 | 130 | }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); |
| 127 | 131 | }); |
| 128 | 132 | }); |
| 133 | ||
| 134 | describe("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 | 55 | }), |
| 56 | 56 | }); |
| 57 | 57 | |
| 58 | export 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 | ||
| 58 | 68 | export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ |
| 59 | 69 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, |
| 60 | 70 | result: Schema.Array(DesktopEnvironmentBootstrapSchema), |
apps/desktop/src/preload.ts+13| ... | ... | @@ -116,6 +116,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 116 | 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 | 132 | getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), |
| 120 | 133 | setUpdateChannel: (channel) => |
| 121 | 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 | 28 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 29 | 29 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 30 | 30 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 31 | import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; | |
| 31 | import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; | |
| 32 | 32 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 33 | 33 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 34 | 34 | import * as PreviewManager from "../preview/Manager.ts"; |
| ... | ... | @@ -46,6 +46,7 @@ const environmentInput = { |
| 46 | 46 | } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; |
| 47 | 47 | |
| 48 | 48 | function makeFakeBrowserWindow() { |
| 49 | const windowListeners = new Map<string, (...args: readonly unknown[]) => void>(); | |
| 49 | 50 | const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>(); |
| 50 | 51 | const webContents = { |
| 51 | 52 | copyImageAt: vi.fn(), |
| ... | ... | @@ -66,10 +67,13 @@ function makeFakeBrowserWindow() { |
| 66 | 67 | close: vi.fn(), |
| 67 | 68 | focus: vi.fn(), |
| 68 | 69 | isDestroyed: vi.fn(() => false), |
| 70 | isFullScreen: vi.fn(() => false), | |
| 69 | 71 | isMinimized: vi.fn(() => false), |
| 70 | 72 | isVisible: vi.fn(() => true), |
| 71 | 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 | 77 | once: vi.fn(), |
| 74 | 78 | restore: vi.fn(), |
| 75 | 79 | setBackgroundColor: vi.fn(), |
| ... | ... | @@ -88,6 +92,7 @@ function makeFakeBrowserWindow() { |
| 88 | 92 | send: webContents.send, |
| 89 | 93 | setAutoHideCursor: window.setAutoHideCursor, |
| 90 | 94 | webContentsListeners, |
| 95 | windowListeners, | |
| 91 | 96 | }; |
| 92 | 97 | } |
| 93 | 98 | |
| ... | ... | @@ -338,6 +343,37 @@ describe("DesktopWindow", () => { |
| 338 | 343 | }), |
| 339 | 344 | ); |
| 340 | 345 | |
| 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 | 377 | it.effect("recovers when the development renderer is temporarily unreachable", () => |
| 342 | 378 | Effect.gen(function* () { |
| 343 | 379 | const fakeWindow = makeFakeBrowserWindow(); |
apps/desktop/src/window/DesktopWindow.ts+10-1| ... | ... | @@ -15,7 +15,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; |
| 15 | 15 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 16 | 16 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 17 | 17 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 18 | import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; | |
| 18 | import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; | |
| 19 | 19 | import * as PreviewManager from "../preview/Manager.ts"; |
| 20 | 20 | |
| 21 | 21 | const TITLEBAR_HEIGHT = 40; |
| ... | ... | @@ -365,6 +365,15 @@ export const make = Effect.gen(function* () { |
| 365 | 365 | window.setTitle(environment.displayName); |
| 366 | 366 | }); |
| 367 | 367 | |
| 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 | 377 | let developmentLoadRetryIndex = 0; |
| 369 | 378 | let developmentLoadRetryFiber: Fiber.Fiber<void, never> | undefined; |
| 370 | 379 | const clearDevelopmentLoadRetry = () => { |
apps/web/src/components/AppSidebarLayout.tsx+26-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useEffect, type CSSProperties, type ReactNode } from "react"; | |
| 2 | import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; | |
| 3 | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | |
| 5 | 5 | import { isElectron } from "../env"; |
| ... | ... | @@ -55,11 +55,35 @@ function SidebarControl() { |
| 55 | 55 | |
| 56 | 56 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 57 | 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 | 65 | const macosWindowControlsStyle = |
| 59 | isElectron && isMacPlatform(navigator.platform) | |
| 66 | isMacosDesktop && !isWindowFullscreen | |
| 60 | 67 | ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) |
| 61 | 68 | : undefined; |
| 62 | 69 | |
| 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 | 87 | useEffect(() => { |
| 64 | 88 | const onMenuAction = window.desktopBridge?.onMenuAction; |
| 65 | 89 | if (typeof onMenuAction !== "function") { |
packages/contracts/src/ipc.ts+2| ... | ... | @@ -994,6 +994,8 @@ export interface DesktopBridge { |
| 994 | 994 | ) => Promise<T | null>; |
| 995 | 995 | openExternal: (url: string) => Promise<boolean>; |
| 996 | 996 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 997 | getWindowFullscreenState: () => boolean; | |
| 998 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; | |
| 997 | 999 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 998 | 1000 | setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>; |
| 999 | 1001 | checkForUpdate: () => Promise<DesktopUpdateCheckResult>; |