authorgravatar for me@anirudhs.netAnirudh Coontoor <me@anirudhs.net> 2026-07-20 18:13:22+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 14:43:22+02:00
log2c199aacac3244adc93c5f4a9c838683e93946dd
tree2d217d0a399d187cc47050cf938400d88306a294
parent8ca4eec9ca46e1dd2f2427ae2405f5f417e8f95d
signature Signed by PGP key B5690EEEBB952194

fix(desktop): preserve main window bounds (#3851)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

13 files changed, 917 insertions(+), 16 deletions(-)

apps/desktop/src/app/DesktopLifecycle.ts+7-1
...@@ -73,8 +73,14 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(...@@ -73,8 +73,14 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(
73}73}
7474
75const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(75const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
76 function* (): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown> {76 function* (): Effect.fn.Return<
77 void,
78 never,
79 DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow
80 > {
77 const shutdown = yield* DesktopShutdown.DesktopShutdown;81 const shutdown = yield* DesktopShutdown.DesktopShutdown;
82 const desktopWindow = yield* DesktopWindow.DesktopWindow;
83 yield* desktopWindow.flushMainWindowBounds;
78 yield* shutdown.request;84 yield* shutdown.request;
79 yield* shutdown.awaitComplete;85 yield* shutdown.awaitComplete;
80 },86 },
apps/desktop/src/backend/DesktopBackendPool.test.ts+1
...@@ -76,6 +76,7 @@ function makePoolLayer(...@@ -76,6 +76,7 @@ function makePoolLayer(
76 showConnectingSplash: Effect.void,76 showConnectingSplash: Effect.void,
77 handleBackendReady: () => Effect.void,77 handleBackendReady: () => Effect.void,
78 handleBackendNotReady: Effect.void,78 handleBackendNotReady: Effect.void,
79 flushMainWindowBounds: Effect.void,
79 dispatchMenuAction: () => Effect.die("unexpected menu action"),80 dispatchMenuAction: () => Effect.die("unexpected menu action"),
80 syncAppearance: Effect.void,81 syncAppearance: Effect.void,
81 } satisfies DesktopWindow.DesktopWindow["Service"]),82 } satisfies DesktopWindow.DesktopWindow["Service"]),
apps/desktop/src/backend/DesktopServerExposure.test.ts+1
...@@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => {...@@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => {
250 const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {250 const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
251 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),251 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
252 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),252 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
253 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
253 setServerExposureMode: () => Effect.fail(settingsFailure),254 setServerExposureMode: () => Effect.fail(settingsFailure),
254 setTailscaleServe: () => Effect.fail(settingsFailure),255 setTailscaleServe: () => Effect.fail(settingsFailure),
255 setUpdateChannel: () => Effect.die("unexpected update channel change"),256 setUpdateChannel: () => Effect.die("unexpected update channel change"),
apps/desktop/src/settings/DesktopAppSettings.test.ts+45
...@@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import * as DesktopAppSettings from "./DesktopAppSettings.ts";11import * as DesktopAppSettings from "./DesktopAppSettings.ts";
1212
13const DesktopSettingsPatch = Schema.Struct({13const DesktopSettingsPatch = Schema.Struct({
14 mainWindowBounds: Schema.optionalKey(
15 Schema.NullOr(
16 Schema.Struct({
17 x: Schema.Number,
18 y: Schema.Number,
19 width: Schema.Number,
20 height: Schema.Number,
21 }),
22 ),
23 ),
24 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
14 serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])),25 serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])),
15 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),26 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
16 tailscaleServePort: Schema.optionalKey(Schema.Number),27 tailscaleServePort: Schema.optionalKey(Schema.Number),
...@@ -91,6 +102,8 @@ describe("DesktopSettings", () => {...@@ -91,6 +102,8 @@ describe("DesktopSettings", () => {
91 assert.deepEqual(102 assert.deepEqual(
92 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),103 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
93 {104 {
105 mainWindowBounds: null,
106 mainWindowMaximized: false,
94 serverExposureMode: "local-only",107 serverExposureMode: "local-only",
95 tailscaleServeEnabled: false,108 tailscaleServeEnabled: false,
96 tailscaleServePort: 443,109 tailscaleServePort: 443,
...@@ -116,6 +129,8 @@ describe("DesktopSettings", () => {...@@ -116,6 +129,8 @@ describe("DesktopSettings", () => {
116 });129 });
117130
118 assert.deepEqual(yield* settings.load, {131 assert.deepEqual(yield* settings.load, {
132 mainWindowBounds: null,
133 mainWindowMaximized: false,
119 serverExposureMode: "network-accessible",134 serverExposureMode: "network-accessible",
120 tailscaleServeEnabled: true,135 tailscaleServeEnabled: true,
121 tailscaleServePort: 8443,136 tailscaleServePort: 8443,
...@@ -215,10 +230,13 @@ describe("DesktopSettings", () => {...@@ -215,10 +230,13 @@ describe("DesktopSettings", () => {
215 "serverExposureMode": "network-accessible",230 "serverExposureMode": "network-accessible",
216 "tailscaleServeEnabled": true,231 "tailscaleServeEnabled": true,
217 "tailscaleServePort": 8443,232 "tailscaleServePort": 8443,
233 "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 },
218 }\n`,234 }\n`,
219 );235 );
220236
221 assert.deepEqual(yield* settings.load, {237 assert.deepEqual(yield* settings.load, {
238 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },
239 mainWindowMaximized: false,
222 serverExposureMode: "network-accessible",240 serverExposureMode: "network-accessible",
223 tailscaleServeEnabled: true,241 tailscaleServeEnabled: true,
224 tailscaleServePort: 8443,242 tailscaleServePort: 8443,
...@@ -232,6 +250,24 @@ describe("DesktopSettings", () => {...@@ -232,6 +250,24 @@ describe("DesktopSettings", () => {
232 ),250 ),
233 );251 );
234252
253 it.effect("rejects window bounds that do not satisfy the domain schema", () =>
254 withSettings(
255 Effect.gen(function* () {
256 const settings = yield* DesktopAppSettings.DesktopAppSettings;
257 yield* writeSettingsPatch({
258 mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 },
259 mainWindowMaximized: true,
260 serverExposureMode: "network-accessible",
261 });
262
263 const loaded = yield* settings.load;
264 assert.isNull(loaded.mainWindowBounds);
265 assert.isFalse(loaded.mainWindowMaximized);
266 assert.equal(loaded.serverExposureMode, "network-accessible");
267 }),
268 ),
269 );
270
235 it.effect("persists sparse desktop settings documents", () =>271 it.effect("persists sparse desktop settings documents", () =>
236 withSettings(272 withSettings(
237 Effect.gen(function* () {273 Effect.gen(function* () {
...@@ -239,12 +275,15 @@ describe("DesktopSettings", () => {...@@ -239,12 +275,15 @@ describe("DesktopSettings", () => {
239 const fileSystem = yield* FileSystem.FileSystem;275 const fileSystem = yield* FileSystem.FileSystem;
240 const settings = yield* DesktopAppSettings.DesktopAppSettings;276 const settings = yield* DesktopAppSettings.DesktopAppSettings;
241277
278 yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true);
242 yield* settings.setServerExposureMode("network-accessible");279 yield* settings.setServerExposureMode("network-accessible");
243280
244 const persisted = yield* decodeDesktopSettingsPatch(281 const persisted = yield* decodeDesktopSettingsPatch(
245 yield* fileSystem.readFileString(environment.desktopSettingsPath),282 yield* fileSystem.readFileString(environment.desktopSettingsPath),
246 );283 );
247 assert.deepEqual(persisted, {284 assert.deepEqual(persisted, {
285 mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 },
286 mainWindowMaximized: true,
248 serverExposureMode: "network-accessible",287 serverExposureMode: "network-accessible",
249 } satisfies typeof DesktopSettingsPatch.Type);288 } satisfies typeof DesktopSettingsPatch.Type);
250 }),289 }),
...@@ -261,6 +300,8 @@ describe("DesktopSettings", () => {...@@ -261,6 +300,8 @@ describe("DesktopSettings", () => {
261 });300 });
262301
263 assert.deepEqual(yield* settings.load, {302 assert.deepEqual(yield* settings.load, {
303 mainWindowBounds: null,
304 mainWindowMaximized: false,
264 serverExposureMode: "local-only",305 serverExposureMode: "local-only",
265 tailscaleServeEnabled: false,306 tailscaleServeEnabled: false,
266 tailscaleServePort: 443,307 tailscaleServePort: 443,
...@@ -286,6 +327,8 @@ describe("DesktopSettings", () => {...@@ -286,6 +327,8 @@ describe("DesktopSettings", () => {
286 });327 });
287328
288 assert.deepEqual(yield* settings.load, {329 assert.deepEqual(yield* settings.load, {
330 mainWindowBounds: null,
331 mainWindowMaximized: false,
289 serverExposureMode: "local-only",332 serverExposureMode: "local-only",
290 tailscaleServeEnabled: false,333 tailscaleServeEnabled: false,
291 tailscaleServePort: 443,334 tailscaleServePort: 443,
...@@ -310,6 +353,8 @@ describe("DesktopSettings", () => {...@@ -310,6 +353,8 @@ describe("DesktopSettings", () => {
310 });353 });
311354
312 assert.deepEqual(yield* settings.load, {355 assert.deepEqual(yield* settings.load, {
356 mainWindowBounds: null,
357 mainWindowMaximized: false,
313 serverExposureMode: "local-only",358 serverExposureMode: "local-only",
314 tailscaleServeEnabled: true,359 tailscaleServeEnabled: true,
315 tailscaleServePort: 443,360 tailscaleServePort: 443,
apps/desktop/src/settings/DesktopAppSettings.ts+77
...@@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts...@@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts
20import { isValidDistroName } from "../wsl/wslPathParsing.ts";20import { isValidDistroName } from "../wsl/wslPathParsing.ts";
2121
22export interface DesktopSettings {22export interface DesktopSettings {
23 readonly mainWindowBounds: DesktopWindowBounds | null;
24 readonly mainWindowMaximized: boolean;
23 readonly serverExposureMode: DesktopServerExposureMode;25 readonly serverExposureMode: DesktopServerExposureMode;
24 readonly tailscaleServeEnabled: boolean;26 readonly tailscaleServeEnabled: boolean;
25 readonly tailscaleServePort: number;27 readonly tailscaleServePort: number;
...@@ -48,8 +50,25 @@ export interface DesktopSettingsChange {...@@ -48,8 +50,25 @@ export interface DesktopSettingsChange {
48}50}
4951
50export const DEFAULT_TAILSCALE_SERVE_PORT = 443;52export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
53const MIN_MAIN_WINDOW_SIZE = {
54 width: 840,
55 height: 620,
56} as const;
57export const DesktopWindowBoundsSchema = Schema.Struct({
58 x: Schema.Int,
59 y: Schema.Int,
60 width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)),
61 height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)),
62});
63export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type;
64export const DEFAULT_MAIN_WINDOW_SIZE = {
65 width: 1100,
66 height: 780,
67} as const;
5168
52export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {69export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
70 mainWindowBounds: null,
71 mainWindowMaximized: false,
53 serverExposureMode: "local-only",72 serverExposureMode: "local-only",
54 tailscaleServeEnabled: false,73 tailscaleServeEnabled: false,
55 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,74 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
...@@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {...@@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
60 wslOnly: false,79 wslOnly: false,
61};80};
6281
82const DesktopWindowBoundsDocument = Schema.Struct({
83 x: Schema.Number,
84 y: Schema.Number,
85 width: Schema.Number,
86 height: Schema.Number,
87});
88
63const DesktopSettingsDocument = Schema.Struct({89const DesktopSettingsDocument = Schema.Struct({
90 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),
91 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
64 serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),92 serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),
65 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),93 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
66 tailscaleServePort: Schema.optionalKey(Schema.Number),94 tailscaleServePort: Schema.optionalKey(Schema.Number),
...@@ -81,6 +109,8 @@ type Mutable<T> = { -readonly [K in keyof T]: T[K] };...@@ -81,6 +109,8 @@ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
81const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument);109const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument);
82const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson);110const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson);
83const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson);111const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson);
112const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema);
113const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema);
84114
85const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({115const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({
86 settings,116 settings,
...@@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service<...@@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service<
114 {144 {
115 readonly load: Effect.Effect<DesktopSettings>;145 readonly load: Effect.Effect<DesktopSettings>;
116 readonly get: Effect.Effect<DesktopSettings>;146 readonly get: Effect.Effect<DesktopSettings>;
147 readonly setMainWindowBounds: (
148 bounds: DesktopWindowBounds,
149 isMaximized: boolean,
150 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
117 readonly setServerExposureMode: (151 readonly setServerExposureMode: (
118 mode: DesktopServerExposureMode,152 mode: DesktopServerExposureMode,
119 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;153 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
...@@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null {...@@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null {
158 return typeof value === "string" && isValidDistroName(value) ? value : null;192 return typeof value === "string" && isValidDistroName(value) ? value : null;
159}193}
160194
195export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null {
196 return Option.getOrNull(decodeDesktopWindowBounds(value));
197}
198
161function normalizeDesktopSettingsDocument(199function normalizeDesktopSettingsDocument(
162 parsed: DesktopSettingsDocument,200 parsed: DesktopSettingsDocument,
163 appVersion: string,201 appVersion: string,
164): DesktopSettings {202): DesktopSettings {
165 const defaultSettings = resolveDefaultDesktopSettings(appVersion);203 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
204 const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds);
166 const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel);205 const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel);
167 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;206 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
168 const updateChannelConfiguredByUser =207 const updateChannelConfiguredByUser =
...@@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument(...@@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument(
177 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");216 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");
178217
179 return {218 return {
219 mainWindowBounds,
220 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
180 serverExposureMode:221 serverExposureMode:
181 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",222 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
182 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,223 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
...@@ -197,6 +238,12 @@ function toDesktopSettingsDocument(...@@ -197,6 +238,12 @@ function toDesktopSettingsDocument(
197): DesktopSettingsDocument {238): DesktopSettingsDocument {
198 const document: Mutable<DesktopSettingsDocument> = {};239 const document: Mutable<DesktopSettingsDocument> = {};
199240
241 if (settings.mainWindowBounds !== null) {
242 document.mainWindowBounds = settings.mainWindowBounds;
243 }
244 if (settings.mainWindowMaximized) {
245 document.mainWindowMaximized = true;
246 }
200 if (settings.serverExposureMode !== defaults.serverExposureMode) {247 if (settings.serverExposureMode !== defaults.serverExposureMode) {
201 document.serverExposureMode = settings.serverExposureMode;248 document.serverExposureMode = settings.serverExposureMode;
202 }249 }
...@@ -237,6 +284,22 @@ function setServerExposureMode(...@@ -237,6 +284,22 @@ function setServerExposureMode(
237 };284 };
238}285}
239286
287function setMainWindowBounds(
288 settings: DesktopSettings,
289 bounds: DesktopWindowBounds,
290 isMaximized: boolean,
291): DesktopSettings {
292 return settings.mainWindowBounds !== null &&
293 desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) &&
294 settings.mainWindowMaximized === isMaximized
295 ? settings
296 : {
297 ...settings,
298 mainWindowBounds: bounds,
299 mainWindowMaximized: isMaximized,
300 };
301}
302
240function setTailscaleServe(303function setTailscaleServe(
241 settings: DesktopSettings,304 settings: DesktopSettings,
242 input: { readonly enabled: boolean; readonly port: Option.Option<number> },305 input: { readonly enabled: boolean; readonly port: Option.Option<number> },
...@@ -431,6 +494,18 @@ export const make = Effect.gen(function* () {...@@ -431,6 +494,18 @@ export const make = Effect.gen(function* () {
431 );494 );
432 return yield* SynchronizedRef.setAndGet(settingsRef, settings);495 return yield* SynchronizedRef.setAndGet(settingsRef, settings);
433 }).pipe(Effect.withSpan("desktop.settings.load")),496 }).pipe(Effect.withSpan("desktop.settings.load")),
497 setMainWindowBounds: (bounds, isMaximized) =>
498 persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe(
499 Effect.withSpan("desktop.settings.setMainWindowBounds", {
500 attributes: {
501 x: bounds.x,
502 y: bounds.y,
503 width: bounds.width,
504 height: bounds.height,
505 isMaximized,
506 },
507 }),
508 ),
434 setServerExposureMode: (mode) =>509 setServerExposureMode: (mode) =>
435 persist((settings) => setServerExposureMode(settings, mode)).pipe(510 persist((settings) => setServerExposureMode(settings, mode)).pipe(
436 Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),511 Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),
...@@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET...@@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET
488 return DesktopAppSettings.of({563 return DesktopAppSettings.of({
489 get: SynchronizedRef.get(settingsRef),564 get: SynchronizedRef.get(settingsRef),
490 load: SynchronizedRef.get(settingsRef),565 load: SynchronizedRef.get(settingsRef),
566 setMainWindowBounds: (bounds, isMaximized) =>
567 update((settings) => setMainWindowBounds(settings, bounds, isMaximized)),
491 setServerExposureMode: (mode) =>568 setServerExposureMode: (mode) =>
492 update((settings) => setServerExposureMode(settings, mode)),569 update((settings) => setServerExposureMode(settings, mode)),
493 setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)),570 setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)),
apps/desktop/src/updates/DesktopUpdates.test.ts+1
...@@ -158,6 +158,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -158,6 +158,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
158 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {158 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
159 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),159 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
160 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),160 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
161 setMainWindowBounds: () => Effect.die("unexpected main window bounds update"),
161 setServerExposureMode: () => Effect.die("unexpected server exposure update"),162 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
162 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),163 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
163 setUpdateChannel: () => Effect.fail(setUpdateChannelError),164 setUpdateChannel: () => Effect.fail(setUpdateChannelError),
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
...@@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>...@@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
76 showConnectingSplash: Effect.void,76 showConnectingSplash: Effect.void,
77 handleBackendReady: () => Effect.void,77 handleBackendReady: () => Effect.void,
78 handleBackendNotReady: Effect.void,78 handleBackendNotReady: Effect.void,
79 flushMainWindowBounds: Effect.void,
79 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),80 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
80 syncAppearance: Effect.void,81 syncAppearance: Effect.void,
81 } satisfies DesktopWindow.DesktopWindow["Service"]);82 } satisfies DesktopWindow.DesktopWindow["Service"]);
apps/desktop/src/window/DesktopWindow.test.ts+516-2
...@@ -1,12 +1,17 @@...@@ -1,12 +1,17 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Logger from "effect/Logger";
5import * as Option from "effect/Option";8import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";9import * as Ref from "effect/Ref";
10import * as References from "effect/References";
11import * as Schema from "effect/Schema";
7import * as TestClock from "effect/testing/TestClock";12import * as TestClock from "effect/testing/TestClock";
813
9import type * as Electron from "electron";14import * as Electron from "electron";
10import { vi } from "vite-plus/test";15import { vi } from "vite-plus/test";
1116
12vi.mock("electron", async (importOriginal) => ({17vi.mock("electron", async (importOriginal) => ({
...@@ -18,12 +23,20 @@ vi.mock("electron", async (importOriginal) => ({...@@ -18,12 +23,20 @@ vi.mock("electron", async (importOriginal) => ({
18 setUserAgent: vi.fn(),23 setUserAgent: vi.fn(),
19 })),24 })),
20 },25 },
26 screen: {
27 getAllDisplays: vi.fn(() => [
28 {
29 bounds: { x: 0, y: 0, width: 1920, height: 1080 },
30 },
31 ]),
32 },
21}));33}));
2234
23import * as DesktopAssets from "../app/DesktopAssets.ts";35import * as DesktopAssets from "../app/DesktopAssets.ts";
24import * as DesktopConfig from "../app/DesktopConfig.ts";36import * as DesktopConfig from "../app/DesktopConfig.ts";
25import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";37import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
26import * as DesktopState from "../app/DesktopState.ts";38import * as DesktopState from "../app/DesktopState.ts";
39import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
27import * as ElectronMenu from "../electron/ElectronMenu.ts";40import * as ElectronMenu from "../electron/ElectronMenu.ts";
28import * as ElectronShell from "../electron/ElectronShell.ts";41import * as ElectronShell from "../electron/ElectronShell.ts";
29import * as ElectronTheme from "../electron/ElectronTheme.ts";42import * as ElectronTheme from "../electron/ElectronTheme.ts";
...@@ -66,15 +79,21 @@ function makeFakeBrowserWindow() {...@@ -66,15 +79,21 @@ function makeFakeBrowserWindow() {
66 const window = {79 const window = {
67 close: vi.fn(),80 close: vi.fn(),
68 focus: vi.fn(),81 focus: vi.fn(),
82 getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })),
83 getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })),
69 isDestroyed: vi.fn(() => false),84 isDestroyed: vi.fn(() => false),
70 isFullScreen: vi.fn(() => false),85 isFullScreen: vi.fn(() => false),
86 isMaximized: vi.fn(() => false),
71 isMinimized: vi.fn(() => false),87 isMinimized: vi.fn(() => false),
72 isVisible: vi.fn(() => true),88 isVisible: vi.fn(() => true),
73 loadURL: vi.fn(() => Promise.resolve()),89 loadURL: vi.fn(() => Promise.resolve()),
90 maximize: vi.fn(),
74 on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {91 on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
75 windowListeners.set(eventName, listener);92 windowListeners.set(eventName, listener);
76 }),93 }),
77 once: vi.fn(),94 once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
95 windowListeners.set(eventName, listener);
96 }),
78 restore: vi.fn(),97 restore: vi.fn(),
79 setBackgroundColor: vi.fn(),98 setBackgroundColor: vi.fn(),
80 setAutoHideCursor: vi.fn(),99 setAutoHideCursor: vi.fn(),
...@@ -86,7 +105,14 @@ function makeFakeBrowserWindow() {...@@ -86,7 +105,14 @@ function makeFakeBrowserWindow() {
86105
87 return {106 return {
88 window: window as unknown as Electron.BrowserWindow,107 window: window as unknown as Electron.BrowserWindow,
108 getBounds: window.getBounds,
109 getNormalBounds: window.getNormalBounds,
110 isDestroyed: window.isDestroyed,
111 isFullScreen: window.isFullScreen,
112 isMaximized: window.isMaximized,
113 isMinimized: window.isMinimized,
89 loadURL: window.loadURL,114 loadURL: window.loadURL,
115 maximize: window.maximize,
90 openDevTools: webContents.openDevTools,116 openDevTools: webContents.openDevTools,
91 reload: webContents.reload,117 reload: webContents.reload,
92 send: webContents.send,118 send: webContents.send,
...@@ -144,13 +170,57 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(...@@ -144,13 +170,57 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(
144 ),170 ),
145);171);
146172
173const desktopWindowBoundsEquivalence = Schema.toEquivalence(
174 DesktopAppSettings.DesktopWindowBoundsSchema,
175);
176
147function makeTestLayer(input: {177function makeTestLayer(input: {
148 readonly window: Electron.BrowserWindow;178 readonly window: Electron.BrowserWindow;
149 readonly createCount: Ref.Ref<number>;179 readonly createCount: Ref.Ref<number>;
150 readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;180 readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;
151 readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[];181 readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[];
182 readonly desktopSettings?: DesktopAppSettings.DesktopSettings;
183 readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[];
184 readonly mainWindowMaximizedUpdates?: boolean[];
185 readonly beforeMainWindowBoundsUpdate?: (
186 bounds: DesktopAppSettings.DesktopWindowBounds,
187 ) => Effect.Effect<void>;
152 readonly openedExternalUrls?: unknown[];188 readonly openedExternalUrls?: unknown[];
153}) {189}) {
190 let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
191 const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
192 get: Effect.sync(() => desktopSettings),
193 load: Effect.sync(() => desktopSettings),
194 setMainWindowBounds: (bounds, isMaximized) =>
195 Effect.gen(function* () {
196 if (input.beforeMainWindowBoundsUpdate) {
197 yield* input.beforeMainWindowBoundsUpdate(bounds);
198 }
199 const changed =
200 desktopSettings.mainWindowBounds === null ||
201 !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) ||
202 desktopSettings.mainWindowMaximized !== isMaximized;
203 if (changed) {
204 desktopSettings = {
205 ...desktopSettings,
206 mainWindowBounds: bounds,
207 mainWindowMaximized: isMaximized,
208 };
209 input.mainWindowBoundsUpdates?.push(bounds);
210 input.mainWindowMaximizedUpdates?.push(isMaximized);
211 }
212 return { settings: desktopSettings, changed };
213 }),
214 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
215 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
216 setUpdateChannel: () => Effect.die("unexpected update channel change"),
217 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
218 setWslDistro: () => Effect.die("unexpected WSL distro change"),
219 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
220 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
221 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
222 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
223
154 const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {224 const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
155 create: (options) =>225 create: (options) =>
156 Effect.sync(() => {226 Effect.sync(() => {
...@@ -175,6 +245,7 @@ function makeTestLayer(input: {...@@ -175,6 +245,7 @@ function makeTestLayer(input: {
175 Layer.mergeAll(245 Layer.mergeAll(
176 desktopAssetsLayer,246 desktopAssetsLayer,
177 desktopEnvironmentLayer,247 desktopEnvironmentLayer,
248 desktopAppSettingsLayer,
178 desktopServerExposureLayer,249 desktopServerExposureLayer,
179 DesktopState.layer,250 DesktopState.layer,
180 electronMenuLayer,251 electronMenuLayer,
...@@ -272,6 +343,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n...@@ -272,6 +343,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
272 Layer.mergeAll(343 Layer.mergeAll(
273 desktopAssetsLayer,344 desktopAssetsLayer,
274 desktopEnvironmentLayer,345 desktopEnvironmentLayer,
346 DesktopAppSettings.layerTest(),
275 desktopServerExposureLayer,347 desktopServerExposureLayer,
276 electronMenuLayer,348 electronMenuLayer,
277 Layer.succeed(ElectronShell.ElectronShell, {349 Layer.succeed(ElectronShell.ElectronShell, {
...@@ -294,6 +366,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n...@@ -294,6 +366,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
294 });366 });
295367
296describe("DesktopWindow", () => {368describe("DesktopWindow", () => {
369 it("restores bounds only when the window fits within a connected display", () => {
370 const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 };
371 const displays = [
372 { x: 0, y: 0, width: 1920, height: 1080 },
373 { x: 1920, y: 0, width: 2560, height: 1440 },
374 ];
375
376 assert.deepEqual(
377 DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays),
378 persistedBounds,
379 );
380 assert.deepEqual(
381 DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]),
382 DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE,
383 );
384 });
385
297 it("recognizes only same-origin renderer navigations", () => {386 it("recognizes only same-origin renderer navigations", () => {
298 assert.isTrue(387 assert.isTrue(
299 DesktopWindow.isSameOriginRendererNavigation({388 DesktopWindow.isSameOriginRendererNavigation({
...@@ -335,6 +424,10 @@ describe("DesktopWindow", () => {...@@ -335,6 +424,10 @@ describe("DesktopWindow", () => {
335424
336 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));425 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
337 assert.equal(yield* Ref.get(createCount), 1);426 assert.equal(yield* Ref.get(createCount), 1);
427 assert.equal(createdWindowOptions[0]?.width, 1100);
428 assert.equal(createdWindowOptions[0]?.height, 780);
429 assert.isUndefined(createdWindowOptions[0]?.x);
430 assert.isUndefined(createdWindowOptions[0]?.y);
338 assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor);431 assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor);
339 assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);432 assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);
340 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]);433 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]);
...@@ -343,6 +436,427 @@ describe("DesktopWindow", () => {...@@ -343,6 +436,427 @@ describe("DesktopWindow", () => {
343 }),436 }),
344 );437 );
345438
439 it.effect("uses the persisted main window bounds when opening the window", () =>
440 Effect.gen(function* () {
441 const fakeWindow = makeFakeBrowserWindow();
442 const createCount = yield* Ref.make(0);
443 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
444 const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = [];
445 const layer = makeTestLayer({
446 window: fakeWindow.window,
447 createCount,
448 mainWindow,
449 createdWindowOptions,
450 desktopSettings: {
451 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
452 mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 },
453 },
454 });
455
456 yield* Effect.gen(function* () {
457 const desktopWindow = yield* DesktopWindow.DesktopWindow;
458 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
459
460 assert.equal(createdWindowOptions[0]?.width, 1320);
461 assert.equal(createdWindowOptions[0]?.height, 880);
462 assert.equal(createdWindowOptions[0]?.x, 120);
463 assert.equal(createdWindowOptions[0]?.y, 80);
464 }).pipe(Effect.provide(layer));
465 }),
466 );
467
468 it.effect("restores the persisted maximized state", () =>
469 Effect.gen(function* () {
470 const fakeWindow = makeFakeBrowserWindow();
471 const createCount = yield* Ref.make(0);
472 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
473 const layer = makeTestLayer({
474 window: fakeWindow.window,
475 createCount,
476 mainWindow,
477 desktopSettings: {
478 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
479 mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 },
480 mainWindowMaximized: true,
481 },
482 });
483
484 yield* Effect.gen(function* () {
485 const desktopWindow = yield* DesktopWindow.DesktopWindow;
486 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
487
488 assert.equal(fakeWindow.maximize.mock.calls.length, 0);
489 const readyToShow = fakeWindow.windowListeners.get("ready-to-show");
490 if (!readyToShow) {
491 return yield* Effect.die("window ready-to-show listener was not registered");
492 }
493 readyToShow();
494 assert.equal(fakeWindow.maximize.mock.calls.length, 1);
495 }).pipe(Effect.provide(layer));
496 }),
497 );
498
499 it.effect("debounces move and resize bounds updates", () =>
500 Effect.gen(function* () {
501 const fakeWindow = makeFakeBrowserWindow();
502 const createCount = yield* Ref.make(0);
503 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
504 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
505 const layer = makeTestLayer({
506 window: fakeWindow.window,
507 createCount,
508 mainWindow,
509 mainWindowBoundsUpdates,
510 });
511
512 yield* Effect.gen(function* () {
513 const desktopWindow = yield* DesktopWindow.DesktopWindow;
514 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
515
516 const move = fakeWindow.windowListeners.get("move");
517 const resize = fakeWindow.windowListeners.get("resize");
518 if (!move || !resize) {
519 return yield* Effect.die("window bounds listeners were not registered");
520 }
521
522 fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 });
523 move();
524 yield* TestClock.adjust(250);
525
526 fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 });
527 resize();
528 yield* TestClock.adjust(499);
529 assert.deepEqual(mainWindowBoundsUpdates, []);
530
531 yield* TestClock.adjust(1);
532 yield* Effect.promise(() => Promise.resolve());
533 assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]);
534 }).pipe(Effect.provide(layer));
535 }),
536 );
537
538 it.effect("persists normal bounds and state for a maximized window", () =>
539 Effect.gen(function* () {
540 const fakeWindow = makeFakeBrowserWindow();
541 fakeWindow.isMaximized.mockReturnValue(true);
542 fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 });
543 fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 });
544 const createCount = yield* Ref.make(0);
545 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
546 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
547 const mainWindowMaximizedUpdates: boolean[] = [];
548 const layer = makeTestLayer({
549 window: fakeWindow.window,
550 createCount,
551 mainWindow,
552 mainWindowBoundsUpdates,
553 mainWindowMaximizedUpdates,
554 });
555
556 yield* Effect.gen(function* () {
557 const desktopWindow = yield* DesktopWindow.DesktopWindow;
558 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
559
560 const close = fakeWindow.windowListeners.get("close");
561 if (!close) {
562 return yield* Effect.die("window close listener was not registered");
563 }
564 close();
565 yield* Effect.promise(() => Promise.resolve());
566
567 assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]);
568 assert.deepEqual(mainWindowMaximizedUpdates, [true]);
569 assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1);
570 assert.equal(fakeWindow.getBounds.mock.calls.length, 0);
571 }).pipe(Effect.provide(layer));
572 }),
573 );
574
575 it.effect("persists normal bounds and state from the native maximize event", () =>
576 Effect.gen(function* () {
577 const fakeWindow = makeFakeBrowserWindow();
578 const createCount = yield* Ref.make(0);
579 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
580 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
581 const mainWindowMaximizedUpdates: boolean[] = [];
582 const layer = makeTestLayer({
583 window: fakeWindow.window,
584 createCount,
585 mainWindow,
586 mainWindowBoundsUpdates,
587 mainWindowMaximizedUpdates,
588 });
589
590 yield* Effect.gen(function* () {
591 const desktopWindow = yield* DesktopWindow.DesktopWindow;
592 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
593
594 const maximize = fakeWindow.windowListeners.get("maximize");
595 if (!maximize) {
596 return yield* Effect.die("window maximize listener was not registered");
597 }
598
599 fakeWindow.isMaximized.mockReturnValue(true);
600 fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 });
601 fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 });
602 maximize();
603 yield* TestClock.adjust(500);
604 yield* Effect.promise(() => Promise.resolve());
605
606 assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]);
607 assert.deepEqual(mainWindowMaximizedUpdates, [true]);
608 }).pipe(Effect.provide(layer));
609 }),
610 );
611
612 it.effect("does not persist bounds that fail the domain schema", () =>
613 Effect.gen(function* () {
614 const fakeWindow = makeFakeBrowserWindow();
615 fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 });
616 const createCount = yield* Ref.make(0);
617 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
618 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
619 const layer = makeTestLayer({
620 window: fakeWindow.window,
621 createCount,
622 mainWindow,
623 mainWindowBoundsUpdates,
624 });
625
626 yield* Effect.gen(function* () {
627 const desktopWindow = yield* DesktopWindow.DesktopWindow;
628 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
629
630 const resize = fakeWindow.windowListeners.get("resize");
631 if (!resize) {
632 return yield* Effect.die("window resize listener was not registered");
633 }
634 resize();
635 yield* TestClock.adjust(500);
636 yield* Effect.promise(() => Promise.resolve());
637
638 assert.deepEqual(mainWindowBoundsUpdates, []);
639 }).pipe(Effect.provide(layer));
640 }),
641 );
642
643 it.effect("preserves unrestorable bounds until the user changes the window", () =>
644 Effect.gen(function* () {
645 const fakeWindow = makeFakeBrowserWindow();
646 const createCount = yield* Ref.make(0);
647 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
648 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
649 const layer = makeTestLayer({
650 window: fakeWindow.window,
651 createCount,
652 mainWindow,
653 mainWindowBoundsUpdates,
654 desktopSettings: {
655 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
656 mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 },
657 },
658 });
659
660 yield* Effect.gen(function* () {
661 const desktopWindow = yield* DesktopWindow.DesktopWindow;
662 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
663
664 const close = fakeWindow.windowListeners.get("close");
665 const move = fakeWindow.windowListeners.get("move");
666 if (!close || !move) {
667 return yield* Effect.die("window lifecycle listeners were not registered");
668 }
669
670 close();
671 yield* Effect.promise(() => Promise.resolve());
672 assert.deepEqual(mainWindowBoundsUpdates, []);
673
674 fakeWindow.getBounds.mockReturnValue({ x: 80, y: 60, width: 1280, height: 840 });
675 move();
676 yield* TestClock.adjust(500);
677 yield* Effect.promise(() => Promise.resolve());
678 assert.deepEqual(mainWindowBoundsUpdates, [{ x: 80, y: 60, width: 1280, height: 840 }]);
679 }).pipe(Effect.provide(layer));
680 }),
681 );
682
683 it.effect("flushes normal bounds when fullscreen before the debounce completes", () =>
684 Effect.gen(function* () {
685 const fakeWindow = makeFakeBrowserWindow();
686 fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 });
687 fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 });
688 const createCount = yield* Ref.make(0);
689 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
690 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
691 const layer = makeTestLayer({
692 window: fakeWindow.window,
693 createCount,
694 mainWindow,
695 mainWindowBoundsUpdates,
696 });
697
698 yield* Effect.gen(function* () {
699 const desktopWindow = yield* DesktopWindow.DesktopWindow;
700 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
701
702 const resize = fakeWindow.windowListeners.get("resize");
703 if (!resize) {
704 return yield* Effect.die("window resize listener was not registered");
705 }
706 resize();
707 yield* TestClock.adjust(250);
708 fakeWindow.isFullScreen.mockReturnValue(true);
709
710 yield* desktopWindow.flushMainWindowBounds;
711
712 assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]);
713 assert.equal(fakeWindow.getBounds.mock.calls.length, 0);
714 assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1);
715 }).pipe(Effect.provide(layer));
716 }),
717 );
718
719 it.effect("flushes normal bounds when minimized before the debounce completes", () =>
720 Effect.gen(function* () {
721 const fakeWindow = makeFakeBrowserWindow();
722 fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 });
723 fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 });
724 const createCount = yield* Ref.make(0);
725 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
726 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
727 const layer = makeTestLayer({
728 window: fakeWindow.window,
729 createCount,
730 mainWindow,
731 mainWindowBoundsUpdates,
732 });
733
734 yield* Effect.gen(function* () {
735 const desktopWindow = yield* DesktopWindow.DesktopWindow;
736 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
737
738 const resize = fakeWindow.windowListeners.get("resize");
739 if (!resize) {
740 return yield* Effect.die("window resize listener was not registered");
741 }
742 resize();
743 yield* TestClock.adjust(250);
744 fakeWindow.isMinimized.mockReturnValue(true);
745
746 yield* desktopWindow.flushMainWindowBounds;
747
748 assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]);
749 assert.equal(fakeWindow.getBounds.mock.calls.length, 0);
750 assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1);
751 }).pipe(Effect.provide(layer));
752 }),
753 );
754
755 it.effect("logs display lookup failures before falling back to the default size", () =>
756 Effect.gen(function* () {
757 const displayLookupFailure = new Error("screen API unavailable");
758 vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => {
759 throw displayLookupFailure;
760 });
761 const logRecords: Array<{
762 readonly message: unknown;
763 readonly annotations: Readonly<Record<string, unknown>>;
764 }> = [];
765 const logger = Logger.make(({ fiber, message }) => {
766 logRecords.push({
767 message,
768 annotations: { ...fiber.getRef(References.CurrentLogAnnotations) },
769 });
770 });
771 const fakeWindow = makeFakeBrowserWindow();
772 const createCount = yield* Ref.make(0);
773 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
774 const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = [];
775 const layer = makeTestLayer({
776 window: fakeWindow.window,
777 createCount,
778 mainWindow,
779 createdWindowOptions,
780 });
781
782 yield* Effect.gen(function* () {
783 const desktopWindow = yield* DesktopWindow.DesktopWindow;
784 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
785 }).pipe(
786 Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))),
787 );
788
789 const warning = logRecords.find(
790 (record) =>
791 Array.isArray(record.message) &&
792 record.message[0] === "failed to read connected displays; using defaults",
793 );
794 assert.isDefined(warning);
795 assert.strictEqual(warning.annotations.cause, displayLookupFailure);
796 assert.equal(createdWindowOptions[0]?.width, 1100);
797 assert.equal(createdWindowOptions[0]?.height, 780);
798 assert.isUndefined(createdWindowOptions[0]?.x);
799 assert.isUndefined(createdWindowOptions[0]?.y);
800 }),
801 );
802
803 it.effect("persists the current main window bounds before the window closes", () =>
804 Effect.gen(function* () {
805 const fakeWindow = makeFakeBrowserWindow();
806 fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 });
807 const createCount = yield* Ref.make(0);
808 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
809 const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = [];
810 const writeStarted = yield* Deferred.make<void>();
811 const allowWrite = yield* Deferred.make<void>();
812 const flushCompleted = yield* Deferred.make<void>();
813 const layer = makeTestLayer({
814 window: fakeWindow.window,
815 createCount,
816 mainWindow,
817 mainWindowBoundsUpdates,
818 beforeMainWindowBoundsUpdate: () =>
819 Deferred.succeed(writeStarted, undefined).pipe(
820 Effect.andThen(Deferred.await(allowWrite)),
821 Effect.asVoid,
822 ),
823 });
824
825 yield* Effect.gen(function* () {
826 const desktopWindow = yield* DesktopWindow.DesktopWindow;
827 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
828
829 const close = fakeWindow.windowListeners.get("close");
830 if (!close) {
831 return yield* Effect.die("window close listener was not registered");
832 }
833 close();
834 yield* Deferred.await(writeStarted);
835 fakeWindow.isDestroyed.mockReturnValue(true);
836
837 const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe(
838 Effect.andThen(Deferred.succeed(flushCompleted, undefined)),
839 Effect.forkChild({ startImmediately: true }),
840 );
841 yield* Effect.yieldNow;
842 assert.isFalse(yield* Deferred.isDone(flushCompleted));
843
844 yield* Deferred.succeed(allowWrite, undefined);
845 yield* Fiber.join(flushFiber);
846 assert.isTrue(yield* Deferred.isDone(flushCompleted));
847
848 assert.deepEqual(mainWindowBoundsUpdates, [
849 {
850 x: 240,
851 y: 160,
852 width: 1410,
853 height: 930,
854 },
855 ]);
856 }).pipe(Effect.provide(layer));
857 }),
858 );
859
346 it.effect("publishes native macOS fullscreen changes to the renderer", () =>860 it.effect("publishes native macOS fullscreen changes to the renderer", () =>
347 Effect.gen(function* () {861 Effect.gen(function* () {
348 const fakeWindow = makeFakeBrowserWindow();862 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+170-3
...@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";...@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
77
8import type * as Electron from "electron";8import * as Electron from "electron";
99
10import * as DesktopAssets from "../app/DesktopAssets.ts";10import * as DesktopAssets from "../app/DesktopAssets.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
...@@ -17,11 +17,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts";...@@ -17,11 +17,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts";
17import * as ElectronWindow from "../electron/ElectronWindow.ts";17import * as ElectronWindow from "../electron/ElectronWindow.ts";
18import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";18import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
19import * as PreviewManager from "../preview/Manager.ts";19import * as PreviewManager from "../preview/Manager.ts";
20import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2021
21const TITLEBAR_HEIGHT = 40;22const TITLEBAR_HEIGHT = 40;
22const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux23const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
23const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";24const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
24const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";25const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
26const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500;
25const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;27const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;
26const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([28const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([
27 -2, // ERR_FAILED29 -2, // ERR_FAILED
...@@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick<...@@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick<
41type DesktopWindowRuntimeServices =43type DesktopWindowRuntimeServices =
42 | DesktopEnvironment.DesktopEnvironment44 | DesktopEnvironment.DesktopEnvironment
43 | DesktopAssets.DesktopAssets45 | DesktopAssets.DesktopAssets
46 | DesktopAppSettings.DesktopAppSettings
44 | ElectronMenu.ElectronMenu47 | ElectronMenu.ElectronMenu
45 | ElectronShell.ElectronShell48 | ElectronShell.ElectronShell
46 | ElectronTheme.ElectronTheme49 | ElectronTheme.ElectronTheme
...@@ -75,6 +78,7 @@ export class DesktopWindow extends Context.Service<...@@ -75,6 +78,7 @@ export class DesktopWindow extends Context.Service<
75 // window so a "macOS dock click" while the backend is down doesn't78 // window so a "macOS dock click" while the backend is down doesn't
76 // produce a stranded window pointing at nothing.79 // produce a stranded window pointing at nothing.
77 readonly handleBackendNotReady: Effect.Effect<void>;80 readonly handleBackendNotReady: Effect.Effect<void>;
81 readonly flushMainWindowBounds: Effect.Effect<void>;
78 readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;82 readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;
79 readonly syncAppearance: Effect.Effect<void>;83 readonly syncAppearance: Effect.Effect<void>;
80 }84 }
...@@ -99,6 +103,45 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {...@@ -99,6 +103,45 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {
99 return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";103 return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
100}104}
101105
106type DisplayBounds = Pick<Electron.Rectangle, "x" | "y" | "width" | "height">;
107
108function windowFitsWithinDisplay(
109 windowBounds: DesktopAppSettings.DesktopWindowBounds,
110 displayBounds: DisplayBounds,
111): boolean {
112 return (
113 windowBounds.x >= displayBounds.x &&
114 windowBounds.y >= displayBounds.y &&
115 windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width &&
116 windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height
117 );
118}
119
120function windowBoundsEqual(
121 left: DesktopAppSettings.DesktopWindowBounds,
122 right: DesktopAppSettings.DesktopWindowBounds,
123): boolean {
124 return (
125 left.x === right.x &&
126 left.y === right.y &&
127 left.width === right.width &&
128 left.height === right.height
129 );
130}
131
132export function resolveInitialMainWindowBounds(
133 persistedBounds: DesktopAppSettings.DesktopWindowBounds | null,
134 displays: readonly DisplayBounds[],
135): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE {
136 if (
137 persistedBounds !== null &&
138 displays.some((display) => windowFitsWithinDisplay(persistedBounds, display))
139 ) {
140 return persistedBounds;
141 }
142 return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE;
143}
144
102// A self-contained "Connecting to WSL" splash, shown immediately in wsl-only145// A self-contained "Connecting to WSL" splash, shown immediately in wsl-only
103// mode while the WSL backend (which serves the renderer) cold-boots. Inlined as146// mode while the WSL backend (which serves the renderer) cold-boots. Inlined as
104// a data URL so it needs no bundled asset and no backend — pure CSS, no JS.147// a data URL so it needs no bundled asset and no backend — pure CSS, no JS.
...@@ -202,6 +245,7 @@ export const make = Effect.gen(function* () {...@@ -202,6 +245,7 @@ export const make = Effect.gen(function* () {
202 const electronTheme = yield* ElectronTheme.ElectronTheme;245 const electronTheme = yield* ElectronTheme.ElectronTheme;
203 const electronWindow = yield* ElectronWindow.ElectronWindow;246 const electronWindow = yield* ElectronWindow.ElectronWindow;
204 const previewManager = yield* PreviewManager.PreviewManager;247 const previewManager = yield* PreviewManager.PreviewManager;
248 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
205 // Window-side latch for the primary backend's readiness. Set by249 // Window-side latch for the primary backend's readiness. Set by
206 // handleBackendReady (driven by the pool's onReady callback), cleared250 // handleBackendReady (driven by the pool's onReady callback), cleared
207 // by handleBackendNotReady (driven by onShutdown). Only consumed by251 // by handleBackendNotReady (driven by onShutdown). Only consumed by
...@@ -214,6 +258,7 @@ export const make = Effect.gen(function* () {...@@ -214,6 +258,7 @@ export const make = Effect.gen(function* () {
214 const context = yield* Effect.context<DesktopWindowRuntimeServices>();258 const context = yield* Effect.context<DesktopWindowRuntimeServices>();
215 const runFork = Effect.runForkWith(context);259 const runFork = Effect.runForkWith(context);
216 const runPromise = Effect.runPromiseWith(context);260 const runPromise = Effect.runPromiseWith(context);
261 let flushMainWindowBounds: Effect.Effect<void> = Effect.void;
217262
218 const dismissConnectingSplash = Effect.gen(function* () {263 const dismissConnectingSplash = Effect.gen(function* () {
219 const splash = yield* Ref.getAndSet(splashWindowRef, Option.none());264 const splash = yield* Ref.getAndSet(splashWindowRef, Option.none());
...@@ -250,9 +295,31 @@ export const make = Effect.gen(function* () {...@@ -250,9 +295,31 @@ export const make = Effect.gen(function* () {
250 const iconPaths = yield* assets.iconPaths;295 const iconPaths = yield* assets.iconPaths;
251 const iconOption = getIconOption(iconPaths, environment.platform);296 const iconOption = getIconOption(iconPaths, environment.platform);
252 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;297 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
298 const persistedSettings = yield* desktopSettings.get;
299 const persistedBounds = persistedSettings.mainWindowBounds;
300 const displayBoundsResult = yield* Effect.sync(() => {
301 try {
302 return {
303 _tag: "Success" as const,
304 bounds: Electron.screen.getAllDisplays().map((display) => display.bounds),
305 };
306 } catch (cause) {
307 return { _tag: "Failure" as const, cause };
308 }
309 });
310 const displayBounds =
311 displayBoundsResult._tag === "Success"
312 ? displayBoundsResult.bounds
313 : yield* logWindowWarning("failed to read connected displays; using defaults", {
314 cause: displayBoundsResult.cause,
315 }).pipe(Effect.as<readonly Electron.Rectangle[]>([]));
316 const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds);
317 const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds;
318 if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) {
319 yield* logWindowWarning("saved main window bounds could not be restored; using defaults");
320 }
253 const window = yield* electronWindow.create({321 const window = yield* electronWindow.create({
254 width: 1100,322 ...initialBounds,
255 height: 780,
256 minWidth: 840,323 minWidth: 840,
257 minHeight: 620,324 minHeight: 620,
258 show: false,325 show: false,
...@@ -274,6 +341,92 @@ export const make = Effect.gen(function* () {...@@ -274,6 +341,92 @@ export const make = Effect.gen(function* () {
274 if (environment.platform === "darwin") {341 if (environment.platform === "darwin") {
275 window.setAutoHideCursor(false);342 window.setAutoHideCursor(false);
276 }343 }
344 let boundsPersistFiber: Fiber.Fiber<void, never> | undefined;
345 let pendingBoundsPersistFiber: Fiber.Fiber<void, never> | undefined;
346 let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds;
347 const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => {
348 if (window.isDestroyed()) {
349 return null;
350 }
351 const bounds =
352 window.isFullScreen() || window.isMaximized() || window.isMinimized()
353 ? window.getNormalBounds()
354 : window.getBounds();
355 return DesktopAppSettings.normalizeMainWindowBounds({
356 x: Math.round(bounds.x),
357 y: Math.round(bounds.y),
358 width: Math.round(bounds.width),
359 height: Math.round(bounds.height),
360 });
361 };
362 const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds();
363 const fallbackWindowMaximized = persistedSettings.mainWindowMaximized;
364 const persistCurrentBounds = (): Fiber.Fiber<void, never> | undefined => {
365 if (!boundsPersistenceEnabled) {
366 return pendingBoundsPersistFiber;
367 }
368 const bounds = readPersistableBounds();
369 if (bounds === null) {
370 return pendingBoundsPersistFiber;
371 }
372 pendingBoundsPersistFiber = runFork(
373 desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe(
374 Effect.asVoid,
375 Effect.catch((error) =>
376 logWindowWarning("failed to persist main window bounds", {
377 message: error.message,
378 }),
379 ),
380 ),
381 );
382 return pendingBoundsPersistFiber;
383 };
384 const scheduleBoundsPersist = () => {
385 if (!boundsPersistenceEnabled) {
386 const currentBounds = readPersistableBounds();
387 if (
388 currentBounds === null ||
389 (fallbackWindowBounds !== null &&
390 windowBoundsEqual(currentBounds, fallbackWindowBounds) &&
391 window.isMaximized() === fallbackWindowMaximized)
392 ) {
393 return;
394 }
395 }
396 boundsPersistenceEnabled = true;
397 if (boundsPersistFiber !== undefined) {
398 const fiber = boundsPersistFiber;
399 boundsPersistFiber = undefined;
400 runFork(Fiber.interrupt(fiber));
401 }
402 boundsPersistFiber = runFork(
403 Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe(
404 Effect.andThen(
405 Effect.sync(() => {
406 boundsPersistFiber = undefined;
407 void persistCurrentBounds();
408 }),
409 ),
410 ),
411 );
412 };
413 const clearBoundsPersist = () => {
414 if (boundsPersistFiber === undefined) {
415 return;
416 }
417 const fiber = boundsPersistFiber;
418 boundsPersistFiber = undefined;
419 runFork(Fiber.interrupt(fiber));
420 };
421 const flushBoundsPersist = Effect.sync(() => {
422 clearBoundsPersist();
423 return persistCurrentBounds();
424 }).pipe(
425 Effect.flatMap((fiber) =>
426 fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid),
427 ),
428 );
429 flushMainWindowBounds = flushBoundsPersist;
277430
278 yield* previewManager.setMainWindow(window);431 yield* previewManager.setMainWindow(window);
279 window.webContents.on("will-attach-webview", (event, webPreferences, params) => {432 window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
...@@ -364,6 +517,13 @@ export const make = Effect.gen(function* () {...@@ -364,6 +517,13 @@ export const make = Effect.gen(function* () {
364 event.preventDefault();517 event.preventDefault();
365 window.setTitle(environment.displayName);518 window.setTitle(environment.displayName);
366 });519 });
520 window.on("resize", scheduleBoundsPersist);
521 window.on("move", scheduleBoundsPersist);
522 window.on("maximize", scheduleBoundsPersist);
523 window.on("unmaximize", scheduleBoundsPersist);
524 window.on("close", () => {
525 runFork(flushBoundsPersist);
526 });
367527
368 if (environment.platform === "darwin") {528 if (environment.platform === "darwin") {
369 window.on("enter-full-screen", () => {529 window.on("enter-full-screen", () => {
...@@ -472,6 +632,9 @@ export const make = Effect.gen(function* () {...@@ -472,6 +632,9 @@ export const make = Effect.gen(function* () {
472 bindFirstRevealTrigger(revealSubscribers, () => {632 bindFirstRevealTrigger(revealSubscribers, () => {
473 // Reveal the real window, then close the connecting splash (if any) so the633 // Reveal the real window, then close the connecting splash (if any) so the
474 // two don't overlap and there's no blank gap between them.634 // two don't overlap and there's no blank gap between them.
635 if (persistedSettings.mainWindowMaximized) {
636 window.maximize();
637 }
475 void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash));638 void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash));
476 });639 });
477640
...@@ -482,6 +645,7 @@ export const make = Effect.gen(function* () {...@@ -482,6 +645,7 @@ export const make = Effect.gen(function* () {
482645
483 window.on("closed", () => {646 window.on("closed", () => {
484 clearDevelopmentLoadRetry();647 clearDevelopmentLoadRetry();
648 clearBoundsPersist();
485 void runPromise(electronWindow.clearMain(Option.some(window)));649 void runPromise(electronWindow.clearMain(Option.some(window)));
486 });650 });
487651
...@@ -598,6 +762,9 @@ export const make = Effect.gen(function* () {...@@ -598,6 +762,9 @@ export const make = Effect.gen(function* () {
598 handleBackendNotReady: Ref.set(backendReadyRef, false).pipe(762 handleBackendNotReady: Ref.set(backendReadyRef, false).pipe(
599 Effect.withSpan("desktop.window.handleBackendNotReady"),763 Effect.withSpan("desktop.window.handleBackendNotReady"),
600 ),764 ),
765 flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe(
766 Effect.withSpan("desktop.window.flushMainWindowBounds"),
767 ),
601 dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) {768 dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) {
602 yield* Effect.annotateCurrentSpan({ action });769 yield* Effect.annotateCurrentSpan({ action });
603 const existingWindow = yield* focusedMainWindow;770 const existingWindow = yield* focusedMainWindow;
apps/web/src/components/AppSidebarLayout.tsx+32-8
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import * as Schema from "effect/Schema";
2import { useEffect, useState, type CSSProperties, type ReactNode } from "react";3import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
3import { useLocation, useNavigate } from "@tanstack/react-router";4import { useLocation, useNavigate } from "@tanstack/react-router";
45
5import { isElectron } from "../env";6import { isElectron } from "../env";
7import { getLocalStorageItem } from "../hooks/useLocalStorage";
6import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";8import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
7import { cn, isMacPlatform } from "../lib/utils";9import { cn, isMacPlatform } from "../lib/utils";
8import { primaryServerKeybindingsAtom } from "../state/server";10import { primaryServerKeybindingsAtom } from "../state/server";
9import ThreadSidebar from "./Sidebar";11import ThreadSidebar from "./Sidebar";
10import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";12import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
13import {
14 resolveInitialThreadSidebarWidth,
15 resolveThreadSidebarMaximumWidth,
16 THREAD_MAIN_CONTENT_MIN_WIDTH,
17 THREAD_SIDEBAR_MIN_WIDTH,
18 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
19} from "./threadSidebarWidth";
11import {20import {
12 Sidebar,21 Sidebar,
13 SidebarProvider,22 SidebarProvider,
...@@ -18,11 +27,20 @@ import {...@@ -18,11 +27,20 @@ import {
18} from "./ui/sidebar";27} from "./ui/sidebar";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";28import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
2029
21const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
22const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
23const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
24const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";30const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
2531
32function readInitialThreadSidebarWidth(): number {
33 try {
34 return resolveInitialThreadSidebarWidth(
35 getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite),
36 window.innerWidth,
37 );
38 } catch (error) {
39 console.error("Could not read persisted thread sidebar width.", error);
40 return resolveInitialThreadSidebarWidth(null, window.innerWidth);
41 }
42}
43
26function SidebarControl() {44function SidebarControl() {
27 const keybindings = useAtomValue(primaryServerKeybindingsAtom);45 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
28 const { toggleSidebar } = useSidebar();46 const { toggleSidebar } = useSidebar();
...@@ -82,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -82,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
82 const navigate = useNavigate();100 const navigate = useNavigate();
83 const pathname = useLocation({ select: (location) => location.pathname });101 const pathname = useLocation({ select: (location) => location.pathname });
84 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);102 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
103 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
104 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth);
85 const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => {105 const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => {
86 const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState;106 const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState;
87 return isMacosDesktop && typeof getWindowFullscreenState === "function"107 return isMacosDesktop && typeof getWindowFullscreenState === "function"
88 ? getWindowFullscreenState()108 ? getWindowFullscreenState()
89 : false;109 : false;
90 });110 });
91 const macosWindowControlsStyle =111 const sidebarProviderStyle = {
92 isMacosDesktop && !isWindowFullscreen112 "--sidebar-width": `${sidebarWidth}px`,
93 ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)113 ...(isMacosDesktop && !isWindowFullscreen
94 : undefined;114 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
115 : {}),
116 } as CSSProperties;
95117
96 useEffect(() => {118 useEffect(() => {
97 if (!isMacosDesktop) return;119 if (!isMacosDesktop) return;
...@@ -131,17 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -131,17 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
131 }, [navigate, pathname]);153 }, [navigate, pathname]);
132154
133 return (155 return (
134 <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>156 <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={sidebarProviderStyle}>
135 <Sidebar157 <Sidebar
136 side="left"158 side="left"
137 collapsible="offcanvas"159 collapsible="offcanvas"
138 className="border-r border-border bg-card text-foreground"160 className="border-r border-border bg-card text-foreground"
139 resizable={{161 resizable={{
162 maxWidth: sidebarMaximumWidth,
140 minWidth: THREAD_SIDEBAR_MIN_WIDTH,163 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
141 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>164 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
142 nextWidth <= currentWidth ||165 nextWidth <= currentWidth ||
143 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,166 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
144 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,167 storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
168 onResize: setSidebarWidth,
145 }}169 }}
146 >170 >
147 <ThreadSidebar />171 <ThreadSidebar />
apps/web/src/components/threadSidebarWidth.test.ts created+34
...@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 resolveInitialThreadSidebarWidth,
5 THREAD_MAIN_CONTENT_MIN_WIDTH,
6 THREAD_SIDEBAR_DEFAULT_WIDTH,
7 THREAD_SIDEBAR_MIN_WIDTH,
8} from "./threadSidebarWidth";
9
10describe("thread sidebar width", () => {
11 it("uses the default width when no preference is stored", () => {
12 expect(resolveInitialThreadSidebarWidth(null, 1200)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH);
13 });
14
15 it("uses a stored width in the initial render", () => {
16 expect(resolveInitialThreadSidebarWidth(360, 1200)).toBe(360);
17 });
18
19 it("clamps a stored width to the sidebar minimum", () => {
20 expect(resolveInitialThreadSidebarWidth(120, 1200)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
21 });
22
23 it("leaves enough room for the main content on a smaller window", () => {
24 const viewportWidth = 1000;
25
26 expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe(
27 viewportWidth - THREAD_MAIN_CONTENT_MIN_WIDTH,
28 );
29 });
30
31 it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => {
32 expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
33 });
34});
apps/web/src/components/threadSidebarWidth.ts created+22
...@@ -0,0 +1,22 @@
1export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
2export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;
3export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
4export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
5
6export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {
7 return Math.max(
8 THREAD_SIDEBAR_MIN_WIDTH,
9 Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,
10 );
11}
12
13export function resolveInitialThreadSidebarWidth(
14 storedWidth: number | null,
15 viewportWidth: number,
16): number {
17 const preferredWidth =
18 storedWidth === null
19 ? THREAD_SIDEBAR_DEFAULT_WIDTH
20 : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth);
21 return Math.min(preferredWidth, resolveThreadSidebarMaximumWidth(viewportWidth));
22}
apps/web/src/components/ui/sidebar.tsx+10-2
...@@ -555,16 +555,24 @@ function SidebarRail({...@@ -555,16 +555,24 @@ function SidebarRail({
555 [onClick, open, resolvedResizable, toggleSidebar],555 [onClick, open, resolvedResizable, toggleSidebar],
556 );556 );
557557
558 React.useEffect(() => {558 React.useLayoutEffect(() => {
559 if (!resolvedResizable?.storageKey || typeof window === "undefined") return;559 if (!resolvedResizable?.storageKey || typeof window === "undefined") return;
560 const rail = railRef.current;560 const rail = railRef.current;
561 if (!rail) return;561 if (!rail) return;
562 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");562 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
563 if (!wrapper) return;563 if (!wrapper) return;
564564
565 const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite);565 let storedWidth: number | null;
566 try {
567 storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite);
568 } catch (error) {
569 console.error("Could not restore persisted sidebar width.", error);
570 return;
571 }
566 if (storedWidth === null) return;572 if (storedWidth === null) return;
567 const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable);573 const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable);
574 // Hydrate the CSS variable before the browser paints so a restored sidebar
575 // never flashes at the default width first.
568 wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`);576 wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`);
569 resolvedResizable.onResize?.(clampedWidth);577 resolvedResizable.onResize?.(clampedWidth);
570 }, [resolvedResizable]);578 }, [resolvedResizable]);