@tryinget/pi-activity-strip 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,53 @@
1
- import { execFile } from "node:child_process";
1
+ // ---
2
+ // summary: "runs the Electron overlay, broker, primary-display window placement, and Niri top-edge alignment"
3
+ // read_when:
4
+ // - "changing overlay lifecycle, window behavior, runtime readiness, or desktop alignment"
5
+ // ---
6
+
7
+ import { execFile, spawn } from "node:child_process";
2
8
  import path from "node:path";
3
9
  import { fileURLToPath } from "node:url";
4
10
  import { promisify } from "node:util";
5
- import { app, BrowserWindow, screen } from "electron";
11
+ import { app, BrowserWindow, ipcMain, screen } from "electron";
6
12
  import { createActivityStripBroker } from "../broker/server.mjs";
13
+ import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
7
14
  import { detectDisplayServer, detectWindowManager } from "../common/compatibility.mjs";
8
- import { ACTIVITY_STRIP_HEIGHT, ACTIVITY_STRIP_WIDTH_PADDING } from "../common/constants.mjs";
15
+ import {
16
+ ACTIVITY_STRIP_EXPANDED_HEIGHT,
17
+ ACTIVITY_STRIP_HEIGHT,
18
+ ACTIVITY_STRIP_NIRI_ANIMATION_SETTLE_MS,
19
+ ACTIVITY_STRIP_WIDTH_PADDING,
20
+ ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
21
+ } from "../common/constants.mjs";
22
+ import {
23
+ focusNiriSession,
24
+ readNiriWindows,
25
+ readNiriWorkspaces,
26
+ resolveSnapshotSession,
27
+ } from "../common/niri-focus.mjs";
9
28
  import { createStripHtml } from "../ui/strip-html.mjs";
29
+ import { createNiriNativeWindowRuntime } from "./niri-native-window-runtime.mjs";
30
+ import { createNiriWorkspaceEventWatcher } from "./niri-workspace-events.mjs";
31
+ import { createBrowserWindowVisibilityRuntime } from "./renderer-visibility-runtime.mjs";
32
+ import { createNiriWorkspaceViewRuntime, haveSameSessionIds } from "./workspace-view-runtime.mjs";
10
33
 
11
34
  const __filename = fileURLToPath(import.meta.url);
12
35
  const __dirname = path.dirname(__filename);
13
36
  const preloadPath = path.join(__dirname, "preload.cjs");
14
- const interactive = process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "0";
37
+ const interactive = process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH !== "1";
15
38
  const execFileAsync = promisify(execFile);
16
39
 
17
40
  let browserWindow = null;
18
41
  let broker = null;
42
+ let workspaceEventWatcher = null;
43
+ let windowCreation = null;
44
+ let appQuitting = false;
45
+ let rendererVisible = !isNiriSession();
46
+ let focusedWorkspaceViewVisible = !isNiriSession();
47
+ let expanded = false;
19
48
  let latestSnapshot = { generatedAt: Date.now(), sessions: [] };
49
+ let focusedSessionId = null;
50
+ let workspaceSessionIds = new Set();
20
51
  const runtimeStatus = {
21
52
  state: "starting",
22
53
  startedAt: Date.now(),
@@ -29,132 +60,188 @@ const runtimeStatus = {
29
60
  warnings: [],
30
61
  error: null,
31
62
  };
63
+ const alignmentController = createLatestOnlyRunner(({ isCurrent }) =>
64
+ niriNativeWindowRuntime.alignWindowToTop(isCurrent),
65
+ );
66
+ const visibilityRuntime = createBrowserWindowVisibilityRuntime(() => browserWindow, interactive);
67
+
68
+ async function applyRendererVisibility(visible, isCurrent = () => true) {
69
+ if (appQuitting) return false;
70
+ const applied = await visibilityRuntime.apply(visible, () => !appQuitting && isCurrent());
71
+ if (applied) rendererVisible = Boolean(visible);
72
+ refreshRuntimeStatus();
73
+ return applied;
74
+ }
32
75
 
33
76
  function isNiriSession() {
34
77
  return Boolean(process.env.NIRI_SOCKET);
35
78
  }
36
79
 
37
- function formatDelta(value) {
38
- const rounded = Math.round(value);
39
- return rounded >= 0 ? `+${rounded}` : String(rounded);
80
+ function isUsableBrowserWindow(window) {
81
+ return Boolean(
82
+ window && !window.isDestroyed() && window.webContents && !window.webContents.isDestroyed(),
83
+ );
40
84
  }
41
85
 
42
- function readNiriPosition(window) {
43
- const position = window?.layout?.tile_pos_in_workspace_view;
44
- if (!Array.isArray(position) || position.length < 2) return null;
45
- return {
46
- x: Number(position[0] ?? 0),
47
- y: Number(position[1] ?? 0),
48
- };
49
- }
86
+ async function ensureBrowserWindow() {
87
+ if (appQuitting) return null;
88
+ if (windowCreation) {
89
+ const pendingWindow = await windowCreation;
90
+ return isUsableBrowserWindow(pendingWindow) ? pendingWindow : null;
91
+ }
92
+ if (isUsableBrowserWindow(browserWindow)) return browserWindow;
50
93
 
51
- async function getNiriWindowByPid(pid) {
52
- if (!isNiriSession()) return null;
94
+ const staleWindow = browserWindow;
95
+ browserWindow = null;
96
+ rendererVisible = false;
97
+ focusedWorkspaceViewVisible = false;
98
+ if (staleWindow && !staleWindow.isDestroyed()) staleWindow.destroy();
53
99
 
54
- try {
55
- const { stdout } = await execFileAsync("niri", ["msg", "-j", "windows"], {
56
- env: process.env,
100
+ runtimeStatus.state = "starting";
101
+ runtimeStatus.error = null;
102
+ refreshRuntimeStatus();
103
+ windowCreation = createWindow()
104
+ .catch((error) => {
105
+ runtimeStatus.state = "error";
106
+ runtimeStatus.error = error instanceof Error ? error.message : String(error);
107
+ refreshRuntimeStatus();
108
+ throw error;
109
+ })
110
+ .finally(() => {
111
+ windowCreation = null;
57
112
  });
58
- const windows = JSON.parse(stdout);
59
- if (!Array.isArray(windows)) return null;
60
113
 
61
- return (
62
- windows.find((window) => window?.pid === pid && window?.is_floating) ??
63
- windows.find((window) => window?.pid === pid) ??
64
- null
65
- );
66
- } catch {
67
- return null;
68
- }
114
+ const createdWindow = await windowCreation;
115
+ return isUsableBrowserWindow(createdWindow) ? createdWindow : null;
69
116
  }
70
117
 
71
- async function moveWindowToTopViaNiri() {
72
- if (!browserWindow || browserWindow.isDestroyed() || !isNiriSession()) return false;
73
-
74
- const target = currentBounds();
75
- const niriWindow = await getNiriWindowByPid(process.pid);
76
- if (!niriWindow?.id) return false;
77
-
78
- const currentPosition = readNiriPosition(niriWindow);
79
- if (!currentPosition) return false;
80
-
81
- let deltaX = target.x - currentPosition.x;
82
- let deltaY = target.y - currentPosition.y;
83
- if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) return true;
84
-
85
- for (let attempt = 0; attempt < 5; attempt += 1) {
86
- try {
87
- await execFileAsync(
88
- "niri",
89
- [
90
- "msg",
91
- "action",
92
- "move-floating-window",
93
- "--id",
94
- String(niriWindow.id),
95
- "-x",
96
- formatDelta(deltaX),
97
- "-y",
98
- formatDelta(deltaY),
99
- ],
100
- { env: process.env },
101
- );
102
- } catch {
103
- return false;
104
- }
105
-
106
- await new Promise((resolve) => setTimeout(resolve, 80));
107
- const refreshedWindow = await getNiriWindowByPid(process.pid);
108
- const refreshedPosition = readNiriPosition(refreshedWindow);
109
- if (!refreshedPosition) return false;
110
-
111
- deltaX = target.x - refreshedPosition.x;
112
- deltaY = target.y - refreshedPosition.y;
113
- if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) return true;
114
- }
118
+ const getNiriWindows = () =>
119
+ readNiriWindows(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
120
+ const getNiriWorkspaces = () =>
121
+ readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
122
+
123
+ function sendRendererSnapshot() {
124
+ if (appQuitting || !isUsableBrowserWindow(browserWindow)) return;
125
+ const sessions = isNiriSession()
126
+ ? latestSnapshot.sessions.filter((session) => workspaceSessionIds.has(session.sessionId))
127
+ : latestSnapshot.sessions;
128
+ browserWindow.webContents.send("pi-activity-strip:snapshot", {
129
+ ...latestSnapshot,
130
+ sessions,
131
+ focusedSessionId,
132
+ });
133
+ }
115
134
 
116
- return false;
135
+ function publishWorkspaceView(view) {
136
+ workspaceSessionIds = new Set(view.sessions.map((session) => session.sessionId));
137
+ focusedSessionId = view.focusedSessionId;
138
+ focusedWorkspaceViewVisible = Boolean(view.workspace?.is_focused && view.sessions.length > 0);
139
+ sendRendererSnapshot();
117
140
  }
118
141
 
119
- async function alignWindowToTop() {
120
- if (!browserWindow || browserWindow.isDestroyed()) return;
142
+ const niriNativeWindowRuntime = createNiriNativeWindowRuntime({
143
+ execFileAsync,
144
+ env: process.env,
145
+ timeoutMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
146
+ processId: process.pid,
147
+ readWindows: getNiriWindows,
148
+ getBrowserWindow: () => browserWindow,
149
+ getBounds: currentBounds,
150
+ isNiriSession,
151
+ });
121
152
 
122
- const target = currentBounds();
123
- if (!isNiriSession()) {
124
- browserWindow.setBounds(target, false);
153
+ const workspaceViewRuntime = createNiriWorkspaceViewRuntime({
154
+ readWindows: getNiriWindows,
155
+ readWorkspaces: getNiriWorkspaces,
156
+ getSessions: () => latestSnapshot.sessions,
157
+ getStripWindow: niriNativeWindowRuntime.resolveProcessWindow,
158
+ isWindowVisible: () =>
159
+ Boolean(browserWindow && !browserWindow.isDestroyed() && browserWindow.isVisible()),
160
+ isWindowExpanded: () => expanded,
161
+ showWindow: async () => {
162
+ if (appQuitting) return;
163
+ const window = await ensureBrowserWindow();
164
+ if (!window || appQuitting) return;
165
+ window.showInactive?.();
166
+ },
167
+ hideWindow: () => {
168
+ rendererVisible = false;
169
+ focusedWorkspaceViewVisible = false;
170
+ browserWindow?.hide?.();
171
+ refreshRuntimeStatus();
172
+ },
173
+ concealWindow: async () => {
174
+ const window = await ensureBrowserWindow();
175
+ if (!window) return false;
176
+ return applyRendererVisibility(false);
177
+ },
178
+ revealWindow: (isCurrent) => applyRendererVisibility(true, isCurrent),
179
+ cancelReveal: () => {
180
+ void applyRendererVisibility(false);
181
+ },
182
+ collapseWindow: async () => {
183
+ browserWindow?.webContents.send("pi-activity-strip:collapse");
184
+ await setExpanded(false, { reconcile: false });
185
+ },
186
+ publishView: publishWorkspaceView,
187
+ moveWindowToWorkspace: niriNativeWindowRuntime.moveWindowToWorkspace,
188
+ alignWindow: niriNativeWindowRuntime.alignWindowToTop,
189
+ settleWindow: async (isCurrent) => {
190
+ await new Promise((resolve) => setTimeout(resolve, ACTIVITY_STRIP_NIRI_ANIMATION_SETTLE_MS));
191
+ return isCurrent();
192
+ },
193
+ isWindowAligned: niriNativeWindowRuntime.isWindowAligned,
194
+ identityOptions: { env: process.env },
195
+ });
196
+
197
+ function requestWindowRecovery() {
198
+ if (appQuitting) return;
199
+ if (isNiriSession()) {
200
+ workspaceViewRuntime.request();
125
201
  return;
126
202
  }
203
+ void ensureBrowserWindow()
204
+ .then((window) => {
205
+ if (!window || appQuitting) return;
206
+ window.showInactive?.();
207
+ sendRendererSnapshot();
208
+ scheduleTopAlignment();
209
+ })
210
+ .catch(() => {});
211
+ }
127
212
 
128
- const niriWindow = await getNiriWindowByPid(process.pid);
129
- const currentSize = niriWindow?.layout?.window_size;
130
- const widthMatches = Array.isArray(currentSize) && Number(currentSize[0]) === target.width;
131
- const heightMatches = Array.isArray(currentSize) && Number(currentSize[1]) === target.height;
132
-
133
- if (!widthMatches || !heightMatches) {
134
- browserWindow.setSize(target.width, target.height, false);
135
- await new Promise((resolve) => setTimeout(resolve, 120));
136
- }
213
+ function beginShutdown() {
214
+ if (appQuitting) return;
215
+ appQuitting = true;
216
+ workspaceEventWatcher?.stop();
217
+ visibilityRuntime.dispose();
218
+ }
137
219
 
138
- for (let attempt = 0; attempt < 16; attempt += 1) {
139
- if (await moveWindowToTopViaNiri()) return;
140
- await new Promise((resolve) => setTimeout(resolve, 90));
141
- }
220
+ function requestTopAlignment() {
221
+ if (isNiriSession()) workspaceViewRuntime.request();
222
+ else alignmentController.request();
142
223
  }
143
224
 
144
225
  function scheduleTopAlignment() {
145
- alignWindowToTop().catch(() => {});
226
+ if (isNiriSession()) {
227
+ workspaceViewRuntime.request();
228
+ return;
229
+ }
230
+ requestTopAlignment();
146
231
 
147
232
  for (const delayMs of [150, 450, 900, 1500, 2400]) {
148
- setTimeout(() => {
149
- alignWindowToTop().catch(() => {});
150
- }, delayMs);
233
+ setTimeout(requestTopAlignment, delayMs);
151
234
  }
152
235
  }
153
236
 
154
237
  function refreshRuntimeStatus() {
155
238
  runtimeStatus.alignmentMode = isNiriSession() ? "niri" : "generic";
156
239
  runtimeStatus.windowVisible = Boolean(
157
- browserWindow && !browserWindow.isDestroyed() && browserWindow.isVisible(),
240
+ rendererVisible &&
241
+ focusedWorkspaceViewVisible &&
242
+ browserWindow &&
243
+ !browserWindow.isDestroyed() &&
244
+ browserWindow.isVisible(),
158
245
  );
159
246
 
160
247
  if (!app.isReady()) {
@@ -193,30 +280,49 @@ function currentBounds() {
193
280
  x: bounds.x + ACTIVITY_STRIP_WIDTH_PADDING / 2,
194
281
  y: bounds.y,
195
282
  width: Math.max(420, bounds.width - ACTIVITY_STRIP_WIDTH_PADDING),
196
- height: ACTIVITY_STRIP_HEIGHT,
283
+ height: expanded ? ACTIVITY_STRIP_EXPANDED_HEIGHT : ACTIVITY_STRIP_HEIGHT,
197
284
  };
198
285
  }
199
286
 
287
+ async function setExpanded(nextExpanded, { reconcile = true } = {}) {
288
+ if (!interactive || !browserWindow || browserWindow.isDestroyed()) return { ok: false };
289
+ const next = Boolean(nextExpanded);
290
+ expanded = next;
291
+ const bounds = currentBounds();
292
+ // Always reconcile the native surface, even when the logical state already matches. BrowserWindow
293
+ // blur can collapse main-process state before the renderer has removed its expanded layout.
294
+ browserWindow.setSize(bounds.width, bounds.height, false);
295
+ if (reconcile) {
296
+ if (isNiriSession()) workspaceViewRuntime.request();
297
+ else scheduleTopAlignment();
298
+ }
299
+ return { ok: true, expanded };
300
+ }
301
+
200
302
  function updateWindowBounds() {
201
303
  if (!browserWindow || browserWindow.isDestroyed()) return;
202
304
  refreshRuntimeStatus();
203
- scheduleTopAlignment();
305
+ if (isNiriSession()) workspaceViewRuntime.request();
306
+ else scheduleTopAlignment();
204
307
  }
205
308
 
206
309
  async function createWindow() {
207
- browserWindow = new BrowserWindow({
310
+ const createdWindow = new BrowserWindow({
208
311
  ...currentBounds(),
209
312
  title: "Pi Activity Strip",
210
313
  frame: false,
211
314
  transparent: true,
212
- resizable: false,
315
+ hasShadow: false,
316
+ // Keep the Wayland surface resizable so Electron can honor both grow and shrink configure
317
+ // requests. The application still owns and continuously supplies the exact two legal heights.
318
+ resizable: true,
213
319
  movable: false,
214
320
  minimizable: false,
215
321
  maximizable: false,
216
322
  closable: true,
217
323
  skipTaskbar: true,
218
324
  show: false,
219
- focusable: interactive,
325
+ focusable: !isNiriSession() && interactive,
220
326
  alwaysOnTop: true,
221
327
  webPreferences: {
222
328
  preload: preloadPath,
@@ -227,37 +333,69 @@ async function createWindow() {
227
333
  },
228
334
  backgroundColor: "#00000000",
229
335
  });
336
+ browserWindow = createdWindow;
230
337
 
231
- browserWindow.setAlwaysOnTop(true, "screen-saver");
232
- browserWindow.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true });
233
- browserWindow.setIgnoreMouseEvents(!interactive, { forward: true });
338
+ createdWindow.setAlwaysOnTop(true, "screen-saver");
339
+ createdWindow.setIgnoreMouseEvents(isNiriSession() || !interactive, { forward: true });
234
340
 
235
- browserWindow.once("ready-to-show", () => {
341
+ createdWindow.once("ready-to-show", () => {
342
+ if (appQuitting || browserWindow !== createdWindow || createdWindow.isDestroyed()) return;
236
343
  runtimeStatus.state = "ready";
237
344
  runtimeStatus.readyAt = Date.now();
238
345
  runtimeStatus.error = null;
239
- browserWindow?.showInactive?.();
240
- browserWindow?.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
346
+ if (!isNiriSession()) {
347
+ createdWindow.showInactive?.();
348
+ sendRendererSnapshot();
349
+ scheduleTopAlignment();
350
+ }
241
351
  refreshRuntimeStatus();
242
- scheduleTopAlignment();
243
352
  });
244
353
 
245
- const html = createStripHtml({ interactive });
246
- await browserWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
354
+ createdWindow.on("show", () => refreshRuntimeStatus());
355
+ createdWindow.on("hide", () => refreshRuntimeStatus());
356
+ createdWindow.on("blur", () => {
357
+ if (browserWindow !== createdWindow) return;
358
+ createdWindow.webContents.send("pi-activity-strip:collapse");
359
+ setExpanded(false).catch(() => {});
360
+ });
361
+ createdWindow.webContents.on("render-process-gone", () => {
362
+ if (appQuitting || browserWindow !== createdWindow) return;
363
+ rendererVisible = false;
364
+ focusedWorkspaceViewVisible = false;
365
+ runtimeStatus.state = "starting";
366
+ refreshRuntimeStatus();
367
+ if (!createdWindow.isDestroyed()) createdWindow.destroy();
368
+ });
369
+ createdWindow.on("closed", () => {
370
+ if (browserWindow !== createdWindow) return;
371
+ rendererVisible = false;
372
+ focusedWorkspaceViewVisible = false;
373
+ browserWindow = null;
374
+ if (!appQuitting) runtimeStatus.state = "starting";
375
+ refreshRuntimeStatus();
376
+ if (!appQuitting) {
377
+ const recoveryTimer = setTimeout(requestWindowRecovery, 0);
378
+ recoveryTimer.unref?.();
379
+ }
380
+ });
381
+
382
+ const html = createStripHtml({ interactive, initiallyVisible: !isNiriSession() });
383
+ try {
384
+ await createdWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
385
+ } catch (error) {
386
+ if (browserWindow === createdWindow) browserWindow = null;
387
+ if (!createdWindow.isDestroyed()) createdWindow.destroy();
388
+ throw error;
389
+ }
247
390
 
248
- if (!browserWindow.isVisible()) {
249
- browserWindow.showInactive?.();
250
- browserWindow.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
391
+ if (!isNiriSession() && !createdWindow.isVisible()) {
392
+ createdWindow.showInactive?.();
393
+ sendRendererSnapshot();
251
394
  refreshRuntimeStatus();
252
395
  scheduleTopAlignment();
253
396
  }
254
397
 
255
- browserWindow.on("show", () => refreshRuntimeStatus());
256
- browserWindow.on("hide", () => refreshRuntimeStatus());
257
- browserWindow.on("closed", () => {
258
- browserWindow = null;
259
- refreshRuntimeStatus();
260
- });
398
+ return createdWindow;
261
399
  }
262
400
 
263
401
  async function main() {
@@ -266,17 +404,45 @@ async function main() {
266
404
  return;
267
405
  }
268
406
 
407
+ const focusSession = async (sessionId) => {
408
+ const session = resolveSnapshotSession(latestSnapshot.sessions, sessionId);
409
+ if (!session) {
410
+ return {
411
+ ok: false,
412
+ error: "Session is no longer present or is ambiguous; focus did nothing.",
413
+ };
414
+ }
415
+ const result = await focusNiriSession(session, execFileAsync, process.env);
416
+ if (result.ok) {
417
+ focusedSessionId = session.sessionId;
418
+ sendRendererSnapshot();
419
+ }
420
+ return result;
421
+ };
269
422
  broker = await createActivityStripBroker({
423
+ focusSession,
270
424
  getRuntimeStatus: () => ({
271
425
  ...runtimeStatus,
272
426
  warnings: [...runtimeStatus.warnings],
273
427
  }),
274
428
  });
429
+ ipcMain.handle("pi-activity-strip:focus", (_event, sessionId) => focusSession(String(sessionId)));
430
+ ipcMain.handle("pi-activity-strip:set-expanded", (_event, nextExpanded) =>
431
+ setExpanded(Boolean(nextExpanded)),
432
+ );
433
+ ipcMain.on(visibilityRuntime.appliedChannel, visibilityRuntime.acknowledge);
275
434
  broker.on("snapshot", (snapshot) => {
435
+ const sessionMembershipChanged = !haveSameSessionIds(
436
+ latestSnapshot.sessions,
437
+ snapshot.sessions,
438
+ );
276
439
  latestSnapshot = snapshot;
277
- browserWindow?.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
440
+ if (!resolveSnapshotSession(latestSnapshot.sessions, focusedSessionId)) focusedSessionId = null;
441
+ sendRendererSnapshot();
442
+ if (isNiriSession() && sessionMembershipChanged) workspaceViewRuntime.request();
278
443
  });
279
444
  broker.on("shutdown-requested", async () => {
445
+ beginShutdown();
280
446
  await broker?.stop();
281
447
  app.quit();
282
448
  });
@@ -285,18 +451,32 @@ async function main() {
285
451
 
286
452
  await app.whenReady();
287
453
  refreshRuntimeStatus();
288
- await createWindow();
454
+ if (!(await ensureBrowserWindow())) {
455
+ throw new Error("Activity strip BrowserWindow did not become usable.");
456
+ }
289
457
 
290
458
  screen.on("display-metrics-changed", () => updateWindowBounds());
291
459
  screen.on("display-added", () => updateWindowBounds());
292
460
  screen.on("display-removed", () => updateWindowBounds());
461
+ if (isNiriSession()) {
462
+ workspaceViewRuntime.request();
463
+ workspaceEventWatcher = createNiriWorkspaceEventWatcher({
464
+ spawn,
465
+ env: process.env,
466
+ onFocusedWorkspace: () => workspaceViewRuntime.request(),
467
+ onFallback: () => workspaceViewRuntime.requestPassive(),
468
+ fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
469
+ });
470
+ }
293
471
 
294
- app.on("second-instance", () => {
295
- browserWindow?.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
296
- alignWindowToTop().catch(() => {});
297
- });
472
+ app.on("second-instance", () => requestWindowRecovery());
298
473
 
299
474
  app.on("before-quit", async () => {
475
+ beginShutdown();
476
+ ipcMain.removeHandler("pi-activity-strip:focus");
477
+ ipcMain.removeHandler("pi-activity-strip:set-expanded");
478
+ ipcMain.removeListener(visibilityRuntime.appliedChannel, visibilityRuntime.acknowledge);
479
+ visibilityRuntime.dispose();
300
480
  await broker?.stop();
301
481
  });
302
482