diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d49d2cdebb701..7765a05dcb42d 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -22,6 +22,7 @@ import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; @@ -324,6 +325,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* previewPasskeys.configure; yield* applicationMenu.configure; + yield* (yield* DesktopTray.DesktopTray).configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* DesktopStartupRecovery.listen; diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 6a6e991fc6b4b..34d12f1f3ac69 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -14,6 +14,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; function layerElectronApp( @@ -105,6 +106,49 @@ function layerDesktopWindow( } describe("DesktopLifecycle", () => { + it.effect.each([ + { platform: "win32", tray: true, quits: 0 }, + { platform: "win32", tray: false, quits: 1 }, + { platform: "linux", tray: false, quits: 1 }, + { platform: "darwin", tray: false, quits: 0 }, + ] as const)("handles last-window closure on $platform with tray=$tray", (input) => + Effect.gen(function* () { + const listeners = new Map void>(); + const quits = yield* Ref.make(0); + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge( + layerElectronApp( + listeners, + Ref.update(quits, (count) => count + 1), + ), + ), + Layer.provideMerge(layerElectronTheme), + Layer.provideMerge(layerElectronWindow()), + Layer.provideMerge(layerDesktopWindow()), + Layer.provideMerge( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: input.platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(input.tray), + }), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + yield* (yield* DesktopLifecycle.DesktopLifecycle).register; + listeners.get("window-all-closed")!(); + assert.equal(yield* Ref.get(quits), input.quits); + }), + ).pipe(Effect.provide(layer)); + }), + ); it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray)( "lets the updater's quit event proceed on %s", (platform) => { @@ -129,6 +173,12 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(layerEnvironment), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); return Effect.scoped( @@ -201,6 +251,12 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(layerEnvironment), Layer.provideMerge(layerDesktopShutdown), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); yield* Effect.scoped( @@ -244,6 +300,12 @@ describe("DesktopLifecycle", () => { // Nothing marks this shutdown complete, so it stays pending. Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); yield* Effect.scoped( @@ -296,6 +358,12 @@ describe("DesktopLifecycle", () => { Layer.provideMerge(layerEnvironment), Layer.provideMerge(DesktopShutdown.layer), Layer.provideMerge(DesktopState.layer), + Layer.provideMerge( + Layer.succeed(DesktopTray.DesktopTray, { + configure: Effect.void, + keepsHostAlive: Effect.succeed(false), + }), + ), ); yield* Effect.scoped( @@ -303,11 +371,16 @@ describe("DesktopLifecycle", () => { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const state = yield* DesktopState.DesktopState; yield* lifecycle.register; + appListeners.get("second-instance")?.({}, ["t3code"]); + assert.equal(activationCount, 1); + appListeners.get("second-instance")?.({}, ["t3code", "t3code://app/threads/1"]); + assert.equal(activationCount, 1); yield* Ref.set(state.quitting, true); appListeners.get("activate")?.(); + appListeners.get("second-instance")?.({}, ["t3code"]); - assert.equal(activationCount, 0); + assert.equal(activationCount, 1); }), ).pipe(Effect.provide(layer)); }), diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 93347949e00fa..2beb0ae33dc0c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -16,6 +16,7 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopState from "./DesktopState.ts"; +import * as DesktopTray from "./DesktopTray.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; export class DesktopLifecycleRelaunchError extends Schema.TaggedError()( @@ -40,10 +41,11 @@ export type DesktopLifecycleRuntimeServices = type DesktopLifecycleRegistrationServices = | DesktopLifecycleRuntimeServices + | DesktopTray.DesktopTray | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow + * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow | DesktopTray */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, @@ -282,12 +284,26 @@ export const make = DesktopLifecycle.of({ }).pipe(Effect.withSpan("desktop.lifecycle.activate")), ); }); + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { + if (argv.some((argument) => argument.includes("://"))) return; + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + if (!(yield* Ref.get(state.quitting))) yield* desktopWindow.activate; + }).pipe(Effect.withSpan("desktop.lifecycle.secondInstance")), + ); + }); yield* electronApp.on("window-all-closed", () => { void runEffect( Effect.gen(function* () { const app = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; - if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + const tray = yield* DesktopTray.DesktopTray; + if ( + environment.platform !== "darwin" && + !(yield* tray.keepsHostAlive) && + !(yield* Ref.get(state.quitting)) + ) { yield* app.quit; } }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), diff --git a/apps/desktop/src/app/DesktopTray.test.ts b/apps/desktop/src/app/DesktopTray.test.ts new file mode 100644 index 0000000000000..2a43fa0baeb09 --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.test.ts @@ -0,0 +1,92 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopTray from "./DesktopTray.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; + +describe("DesktopTray", () => { + it.effect.each([ + { platform: "win32", icon: true, native: true, enabled: true, creates: 1 }, + { platform: "win32", icon: false, native: true, enabled: false, creates: 0 }, + { platform: "win32", icon: true, native: false, enabled: false, creates: 2 }, + { platform: "darwin", icon: true, native: true, enabled: false, creates: 0 }, + { platform: "linux", icon: true, native: true, enabled: false, creates: 0 }, + ] as const)("keeps hosting only with a usable Windows tray: $platform/$icon/$native", (input) => + Effect.gen(function* () { + let creates = 0; + let releases = 0; + let opens = 0; + let quits = 0; + let callbacks: Parameters[0] | undefined; + const stateLayer = DesktopState.layer; + const layer = DesktopTray.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + stateLayer, + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: input.platform, + displayName: "T3 Code", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.mock(DesktopAssets.DesktopAssets)({ + iconPaths: Effect.succeed({ + ico: input.icon ? Option.some("icon.ico") : Option.none(), + png: Option.none(), + icns: Option.none(), + }), + }), + Layer.mock(DesktopWindow.DesktopWindow)({ + activate: Effect.sync(() => { + opens += 1; + }), + }), + Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.sync(() => { + quits += 1; + }), + }), + Layer.succeed(ElectronTray.ElectronTray, { + create: (value) => + Effect.acquireRelease( + Effect.sync(() => { + creates += 1; + callbacks = value; + return input.native; + }), + (created) => + Effect.sync(() => { + if (created) releases += 1; + }), + ), + }), + ), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const tray = yield* DesktopTray.DesktopTray; + yield* tray.configure; + yield* tray.configure; + assert.equal(yield* tray.keepsHostAlive, input.enabled); + assert.equal(creates, input.creates); + if (input.enabled) { + callbacks!.open(); + assert.equal(opens, 1); + yield* Ref.set((yield* DesktopState.DesktopState).quitting, true); + callbacks!.open(); + assert.equal(opens, 1); + callbacks!.quit(); + assert.equal(quits, 1); + } + }).pipe(Effect.provide(layer)), + ); + assert.equal(releases, input.enabled ? 1 : 0); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopTray.ts b/apps/desktop/src/app/DesktopTray.ts new file mode 100644 index 0000000000000..46a11a4dd8d13 --- /dev/null +++ b/apps/desktop/src/app/DesktopTray.ts @@ -0,0 +1,64 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTray from "../electron/ElectronTray.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +export class DesktopTray extends Context.Service< + DesktopTray, + { + readonly configure: Effect.Effect; + readonly keepsHostAlive: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopTray") {} + +const { logWarning } = makeComponentLogger("desktop-tray"); + +export const layer = Layer.effect( + DesktopTray, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const assets = yield* DesktopAssets.DesktopAssets; + const window = yield* DesktopWindow.DesktopWindow; + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + const native = yield* ElectronTray.ElectronTray; + const enabled = yield* Ref.make(false); + const runFork = Effect.runForkWith(yield* Effect.context()); + const open = Effect.gen(function* () { + if (!(yield* Ref.get(state.quitting))) yield* window.activate; + }).pipe(Effect.catchCause((cause) => logWarning("failed to open window", { cause }))); + + return DesktopTray.of({ + keepsHostAlive: Ref.get(enabled), + configure: Effect.gen(function* () { + if (environment.platform !== "win32" || (yield* Ref.get(enabled))) return; + const icon = (yield* assets.iconPaths).ico; + if (Option.isNone(icon)) { + yield* logWarning("tray icon unavailable; closing the last window will quit"); + return; + } + const created = yield* native.create({ + icon: icon.value, + name: environment.displayName, + open: () => { + runFork(open); + }, + quit: () => { + runFork(app.quit); + }, + }); + yield* Ref.set(enabled, created); + if (!created) yield* logWarning("tray creation failed; closing the last window will quit"); + }), + }); + }), +); diff --git a/apps/desktop/src/electron/ElectronTray.test.ts b/apps/desktop/src/electron/ElectronTray.test.ts new file mode 100644 index 0000000000000..8e4d1f2c6cb78 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; +import * as Electron from "electron"; +import * as ElectronTray from "./ElectronTray.ts"; + +vi.mock("electron", () => ({ Tray: vi.fn(), Menu: { buildFromTemplate: vi.fn() } })); + +describe("ElectronTray", () => { + it.effect.each(["success", "constructor", "menu"] as const)( + "releases native resources when setup outcome is %s", + (outcome) => + Effect.gen(function* () { + const destroy = vi.fn(); + const setContextMenu = vi.fn(() => { + if (outcome === "menu") throw new Error("menu unavailable"); + }); + vi.mocked(Electron.Tray).mockImplementation(function () { + if (outcome === "constructor") throw new Error("tray unavailable"); + return { + destroy, + setContextMenu, + setToolTip: vi.fn(), + on: vi.fn(), + } as unknown as Electron.Tray; + }); + yield* Effect.scoped( + Effect.gen(function* () { + const tray = yield* ElectronTray.ElectronTray; + const created = yield* tray.create({ + icon: "icon.ico", + name: "T3 Code", + open: () => {}, + quit: () => {}, + }); + assert.equal(created, outcome === "success"); + assert.equal(destroy.mock.calls.length, outcome === "menu" ? 1 : 0); + }).pipe(Effect.provide(ElectronTray.layer)), + ); + assert.equal(destroy.mock.calls.length, outcome === "constructor" ? 0 : 1); + }), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTray.ts b/apps/desktop/src/electron/ElectronTray.ts new file mode 100644 index 0000000000000..8b848562210d2 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTray.ts @@ -0,0 +1,44 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Electron from "electron"; + +export class ElectronTray extends Context.Service< + ElectronTray, + { + readonly create: (input: { + readonly icon: string; + readonly name: string; + readonly open: () => void; + readonly quit: () => void; + }) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTray") {} + +export const layer = Layer.succeed(ElectronTray, { + create: Effect.fn("ElectronTray.create")((input) => + Effect.acquireRelease( + Effect.sync(() => { + let tray: Electron.Tray | undefined; + try { + tray = new Electron.Tray(input.icon); + tray.setToolTip(`${input.name}: running in the background`); + tray.setContextMenu( + Electron.Menu.buildFromTemplate([ + { label: `Open ${input.name}`, click: input.open }, + { type: "separator" }, + { label: `Quit ${input.name}`, click: input.quit }, + ]), + ); + tray.on("double-click", input.open); + return tray; + } catch { + tray?.destroy(); + return undefined; + } + }), + (tray) => Effect.sync(() => tray?.destroy()), + ).pipe(Effect.map((tray) => tray !== undefined)), + ), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9a3c327e6b86b..48c79267bcdd7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -26,6 +26,8 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronTray from "./electron/ElectronTray.ts"; +import * as DesktopTray from "./app/DesktopTray.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; @@ -210,6 +212,9 @@ const layerDesktopApplication = Layer.mergeAll( DesktopCliCommand.layer, layerDesktopSsh, ).pipe( + Layer.provideMerge( + DesktopTray.layer.pipe(Layer.provide(ElectronTray.layer), Layer.provide(layerDesktopWindow)), + ), Layer.provideMerge(layerDesktopSnapShot), Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(layerDesktopWslBackend), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8b6ab7c7d8a5e..a58b9f169dfbc 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -455,6 +455,27 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111" const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); describe("DesktopWindow", () => { + it.effect( + "keeps a closed renderer released across backend restarts until explicitly reopened", + () => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + fake.windowListeners.get("closed")!(); + yield* Ref.set(mainWindow, Option.none()); + yield* windows.handleBackendNotReady; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + yield* windows.activate; + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); it("restores bounds only when the window fits within a connected display", () => { const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; const displays = [ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index c1c0cf5446cd2..e7aa620ce8592 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -303,12 +303,15 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let windowRequested = true; let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); if (Option.isSome(splash) && !splash.value.isDestroyed()) { + const requested = windowRequested; splash.value.close(); + windowRequested = requested; } }); @@ -825,6 +828,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + windowRequested = false; clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); @@ -844,6 +848,7 @@ export const make = Effect.gen(function* () { const ensureMain = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); + windowRequested = true; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { return existingWindow; @@ -870,6 +875,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; + if (!windowRequested) return; if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; @@ -910,6 +916,9 @@ export const make = Effect.gen(function* () { } yield* rendererHistory.register(splash.webContents, { surface: "splash" }); yield* Ref.set(splashWindowRef, Option.some(splash)); + splash.on("close", () => { + windowRequested = false; + }); splash.once("closed", () => { void runPromise(Ref.set(splashWindowRef, Option.none())); }); @@ -964,6 +973,7 @@ export const make = Effect.gen(function* () { }), activate: Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; + windowRequested = true; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value); diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 566f7ab9d6065..403c095ca8e87 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -3,6 +3,14 @@ On Linux and macOS, T3 Code can run as a service for your user so you do not need to keep a terminal open. +On Windows, closing the desktop window keeps its server and running agents +available to your paired devices. Reopen it from the system tray or launch +T3 Code again. +Choose **Quit T3 Code** in the tray to stop the app and its server completely. +If the tray cannot be created, closing the last window quits the app. +Background hosting requires your PC to remain awake and your Windows session +to stay signed in; it does not start automatically after a reboot. + ## Manage the service Install the `t3` CLI first ([Install T3 Code](./install.md#command-line)), then