Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class DesktopEnvironment extends Context.Service<
readonly clientSettingsPath: string;
readonly savedEnvironmentRegistryPath: string;
readonly serverSettingsPath: string;
readonly windowStatePath: string;
readonly logDir: string;
readonly browserArtifactsDir: string;
readonly rootDir: string;
Expand Down Expand Up @@ -178,6 +179,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
clientSettingsPath: path.join(stateDir, "client-settings.json"),
savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),
serverSettingsPath: path.join(stateDir, "settings.json"),
windowStatePath: path.join(stateDir, "window-state.json"),
logDir: path.join(stateDir, "logs"),
browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
rootDir,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
import * as BrowserSession from "./preview/BrowserSession.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
import * as DesktopWindowState from "./window/DesktopWindowState.ts";
import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";

Expand Down Expand Up @@ -124,6 +125,7 @@ const desktopFoundationLayer = Layer.mergeAll(
DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)),
DesktopAssets.layer,
DesktopObservability.layer,
DesktopWindowState.layer,
).pipe(Layer.provideMerge(desktopEnvironmentLayer));

const desktopSshLayer = desktopSshEnvironmentLayer.pipe(
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
import * as DesktopWindow from "./DesktopWindow.ts";
import * as DesktopWindowState from "./DesktopWindowState.ts";
import * as PreviewManager from "../preview/Manager.ts";

const environmentInput = {
Expand Down Expand Up @@ -91,6 +92,16 @@ function makeFakeBrowserWindow() {
};
}

// Stubbed so these tests stay filesystem-free. DesktopWindowState has its own suite.
const desktopWindowStateLayer = Layer.succeed(DesktopWindowState.DesktopWindowState, {
load: () =>
Effect.succeed({
bounds: { x: 0, y: 0, width: 1100, height: 780 },
restoreMode: "normal" as const,
}),
attach: () => Effect.void,
} satisfies DesktopWindowState.DesktopWindowState["Service"]);

const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
iconPaths: Effect.succeed({
ico: Option.none<string>(),
Expand Down Expand Up @@ -171,6 +182,7 @@ function makeTestLayer(input: {
desktopAssetsLayer,
desktopEnvironmentLayer,
desktopServerExposureLayer,
desktopWindowStateLayer,
DesktopState.layer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
Expand Down Expand Up @@ -268,6 +280,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
desktopAssetsLayer,
desktopEnvironmentLayer,
desktopServerExposureLayer,
desktopWindowStateLayer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: () => Effect.succeed(true),
Expand Down
29 changes: 24 additions & 5 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";
import * as PreviewManager from "../preview/Manager.ts";
import * as DesktopWindowState from "./DesktopWindowState.ts";

const DEFAULT_WINDOW_WIDTH = 1100;
const DEFAULT_WINDOW_HEIGHT = 780;
const MIN_WINDOW_WIDTH = 840;
const MIN_WINDOW_HEIGHT = 620;
const TITLEBAR_HEIGHT = 40;
const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
Expand Down Expand Up @@ -45,7 +50,8 @@ type DesktopWindowRuntimeServices =
| ElectronShell.ElectronShell
| ElectronTheme.ElectronTheme
| ElectronWindow.ElectronWindow
| PreviewManager.PreviewManager;
| PreviewManager.PreviewManager
| DesktopWindowState.DesktopWindowState;

export type DesktopWindowError =
| ElectronWindow.ElectronWindowCreateError
Expand Down Expand Up @@ -202,6 +208,7 @@ export const make = Effect.gen(function* () {
const electronTheme = yield* ElectronTheme.ElectronTheme;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const previewManager = yield* PreviewManager.PreviewManager;
const windowState = yield* DesktopWindowState.DesktopWindowState;
// 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
Expand Down Expand Up @@ -250,11 +257,18 @@ export const make = Effect.gen(function* () {
const iconPaths = yield* assets.iconPaths;
const iconOption = getIconOption(iconPaths, environment.platform);
const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
const initialWindowState = yield* windowState.load({
defaultBounds: { x: 0, y: 0, width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT },
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
});
const window = yield* electronWindow.create({
width: 1100,
height: 780,
minWidth: 840,
minHeight: 620,
x: initialWindowState.bounds.x,
y: initialWindowState.bounds.y,
width: initialWindowState.bounds.width,
height: initialWindowState.bounds.height,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
show: false,
autoHideMenuBar: true,
...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}),
Expand All @@ -275,6 +289,7 @@ export const make = Effect.gen(function* () {
window.setAutoHideCursor(false);
}

yield* windowState.attach(window);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium window/DesktopWindow.ts:292

windowState.attach(window) is called immediately after window creation, before the reveal callback runs window.maximize(). If the window is closed before ready-to-show/did-finish-load fires (e.g. slow or failed renderer startup), the close handler persists window.isMaximized() as false, overwriting the saved maximized state. The next launch then restores a normal-sized window instead of the user's maximized state. Consider calling window.maximize() right after windowState.attach, or deferring windowState.attach until after the maximize is applied.

Also found in 1 other location(s)

apps/desktop/src/window/DesktopWindowState.ts:251

When resolveDocument sees window.isFullScreen(), it unconditionally writes restoreMode: &#34;fullscreen-origin&#34; and drops whether the pre-fullscreen window was maximized. If the user enters macOS fullscreen from a maximized window and quits there, the next launch restores a large normal window instead of re-maximizing, so the previous maximize state is silently lost.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/window/DesktopWindow.ts around line 292:

`windowState.attach(window)` is called immediately after window creation, before the reveal callback runs `window.maximize()`. If the window is closed before `ready-to-show`/`did-finish-load` fires (e.g. slow or failed renderer startup), the close handler persists `window.isMaximized()` as `false`, overwriting the saved `maximized` state. The next launch then restores a normal-sized window instead of the user's maximized state. Consider calling `window.maximize()` right after `windowState.attach`, or deferring `windowState.attach` until after the maximize is applied.

Also found in 1 other location(s):
- apps/desktop/src/window/DesktopWindowState.ts:251 -- When `resolveDocument` sees `window.isFullScreen()`, it unconditionally writes `restoreMode: "fullscreen-origin"` and drops whether the pre-fullscreen window was maximized. If the user enters macOS fullscreen from a maximized window and quits there, the next launch restores a large normal window instead of re-maximizing, so the previous maximize state is silently lost.

yield* previewManager.setMainWindow(window);
window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
if (
Expand Down Expand Up @@ -461,6 +476,10 @@ export const make = Effect.gen(function* () {
revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
}
bindFirstRevealTrigger(revealSubscribers, () => {
// Re-maximize before showing so the window doesn't flash at normal bounds first.
if (initialWindowState.restoreMode === "maximized" && !window.isDestroyed()) {
window.maximize();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early quit drops maximized mode

Medium Severity

Restored maximized mode is applied only in the first-reveal callback, while attach persists on close from the live window via readRestorableState. If the user quits while the main window is still hidden (e.g. during the connecting splash), isMaximized() is false and the saved file drops back to normal, losing maximized layout on the next launch.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1708db1. Configure here.

// Reveal the real window, then close the connecting splash (if any) so the
// two don't overlap and there's no blank gap between them.
void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash));
Expand Down
Loading
Loading