diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 0135225f90c48..7e47724eb6945 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -191,6 +191,18 @@ describe("DesktopSettings", () => { ), ); + it.effect("persists and reloads compositor bounds below the preferred minimum", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const mainWindowBounds = { x: 12, y: 563, width: 280, height: 240 }; + yield* settings.setMainWindowBounds(mainWindowBounds, false); + const loaded = yield* settings.load; + assert.deepEqual(loaded.mainWindowBounds, mainWindowBounds); + }), + ), + ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 5767057b4a35d..7b86498be329c 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -47,15 +47,12 @@ export interface DesktopSettingsChange { } const DEFAULT_TAILSCALE_SERVE_PORT = 443; -const MIN_MAIN_WINDOW_SIZE = { - width: 840, - height: 620, -} as const; +// Compositors can assign bounds smaller than the requested native minimum. export const DesktopWindowBoundsSchema = Schema.Struct({ x: Schema.Int, y: Schema.Int, - width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), - height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), + width: Schema.Int.check(Schema.isGreaterThan(0)), + height: Schema.Int.check(Schema.isGreaterThan(0)), }); export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; export const DEFAULT_MAIN_WINDOW_SIZE = { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8b6ab7c7d8a5e..d5b0e5c057cfe 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -25,9 +25,14 @@ vi.mock("electron", async (importOriginal) => ({ })), }, screen: { + getPrimaryDisplay: vi.fn(() => ({ + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + })), getAllDisplays: vi.fn(() => [ { bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, }, ]), }, @@ -895,6 +900,91 @@ describe("DesktopWindow", () => { }), ); + it.effect.each([ + { + scenario: "missing saved bounds and a taskbar on a small display", + workArea: { x: -1280, y: 26, width: 1280, height: 694 }, + savedBounds: null, + expected: { x: -1190, y: 26, width: 1100, height: 694, minWidth: 360, minHeight: 320 }, + }, + { + scenario: "missing saved bounds and default size that just fits above the Dock", + workArea: { x: 0, y: 25, width: 1440, height: 800 }, + savedBounds: null, + expected: { x: 170, y: 35, width: 1100, height: 780, minWidth: 360, minHeight: 320 }, + }, + { + scenario: "offscreen saved bounds and a work area below the preferred minimum", + workArea: { x: 40, y: 26, width: 280, height: 240 }, + savedBounds: { x: 2040, y: 80, width: 1320, height: 880 }, + expected: { x: 40, y: 26, width: 280, height: 240, minWidth: 280, minHeight: 240 }, + }, + { + scenario: "saved bounds below the preferred minimum on a large display", + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + savedBounds: { x: 12, y: 563, width: 280, height: 240 }, + expected: { x: 12, y: 563, width: 280, height: 240, minWidth: 280, minHeight: 240 }, + }, + { + scenario: "saved bounds overlapping the taskbar within the full display", + workArea: { x: 0, y: 0, width: 1920, height: 1040 }, + savedBounds: { x: 100, y: 286, width: 1100, height: 780 }, + expected: { x: 100, y: 286, width: 1100, height: 780, minWidth: 360, minHeight: 320 }, + }, + { + scenario: "a small primary display listed after a large secondary display", + workArea: { x: -800, y: 26, width: 800, height: 574 }, + secondaryWorkArea: { x: 0, y: 0, width: 1920, height: 1080 }, + savedBounds: null, + expected: { x: -800, y: 26, width: 800, height: 574, minWidth: 360, minHeight: 320 }, + }, + ])( + "opens a window that fits with $scenario", + ({ workArea, secondaryWorkArea, savedBounds, expected }) => + Effect.gen(function* () { + const previousPrimary = Electron.screen.getPrimaryDisplay(); + const display = { + ...Electron.screen.getAllDisplays()[0]!, + bounds: { x: workArea.x, y: 0, width: workArea.width, height: workArea.height + 26 }, + workArea, + }; + vi.mocked(Electron.screen.getAllDisplays).mockReturnValueOnce([ + ...(secondaryWorkArea + ? [{ ...display, bounds: secondaryWorkArea, workArea: secondaryWorkArea }] + : []), + display, + ]); + vi.mocked(Electron.screen.getPrimaryDisplay).mockReturnValue(display); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + vi.mocked(Electron.screen.getPrimaryDisplay).mockReturnValue(previousPrimary); + }), + ); + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: savedBounds, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + }).pipe(Effect.provide(layer)); + + assert.equal(createdWindowOptions.length, 1); + assert.containsSubset(createdWindowOptions[0], expected); + }).pipe(Effect.scoped), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6270f80a4a7e6..baf4aac3c48d0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -55,6 +55,7 @@ function syncMacosWindowButtons(window: Electron.BrowserWindow): void { const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const MIN_MAIN_WINDOW_SIZE = { width: 360, height: 320 } as const; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; @@ -163,6 +164,7 @@ function windowBoundsEqual( export function resolveInitialMainWindowBounds( persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, displays: readonly DisplayBounds[], + primaryWorkArea: DisplayBounds | null = null, ): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { if ( persistedBounds !== null && @@ -170,7 +172,18 @@ export function resolveInitialMainWindowBounds( ) { return persistedBounds; } - return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; + const display = primaryWorkArea; + const defaults = DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; + // Electron centers an unpositioned macOS window on the full screen, which can put it under the Dock. + if (!display) return defaults; + const width = Math.min(defaults.width, display.width); + const height = Math.min(defaults.height, display.height); + return { + x: display.x + Math.floor((display.width - width) / 2), + y: display.y + Math.floor((display.height - height) / 2), + width, + height, + }; } function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { @@ -354,6 +367,7 @@ export const make = Effect.gen(function* () { return { _tag: "Success" as const, bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), + primaryWorkArea: Electron.screen.getPrimaryDisplay().workArea, }; } catch (cause) { return { _tag: "Failure" as const, cause }; @@ -365,16 +379,20 @@ export const make = Effect.gen(function* () { : yield* logWindowWarning("failed to read connected displays; using defaults", { cause: displayBoundsResult.cause, }).pipe(Effect.as([])); - const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + const initialBounds = resolveInitialMainWindowBounds( + persistedBounds, + displayBounds, + displayBoundsResult._tag === "Success" ? displayBoundsResult.primaryWorkArea : null, + ); const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; - if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { + if (persistedBounds !== null && !restoredPersistedBounds) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, - minWidth: 840, - minHeight: 620, + minWidth: Math.min(MIN_MAIN_WINDOW_SIZE.width, initialBounds.width), + minHeight: Math.min(MIN_MAIN_WINDOW_SIZE.height, initialBounds.height), show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}),