@tryinget/pi-activity-strip 0.4.0 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +96 -152
  2. package/bin/pi-activity-strip.mjs +31 -62
  3. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/Cargo.lock +1045 -0
  6. package/native/panel/Cargo.toml +23 -0
  7. package/native/panel/src/app.rs +606 -0
  8. package/native/panel/src/card_view.rs +303 -0
  9. package/native/panel/src/main.rs +31 -0
  10. package/native/panel/src/protocol.rs +234 -0
  11. package/native/panel/src/runtime.rs +110 -0
  12. package/native/panel/src/style.css +176 -0
  13. package/package.json +13 -1
  14. package/src/broker/server.mjs +43 -7
  15. package/src/broker/session-store.mjs +41 -13
  16. package/src/client/broker-client.mjs +10 -3
  17. package/src/client/session-telemetry.mjs +54 -14
  18. package/src/common/activity-order.mjs +15 -9
  19. package/src/common/alignment-controller.mjs +24 -42
  20. package/src/common/card-display.mjs +5 -0
  21. package/src/common/compatibility.mjs +15 -13
  22. package/src/common/constants.mjs +10 -28
  23. package/src/common/contracts.d.ts +42 -4
  24. package/src/common/contracts.ts +42 -4
  25. package/src/common/niri-focus.mjs +79 -79
  26. package/src/common/protocol.mjs +48 -16
  27. package/src/common/session-cards.mjs +102 -0
  28. package/src/common/status-report.mjs +2 -1
  29. package/src/common/telemetry.mjs +8 -1
  30. package/src/common/terminal-identity.mjs +149 -0
  31. package/src/native/main.mjs +393 -0
  32. package/src/native/panel-projection.mjs +99 -0
  33. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
  34. package/src/common/electron.mjs +0 -57
  35. package/src/electron/main.mjs +0 -499
  36. package/src/electron/niri-native-window-runtime.mjs +0 -178
  37. package/src/electron/preload.cjs +0 -50
  38. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  39. package/src/electron/workspace-view-runtime.mjs +0 -360
  40. package/src/ui/strip-html.mjs +0 -500
@@ -1,499 +0,0 @@
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";
8
- import path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
- import { promisify } from "node:util";
11
- import { app, BrowserWindow, ipcMain, screen } from "electron";
12
- import { createActivityStripBroker } from "../broker/server.mjs";
13
- import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
14
- import { detectDisplayServer, detectWindowManager } from "../common/compatibility.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";
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";
33
-
34
- const __filename = fileURLToPath(import.meta.url);
35
- const __dirname = path.dirname(__filename);
36
- const preloadPath = path.join(__dirname, "preload.cjs");
37
- const interactive = process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH !== "1";
38
- const execFileAsync = promisify(execFile);
39
-
40
- let browserWindow = null;
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;
48
- let latestSnapshot = { generatedAt: Date.now(), sessions: [] };
49
- let focusedSessionId = null;
50
- let workspaceSessionIds = new Set();
51
- const runtimeStatus = {
52
- state: "starting",
53
- startedAt: Date.now(),
54
- readyAt: null,
55
- windowVisible: false,
56
- displayServer: detectDisplayServer(process.env),
57
- windowManager: detectWindowManager(process.env),
58
- displayCount: null,
59
- alignmentMode: isNiriSession() ? "niri" : "generic",
60
- warnings: [],
61
- error: null,
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
- }
75
-
76
- function isNiriSession() {
77
- return Boolean(process.env.NIRI_SOCKET);
78
- }
79
-
80
- function isUsableBrowserWindow(window) {
81
- return Boolean(
82
- window && !window.isDestroyed() && window.webContents && !window.webContents.isDestroyed(),
83
- );
84
- }
85
-
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;
93
-
94
- const staleWindow = browserWindow;
95
- browserWindow = null;
96
- rendererVisible = false;
97
- focusedWorkspaceViewVisible = false;
98
- if (staleWindow && !staleWindow.isDestroyed()) staleWindow.destroy();
99
-
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;
112
- });
113
-
114
- const createdWindow = await windowCreation;
115
- return isUsableBrowserWindow(createdWindow) ? createdWindow : null;
116
- }
117
-
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
- }
134
-
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();
140
- }
141
-
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
- });
152
-
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();
201
- return;
202
- }
203
- void ensureBrowserWindow()
204
- .then((window) => {
205
- if (!window || appQuitting) return;
206
- window.showInactive?.();
207
- sendRendererSnapshot();
208
- scheduleTopAlignment();
209
- })
210
- .catch(() => {});
211
- }
212
-
213
- function beginShutdown() {
214
- if (appQuitting) return;
215
- appQuitting = true;
216
- workspaceEventWatcher?.stop();
217
- visibilityRuntime.dispose();
218
- }
219
-
220
- function requestTopAlignment() {
221
- if (isNiriSession()) workspaceViewRuntime.request();
222
- else alignmentController.request();
223
- }
224
-
225
- function scheduleTopAlignment() {
226
- if (isNiriSession()) {
227
- workspaceViewRuntime.request();
228
- return;
229
- }
230
- requestTopAlignment();
231
-
232
- for (const delayMs of [150, 450, 900, 1500, 2400]) {
233
- setTimeout(requestTopAlignment, delayMs);
234
- }
235
- }
236
-
237
- function refreshRuntimeStatus() {
238
- runtimeStatus.alignmentMode = isNiriSession() ? "niri" : "generic";
239
- runtimeStatus.windowVisible = Boolean(
240
- rendererVisible &&
241
- focusedWorkspaceViewVisible &&
242
- browserWindow &&
243
- !browserWindow.isDestroyed() &&
244
- browserWindow.isVisible(),
245
- );
246
-
247
- if (!app.isReady()) {
248
- runtimeStatus.warnings = isNiriSession()
249
- ? []
250
- : [
251
- "Top-edge repair is optimized for Niri. Other window managers fall back to generic Electron bounds and may need manual adjustment.",
252
- ];
253
- return;
254
- }
255
-
256
- const displays = screen.getAllDisplays();
257
- runtimeStatus.displayCount = displays.length;
258
- const warnings = [];
259
- if (displays.length > 1) {
260
- warnings.push(
261
- `Detected ${displays.length} displays; the strip currently renders on the primary display only.`,
262
- );
263
- }
264
- if (!isNiriSession()) {
265
- warnings.push(
266
- "Top-edge repair is optimized for Niri. Other window managers fall back to generic Electron bounds and may need manual adjustment.",
267
- );
268
- }
269
- runtimeStatus.warnings = warnings;
270
- }
271
-
272
- function currentDisplay() {
273
- return screen.getPrimaryDisplay();
274
- }
275
-
276
- function currentBounds() {
277
- const display = currentDisplay();
278
- const bounds = display.bounds;
279
- return {
280
- x: bounds.x + ACTIVITY_STRIP_WIDTH_PADDING / 2,
281
- y: bounds.y,
282
- width: Math.max(420, bounds.width - ACTIVITY_STRIP_WIDTH_PADDING),
283
- height: expanded ? ACTIVITY_STRIP_EXPANDED_HEIGHT : ACTIVITY_STRIP_HEIGHT,
284
- };
285
- }
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
-
302
- function updateWindowBounds() {
303
- if (!browserWindow || browserWindow.isDestroyed()) return;
304
- refreshRuntimeStatus();
305
- if (isNiriSession()) workspaceViewRuntime.request();
306
- else scheduleTopAlignment();
307
- }
308
-
309
- async function createWindow() {
310
- const createdWindow = new BrowserWindow({
311
- ...currentBounds(),
312
- title: "Pi Activity Strip",
313
- frame: false,
314
- transparent: true,
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,
319
- movable: false,
320
- minimizable: false,
321
- maximizable: false,
322
- closable: true,
323
- skipTaskbar: true,
324
- show: false,
325
- focusable: !isNiriSession() && interactive,
326
- alwaysOnTop: true,
327
- webPreferences: {
328
- preload: preloadPath,
329
- contextIsolation: true,
330
- sandbox: true,
331
- nodeIntegration: false,
332
- backgroundThrottling: false,
333
- },
334
- backgroundColor: "#00000000",
335
- });
336
- browserWindow = createdWindow;
337
-
338
- createdWindow.setAlwaysOnTop(true, "screen-saver");
339
- createdWindow.setIgnoreMouseEvents(isNiriSession() || !interactive, { forward: true });
340
-
341
- createdWindow.once("ready-to-show", () => {
342
- if (appQuitting || browserWindow !== createdWindow || createdWindow.isDestroyed()) return;
343
- runtimeStatus.state = "ready";
344
- runtimeStatus.readyAt = Date.now();
345
- runtimeStatus.error = null;
346
- if (!isNiriSession()) {
347
- createdWindow.showInactive?.();
348
- sendRendererSnapshot();
349
- scheduleTopAlignment();
350
- }
351
- refreshRuntimeStatus();
352
- });
353
-
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
- }
390
-
391
- if (!isNiriSession() && !createdWindow.isVisible()) {
392
- createdWindow.showInactive?.();
393
- sendRendererSnapshot();
394
- refreshRuntimeStatus();
395
- scheduleTopAlignment();
396
- }
397
-
398
- return createdWindow;
399
- }
400
-
401
- async function main() {
402
- if (!app.requestSingleInstanceLock()) {
403
- app.quit();
404
- return;
405
- }
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
- };
422
- broker = await createActivityStripBroker({
423
- focusSession,
424
- getRuntimeStatus: () => ({
425
- ...runtimeStatus,
426
- warnings: [...runtimeStatus.warnings],
427
- }),
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);
434
- broker.on("snapshot", (snapshot) => {
435
- const sessionMembershipChanged = !haveSameSessionIds(
436
- latestSnapshot.sessions,
437
- snapshot.sessions,
438
- );
439
- latestSnapshot = snapshot;
440
- if (!resolveSnapshotSession(latestSnapshot.sessions, focusedSessionId)) focusedSessionId = null;
441
- sendRendererSnapshot();
442
- if (isNiriSession() && sessionMembershipChanged) workspaceViewRuntime.request();
443
- });
444
- broker.on("shutdown-requested", async () => {
445
- beginShutdown();
446
- await broker?.stop();
447
- app.quit();
448
- });
449
-
450
- app.setName("pi-activity-strip");
451
-
452
- await app.whenReady();
453
- refreshRuntimeStatus();
454
- if (!(await ensureBrowserWindow())) {
455
- throw new Error("Activity strip BrowserWindow did not become usable.");
456
- }
457
-
458
- screen.on("display-metrics-changed", () => updateWindowBounds());
459
- screen.on("display-added", () => updateWindowBounds());
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
- }
471
-
472
- app.on("second-instance", () => requestWindowRecovery());
473
-
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();
480
- await broker?.stop();
481
- });
482
-
483
- app.on("window-all-closed", () => {
484
- // Keep the broker alive until an explicit stop or process exit.
485
- });
486
- }
487
-
488
- main().catch(async (error) => {
489
- runtimeStatus.state = "error";
490
- runtimeStatus.error = error instanceof Error ? error.message : String(error);
491
- refreshRuntimeStatus();
492
- console.error(error?.stack ?? error?.message ?? String(error));
493
- try {
494
- await broker?.stop();
495
- } catch {
496
- // ignore cleanup errors on fatal exit
497
- }
498
- process.exit(1);
499
- });
@@ -1,178 +0,0 @@
1
- // ---
2
- // summary: "owns Niri native strip remapping, floating conversion, and exact top-edge alignment"
3
- // read_when:
4
- // - "changing compositor-native activity-strip placement or animation detection"
5
- // ---
6
-
7
- import { hasNiriFloatingPosition, isNiriWindowAligned } from "../common/alignment-controller.mjs";
8
- import { resolveActivityStripWindow } from "../common/niri-focus.mjs";
9
-
10
- const defaultWait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
11
-
12
- function formatDelta(value) {
13
- const rounded = Math.round(value);
14
- return rounded >= 0 ? `+${rounded}` : String(rounded);
15
- }
16
-
17
- function readPosition(window) {
18
- const position = window?.layout?.tile_pos_in_workspace_view;
19
- if (!Array.isArray(position) || position.length < 2) return null;
20
- return {
21
- x: Number(position[0] ?? 0),
22
- y: Number(position[1] ?? 0),
23
- };
24
- }
25
-
26
- /**
27
- * @param {{
28
- * execFileAsync: (file: string, args: string[], options: object) => Promise<unknown>;
29
- * env: NodeJS.ProcessEnv;
30
- * timeoutMs: number;
31
- * processId: number;
32
- * readWindows: () => Promise<Array<Record<string, unknown>>>;
33
- * getBrowserWindow: () => import("electron").BrowserWindow | null;
34
- * getBounds: () => {x: number; y: number; width: number; height: number};
35
- * isNiriSession: () => boolean;
36
- * wait?: (milliseconds: number) => Promise<unknown>;
37
- * }} options
38
- */
39
- export function createNiriNativeWindowRuntime(options) {
40
- const wait = options.wait ?? defaultWait;
41
-
42
- function resolveProcessWindow(windows) {
43
- return resolveActivityStripWindow(
44
- windows.filter((window) => window?.pid === options.processId),
45
- );
46
- }
47
-
48
- async function getProcessWindow() {
49
- return resolveProcessWindow(await options.readWindows());
50
- }
51
-
52
- async function moveWindowToWorkspace(stripWindow, workspace) {
53
- const reference = workspace.name || workspace.idx;
54
- if (reference == null) return false;
55
- try {
56
- await options.execFileAsync(
57
- "niri",
58
- [
59
- "msg",
60
- "action",
61
- "move-window-to-workspace",
62
- "--window-id",
63
- String(stripWindow.id),
64
- "--focus",
65
- "false",
66
- String(reference),
67
- ],
68
- { env: options.env, timeout: options.timeoutMs },
69
- );
70
- return true;
71
- } catch {
72
- return false;
73
- }
74
- }
75
-
76
- async function moveWindowToTop(isCurrent) {
77
- let animated = false;
78
- const browserWindow = options.getBrowserWindow();
79
- if (!isCurrent() || !browserWindow || browserWindow.isDestroyed() || !options.isNiriSession()) {
80
- return { ok: false, animated };
81
- }
82
-
83
- let niriWindow = await getProcessWindow();
84
- if (!isCurrent() || !niriWindow?.id) return { ok: false, animated };
85
- if (niriWindow.is_floating !== true) {
86
- try {
87
- await options.execFileAsync(
88
- "niri",
89
- ["msg", "action", "move-window-to-floating", "--id", String(niriWindow.id)],
90
- { env: options.env },
91
- );
92
- animated = true;
93
- } catch {
94
- return { ok: false, animated };
95
- }
96
- }
97
-
98
- for (let attempt = 0; attempt < 8; attempt += 1) {
99
- if (!isCurrent()) return { ok: false, animated };
100
- const position = readPosition(niriWindow);
101
- if (hasNiriFloatingPosition(niriWindow) && position) {
102
- const target = options.getBounds();
103
- if (isNiriWindowAligned(niriWindow, target)) return { ok: true, animated };
104
- const deltaX = target.x - position.x;
105
- const deltaY = target.y - position.y;
106
- if (Math.abs(deltaX) >= 1 || Math.abs(deltaY) >= 1) {
107
- try {
108
- await options.execFileAsync(
109
- "niri",
110
- [
111
- "msg",
112
- "action",
113
- "move-floating-window",
114
- "--id",
115
- String(niriWindow.id),
116
- "-x",
117
- formatDelta(deltaX),
118
- "-y",
119
- formatDelta(deltaY),
120
- ],
121
- { env: options.env },
122
- );
123
- animated = true;
124
- } catch {
125
- return { ok: false, animated };
126
- }
127
- }
128
- }
129
- await wait(60);
130
- if (!isCurrent()) return { ok: false, animated };
131
- niriWindow = await getProcessWindow();
132
- if (!niriWindow?.id) return { ok: false, animated };
133
- }
134
- return { ok: false, animated };
135
- }
136
-
137
- async function alignWindowToTop(isCurrent) {
138
- const browserWindow = options.getBrowserWindow();
139
- if (!isCurrent() || !browserWindow || browserWindow.isDestroyed()) {
140
- return { ok: false, animated: false };
141
- }
142
- if (!options.isNiriSession()) {
143
- browserWindow.setBounds(options.getBounds(), false);
144
- return { ok: true, animated: false };
145
- }
146
-
147
- const niriWindow = await getProcessWindow();
148
- if (!isCurrent()) return { ok: false, animated: false };
149
- const target = options.getBounds();
150
- const currentSize = niriWindow?.layout?.window_size;
151
- const widthMatches = Array.isArray(currentSize) && Number(currentSize[0]) === target.width;
152
- const heightMatches = Array.isArray(currentSize) && Number(currentSize[1]) === target.height;
153
- let animated = false;
154
-
155
- if (!widthMatches || !heightMatches) {
156
- browserWindow.setSize(target.width, target.height, false);
157
- animated = true;
158
- await wait(80);
159
- if (!isCurrent()) return { ok: false, animated };
160
- }
161
-
162
- for (let attempt = 0; attempt < 8; attempt += 1) {
163
- const result = await moveWindowToTop(isCurrent);
164
- animated ||= result.animated;
165
- if (result.ok) return { ok: true, animated };
166
- if (!isCurrent()) return { ok: false, animated };
167
- await wait(60);
168
- }
169
- return { ok: false, animated };
170
- }
171
-
172
- return {
173
- alignWindowToTop,
174
- isWindowAligned: (window) => isNiriWindowAligned(window, options.getBounds()),
175
- moveWindowToWorkspace,
176
- resolveProcessWindow,
177
- };
178
- }