Skip to content
Merged
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
25 changes: 24 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron";
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, powerMonitor, protocol, safeStorage, shell } from "electron";

if (app.isPackaged && process.env.ADE_RUNTIME_PACKAGED === undefined) {
process.env.ADE_RUNTIME_PACKAGED = "1";
Expand Down Expand Up @@ -6985,6 +6985,29 @@ app.whenReady().then(async () => {
onOutput: handleAttentionNotchOutput,
onRefreshRequested: requestAttentionNotchRefresh,
});
// Sleep does not always lock the machine, so resume must clear suspension
// without overriding the independent lock state.
let notchScreenLocked = false;
let notchSystemSuspended = false;
const syncNotchScreenState = () => {
attentionNotchHelper?.setScreenAwake(!notchScreenLocked && !notchSystemSuspended);
};
powerMonitor?.on?.("lock-screen", () => {
notchScreenLocked = true;
syncNotchScreenState();
});
powerMonitor?.on?.("unlock-screen", () => {
notchScreenLocked = false;
syncNotchScreenState();
});
powerMonitor?.on?.("suspend", () => {
notchSystemSuspended = true;
syncNotchScreenState();
});
powerMonitor?.on?.("resume", () => {
notchSystemSuspended = false;
syncNotchScreenState();
});

attentionIpcBridge = registerIpc({
getCtx: () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ describe("AttentionNotchHelper", () => {
expect(spawnMock).not.toHaveBeenCalled();
});

it("requests account refreshes only while the native notch is enabled", () => {
it("reconciles refresh cadence across surface, screen, and enabled state", () => {
vi.useFakeTimers();
try {
const child = fakeChild();
Expand All @@ -315,6 +315,7 @@ describe("AttentionNotchHelper", () => {
onOutput: vi.fn(),
onRefreshRequested,
refreshIntervalMs: 1_000,
idleRefreshIntervalMs: 4_000,
platform: "darwin",
});

Expand All @@ -328,9 +329,29 @@ describe("AttentionNotchHelper", () => {
soundsEnabled: false,
});
child.emit("spawn");
vi.advanceTimersByTime(2_100);
vi.advanceTimersByTime(3_999);
expect(onRefreshRequested).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onRefreshRequested).toHaveBeenCalledTimes(1);

(child.stdout as PassThrough).write(
`${JSON.stringify({ type: "surface", displayId: 1, surface: "menu_bar" })}\n`,
);
vi.advanceTimersByTime(999);
expect(onRefreshRequested).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(onRefreshRequested).toHaveBeenCalledTimes(2);

helper.setScreenAwake(false);
vi.advanceTimersByTime(3_999);
expect(onRefreshRequested).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(1);
expect(onRefreshRequested).toHaveBeenCalledTimes(3);

helper.setScreenAwake(true);
vi.advanceTimersByTime(1_000);
expect(onRefreshRequested).toHaveBeenCalledTimes(4);

helper.updateSettings({
enabled: false,
revealMode: "hover",
Expand All @@ -340,15 +361,15 @@ describe("AttentionNotchHelper", () => {
celebrationsEnabled: false,
soundsEnabled: false,
});
vi.advanceTimersByTime(2_000);
expect(onRefreshRequested).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(8_000);
expect(onRefreshRequested).toHaveBeenCalledTimes(4);
helper.dispose();
} finally {
vi.useRealTimers();
}
});

it("restarts after a spawn error without leaving a refresh timer running", () => {
it("restarts after a spawn error without retaining the previous surface or refresh timer", () => {
vi.useFakeTimers();
try {
const failedChild = fakeChild();
Expand All @@ -363,6 +384,7 @@ describe("AttentionNotchHelper", () => {
onOutput: vi.fn(),
onRefreshRequested,
refreshIntervalMs: 1_000,
idleRefreshIntervalMs: 4_000,
restartDelayMs: 100,
platform: "darwin",
});
Expand All @@ -385,15 +407,25 @@ describe("AttentionNotchHelper", () => {
celebrationsEnabled: true,
soundsEnabled: false,
});
failedChild.emit("spawn");
(failedChild.stdout as PassThrough).write(
`${JSON.stringify({ type: "surface", displayId: 1, surface: "menu_bar" })}\n`,
);
failedChild.emit("error", new Error("spawn ENOEXEC"));
failedChild.emit("close", -2, null);
expect(helper.getHealth()).toMatchObject({
state: "starting",
surface: null,
});
vi.advanceTimersByTime(1_000);

expect(onRefreshRequested).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(2);

restartedChild.emit("spawn");
vi.advanceTimersByTime(1_000);
expect(onRefreshRequested).not.toHaveBeenCalled();
vi.advanceTimersByTime(3_000);
expect(onRefreshRequested).toHaveBeenCalledOnce();
helper.dispose();
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const MAX_HELPER_LINE_BYTES = 256 * 1024;
const MAX_RESTART_ATTEMPTS = 3;
const GRACEFUL_SHUTDOWN_MS = 500;
const DEFAULT_REFRESH_INTERVAL_MS = 15_000;
const IDLE_REFRESH_INTERVAL_MS = 60_000;
const MAX_PENDING_WRITES = 8;

export type AttentionNotchOutput =
Expand Down Expand Up @@ -63,6 +64,7 @@ type AttentionNotchHelperOptions = {
onOutput: (output: AttentionNotchOutput) => void;
onRefreshRequested?: () => void;
refreshIntervalMs?: number;
idleRefreshIntervalMs?: number;
restartDelayMs?: number;
platform?: NodeJS.Platform;
};
Expand All @@ -85,11 +87,13 @@ export class AttentionNotchHelper {
private restartTimer: NodeJS.Timeout | null = null;
private stableTimer: NodeJS.Timeout | null = null;
private refreshTimer: NodeJS.Timeout | null = null;
private refreshTimerIntervalMs: number | null = null;
private stdoutBuffer = "";
private latestSnapshot: AttentionSnapshot | null = null;
private latestSettings: AttentionNotchSettings | null = null;
private lastProtocolError: string | null = null;
private lastSurface: AttentionNotchHealth["surface"] = null;
private screenAwake = true;
private stdinBackpressured = false;
private pendingWrites: AttentionNotchInput[] = [];

Expand Down Expand Up @@ -156,6 +160,7 @@ export class AttentionNotchHelper {
});
child.once("close", (code, signal) => {
this.childReady = false;
this.lastSurface = null;
this.stopRefreshTimer();
if (this.stableTimer) {
clearTimeout(this.stableTimer);
Expand Down Expand Up @@ -289,6 +294,12 @@ export class AttentionNotchHelper {
this.write({ type: "visibility", visible });
}

setScreenAwake(awake: boolean): void {
if (this.screenAwake === awake) return;
this.screenAwake = awake;
this.ensureRefreshTimer();
}

reanchor(): void {
this.write({ type: "reanchor" });
}
Expand Down Expand Up @@ -388,7 +399,10 @@ export class AttentionNotchHelper {
try {
const parsed = JSON.parse(line) as unknown;
if (isAttentionNotchOutput(parsed)) {
if (parsed.type === "surface") this.lastSurface = parsed.surface;
if (parsed.type === "surface") {
this.lastSurface = parsed.surface;
this.ensureRefreshTimer();
}
if (parsed.type === "protocol_error") this.lastProtocolError = parsed.message;
this.options.onOutput(parsed);
} else {
Expand All @@ -413,17 +427,25 @@ export class AttentionNotchHelper {
this.restartTimer.unref();
}

private activeRefreshIntervalMs(): number {
return (this.lastSurface != null && this.screenAwake)
? (this.options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS)
: (this.options.idleRefreshIntervalMs ?? IDLE_REFRESH_INTERVAL_MS);
}

private ensureRefreshTimer(): void {
if (
this.refreshTimer
|| this.disposed
this.disposed
|| this.latestSettings?.enabled !== true
|| !this.child
|| !this.childReady
|| !this.options.onRefreshRequested
) {
return;
}
const intervalMs = this.activeRefreshIntervalMs();
if (this.refreshTimer && this.refreshTimerIntervalMs === intervalMs) return;
this.stopRefreshTimer();
this.refreshTimer = setInterval(() => {
try {
this.options.onRefreshRequested?.();
Expand All @@ -432,14 +454,15 @@ export class AttentionNotchHelper {
error: error instanceof Error ? error.message : String(error),
});
}
}, this.options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS);
}, intervalMs);
this.refreshTimerIntervalMs = intervalMs;
this.refreshTimer.unref();
}

private stopRefreshTimer(): void {
if (!this.refreshTimer) return;
clearInterval(this.refreshTimer);
if (this.refreshTimer) clearInterval(this.refreshTimer);
this.refreshTimer = null;
this.refreshTimerIntervalMs = null;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3198,7 +3198,7 @@ describe("local runtime connection pool", () => {
);
});

it("routes machine sync calls without adding a project id", async () => {
it("routes machine sync calls without adding a project id or a timeout override", async () => {
const call = vi.fn().mockResolvedValue({
mode: "standalone",
connectedPeers: [],
Expand All @@ -3222,12 +3222,15 @@ describe("local runtime connection pool", () => {
connectedPeers: [],
});

expect(call).toHaveBeenCalledWith("sync.getStatus", {
includeTransferReadiness: true,
});
// Callers that ask for no budget keep the runtime client's own default.
expect(call).toHaveBeenCalledWith(
"sync.getStatus",
{ includeTransferReadiness: true },
{},
);
});

it("routes Attention through the machine scope without adding a project id", async () => {
it("routes Attention through the machine scope with the sync-domain timeout", async () => {
const call = vi.fn().mockResolvedValue({ revision: 4, items: [] });
const pool = new LocalRuntimeConnectionPool("1.2.3", {
debug: vi.fn(),
Expand All @@ -3245,10 +3248,14 @@ describe("local runtime connection pool", () => {
since: 3,
streamId: "account-stream",
})).resolves.toEqual({ revision: 4, items: [] });
expect(call).toHaveBeenCalledWith("attention.call", {
action: "getSnapshot",
args: { since: 3, streamId: "account-stream" },
});
expect(call).toHaveBeenCalledWith(
"attention.call",
{
action: "getSnapshot",
args: { since: 3, streamId: "account-stream" },
},
{ timeoutMs: LOCAL_RUNTIME_SYNC_TIMEOUT_MS },
);
});

it("keeps foreground catalog metadata authoritative while routing background actions", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1396,16 +1396,23 @@ export class LocalRuntimeConnectionPool {
async callSync<T>(
method: string,
params: Record<string, unknown> = {},
options: { timeoutMs?: number } = {},
): Promise<T> {
const entry = await this.connect();
return await entry.client.call(method, params) as T;
return await entry.client.call(method, params, options) as T;
}

async callAttention<T>(
action: string,
args: Record<string, unknown> = {},
): Promise<T> {
return await this.callSync<T>("attention.call", { action, args });
// An Attention snapshot poll that inherits the ten-minute runtime budget
// pins the renderer on "syncing" long after the account stream has wedged.
return await this.callSync<T>(
"attention.call",
{ action, args },
{ timeoutMs: LOCAL_RUNTIME_SYNC_TIMEOUT_MS },
);
}

async callActionForRoot(
Expand Down
Loading