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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions apps/desktop/src/electron/ElectronPowerSaveBlocker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import * as ElectronPowerSaveBlocker from "./ElectronPowerSaveBlocker.ts";

function makeStubApi() {
const calls: string[] = [];
const started = new Set<number>();
let nextId = 0;
const api: ElectronPowerSaveBlocker.PowerSaveBlockerApi = {
start: (type) => {
const id = nextId++;
started.add(id);
calls.push(`start:${type}:${id}`);
return id;
},
stop: (id) => {
calls.push(`stop:${id}`);
return started.delete(id);
},
isStarted: (id) => started.has(id),
};
return { api, calls, started };
}

describe("ElectronPowerSaveBlocker", () => {
it.effect("starts a single prevent-app-suspension blocker and is idempotent", () =>
Effect.gen(function* () {
const { api, calls, started } = makeStubApi();
const service = ElectronPowerSaveBlocker.make(api);

assert.isTrue(yield* service.setKeepAwake(true));
assert.isTrue(yield* service.setKeepAwake(true));

assert.deepStrictEqual(calls, ["start:prevent-app-suspension:0"]);
assert.strictEqual(started.size, 1);
}),
);

it.effect("stops the held blocker and reports inactive", () =>
Effect.gen(function* () {
const { api, calls, started } = makeStubApi();
const service = ElectronPowerSaveBlocker.make(api);

yield* service.setKeepAwake(true);
assert.isFalse(yield* service.setKeepAwake(false));

assert.deepStrictEqual(calls, ["start:prevent-app-suspension:0", "stop:0"]);
assert.strictEqual(started.size, 0);
}),
);

it.effect("treats release without a held blocker as a no-op", () =>
Effect.gen(function* () {
const { api, calls } = makeStubApi();
const service = ElectronPowerSaveBlocker.make(api);

assert.isFalse(yield* service.setKeepAwake(false));
assert.deepStrictEqual(calls, []);
}),
);

it.effect("re-acquires a fresh blocker after a release", () =>
Effect.gen(function* () {
const { api, calls } = makeStubApi();
const service = ElectronPowerSaveBlocker.make(api);

yield* service.setKeepAwake(true);
yield* service.setKeepAwake(false);
yield* service.setKeepAwake(true);

assert.deepStrictEqual(calls, [
"start:prevent-app-suspension:0",
"stop:0",
"start:prevent-app-suspension:1",
]);
}),
);

it.effect("restarts instead of trusting a blocker Electron no longer reports as started", () =>
Effect.gen(function* () {
const { api, calls, started } = makeStubApi();
const service = ElectronPowerSaveBlocker.make(api);

yield* service.setKeepAwake(true);
started.clear();

assert.isTrue(yield* service.setKeepAwake(true));
assert.deepStrictEqual(calls, [
"start:prevent-app-suspension:0",
"start:prevent-app-suspension:1",
]);
}),
);
});
54 changes: 54 additions & 0 deletions apps/desktop/src/electron/ElectronPowerSaveBlocker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as Electron from "electron";

/**
* The subset of `Electron.powerSaveBlocker` the service needs, injectable so
* tests can observe start/stop calls without an Electron runtime.
*/
export interface PowerSaveBlockerApi {
start(type: "prevent-app-suspension" | "prevent-display-sleep"): number;
stop(id: number): boolean;
isStarted(id: number): boolean;
}

/**
* Holds at most one `prevent-app-suspension` power-save blocker for the whole
* app, toggled by the renderer while a locally hosted agent is working. The
* single slot is last-writer-wins across windows; the desktop runs one main
* window today. No app-quit cleanup is needed: the OS releases the assertion
* when the process exits.
*/
export class ElectronPowerSaveBlocker extends Context.Service<
ElectronPowerSaveBlocker,
{
readonly setKeepAwake: (keepAwake: boolean) => Effect.Effect<boolean>;
}
>()("@t3tools/desktop/electron/ElectronPowerSaveBlocker") {}

export const make = (api: PowerSaveBlockerApi): ElectronPowerSaveBlocker["Service"] => {
let blockerId: number | null = null;

const isHeld = () => blockerId !== null && api.isStarted(blockerId);

return ElectronPowerSaveBlocker.of({
setKeepAwake: (keepAwake) =>
Effect.sync(() => {
if (keepAwake) {
if (!isHeld()) {
blockerId = api.start("prevent-app-suspension");
}
return true;
}
if (blockerId !== null && api.isStarted(blockerId)) {
api.stop(blockerId);
}
blockerId = null;
return false;
}),
});
};

export const layer = Layer.sync(ElectronPowerSaveBlocker, () => make(Electron.powerSaveBlocker));
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
issueSshWebSocketTicket,
resolveSshPasswordPrompt,
} from "./methods/sshEnvironment.ts";
import { setKeepAwake } from "./methods/power.ts";
import {
checkForUpdate,
downloadUpdate,
Expand Down Expand Up @@ -81,6 +82,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(pickFolder);
yield* ipc.handle(confirm);
yield* ipc.handle(setTheme);
yield* ipc.handle(setKeepAwake);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(getUpdateState);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const CONFIRM_CHANNEL = "desktop:confirm";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const SET_KEEP_AWAKE_CHANNEL = "desktop:set-keep-awake";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/ipc/methods/power.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as ElectronPowerSaveBlocker from "../../electron/ElectronPowerSaveBlocker.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";

export const setKeepAwake = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SET_KEEP_AWAKE_CHANNEL,
payload: Schema.Boolean,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.power.setKeepAwake")(function* (keepAwake) {
const blocker = yield* ElectronPowerSaveBlocker.ElectronPowerSaveBlocker;
return yield* blocker.setKeepAwake(keepAwake);
}),
});
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as ElectronApp from "./electron/ElectronApp.ts";
import * as ElectronDialog from "./electron/ElectronDialog.ts";
import * as ElectronMenu from "./electron/ElectronMenu.ts";
import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts";
import * as ElectronPowerSaveBlocker from "./electron/ElectronPowerSaveBlocker.ts";
import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts";
import * as ElectronShell from "./electron/ElectronShell.ts";
Expand Down Expand Up @@ -118,6 +119,7 @@ const electronLayer = Layer.mergeAll(
ElectronDialog.layer,
ElectronMenu.layer,
ElectronPowerMonitor.layer,
ElectronPowerSaveBlocker.layer,
ElectronProtocol.layer,
ElectronSafeStorage.layer,
ElectronShell.layer,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
setKeepAwake: (keepAwake) => ipcRenderer.invoke(IpcChannels.SET_KEEP_AWAKE_CHANNEL, keepAwake),
showContextMenu: (items, position) =>
ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
items,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
caffeinateWhileAgentsRunning: false,
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopState from "../app/DesktopState.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as ElectronPowerSaveBlocker from "../electron/ElectronPowerSaveBlocker.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
Expand All @@ -58,6 +59,16 @@ const environmentInput = {
runningUnderArm64Translation: false,
} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;

const electronPowerSaveBlockerLayer = Layer.sync(
ElectronPowerSaveBlocker.ElectronPowerSaveBlocker,
() =>
ElectronPowerSaveBlocker.make({
start: () => 1,
stop: () => true,
isStarted: () => true,
}),
);

function makeFakeBrowserWindow() {
const windowListeners = new Map<string, (...args: readonly unknown[]) => void>();
const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();
Expand Down Expand Up @@ -249,6 +260,7 @@ function makeTestLayer(input: {
desktopServerExposureLayer,
DesktopState.layer,
electronMenuLayer,
electronPowerSaveBlockerLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: (url) =>
Effect.sync(() => {
Expand Down Expand Up @@ -347,6 +359,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
DesktopAppSettings.layerTest(),
desktopServerExposureLayer,
electronMenuLayer,
electronPowerSaveBlockerLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: () => Effect.succeed(true),
copyText: () => Effect.void,
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import { makeComponentLogger } from "../app/DesktopObservability.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as ElectronPowerSaveBlocker from "../electron/ElectronPowerSaveBlocker.ts";
import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
Expand Down Expand Up @@ -251,6 +252,7 @@ export const make = Effect.gen(function* () {
const electronShell = yield* ElectronShell.ElectronShell;
const electronTheme = yield* ElectronTheme.ElectronTheme;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const powerSaveBlocker = yield* ElectronPowerSaveBlocker.ElectronPowerSaveBlocker;
const previewManager = yield* PreviewManager.PreviewManager;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
// Window-side latch for the primary backend's readiness. Set by
Expand Down Expand Up @@ -650,6 +652,10 @@ export const make = Effect.gen(function* () {
// that dies immediately on boot cannot reload-loop forever.
runFork(
Effect.gen(function* () {
// The gone renderer can no longer release the keep-awake assertion
// it may hold; drop it here so a dead window cannot pin the machine
// awake. A recovered renderer re-sends its computed state on mount.
yield* powerSaveBlocker.setKeepAwake(false);
const now = yield* Clock.currentTimeMillis;
rendererRecoveryTimestamps = rendererRecoveryTimestamps.filter(
(timestamp) => now - timestamp < RENDERER_RECOVERY_WINDOW_MS,
Expand Down Expand Up @@ -696,6 +702,7 @@ export const make = Effect.gen(function* () {
window.on("closed", () => {
clearDevelopmentLoadRetry();
clearBoundsPersist();
void runPromise(powerSaveBlocker.setKeepAwake(false));
void runPromise(electronWindow.clearMain(Option.some(window)));
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection";
import type { ServerConfig } from "@t3tools/contracts";
import { useMemo } from "react";

import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
import { isDesktopHostedConnectionTarget } from "~/connection/desktopLocal";
import {
buildLocalEnvironmentUpdateGroups,
deriveEnvironmentDisplayLabel,
Expand All @@ -12,16 +11,6 @@ import {
type LocalEnvironmentUpdateGroup,
} from "./ProviderUpdateLaunchNotification.logic";

/**
* A local environment is either the same-origin primary backend or a
* desktop-local secondary (the parallel WSL backend), which connects over
* loopback with a bearer token and carries a `local:<backendInstanceId>`
* connection id. SSH, relay, and other remote targets are excluded.
*/
function isLocalConnectionTarget(target: ConnectionCatalogEntry["target"]): boolean {
return target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target);
}

function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateConnectionState {
switch (phase) {
case "connected":
Expand Down Expand Up @@ -58,7 +47,7 @@ export function useLocalEnvironmentUpdateGroups(): {
const inputs: LocalEnvironmentProvidersInput[] = [];

for (const environment of environments) {
if (!isLocalConnectionTarget(environment.entry.target)) {
if (!isDesktopHostedConnectionTarget(environment.entry.target)) {
continue;
}

Expand Down
Loading
Loading