diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 4583e5124091..87a517df629e 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -20,6 +20,7 @@ export interface MakeDesktopEnvironmentInput { readonly dirname: string; readonly homeDirectory: string; readonly platform: NodeJS.Platform; + readonly systemVersion?: string | undefined; readonly processArch: string; readonly appVersion: string; readonly appPath: string; @@ -34,6 +35,7 @@ export class DesktopEnvironment extends Context.Service< readonly path: Path.Path; readonly dirname: string; readonly platform: NodeJS.Platform; + readonly systemVersion: string | undefined; readonly processArch: string; readonly isPackaged: boolean; readonly isDevelopment: boolean; @@ -190,6 +192,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( path, dirname: input.dirname, platform: input.platform, + systemVersion: input.systemVersion, processArch: input.processArch, isPackaged: input.isPackaged, isDevelopment, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f5ff3d5f6af6..37284c37be3b 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -94,6 +94,7 @@ function makeDesktopWindowLayer( flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, dispatchMenuAction: () => Effect.void, zoomMain: () => Effect.void, + isBackdropEnabled: () => false, syncAppearance: Effect.void, }); } diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 97d4359e1663..47710d7862aa 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -94,6 +94,7 @@ function makePoolLayer( flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), zoomMain: () => Effect.die("unexpected zoom"), + isBackdropEnabled: () => false, syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7971..c7881ec4e461 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -35,6 +35,7 @@ import { getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getSystemLocale, + getWindowBackdropState, getWindowFullscreenState, openExternal, probeRemoteEditors, @@ -53,6 +54,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); + yield* ipc.handleSync(getWindowBackdropState); 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 c4ef82ec8cb7..3657c25378b6 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,8 @@ export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; +export const GET_WINDOW_BACKDROP_STATE_CHANNEL = "desktop:get-window-backdrop-state"; +export const WINDOW_BACKDROP_STATE_CHANNEL = "desktop:window-backdrop-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"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index dd0625759e94..77b759a836f7 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -4,6 +4,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -24,5 +25,7 @@ export const setClientSettings = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) { const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; yield* clientSettings.set(settings); + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.syncAppearance; }), }); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..99633f7c0688 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -8,10 +8,13 @@ import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { getLocalEnvironmentBootstraps, + getWindowBackdropState, getWindowFullscreenState, pickProjectFavicon, } from "./window.ts"; @@ -153,6 +156,30 @@ describe("getWindowFullscreenState", () => { }); }); +describe("getWindowBackdropState", () => { + it.effect("returns false when the current platform is not Windows", () => { + const window = {} as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.isFalse(yield* getWindowBackdropState.handler()); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.mock(DesktopWindow.DesktopWindow)({ + isBackdropEnabled: () => false, + }), + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ), + ); + }); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..10207a00b140 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -33,6 +33,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 * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; import { @@ -85,6 +86,20 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getWindowBackdropState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_WINDOW_BACKDROP_STATE_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getWindowBackdropState")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.platform !== "win32") return false; + + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + return Option.isSome(window) && desktopWindow.isBackdropEnabled(window.value); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a1..cc609cb80d3e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -75,6 +75,7 @@ const desktopEnvironmentLayer = Layer.unwrap( dirname: __dirname, homeDirectory: NodeOS.homedir(), platform, + systemVersion: process.getSystemVersion(), processArch, ...metadata, }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d1313ff2e767..ce32233fe4eb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -140,6 +140,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + getWindowBackdropState: () => + ipcRenderer.sendSync(IpcChannels.GET_WINDOW_BACKDROP_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { if (typeof fullscreen !== "boolean") return; @@ -151,6 +153,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); }; }, + onWindowBackdropStateChange: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, enabled: unknown) => { + if (typeof enabled !== "boolean") return; + listener(enabled); + }; + + ipcRenderer.on(IpcChannels.WINDOW_BACKDROP_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WINDOW_BACKDROP_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/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index e32a5e2d0807..12c9b31b7131 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -25,6 +25,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", + desktopBackdropEnabled: true, favorites: [], fontFamilyCode: "", fontFamilyComposer: "", diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 595b0dd113d3..4702347d7c0f 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -83,6 +83,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), zoomMain: (direction) => Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), + isBackdropEnabled: () => false, syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..e9a9f3a56f8d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -43,7 +43,11 @@ 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, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + WINDOW_BACKDROP_STATE_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"; @@ -52,6 +56,7 @@ const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", homeDirectory: "/Users/alice", platform: "darwin", + systemVersion: undefined, processArch: "arm64", appVersion: "1.2.3", appPath: "/repo", @@ -104,6 +109,7 @@ function makeFakeBrowserWindow() { }), restore: vi.fn(), setBackgroundColor: vi.fn(), + setBackgroundMaterial: vi.fn(), setAutoHideCursor: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), @@ -126,6 +132,8 @@ function makeFakeBrowserWindow() { send: webContents.send, setZoomLevel: webContents.setZoomLevel, setBackgroundThrottling: webContents.setBackgroundThrottling, + setBackgroundColor: window.setBackgroundColor, + setBackgroundMaterial: window.setBackgroundMaterial, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, windowListeners, @@ -176,17 +184,23 @@ const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronTheme["Service"]); -const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_PORT: "3773", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", - }), +const makeDesktopEnvironmentLayer = ( + platform: NodeJS.Platform = environmentInput.platform, + systemVersion: string | undefined = environmentInput.systemVersion, +) => + DesktopEnvironment.layer({ ...environmentInput, platform, systemVersion }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), ), - ), -); + ); + +const desktopEnvironmentLayer = makeDesktopEnvironmentLayer(); const desktopWindowBoundsEquivalence = Schema.toEquivalence( DesktopAppSettings.DesktopWindowBoundsSchema, @@ -205,6 +219,8 @@ function makeTestLayer(input: { ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; readonly previewZoomReapplies?: number[]; + readonly platform?: NodeJS.Platform; + readonly systemVersion?: string; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -263,7 +279,7 @@ function makeTestLayer(input: { Layer.provide( Layer.mergeAll( desktopAssetsLayer, - desktopEnvironmentLayer, + makeDesktopEnvironmentLayer(input.platform, input.systemVersion), desktopAppSettingsLayer, desktopClientSettingsLayer, desktopServerExposureLayer, @@ -394,6 +410,93 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("uses transparent Acrylic-ready window options only on Windows", () => { + assert.isTrue(DesktopWindow.isWindowsAcrylicSupported("win32", "10.0.22621")); + assert.isTrue(DesktopWindow.isWindowsAcrylicSupported("win32", "10.0.26100")); + assert.isFalse(DesktopWindow.isWindowsAcrylicSupported("win32", "10.0.22000")); + assert.isFalse(DesktopWindow.isWindowsAcrylicSupported("win32", undefined)); + assert.isFalse(DesktopWindow.isWindowsAcrylicSupported("darwin", "10.0.22621")); + + assert.deepEqual(DesktopWindow.getWindowBackdropOptions("win32", true, true, true), { + backgroundColor: "#00000000", + backgroundMaterial: "acrylic", + frame: false, + roundedCorners: true, + thickFrame: true, + transparent: true, + }); + assert.deepEqual(DesktopWindow.getWindowBackdropOptions("win32", true, false, true), { + backgroundColor: "#0a0a0a", + backgroundMaterial: "none", + frame: false, + roundedCorners: true, + thickFrame: true, + transparent: true, + }); + assert.deepEqual(DesktopWindow.getWindowBackdropOptions("win32", true, true, false), { + backgroundColor: "#0a0a0a", + backgroundMaterial: "none", + frame: false, + roundedCorners: true, + thickFrame: true, + transparent: true, + }); + assert.deepEqual(DesktopWindow.getWindowBackdropOptions("darwin", true), { + backgroundColor: "#0a0a0a", + }); + }); + + it.effect("does not apply the Windows backdrop to unowned auxiliary windows", () => + 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, + platform: "win32", + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.syncAppearance; + }).pipe(Effect.provide(layer)); + + assert.equal(fakeWindow.setBackgroundMaterial.mock.calls.length, 0); + assert.equal(fakeWindow.setBackgroundColor.mock.calls.length, 0); + }), + ); + + it.effect("keeps the Windows window opaque when Acrylic is unsupported", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + platform: "win32", + systemVersion: "10.0.22000", + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.backgroundMaterial, "none"); + assert.equal(createdWindowOptions[0]?.backgroundColor, "#ffffff"); + assert.deepEqual(fakeWindow.setBackgroundMaterial.mock.calls, [["none"]]); + assert.deepEqual(fakeWindow.setBackgroundColor.mock.calls, [["#ffffff"]]); + assert.isFalse(desktopWindow.isBackdropEnabled(fakeWindow.window)); + assert.deepEqual(fakeWindow.send.mock.calls, [[WINDOW_BACKDROP_STATE_CHANNEL, false]]); + }).pipe(Effect.provide(layer)); + }), + ); + it("restores bounds only when the window fits within a connected display", () => { const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; const displays = [ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..c762452d4af9 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; @@ -21,6 +22,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, QUIT_SHORTCUT_CHANNEL, + WINDOW_BACKDROP_STATE_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -34,6 +36,9 @@ const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linu const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; +const WINDOWS_TRANSPARENT_BACKGROUND_COLOR = "#00000000"; +const WINDOWS_ACRYLIC_MATERIAL = "acrylic" as const; +const WINDOWS_ACRYLIC_MIN_BUILD = 22621; // Windows 11 22H2 const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; // Renderer crash (usually V8 OOM on long sessions) recovery: reload after a // short delay, at most MAX_ATTEMPTS times per rolling WINDOW so a renderer @@ -106,6 +111,7 @@ export class DesktopWindow extends Context.Service< // guest page instead of the app UI. The menu routes here to always target // the main window. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; + readonly isBackdropEnabled: (window: Electron.BrowserWindow) => boolean; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -129,6 +135,157 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +export function isWindowsAcrylicSupported( + platform: NodeJS.Platform, + systemVersion: string | undefined, +): boolean { + if (platform !== "win32" || systemVersion === undefined) { + return false; + } + + const build = Number.parseInt(systemVersion.split(".")[2] ?? "", 10); + return Number.isInteger(build) && build >= WINDOWS_ACRYLIC_MIN_BUILD; +} + +type WindowBackdropState = { + readonly windowsWithAcrylicBackdrop: WeakSet; + readonly windowsWithoutAcrylicBackdrop: WeakSet; + readonly windowsManagedForBackdrop: WeakSet; + readonly windowsWithBackdropStateListener: WeakSet; +}; + +const WindowsBackdropMaterial = Schema.Literals(["acrylic", "none"]); + +class DesktopWindowBackdropError extends Schema.TaggedErrorClass()( + "DesktopWindowBackdropError", + { + material: WindowsBackdropMaterial, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to apply Windows backdrop material "${this.material}".`; + } +} + +function sendWindowBackdropState( + window: Electron.BrowserWindow, + backdropState: WindowBackdropState, +): void { + if (window.isDestroyed()) return; + + try { + window.webContents.send( + WINDOW_BACKDROP_STATE_CHANNEL, + backdropState.windowsWithAcrylicBackdrop.has(window), + ); + } catch { + // The renderer may not be ready yet. The did-finish-load listener retries it. + } +} + +function registerWindowBackdropStateSync( + window: Electron.BrowserWindow, + backdropState: WindowBackdropState, +): void { + if (!backdropState.windowsWithBackdropStateListener.has(window)) { + try { + window.webContents.on("did-finish-load", () => + sendWindowBackdropState(window, backdropState), + ); + backdropState.windowsWithBackdropStateListener.add(window); + } catch { + // Native appearance must remain best effort if a test double or old shell + // does not expose the renderer event surface. + } + } + + sendWindowBackdropState(window, backdropState); +} + +export function getWindowBackdropOptions( + platform: NodeJS.Platform, + shouldUseDarkColors: boolean, + desktopBackdropEnabled = true, + windowsAcrylicSupported = false, +): Pick< + Electron.BrowserWindowConstructorOptions, + | "backgroundColor" + | "backgroundMaterial" + | "frame" + | "roundedCorners" + | "thickFrame" + | "transparent" +> { + if (platform !== "win32") { + return { backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors) }; + } + + const useAcrylic = desktopBackdropEnabled && windowsAcrylicSupported; + + return { + backgroundColor: useAcrylic + ? WINDOWS_TRANSPARENT_BACKGROUND_COLOR + : getInitialWindowBackgroundColor(shouldUseDarkColors), + backgroundMaterial: useAcrylic ? WINDOWS_ACRYLIC_MATERIAL : "none", + frame: false, + roundedCorners: true, + thickFrame: true, + transparent: true, + }; +} + +function applyWindowsBackdrop( + window: Electron.BrowserWindow, + platform: NodeJS.Platform, + shouldUseDarkColors: boolean, + desktopBackdropEnabled: boolean, + windowsAcrylicSupported: boolean, + backdropState: WindowBackdropState, +): Effect.Effect { + if (platform !== "win32") { + return Effect.void; + } + + const useAcrylic = desktopBackdropEnabled && windowsAcrylicSupported; + const material = useAcrylic ? WINDOWS_ACRYLIC_MATERIAL : "none"; + + return Effect.try({ + try: () => { + backdropState.windowsManagedForBackdrop.add(window); + window.setBackgroundMaterial(material); + if (useAcrylic) { + backdropState.windowsWithAcrylicBackdrop.add(window); + backdropState.windowsWithoutAcrylicBackdrop.delete(window); + window.setBackgroundColor(WINDOWS_TRANSPARENT_BACKGROUND_COLOR); + } else { + backdropState.windowsWithAcrylicBackdrop.delete(window); + backdropState.windowsWithoutAcrylicBackdrop.add(window); + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + } + }, + catch: (cause) => new DesktopWindowBackdropError({ material, cause }), + }).pipe( + Effect.andThen(Effect.sync(() => registerWindowBackdropStateSync(window, backdropState))), + Effect.catchTags({ + DesktopWindowBackdropError: (error) => + Effect.gen(function* () { + backdropState.windowsWithAcrylicBackdrop.delete(window); + backdropState.windowsWithoutAcrylicBackdrop.add(window); + try { + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + } catch { + // Preserve the original backdrop failure; window creation must stay best effort. + } + registerWindowBackdropStateSync(window, backdropState); + yield* logWindowWarning("Windows backdrop material unavailable; using solid background", { + cause: error.cause, + }); + }), + }), + ); +} + type DisplayBounds = Pick; function windowFitsWithinDisplay( @@ -171,8 +328,12 @@ export function resolveInitialMainWindowBounds( // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. -function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { - const background = getInitialWindowBackgroundColor(shouldUseDarkColors); +function buildConnectingSplashDataUrl( + shouldUseDarkColors: boolean, + platform: NodeJS.Platform, +): string { + const background = + platform === "win32" ? "transparent" : getInitialWindowBackgroundColor(shouldUseDarkColors); const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; @@ -232,13 +393,37 @@ function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, platform: NodeJS.Platform, + desktopBackdropEnabled: boolean, + windowsAcrylicSupported: boolean, + backdropState: WindowBackdropState, ): Effect.Effect { - return Effect.sync(() => { + return Effect.gen(function* () { if (window.isDestroyed()) { return; } - window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + if (platform === "win32") { + if (backdropState.windowsManagedForBackdrop.has(window)) { + const useAcrylic = desktopBackdropEnabled && windowsAcrylicSupported; + if (useAcrylic && backdropState.windowsWithAcrylicBackdrop.has(window)) { + window.setBackgroundColor(WINDOWS_TRANSPARENT_BACKGROUND_COLOR); + } else if (!useAcrylic && backdropState.windowsWithoutAcrylicBackdrop.has(window)) { + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + } else { + yield* applyWindowsBackdrop( + window, + platform, + shouldUseDarkColors, + desktopBackdropEnabled, + windowsAcrylicSupported, + backdropState, + ); + } + } + } else { + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + } + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); @@ -274,6 +459,10 @@ export const make = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; + const windowsAcrylicSupported = isWindowsAcrylicSupported( + environment.platform, + environment.systemVersion, + ); // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -283,9 +472,25 @@ export const make = Effect.gen(function* () { // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); + const backdropState: WindowBackdropState = { + windowsWithAcrylicBackdrop: new WeakSet(), + windowsWithoutAcrylicBackdrop: new WeakSet(), + windowsManagedForBackdrop: new WeakSet(), + windowsWithBackdropStateListener: new WeakSet(), + }; + const isBackdropEnabled = (window: Electron.BrowserWindow): boolean => + backdropState.windowsWithAcrylicBackdrop.has(window); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + const getDesktopBackdropEnabled = clientSettings.get.pipe( + Effect.map((settings) => + Option.match(settings, { + onNone: () => DEFAULT_CLIENT_SETTINGS.desktopBackdropEnabled, + onSome: (value) => value.desktopBackdropEnabled, + }), + ), + ); let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { @@ -323,6 +528,7 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const desktopBackdropEnabled = yield* getDesktopBackdropEnabled; const persistedSettings = yield* desktopSettings.get; const persistedBounds = persistedSettings.mainWindowBounds; const displayBoundsResult = yield* Effect.sync(() => { @@ -353,7 +559,12 @@ export const make = Effect.gen(function* () { show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + ...getWindowBackdropOptions( + environment.platform, + shouldUseDarkColors, + desktopBackdropEnabled, + windowsAcrylicSupported, + ), ...iconOption, title: environment.displayName, ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), @@ -371,6 +582,14 @@ export const make = Effect.gen(function* () { webviewTag: true, }, }); + yield* applyWindowsBackdrop( + window, + environment.platform, + shouldUseDarkColors, + desktopBackdropEnabled, + windowsAcrylicSupported, + backdropState, + ); if (environment.platform === "darwin") { window.setAutoHideCursor(false); @@ -795,6 +1014,7 @@ export const make = Effect.gen(function* () { if (Option.isSome(existingWindow)) return; const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const desktopBackdropEnabled = yield* getDesktopBackdropEnabled; const splash = yield* electronWindow.create({ width: 360, height: 220, @@ -806,7 +1026,12 @@ export const make = Effect.gen(function* () { center: true, show: false, skipTaskbar: false, - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + ...getWindowBackdropOptions( + environment.platform, + shouldUseDarkColors, + desktopBackdropEnabled, + windowsAcrylicSupported, + ), title: environment.displayName, webPreferences: { contextIsolation: true, @@ -823,7 +1048,15 @@ export const make = Effect.gen(function* () { splash.show(); } }); - void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); + yield* applyWindowsBackdrop( + splash, + environment.platform, + shouldUseDarkColors, + desktopBackdropEnabled, + windowsAcrylicSupported, + backdropState, + ); + void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors, environment.platform)); yield* logWindowInfo("connecting splash shown"); }).pipe( // The splash is best-effort UX — never let it fail startup. @@ -908,10 +1141,19 @@ export const make = Effect.gen(function* () { // own zoom, so put each guest back where the preview left it. yield* previewManager.reapplyZoom(); }), + isBackdropEnabled, syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const desktopBackdropEnabled = yield* getDesktopBackdropEnabled; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors, environment.platform), + syncWindowAppearance( + window, + shouldUseDarkColors, + environment.platform, + desktopBackdropEnabled, + windowsAcrylicSupported, + backdropState, + ), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 42e618500481..6c123f7cdb44 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6849,7 +6849,7 @@ function ChatViewContent(props: ChatViewProps) { composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); return ( -
+
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
{!rightPanelOpen ? panelLayoutControls : null} ) : ( <> -
+
{isSelectedPatchTruncated && (

This diff was truncated because it exceeded the preview limit. The changes shown are diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index a9b7cf542e02..c12ab61c1697 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -28,7 +28,7 @@ export function DiffPanelShell(props: { return (

-
+
{isElectron ? ( No active thread diff --git a/apps/web/src/components/SplashScreen.tsx b/apps/web/src/components/SplashScreen.tsx index a0b593a95078..5d09f75875e2 100644 --- a/apps/web/src/components/SplashScreen.tsx +++ b/apps/web/src/components/SplashScreen.tsx @@ -1,6 +1,6 @@ export function SplashScreen() { return ( -
+
T3 Code
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index abd9bf9edfd5..b85a321a968a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1392,7 +1392,7 @@ export default function ThreadTerminalDrawer({