diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index dc3769bb814f..ddbd678937eb 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -43,6 +43,7 @@ import { pickFolder, pickProjectFavicon, pickThemeFiles, + restartApp, setTheme, showContextMenu, } from "./methods/window.ts"; @@ -123,6 +124,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(restartApp); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 43ecee06c0ca..72824ea86c0c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -11,6 +11,7 @@ export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const RESTART_APP_CHANNEL = "desktop:restart-app"; export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..e236cd85d2fd 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -6,14 +6,22 @@ import { vi } from "vite-plus/test"; import type * as Electron from "electron"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, pickProjectFavicon, + restartApp, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -153,6 +161,49 @@ describe("getWindowFullscreenState", () => { }); }); +describe("restartApp", () => { + it.effect("requests a graceful command-palette relaunch", () => { + const relaunchReasons: string[] = []; + const lifecycleLayer = Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + register: Effect.void, + }), + ); + const unusedRuntimeLayer = Layer.mergeAll( + DesktopShutdown.layer, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of( + {} as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + Layer.succeed( + DesktopWindow.DesktopWindow, + DesktopWindow.DesktopWindow.of({} as DesktopWindow.DesktopWindow["Service"]), + ), + Layer.succeed( + ElectronApp.ElectronApp, + ElectronApp.ElectronApp.of({} as ElectronApp.ElectronApp["Service"]), + ), + Layer.succeed( + ElectronTheme.ElectronTheme, + ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), + ), + ); + + return Effect.gen(function* () { + yield* restartApp.handler(undefined); + assert.deepEqual(relaunchReasons, ["command-palette"]); + }).pipe(Effect.provide(Layer.merge(lifecycleLayer, unusedRuntimeLayer))); + }); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 61de1361a311..480ddb6f7dc7 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -25,6 +25,7 @@ import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -86,6 +87,16 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); +export const restartApp = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESTART_APP_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.restartApp")(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.relaunch("command-palette"); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7da32d7913ae..2efa93f57c03 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -211,6 +211,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); }; }, + restartApp: () => ipcRenderer.invoke(IpcChannels.RESTART_APP_CHANNEL), getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..6f5b41989755 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -24,6 +24,7 @@ const DesktopSettingsPatch = Schema.Struct({ }), ), ), + mainWindowFullscreen: Schema.optionalKey(Schema.Boolean), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), @@ -107,6 +108,7 @@ describe("DesktopSettings", () => { { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -136,6 +138,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -243,6 +246,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -263,12 +267,14 @@ describe("DesktopSettings", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + mainWindowFullscreen: true, mainWindowMaximized: true, serverExposureMode: "network-accessible", }); const loaded = yield* settings.load; assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowFullscreen); assert.isFalse(loaded.mainWindowMaximized); assert.equal(loaded.serverExposureMode, "network-accessible"); }), @@ -299,6 +305,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -320,7 +327,11 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); + yield* settings.setMainWindowBounds( + { x: -1200, y: 40, width: 1440, height: 960 }, + true, + true, + ); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( @@ -328,6 +339,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(persisted, { mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowFullscreen: true, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); @@ -347,6 +359,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -375,6 +388,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -402,6 +416,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: true, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 3bd235018022..c4f76347fa37 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -27,6 +27,7 @@ import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; + readonly mainWindowFullscreen: boolean; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; @@ -75,6 +76,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, + mainWindowFullscreen: false, mainWindowMaximized: false, serverExposureMode: "local-only", tailscaleServeEnabled: false, @@ -96,6 +98,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ const DesktopSettingsDocument = Schema.Struct({ linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowFullscreen: Schema.optionalKey(Schema.Boolean), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), @@ -155,6 +158,7 @@ export class DesktopAppSettings extends Context.Service< readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, + isFullscreen: boolean, ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, @@ -226,6 +230,7 @@ function normalizeDesktopSettingsDocument( return { linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, + mainWindowFullscreen: mainWindowBounds !== null && parsed.mainWindowFullscreen === true, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", @@ -253,6 +258,9 @@ function toDesktopSettingsDocument( if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } + if (settings.mainWindowFullscreen) { + document.mainWindowFullscreen = true; + } if (settings.mainWindowMaximized) { document.mainWindowMaximized = true; } @@ -300,14 +308,17 @@ function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, isMaximized: boolean, + isFullscreen: boolean, ): DesktopSettings { return settings.mainWindowBounds !== null && desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && - settings.mainWindowMaximized === isMaximized + settings.mainWindowMaximized === isMaximized && + settings.mainWindowFullscreen === isFullscreen ? settings : { ...settings, mainWindowBounds: bounds, + mainWindowFullscreen: isFullscreen, mainWindowMaximized: isMaximized, }; } @@ -507,14 +518,15 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds, isMaximized) => - persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( + setMainWindowBounds: (bounds, isMaximized, isFullscreen) => + persist((settings) => setMainWindowBounds(settings, bounds, isMaximized, isFullscreen)).pipe( Effect.withSpan("desktop.settings.setMainWindowBounds", { attributes: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, + isFullscreen, isMaximized, }, }), @@ -576,8 +588,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds, isMaximized) => - update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setMainWindowBounds: (bounds, isMaximized, isFullscreen) => + update((settings) => setMainWindowBounds(settings, bounds, isMaximized, isFullscreen)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7bbb5c1da024..f6917489a587 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -213,6 +213,7 @@ function makeTestLayer(input: { readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly desktopSettings?: DesktopAppSettings.DesktopSettings; readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly mainWindowFullscreenUpdates?: boolean[]; readonly mainWindowMaximizedUpdates?: boolean[]; readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, @@ -227,7 +228,7 @@ function makeTestLayer(input: { const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => desktopSettings), load: Effect.sync(() => desktopSettings), - setMainWindowBounds: (bounds, isMaximized) => + setMainWindowBounds: (bounds, isMaximized, isFullscreen) => Effect.gen(function* () { if (input.beforeMainWindowBoundsUpdate) { yield* input.beforeMainWindowBoundsUpdate(bounds); @@ -235,14 +236,17 @@ function makeTestLayer(input: { const changed = desktopSettings.mainWindowBounds === null || !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || + desktopSettings.mainWindowFullscreen !== isFullscreen || desktopSettings.mainWindowMaximized !== isMaximized; if (changed) { desktopSettings = { ...desktopSettings, mainWindowBounds: bounds, + mainWindowFullscreen: isFullscreen, mainWindowMaximized: isMaximized, }; input.mainWindowBoundsUpdates?.push(bounds); + input.mainWindowFullscreenUpdates?.push(isFullscreen); input.mainWindowMaximizedUpdates?.push(isMaximized); } return { settings: desktopSettings, changed }; @@ -620,6 +624,7 @@ describe("DesktopWindow", () => { assert.equal(createdWindowOptions[0]?.height, 780); assert.isUndefined(createdWindowOptions[0]?.x); assert.isUndefined(createdWindowOptions[0]?.y); + assert.isUndefined(createdWindowOptions[0]?.fullscreen); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); @@ -770,6 +775,55 @@ describe("DesktopWindow", () => { }), ); + it.effect("restores the persisted native macOS fullscreen state", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const mainWindowFullscreenUpdates: boolean[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + mainWindowFullscreenUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + mainWindowFullscreen: true, + mainWindowMaximized: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.fullscreen, true); + assert.equal(fakeWindow.setFullScreen.mock.calls.length, 0); + const resize = fakeWindow.windowListeners.get("resize"); + const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + if (!resize || !readyToShow || !enterFullscreen) { + return yield* Effect.die("window startup listeners were not registered"); + } + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + resize(); + yield* TestClock.adjust(500); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowFullscreenUpdates, []); + readyToShow(); + assert.equal(fakeWindow.maximize.mock.calls.length, 0); + + fakeWindow.isFullScreen.mockReturnValue(true); + enterFullscreen(); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowFullscreenUpdates, [true]); + }).pipe(Effect.provide(layer)); + }), + ); + // The window boots hidden with throttling disabled so first paint runs at // full speed; the first reveal must hand it back to normal hidden-window // throttling or a minimized window stays expensive forever. @@ -983,6 +1037,52 @@ describe("DesktopWindow", () => { }), ); + it.effect("preserves off-display bounds throughout a fullscreen restore", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 }, + mainWindowFullscreen: true, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + const resize = fakeWindow.windowListeners.get("resize"); + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); + if (!resize || !enterFullscreen || !leaveFullscreen) { + return yield* Effect.die("window fullscreen listeners were not registered"); + } + + resize(); + yield* TestClock.adjust(500); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.isFullScreen.mockReturnValue(true); + enterFullscreen(); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowBoundsUpdates, []); + + fakeWindow.isFullScreen.mockReturnValue(false); + leaveFullscreen(); + yield* desktopWindow.flushMainWindowBounds; + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 0, y: 0, width: 1100, height: 780 }]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("flushes normal bounds when fullscreen before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -991,11 +1091,13 @@ describe("DesktopWindow", () => { const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowFullscreenUpdates: boolean[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, mainWindowBoundsUpdates, + mainWindowFullscreenUpdates, }); yield* Effect.gen(function* () { @@ -1013,6 +1115,7 @@ describe("DesktopWindow", () => { yield* desktopWindow.flushMainWindowBounds; assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); + assert.deepEqual(mainWindowFullscreenUpdates, [true]); assert.equal(fakeWindow.getBounds.mock.calls.length, 0); assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); }).pipe(Effect.provide(layer)); @@ -1160,15 +1263,17 @@ describe("DesktopWindow", () => { }), ); - it.effect("publishes native macOS fullscreen changes to the renderer", () => + it.effect("persists and publishes native macOS fullscreen changes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowFullscreenUpdates: boolean[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, + mainWindowFullscreenUpdates, }); yield* Effect.gen(function* () { @@ -1181,8 +1286,17 @@ describe("DesktopWindow", () => { return yield* Effect.die("fullscreen listeners were not registered"); } + fakeWindow.isFullScreen.mockReturnValue(true); enterFullscreen(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + fakeWindow.isFullScreen.mockReturnValue(false); leaveFullscreen(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowFullscreenUpdates, [true, false]); assert.deepEqual(fakeWindow.send.mock.calls, [ [WINDOW_FULLSCREEN_STATE_CHANNEL, true], [WINDOW_FULLSCREEN_STATE_CHANNEL, false], diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0a966ec36e4d..6efd49692ebe 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -371,6 +371,8 @@ export const make = Effect.gen(function* () { }).pipe(Effect.as([])); const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; + const restoreFullscreen = + persistedSettings.mainWindowFullscreen && environment.platform === "darwin"; if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } @@ -379,6 +381,7 @@ export const make = Effect.gen(function* () { minWidth: 840, minHeight: 620, show: false, + ...(restoreFullscreen ? { fullscreen: true } : {}), autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), @@ -405,6 +408,7 @@ export const make = Effect.gen(function* () { } let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; + let fullscreenRestorePending = restoreFullscreen; let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { if (window.isDestroyed()) { @@ -422,9 +426,10 @@ export const make = Effect.gen(function* () { }); }; const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowFullscreen = persistedSettings.mainWindowFullscreen; const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; const persistCurrentBounds = (): Fiber.Fiber | undefined => { - if (!boundsPersistenceEnabled) { + if (!boundsPersistenceEnabled || fullscreenRestorePending) { return pendingBoundsPersistFiber; } const bounds = readPersistableBounds(); @@ -432,25 +437,37 @@ export const make = Effect.gen(function* () { return pendingBoundsPersistFiber; } pendingBoundsPersistFiber = runFork( - desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( - Effect.asVoid, - Effect.catch((error) => - logWindowWarning("failed to persist main window bounds", { - message: error.message, - }), + desktopSettings + .setMainWindowBounds( + bounds, + window.isMaximized(), + environment.platform === "darwin" && window.isFullScreen(), + ) + .pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), ), - ), ); return pendingBoundsPersistFiber; }; const scheduleBoundsPersist = () => { + // Native startup transitions do not replace the saved normal window bounds. + if (fullscreenRestorePending) { + return; + } if (!boundsPersistenceEnabled) { const currentBounds = readPersistableBounds(); if ( currentBounds === null || (fallbackWindowBounds !== null && windowBoundsEqual(currentBounds, fallbackWindowBounds) && - window.isMaximized() === fallbackWindowMaximized) + window.isMaximized() === fallbackWindowMaximized && + (environment.platform !== "darwin" || + window.isFullScreen() === fallbackWindowFullscreen)) ) { return; } @@ -657,10 +674,14 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.on("enter-full-screen", () => { + fullscreenRestorePending = false; window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); + scheduleBoundsPersist(); }); window.on("leave-full-screen", () => { + fullscreenRestorePending = false; window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); + scheduleBoundsPersist(); }); } @@ -795,12 +816,15 @@ export const make = Effect.gen(function* () { if (!window.isDestroyed()) { window.webContents.setBackgroundThrottling(true); } - // 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. - if (persistedSettings.mainWindowMaximized) { - window.maximize(); - } - void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); + void runPromise( + Effect.gen(function* () { + if (!restoreFullscreen && persistedSettings.mainWindowMaximized) { + window.maximize(); + } + yield* electronWindow.reveal(window); + yield* dismissConnectingSplash; + }), + ); }); loadApplication(); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d146120f719d..5302db4a6ea6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -46,6 +46,7 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, + RotateCwIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -1689,6 +1690,19 @@ function OpenCommandPaletteDialog(props: { }, }); + const restartApp = window.desktopBridge?.restartApp; + if (restartApp) { + actionItems.push({ + kind: "action", + value: "action:restart-app", + searchTerms: ["restart", "relaunch", "reload", "desktop", "app", "t3 code"], + title: "Restart T3 Code", + description: "Active tasks will be interrupted", + icon: , + run: restartApp, + }); + } + // There is no projects listing page; the action targets the contextual // project (active thread/draft, falling back to the first sidebar group). const contextualProjectGroup = diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 4e46e97a3a56..e017f3fad862 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -60,6 +60,10 @@ shortcut; assign one in **Settings → Keybindings**. `chat.newLocal` skips that chooser. Both use your [new-thread defaults](./thread-sidebar.md#start-a-thread). +Desktop builds include **Restart T3 Code** in the command palette. It gracefully +stops the desktop-managed server before relaunching the app, so saved window state +is flushed first. Active tasks are interrupted. + ## Reserved shortcuts In the desktop app, `mod+w` closes the focused terminal or the active right-panel diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..e56ced594e8c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1316,6 +1316,8 @@ export interface DesktopBridge { onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; + /** Optional while older desktop shells can host a newer web client. */ + restartApp?: () => Promise; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise;