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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts";
import * as DesktopIpc from "./DesktopIpc.ts";
import { installNotificationBadge } from "./methods/notificationBadge.ts";
import { installUserPresence } from "./methods/userPresence.ts";
import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts";
import {
Expand Down Expand Up @@ -82,6 +83,7 @@ import {
export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
const ipc = yield* DesktopIpc.DesktopIpc;
yield* installNotificationBadge();
yield* installUserPresence();
yield* PreviewIpc.installPreviewEventForwarding();

yield* ipc.handle(AppActivationIpc.setReady);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge";
export const GET_USER_IDLE_MS_CHANNEL = "desktop:get-user-idle-ms";
export const USER_AWAY_CHANGED_CHANNEL = "desktop:user-away-changed";
export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
Expand Down
114 changes: 114 additions & 0 deletions apps/desktop/src/ipc/methods/userPresence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";
import { expect, vi } from "vite-plus/test";
import { it } from "@effect/vitest";

const native = vi.hoisted(() => ({
isDestroyed: () => false,
webContents: { send: vi.fn() },
}));
vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => [native] } }));

import * as ElectronPowerMonitor from "../../electron/ElectronPowerMonitor.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import { installUserPresence } from "./userPresence.ts";

function withUserPresence(
body: (machine: {
readonly readIdleMs: () => Effect.Effect<unknown>;
readonly set: (next: {
idleSeconds?: number;
idleState?: ElectronPowerMonitor.ElectronIdleState;
}) => void;
readonly fire: (eventName: string) => void;
}) => Effect.Effect<void>,
initialIdleSeconds = 42,
) {
native.webContents.send.mockClear();
const handlers = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
const powerEvents = new Map<string, () => void>();
const machine = {
idleSeconds: initialIdleSeconds,
idleState: "active" as ElectronPowerMonitor.ElectronIdleState,
};
return Effect.scoped(
Effect.gen(function* () {
yield* installUserPresence();
yield* body({
readIdleMs: () =>
Effect.promise(async () =>
handlers.get("desktop:get-user-idle-ms")!({ sender: { id: 1 } }, undefined),
),
set: (next) => void Object.assign(machine, next),
fire: (eventName) => powerEvents.get(eventName)!(),
});
}),
).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(ElectronPowerMonitor.ElectronPowerMonitor, {
getSystemIdleTime: Effect.sync(() => machine.idleSeconds),
getSystemIdleState: () => Effect.sync(() => machine.idleState),
onSimpleEvent: (eventName, listener) =>
Effect.sync(() => void powerEvents.set(eventName, listener)),
}),
DesktopIpc.layer({
handle: (channel, handler) => void handlers.set(channel, handler),
removeHandler: (channel) => void handlers.delete(channel),
on: vi.fn(),
removeAllListeners: vi.fn(),
}),
),
),
);
}

it.effect("reports the system idle time and treats a locked or sleeping computer as away", () =>
withUserPresence(({ readIdleMs, set, fire }) =>
Effect.gen(function* () {
expect(yield* readIdleMs()).toBe(42_000);
set({ idleState: "locked" });
fire("lock-screen");
expect(yield* readIdleMs()).toBeNull();
set({ idleState: "active" });
fire("suspend");
expect(yield* readIdleMs()).toBeNull();
fire("resume");
expect(yield* readIdleMs()).toBe(42_000);
expect(native.webContents.send).toHaveBeenCalledTimes(3);
expect(native.webContents.send).toHaveBeenCalledWith("desktop:user-away-changed");
}),
),
);

it.effect("tells windows as soon as someone comes back after a minute away", () =>
withUserPresence(({ set }) =>
Effect.gen(function* () {
for (const [idleSeconds, sends] of [
[30, 0],
[0, 0],
[90, 0],
[1, 1],
] as const) {
set({ idleSeconds });
yield* TestClock.adjust("5 seconds");
expect(native.webContents.send).toHaveBeenCalledTimes(sends);
}
}),
),
);

it.effect("leaves presence to window input until the idle clock has moved", () =>
withUserPresence(
({ readIdleMs, set }) =>
Effect.gen(function* () {
expect(yield* readIdleMs()).toBeUndefined();
set({ idleSeconds: 6 });
expect(yield* readIdleMs()).toBe(6_000);
set({ idleSeconds: 0 });
expect(yield* readIdleMs()).toBe(0);
}),
0,
),
);
63 changes: 63 additions & 0 deletions apps/desktop/src/ipc/methods/userPresence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as Electron from "electron";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";

import * as ElectronPowerMonitor from "../../electron/ElectronPowerMonitor.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import { GET_USER_IDLE_MS_CHANNEL, USER_AWAY_CHANGED_CHANNEL } from "../channels.ts";

// Input in other apps reaches no renderer, so the OS idle clock decides; sample it
// to tell windows the moment someone comes back, not on their next heartbeat.
const AWAY_SECONDS = 60;

export const installUserPresence = Effect.fn("desktop.ipc.installUserPresence")(function* () {
const ipc = yield* DesktopIpc.DesktopIpc;
const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor;
let suspended = false;
const broadcastAwayChange = () => {
for (const window of Electron.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(USER_AWAY_CHANGED_CHANNEL);
}
};
yield* powerMonitor.onSimpleEvent("suspend", () => {
suspended = true;
broadcastAwayChange();
});
yield* powerMonitor.onSimpleEvent("resume", () => {
suspended = false;
broadcastAwayChange();
});
yield* powerMonitor.onSimpleEvent("lock-screen", broadcastAwayChange);
yield* powerMonitor.onSimpleEvent("unlock-screen", broadcastAwayChange);
let lastIdleSeconds = 0;
yield* powerMonitor.getSystemIdleTime.pipe(
Effect.tap((idleSeconds) =>
Effect.sync(() => {
if (lastIdleSeconds >= AWAY_SECONDS && idleSeconds < lastIdleSeconds) {
broadcastAwayChange();
}
lastIdleSeconds = idleSeconds;
}),
),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.forkScoped,
);
// Chromium reports 0 forever where it has no idle source (Wayland outside GNOME
// and KDE), which would read as someone always at the computer. Until the clock
// moves once, input in the window decides instead.
let idleClockMoved = false;
yield* ipc.handle(
DesktopIpc.makeIpcMethod({
channel: GET_USER_IDLE_MS_CHANNEL,
payload: Schema.Undefined,
result: Schema.NullishOr(Schema.Number),
handler: Effect.fn("desktop.ipc.getUserIdleMs")(function* () {
if (suspended || (yield* powerMonitor.getSystemIdleState(1)) === "locked") return null;
const idleSeconds = yield* powerMonitor.getSystemIdleTime;
if (idleSeconds > 0) idleClockMoved = true;
return idleClockMoved ? idleSeconds * 1_000 : undefined;
}),
}),
);
});
6 changes: 6 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
},
getUserIdleMs: () => ipcRenderer.invoke(IpcChannels.GET_USER_IDLE_MS_CHANNEL),
onUserAwayChange: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.USER_AWAY_CHANGED_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.USER_AWAY_CHANGED_CHANNEL, handler);
},
onTrackpadScrollEnd: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler);
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/background/BackgroundPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";

import * as ServerSettings from "../serverSettings.ts";
import * as BackgroundPolicy from "./BackgroundPolicy.ts";
Expand Down Expand Up @@ -69,6 +70,27 @@ function layerFor(
}

describe("BackgroundPolicy", () => {
it.effect("counts a computer as attended until its last input is two minutes old", () =>
Effect.gen(function* () {
const policy = yield* BackgroundPolicy.BackgroundPolicy;
const session = AuthSessionId.make("session-1");
const rpcClient = RpcClientId.make(1);

yield* policy.reportClientActivity(session, rpcClient, makeReport());
assert.equal(yield* policy.hasAttendedClient, false);

yield* policy.reportClientActivity(session, rpcClient, makeReport({ userIdleMs: 100_000 }));
assert.equal(yield* policy.hasAttendedClient, true);
yield* TestClock.adjust("20 seconds");
assert.equal(yield* policy.hasAttendedClient, false);

yield* policy.reportClientActivity(session, rpcClient, makeReport({ userIdleMs: 0 }));
assert.equal(yield* policy.hasAttendedClient, true);
yield* policy.removeRpcClient(session, rpcClient);
assert.equal(yield* policy.hasAttendedClient, false);
}).pipe(Effect.provide(layerFor(nominalHostPower))),
);

it.effect("removes all leases for a disconnected websocket connection", () =>
Effect.gen(function* () {
const policy = yield* BackgroundPolicy.BackgroundPolicy;
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/background/BackgroundPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ export class BackgroundPolicy extends Context.Service<
readonly snapshot: Effect.Effect<BackgroundPolicySnapshot>;
readonly shouldRunScopeWork: (scope: BackgroundScope) => Effect.Effect<boolean>;
readonly shouldRunOpportunisticWork: Effect.Effect<boolean>;
readonly hasAttendedClient: Effect.Effect<boolean>;
}
>()("t3/background/BackgroundPolicy") {}

const DEFAULT_LEASE_TTL_MS = 45_000;
// Long enough to read a diff without touching the mouse, short enough that a
// turn finishing after the person walks away still reaches their phone.
const ATTENDED_USER_IDLE_MS = 2 * 60_000;
const MAX_LEASE_TTL_MS = 120_000;
export const MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT = 16;

Expand Down Expand Up @@ -113,6 +117,15 @@ function isForegroundLease(lease: ClientActivityLease, now: DateTime.Utc): boole
return isLeaseActive(lease, now) && lease.visible && (lease.focused || lease.recentlyInteracted);
}

function isAttendedLease(lease: ClientActivityLease, now: DateTime.Utc): boolean {
return (
isLeaseActive(lease, now) &&
lease.lastUserInputAt !== undefined &&
DateTime.toEpochMillis(now) - DateTime.toEpochMillis(lease.lastUserInputAt) <
ATTENDED_USER_IDLE_MS
);
}

function leaseHasScope(lease: ClientActivityLease, scope: BackgroundScope): boolean {
const key = scopeKey(scope);
return lease.scopes.some((leaseScope) => scopeKey(leaseScope) === key);
Expand Down Expand Up @@ -226,6 +239,9 @@ export const make = Effect.fn("background.policy.make")(function* () {
visible: input.visible,
focused: input.focused,
recentlyInteracted: input.recentlyInteracted,
...(input.userIdleMs !== undefined
? { lastUserInputAt: DateTime.subtract(now, { milliseconds: input.userIdleMs }) }
: {}),
...(input.appState !== undefined ? { appState: input.appState } : {}),
...(input.lowPowerMode !== undefined ? { lowPowerMode: input.lowPowerMode } : {}),
scopes: input.scopes,
Expand Down Expand Up @@ -266,6 +282,10 @@ export const make = Effect.fn("background.policy.make")(function* () {
(current) => current.shouldRunOpportunisticWork,
);

const hasAttendedClient = Effect.map(snapshot, (current) =>
current.leases.some((lease) => isAttendedLease(lease, current.updatedAt)),
);

yield* Effect.forever(
Effect.sleep("15 seconds").pipe(
Effect.andThen(
Expand All @@ -291,6 +311,7 @@ export const make = Effect.fn("background.policy.make")(function* () {
snapshot,
shouldRunScopeWork,
shouldRunOpportunisticWork,
hasAttendedClient,
});
});

Expand Down
Loading
Loading