@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.
- package/README.md +96 -152
- package/bin/pi-activity-strip.mjs +31 -62
- package/native/bin/linux-x64-gnu/artifact.json +21 -0
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/Cargo.lock +1045 -0
- package/native/panel/Cargo.toml +23 -0
- package/native/panel/src/app.rs +606 -0
- package/native/panel/src/card_view.rs +303 -0
- package/native/panel/src/main.rs +31 -0
- package/native/panel/src/protocol.rs +234 -0
- package/native/panel/src/runtime.rs +110 -0
- package/native/panel/src/style.css +176 -0
- package/package.json +13 -1
- package/src/broker/server.mjs +43 -7
- package/src/broker/session-store.mjs +41 -13
- package/src/client/broker-client.mjs +10 -3
- package/src/client/session-telemetry.mjs +54 -14
- package/src/common/activity-order.mjs +15 -9
- package/src/common/alignment-controller.mjs +24 -42
- package/src/common/card-display.mjs +5 -0
- package/src/common/compatibility.mjs +15 -13
- package/src/common/constants.mjs +10 -28
- package/src/common/contracts.d.ts +42 -4
- package/src/common/contracts.ts +42 -4
- package/src/common/niri-focus.mjs +79 -79
- package/src/common/protocol.mjs +48 -16
- package/src/common/session-cards.mjs +102 -0
- package/src/common/status-report.mjs +2 -1
- package/src/common/telemetry.mjs +8 -1
- package/src/common/terminal-identity.mjs +149 -0
- package/src/native/main.mjs +393 -0
- package/src/native/panel-projection.mjs +99 -0
- package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
- package/src/common/electron.mjs +0 -57
- package/src/electron/main.mjs +0 -499
- package/src/electron/niri-native-window-runtime.mjs +0 -178
- package/src/electron/preload.cjs +0 -50
- package/src/electron/renderer-visibility-runtime.mjs +0 -107
- package/src/electron/workspace-view-runtime.mjs +0 -360
- package/src/ui/strip-html.mjs +0 -500
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---
|
|
3
|
+
// summary: "runs the broker and workspace projection for the native GTK layer-shell panel"
|
|
4
|
+
// read_when:
|
|
5
|
+
// - "changing native activity-strip supervision or panel protocol"
|
|
6
|
+
// ---
|
|
7
|
+
|
|
8
|
+
import { execFile, spawn } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { createActivityStripBroker } from "../broker/server.mjs";
|
|
15
|
+
import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
|
|
16
|
+
import { ACTIVITY_STRIP_WORKSPACE_SYNC_MS } from "../common/constants.mjs";
|
|
17
|
+
import {
|
|
18
|
+
focusNiriSession,
|
|
19
|
+
readNiriWindows,
|
|
20
|
+
readNiriWorkspaces,
|
|
21
|
+
resolveFocusedWorkspaceView,
|
|
22
|
+
} from "../common/niri-focus.mjs";
|
|
23
|
+
import { haveSameRecordMembership } from "../common/session-cards.mjs";
|
|
24
|
+
import { createNativePanelProjection } from "./panel-projection.mjs";
|
|
25
|
+
import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
|
|
26
|
+
|
|
27
|
+
/** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
|
|
28
|
+
/** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
|
|
29
|
+
/** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const packageRoot = path.resolve(__dirname, "..", "..");
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
34
|
+
|
|
35
|
+
/** @type {import("../broker/server.mjs").ActivityStripBroker | null} */
|
|
36
|
+
let broker = null;
|
|
37
|
+
/** @type {PanelChild | null} */
|
|
38
|
+
let panel = null;
|
|
39
|
+
/** @type {{stop: () => void} | null} */
|
|
40
|
+
let watcher = null;
|
|
41
|
+
let panelReady = false;
|
|
42
|
+
let panelWriteReady = true;
|
|
43
|
+
/** @type {PanelMessage | null} */
|
|
44
|
+
let pendingView = null;
|
|
45
|
+
let shuttingDown = false;
|
|
46
|
+
let panelStderr = "";
|
|
47
|
+
let panelRestartCount = 0;
|
|
48
|
+
|
|
49
|
+
/** @type {ActivityStripRuntimeStatus} */
|
|
50
|
+
const runtimeStatus = {
|
|
51
|
+
state: "starting",
|
|
52
|
+
backend: "native-layer-shell",
|
|
53
|
+
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : "unknown",
|
|
54
|
+
windowManager: isNiriSession() ? "niri" : null,
|
|
55
|
+
alignmentMode: "layer-shell",
|
|
56
|
+
startedAt: Date.now(),
|
|
57
|
+
readyAt: null,
|
|
58
|
+
controllerPid: process.pid,
|
|
59
|
+
panelPid: null,
|
|
60
|
+
windowVisible: false,
|
|
61
|
+
panelExpanded: false,
|
|
62
|
+
clickThrough: process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
|
|
63
|
+
panelMoveCount: 0,
|
|
64
|
+
panelActivationCount: 0,
|
|
65
|
+
panelRestartCount: 0,
|
|
66
|
+
lastMovedCardId: null,
|
|
67
|
+
rendererCardCount: 0,
|
|
68
|
+
rendererCardIds: [],
|
|
69
|
+
warnings: [],
|
|
70
|
+
error: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function isNiriSession() {
|
|
74
|
+
return Boolean(process.env.NIRI_SOCKET);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function panelBinaryPath() {
|
|
78
|
+
const override = process.env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
|
|
79
|
+
const allowUnverified = process.env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
|
|
80
|
+
const candidates = [
|
|
81
|
+
...(override ? [override] : []),
|
|
82
|
+
path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
|
|
83
|
+
...(allowUnverified
|
|
84
|
+
? [path.join(packageRoot, "native", "panel", "target", "release", "pi-activity-strip-panel")]
|
|
85
|
+
: []),
|
|
86
|
+
].filter(Boolean);
|
|
87
|
+
const binary = candidates.find((candidate) => {
|
|
88
|
+
try {
|
|
89
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
if (!binary) {
|
|
96
|
+
throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
|
|
97
|
+
}
|
|
98
|
+
if (allowUnverified) return binary;
|
|
99
|
+
if (process.platform !== "linux" || process.arch !== "x64") {
|
|
100
|
+
throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
|
|
101
|
+
}
|
|
102
|
+
const artifactPath = path.join(path.dirname(binary), "artifact.json");
|
|
103
|
+
let artifact;
|
|
104
|
+
try {
|
|
105
|
+
artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
|
|
108
|
+
}
|
|
109
|
+
const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
|
|
110
|
+
if (
|
|
111
|
+
artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
|
|
112
|
+
artifact.target !== "x86_64-unknown-linux-gnu" ||
|
|
113
|
+
artifact.sha256 !== digest
|
|
114
|
+
) {
|
|
115
|
+
throw new Error("Native panel binary does not match its reviewed artifact receipt.");
|
|
116
|
+
}
|
|
117
|
+
return binary;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {PanelMessage} message */
|
|
121
|
+
function writePanel(message) {
|
|
122
|
+
if (!panelReady || !panelWriteReady || !panel?.stdin?.writable) {
|
|
123
|
+
if (message?.type === "view") pendingView = message;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const accepted = panel.stdin.write(`${JSON.stringify(message)}\n`);
|
|
128
|
+
if (!accepted) {
|
|
129
|
+
panelWriteReady = false;
|
|
130
|
+
const child = panel;
|
|
131
|
+
child.stdin.once("drain", () => {
|
|
132
|
+
if (panel !== child) return;
|
|
133
|
+
panelWriteReady = true;
|
|
134
|
+
if (pendingView) {
|
|
135
|
+
const view = pendingView;
|
|
136
|
+
pendingView = null;
|
|
137
|
+
writePanel(view);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
if (message?.type === "view") pendingView = message;
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const projection = createNativePanelProjection({
|
|
149
|
+
isNiriSession,
|
|
150
|
+
publish(view) {
|
|
151
|
+
runtimeStatus.rendererCardCount = view.sessions.length;
|
|
152
|
+
runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
|
|
153
|
+
writePanel(view);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number}>} */
|
|
158
|
+
async function focusSession(targetId) {
|
|
159
|
+
const session = projection.resolveTarget(targetId);
|
|
160
|
+
if (!session) {
|
|
161
|
+
return { ok: false, error: "Session is no longer present or is ambiguous; focus did nothing." };
|
|
162
|
+
}
|
|
163
|
+
const rawResult = await focusNiriSession(session, execFileAsync, process.env);
|
|
164
|
+
const result = {
|
|
165
|
+
ok: rawResult.ok === true,
|
|
166
|
+
...(typeof rawResult.error === "string" ? { error: rawResult.error } : {}),
|
|
167
|
+
...(Number.isInteger(rawResult.windowId) ? { windowId: Number(rawResult.windowId) } : {}),
|
|
168
|
+
};
|
|
169
|
+
if (result.ok) {
|
|
170
|
+
projection.setFocused(session);
|
|
171
|
+
reconcileRunner.request();
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function focusStrip() {
|
|
177
|
+
if (runtimeStatus.clickThrough) {
|
|
178
|
+
return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
|
|
179
|
+
}
|
|
180
|
+
if (!runtimeStatus.windowVisible || runtimeStatus.rendererCardCount === 0) {
|
|
181
|
+
return { ok: false, error: "No visible native strip exists on this workspace." };
|
|
182
|
+
}
|
|
183
|
+
return writePanel({ type: "focus-strip" })
|
|
184
|
+
? { ok: true }
|
|
185
|
+
: { ok: false, error: "Native panel is not ready for keyboard entry." };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @param {PanelChild} child @param {unknown} chunk */
|
|
189
|
+
function consumePanelEvents(child, chunk) {
|
|
190
|
+
if (panel !== child) return;
|
|
191
|
+
child._activityBuffer = `${child._activityBuffer ?? ""}${String(chunk ?? "")}`;
|
|
192
|
+
let newline = child._activityBuffer.indexOf("\n");
|
|
193
|
+
while (newline >= 0) {
|
|
194
|
+
const line = child._activityBuffer.slice(0, newline).trim();
|
|
195
|
+
child._activityBuffer = child._activityBuffer.slice(newline + 1);
|
|
196
|
+
if (line) {
|
|
197
|
+
try {
|
|
198
|
+
const event = JSON.parse(line);
|
|
199
|
+
if (event.protocol !== 1) throw new Error("unsupported native panel protocol");
|
|
200
|
+
if (event.type === "ready") {
|
|
201
|
+
clearTimeout(child._readyTimer);
|
|
202
|
+
panelReady = true;
|
|
203
|
+
runtimeStatus.state = "ready";
|
|
204
|
+
runtimeStatus.readyAt = Date.now();
|
|
205
|
+
runtimeStatus.panelPid = Number(event.pid) || child.pid || null;
|
|
206
|
+
runtimeStatus.error = null;
|
|
207
|
+
clearTimeout(child._stableTimer);
|
|
208
|
+
child._stableTimer = setTimeout(() => {
|
|
209
|
+
if (panel === child && panelReady) {
|
|
210
|
+
panelRestartCount = 0;
|
|
211
|
+
runtimeStatus.panelRestartCount = 0;
|
|
212
|
+
}
|
|
213
|
+
}, 30_000);
|
|
214
|
+
child._stableTimer.unref?.();
|
|
215
|
+
if (pendingView) {
|
|
216
|
+
const view = pendingView;
|
|
217
|
+
pendingView = null;
|
|
218
|
+
writePanel(view);
|
|
219
|
+
} else {
|
|
220
|
+
projection.send();
|
|
221
|
+
}
|
|
222
|
+
} else if (event.type === "visibility-applied") {
|
|
223
|
+
runtimeStatus.windowVisible = event.visible === true;
|
|
224
|
+
} else if (event.type === "expanded") {
|
|
225
|
+
runtimeStatus.panelExpanded = event.expanded === true;
|
|
226
|
+
} else if (event.type === "activate") {
|
|
227
|
+
runtimeStatus.panelActivationCount = (runtimeStatus.panelActivationCount ?? 0) + 1;
|
|
228
|
+
const cardId = String(event.cardId ?? "");
|
|
229
|
+
void focusSession(cardId).then((result) =>
|
|
230
|
+
writePanel({
|
|
231
|
+
type: "activation-result",
|
|
232
|
+
cardId,
|
|
233
|
+
ok: result.ok === true,
|
|
234
|
+
message: result.ok
|
|
235
|
+
? "Focused Ghostty window."
|
|
236
|
+
: result.error || "Focus failed; nothing moved.",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
} else if (event.type === "moved") {
|
|
240
|
+
runtimeStatus.panelMoveCount = (runtimeStatus.panelMoveCount ?? 0) + 1;
|
|
241
|
+
runtimeStatus.lastMovedCardId = String(event.cardId ?? "") || null;
|
|
242
|
+
} else if (event.type === "error") {
|
|
243
|
+
runtimeStatus.warnings = [String(event.message ?? "Native panel reported an error.")];
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
runtimeStatus.warnings = [
|
|
247
|
+
`Ignored malformed native panel event: ${error instanceof Error ? error.message : String(error)}`,
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
newline = child._activityBuffer.indexOf("\n");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function startPanel() {
|
|
256
|
+
if (shuttingDown) return;
|
|
257
|
+
const binary = panelBinaryPath();
|
|
258
|
+
/** @type {PanelChild} */
|
|
259
|
+
const child = spawn(binary, [], {
|
|
260
|
+
env: process.env,
|
|
261
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
262
|
+
});
|
|
263
|
+
panel = child;
|
|
264
|
+
panelReady = false;
|
|
265
|
+
panelWriteReady = true;
|
|
266
|
+
panelStderr = "";
|
|
267
|
+
runtimeStatus.panelPid = child.pid ?? null;
|
|
268
|
+
runtimeStatus.state = "starting";
|
|
269
|
+
child.stdout.setEncoding("utf8");
|
|
270
|
+
child.stdin.on("error", () => {
|
|
271
|
+
// Exit supervision below owns recovery from a closed panel pipe.
|
|
272
|
+
});
|
|
273
|
+
child.stderr.setEncoding("utf8");
|
|
274
|
+
child.stdout.on("data", (chunk) => consumePanelEvents(child, chunk));
|
|
275
|
+
child.stderr.on("data", (chunk) => {
|
|
276
|
+
if (panel === child) panelStderr = `${panelStderr}${chunk}`.slice(-4000);
|
|
277
|
+
});
|
|
278
|
+
child._readyTimer = setTimeout(() => {
|
|
279
|
+
if (panel === child && !panelReady) child.kill("SIGKILL");
|
|
280
|
+
}, 5000);
|
|
281
|
+
child._readyTimer.unref?.();
|
|
282
|
+
child.on("error", (error) => {
|
|
283
|
+
if (panel === child) runtimeStatus.error = error.message;
|
|
284
|
+
});
|
|
285
|
+
child.on("exit", (code, signal) => {
|
|
286
|
+
clearTimeout(child._readyTimer);
|
|
287
|
+
clearTimeout(child._stableTimer);
|
|
288
|
+
if (panel !== child) return;
|
|
289
|
+
panelReady = false;
|
|
290
|
+
runtimeStatus.windowVisible = false;
|
|
291
|
+
runtimeStatus.panelPid = null;
|
|
292
|
+
if (shuttingDown) return;
|
|
293
|
+
if (panelRestartCount < 3) {
|
|
294
|
+
panelRestartCount += 1;
|
|
295
|
+
runtimeStatus.panelRestartCount = panelRestartCount;
|
|
296
|
+
runtimeStatus.state = "starting";
|
|
297
|
+
runtimeStatus.error = null;
|
|
298
|
+
const timer = setTimeout(startPanel, 250 * 2 ** (panelRestartCount - 1));
|
|
299
|
+
timer.unref?.();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
runtimeStatus.state = "error";
|
|
303
|
+
runtimeStatus.error =
|
|
304
|
+
`Native panel exited (${signal ?? code ?? "unknown"}). ${panelStderr}`.trim();
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const getNiriWindows = () =>
|
|
309
|
+
readNiriWindows(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
|
|
310
|
+
const getNiriWorkspaces = () =>
|
|
311
|
+
readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
|
|
312
|
+
|
|
313
|
+
const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
|
|
314
|
+
if (!isNiriSession()) {
|
|
315
|
+
projection.send();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const [windows, workspaces] = await Promise.all([getNiriWindows(), getNiriWorkspaces()]);
|
|
319
|
+
if (!isCurrent()) return;
|
|
320
|
+
const view = resolveFocusedWorkspaceView(windows, workspaces, projection.getRawSessions(), {
|
|
321
|
+
env: process.env,
|
|
322
|
+
});
|
|
323
|
+
projection.publishWorkspaceView(
|
|
324
|
+
view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
async function shutdown(exitCode = 0) {
|
|
329
|
+
if (shuttingDown) return;
|
|
330
|
+
shuttingDown = true;
|
|
331
|
+
watcher?.stop();
|
|
332
|
+
writePanel({
|
|
333
|
+
protocol: 1,
|
|
334
|
+
type: "view",
|
|
335
|
+
revision: Number.MAX_SAFE_INTEGER,
|
|
336
|
+
visible: false,
|
|
337
|
+
sessions: [],
|
|
338
|
+
});
|
|
339
|
+
panel?.stdin?.end();
|
|
340
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
341
|
+
panel?.kill("SIGTERM");
|
|
342
|
+
await broker?.stop();
|
|
343
|
+
process.exitCode = exitCode;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function main() {
|
|
347
|
+
if (process.env.PI_ACTIVITY_STRIP_RUNTIME_LOCK_HELD !== "1") {
|
|
348
|
+
throw new Error("Native runtime must be launched through the flock-guarded CLI.");
|
|
349
|
+
}
|
|
350
|
+
broker = await createActivityStripBroker({
|
|
351
|
+
focusSession,
|
|
352
|
+
focusStrip,
|
|
353
|
+
getRuntimeStatus: () => ({
|
|
354
|
+
...runtimeStatus,
|
|
355
|
+
warnings: [...(runtimeStatus.warnings ?? [])],
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
broker.on("snapshot", (snapshot) => {
|
|
359
|
+
const membershipChanged = !haveSameRecordMembership(
|
|
360
|
+
projection.getRawSessions(),
|
|
361
|
+
snapshot.sessions,
|
|
362
|
+
);
|
|
363
|
+
projection.updateSnapshot(snapshot);
|
|
364
|
+
if (isNiriSession() && membershipChanged) reconcileRunner.request();
|
|
365
|
+
});
|
|
366
|
+
broker.on("shutdown-requested", () => void shutdown(0));
|
|
367
|
+
|
|
368
|
+
startPanel();
|
|
369
|
+
if (isNiriSession()) {
|
|
370
|
+
reconcileRunner.request();
|
|
371
|
+
watcher = createNiriWorkspaceEventWatcher({
|
|
372
|
+
spawn,
|
|
373
|
+
env: process.env,
|
|
374
|
+
onFocusedWorkspace: () => reconcileRunner.request(),
|
|
375
|
+
onFallback: () => reconcileRunner.request(),
|
|
376
|
+
fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
process.once("SIGINT", () => void shutdown(0));
|
|
381
|
+
process.once("SIGTERM", () => void shutdown(0));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
main().catch(async (error) => {
|
|
385
|
+
runtimeStatus.state = "error";
|
|
386
|
+
runtimeStatus.error = error instanceof Error ? error.message : String(error);
|
|
387
|
+
console.error(error?.stack ?? error);
|
|
388
|
+
try {
|
|
389
|
+
await shutdown(1);
|
|
390
|
+
} catch {
|
|
391
|
+
process.exitCode = 1;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "projects broker snapshots into presentation-ready native-panel views"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing native panel card membership, focus, or transport revisions"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { resolveSnapshotSession } from "../common/niri-focus.mjs";
|
|
8
|
+
import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {Record<string, unknown>} SessionRecord */
|
|
11
|
+
/** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
|
|
12
|
+
/** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null}} WorkspaceView */
|
|
13
|
+
/** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
|
|
14
|
+
|
|
15
|
+
/** @param {NativePanelProjectionOptions} options */
|
|
16
|
+
export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
17
|
+
/** @type {Snapshot} */
|
|
18
|
+
let snapshot = { generatedAt: Date.now(), sessions: [] };
|
|
19
|
+
/** @type {string | null} */
|
|
20
|
+
let focusedSessionId = null;
|
|
21
|
+
/** @type {string | null} */
|
|
22
|
+
let focusedCardId = null;
|
|
23
|
+
/** @type {Record<string, unknown> | null} */
|
|
24
|
+
let workspace = null;
|
|
25
|
+
/** @type {Set<string>} */
|
|
26
|
+
let workspaceCardIds = new Set();
|
|
27
|
+
/** @type {Set<string>} */
|
|
28
|
+
let workspaceRecordKeys = new Set();
|
|
29
|
+
let revision = 0;
|
|
30
|
+
|
|
31
|
+
function getDisplaySessions() {
|
|
32
|
+
const sessions = isNiriSession()
|
|
33
|
+
? snapshot.sessions.filter((session) => workspaceRecordKeys.has(sessionRecordKey(session)))
|
|
34
|
+
: snapshot.sessions;
|
|
35
|
+
return projectSessionCards(sessions, isNiriSession() ? workspaceCardIds : null);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function currentView() {
|
|
39
|
+
revision += 1;
|
|
40
|
+
const sessions = getDisplaySessions();
|
|
41
|
+
return {
|
|
42
|
+
protocol: 1,
|
|
43
|
+
type: "view",
|
|
44
|
+
revision,
|
|
45
|
+
visible: isNiriSession() ? Boolean(workspace?.is_focused && sessions.length > 0) : true,
|
|
46
|
+
focusedSessionId,
|
|
47
|
+
focusedCardId,
|
|
48
|
+
generatedAt: snapshot.generatedAt,
|
|
49
|
+
sessions,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function send() {
|
|
54
|
+
publish(currentView());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function retainValidFocus() {
|
|
58
|
+
const targetId = focusedCardId ?? focusedSessionId;
|
|
59
|
+
if (!targetId || !resolveSnapshotSession(getDisplaySessions(), targetId)) {
|
|
60
|
+
focusedSessionId = null;
|
|
61
|
+
focusedCardId = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
getDisplaySessions,
|
|
67
|
+
getRawSessions: () => snapshot.sessions,
|
|
68
|
+
/** @param {string} targetId */
|
|
69
|
+
resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
|
|
70
|
+
/** @param {WorkspaceView} view */
|
|
71
|
+
publishWorkspaceView(view) {
|
|
72
|
+
workspace = view.workspace ?? null;
|
|
73
|
+
workspaceCardIds = new Set(view.sessions.map((session) => String(session.cardId ?? "")));
|
|
74
|
+
workspaceRecordKeys = new Set(
|
|
75
|
+
view.sessions.flatMap((session) =>
|
|
76
|
+
Array.isArray(session.publisherRecordKeys)
|
|
77
|
+
? session.publisherRecordKeys.map(String)
|
|
78
|
+
: [sessionRecordKey(session)],
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
focusedSessionId = view.focusedSessionId;
|
|
82
|
+
focusedCardId = view.focusedCardId ?? null;
|
|
83
|
+
send();
|
|
84
|
+
},
|
|
85
|
+
send,
|
|
86
|
+
/** @param {SessionRecord} session */
|
|
87
|
+
setFocused(session) {
|
|
88
|
+
focusedSessionId = String(session.sessionId ?? "") || null;
|
|
89
|
+
focusedCardId = String(session.cardId ?? "") || null;
|
|
90
|
+
send();
|
|
91
|
+
},
|
|
92
|
+
/** @param {Snapshot} nextSnapshot */
|
|
93
|
+
updateSnapshot(nextSnapshot) {
|
|
94
|
+
snapshot = nextSnapshot;
|
|
95
|
+
retainValidFocus();
|
|
96
|
+
send();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Trigger focused-workspace reconciliation immediately from Niri's event
|
|
9
9
|
* stream while retaining the slower poll as a fail-closed fallback.
|
|
10
10
|
* @param {{
|
|
11
|
-
* spawn:
|
|
11
|
+
* spawn: typeof import("node:child_process").spawn;
|
|
12
12
|
* env: NodeJS.ProcessEnv;
|
|
13
13
|
* onFocusedWorkspace: (workspaceId: number) => void;
|
|
14
14
|
* onFallback: () => void;
|
|
@@ -22,7 +22,9 @@ export function createNiriWorkspaceEventWatcher(options) {
|
|
|
22
22
|
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
|
|
23
23
|
let buffer = "";
|
|
24
24
|
let stopped = false;
|
|
25
|
+
/** @type {ReturnType<typeof import("node:child_process").spawn> | null} */
|
|
25
26
|
let child = null;
|
|
27
|
+
/** @type {number | null} */
|
|
26
28
|
let lastFocusedWorkspaceId = null;
|
|
27
29
|
|
|
28
30
|
const fallbackTimer = setIntervalFn(() => {
|
|
@@ -30,6 +32,7 @@ export function createNiriWorkspaceEventWatcher(options) {
|
|
|
30
32
|
}, options.fallbackMs);
|
|
31
33
|
fallbackTimer.unref?.();
|
|
32
34
|
|
|
35
|
+
/** @param {unknown} chunk */
|
|
33
36
|
function consume(chunk) {
|
|
34
37
|
if (stopped) return;
|
|
35
38
|
buffer += String(chunk ?? "");
|
package/src/common/electron.mjs
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// ---
|
|
2
|
-
// summary: "resolves an executable Electron binary from explicit settings, local dependencies, or PATH candidates"
|
|
3
|
-
// read_when:
|
|
4
|
-
// - "changing Electron discovery order, environment overrides, or executable checks"
|
|
5
|
-
// ---
|
|
6
|
-
|
|
7
|
-
import { constants } from "node:fs";
|
|
8
|
-
import { access } from "node:fs/promises";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import { DEFAULT_ELECTRON_CANDIDATES } from "./constants.mjs";
|
|
11
|
-
|
|
12
|
-
/** @param {string} targetPath */
|
|
13
|
-
async function canExecute(targetPath) {
|
|
14
|
-
try {
|
|
15
|
-
await access(targetPath, constants.X_OK);
|
|
16
|
-
return true;
|
|
17
|
-
} catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** @param {string} binaryName */
|
|
23
|
-
async function findOnPath(binaryName) {
|
|
24
|
-
for (const entry of String(process.env.PATH ?? "")
|
|
25
|
-
.split(":")
|
|
26
|
-
.filter(Boolean)) {
|
|
27
|
-
const candidate = path.join(entry, binaryName);
|
|
28
|
-
if (await canExecute(candidate)) return candidate;
|
|
29
|
-
}
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** @param {string | null} [explicitPath] */
|
|
34
|
-
export async function locateElectron(explicitPath = null) {
|
|
35
|
-
if (explicitPath) {
|
|
36
|
-
if (await canExecute(explicitPath)) return explicitPath;
|
|
37
|
-
throw new Error(`Configured Electron binary is not executable: ${explicitPath}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const envBinary = process.env.GLIMPSE_ELECTRON_BIN || process.env.PI_ACTIVITY_STRIP_ELECTRON_BIN;
|
|
41
|
-
if (envBinary) {
|
|
42
|
-
if (await canExecute(envBinary)) return envBinary;
|
|
43
|
-
throw new Error(`Configured Electron binary is not executable: ${envBinary}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const localBinary = path.resolve(process.cwd(), "node_modules", ".bin", "electron");
|
|
47
|
-
if (await canExecute(localBinary)) return localBinary;
|
|
48
|
-
|
|
49
|
-
for (const candidate of DEFAULT_ELECTRON_CANDIDATES) {
|
|
50
|
-
const found = await findOnPath(candidate);
|
|
51
|
-
if (found) return found;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
throw new Error(
|
|
55
|
-
"Could not find an Electron binary. Install electron (or electron39 on Arch) or set GLIMPSE_ELECTRON_BIN / PI_ACTIVITY_STRIP_ELECTRON_BIN.",
|
|
56
|
-
);
|
|
57
|
-
}
|