| 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,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(); |
| 49 | 50 | ||
| 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); |
| 53 | 55 |
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"; |
| 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 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 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; | 9 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 8 | export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 10 | export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 9 | export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | 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,10 +1,14 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 4 | 5 | ||
| 6 | import type * as Electron from "electron"; | ||
| 7 | |||
| 5 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; | 8 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; |
| 6 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | 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 | const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { | 13 | const 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 | |||
| 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,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 | export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ | 68 | export 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"; |
| 28 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 28 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 29 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 29 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 30 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 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 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | 32 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 33 | import * as DesktopWindow from "./DesktopWindow.ts"; | 33 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 34 | import * as PreviewManager from "../preview/Manager.ts"; | 34 | import * 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; |
| 47 | 47 | ||
| 48 | function makeFakeBrowserWindow() { | 48 | function 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 | } |
| 93 | 98 | ||
| ... | @@ -338,6 +343,37 @@ describe("DesktopWindow", () => { | ... | @@ -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 | 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"; |
| 15 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 15 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 16 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 16 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 17 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 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 | import * as PreviewManager from "../preview/Manager.ts"; | 19 | import * as PreviewManager from "../preview/Manager.ts"; |
| 20 | 20 | ||
| 21 | const TITLEBAR_HEIGHT = 40; | 21 | const 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 | }); |
| 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 | 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 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 | import { useNavigate } from "@tanstack/react-router"; | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | ||
| 5 | import { isElectron } from "../env"; | 5 | import { isElectron } from "../env"; |
| ... | @@ -55,11 +55,35 @@ function SidebarControl() { | ... | @@ -55,11 +55,35 @@ function SidebarControl() { |
| 55 | 55 | ||
| 56 | export function AppSidebarLayout({ children }: { children: ReactNode }) { | 56 | export 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; |
| 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 | 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>; |