@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.
- package/README.md +37 -6
- package/bin/pi-activity-strip.mjs +55 -5
- package/examples/simulate-sessions.mjs +5 -0
- package/extensions/activity-strip.js +6 -0
- package/extensions/activity-strip.ts +6 -0
- package/package.json +4 -4
- package/src/broker/server.mjs +23 -2
- package/src/broker/session-store.mjs +6 -0
- package/src/client/broker-client.mjs +6 -0
- package/src/client/launcher.mjs +6 -0
- package/src/client/session-telemetry.mjs +11 -2
- package/src/common/activity-order.mjs +68 -0
- package/src/common/alignment-controller.mjs +74 -0
- package/src/common/compatibility.mjs +14 -2
- package/src/common/constants.mjs +10 -0
- package/src/common/contracts.d.ts +10 -0
- package/src/common/contracts.ts +10 -0
- package/src/common/electron.mjs +6 -0
- package/src/common/format.mjs +6 -0
- package/src/common/niri-focus.mjs +322 -0
- package/src/common/protocol.mjs +6 -0
- package/src/common/status-report.mjs +6 -0
- package/src/common/telemetry.mjs +13 -3
- package/src/electron/main.mjs +307 -127
- package/src/electron/niri-native-window-runtime.mjs +178 -0
- package/src/electron/niri-workspace-events.mjs +81 -0
- package/src/electron/preload.cjs +40 -0
- package/src/electron/renderer-visibility-runtime.mjs +107 -0
- package/src/electron/workspace-view-runtime.mjs +360 -0
- package/src/ui/strip-html.mjs +351 -242
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "watches focused Niri workspace events with a bounded polling fallback"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing activity-strip workspace switch latency or Niri event-stream handling"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Trigger focused-workspace reconciliation immediately from Niri's event
|
|
9
|
+
* stream while retaining the slower poll as a fail-closed fallback.
|
|
10
|
+
* @param {{
|
|
11
|
+
* spawn: Function;
|
|
12
|
+
* env: NodeJS.ProcessEnv;
|
|
13
|
+
* onFocusedWorkspace: (workspaceId: number) => void;
|
|
14
|
+
* onFallback: () => void;
|
|
15
|
+
* fallbackMs: number;
|
|
16
|
+
* setIntervalFn?: typeof setInterval;
|
|
17
|
+
* clearIntervalFn?: typeof clearInterval;
|
|
18
|
+
* }} options
|
|
19
|
+
*/
|
|
20
|
+
export function createNiriWorkspaceEventWatcher(options) {
|
|
21
|
+
const setIntervalFn = options.setIntervalFn ?? setInterval;
|
|
22
|
+
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
|
|
23
|
+
let buffer = "";
|
|
24
|
+
let stopped = false;
|
|
25
|
+
let child = null;
|
|
26
|
+
let lastFocusedWorkspaceId = null;
|
|
27
|
+
|
|
28
|
+
const fallbackTimer = setIntervalFn(() => {
|
|
29
|
+
if (!stopped) options.onFallback();
|
|
30
|
+
}, options.fallbackMs);
|
|
31
|
+
fallbackTimer.unref?.();
|
|
32
|
+
|
|
33
|
+
function consume(chunk) {
|
|
34
|
+
if (stopped) return;
|
|
35
|
+
buffer += String(chunk ?? "");
|
|
36
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
37
|
+
while (newlineIndex >= 0) {
|
|
38
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
39
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
40
|
+
if (line) {
|
|
41
|
+
try {
|
|
42
|
+
const event = JSON.parse(line);
|
|
43
|
+
const activation = event?.WorkspaceActivated;
|
|
44
|
+
if (
|
|
45
|
+
activation?.focused === true &&
|
|
46
|
+
Number.isInteger(activation.id) &&
|
|
47
|
+
activation.id !== lastFocusedWorkspaceId
|
|
48
|
+
) {
|
|
49
|
+
lastFocusedWorkspaceId = activation.id;
|
|
50
|
+
options.onFocusedWorkspace(activation.id);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// A malformed event is ignored; the fallback poll remains authoritative.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
newlineIndex = buffer.indexOf("\n");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
child = options.spawn("niri", ["msg", "-j", "event-stream"], {
|
|
62
|
+
env: options.env,
|
|
63
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
64
|
+
});
|
|
65
|
+
child.stdout?.on?.("data", consume);
|
|
66
|
+
child.on?.("error", () => {});
|
|
67
|
+
} catch {
|
|
68
|
+
child = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stop() {
|
|
72
|
+
if (stopped) return;
|
|
73
|
+
stopped = true;
|
|
74
|
+
clearIntervalFn(fallbackTimer);
|
|
75
|
+
child?.stdout?.removeListener?.("data", consume);
|
|
76
|
+
child?.kill?.();
|
|
77
|
+
child = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { stop };
|
|
81
|
+
}
|
package/src/electron/preload.cjs
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "exposes a sandboxed renderer subscription API for activity-strip snapshot IPC messages"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing the preload bridge or renderer snapshot subscription contract"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
1
7
|
const { contextBridge, ipcRenderer } = require("electron");
|
|
2
8
|
|
|
3
9
|
contextBridge.exposeInMainWorld("activityStrip", {
|
|
10
|
+
activate(sessionId) {
|
|
11
|
+
return ipcRenderer.invoke("pi-activity-strip:focus", String(sessionId ?? ""));
|
|
12
|
+
},
|
|
13
|
+
setExpanded(expanded) {
|
|
14
|
+
return ipcRenderer.invoke("pi-activity-strip:set-expanded", Boolean(expanded));
|
|
15
|
+
},
|
|
16
|
+
onCollapse(handler) {
|
|
17
|
+
if (typeof handler !== "function") return () => {};
|
|
18
|
+
const listener = () => handler();
|
|
19
|
+
ipcRenderer.on("pi-activity-strip:collapse", listener);
|
|
20
|
+
return () => ipcRenderer.removeListener("pi-activity-strip:collapse", listener);
|
|
21
|
+
},
|
|
22
|
+
onVisibility(handler) {
|
|
23
|
+
if (typeof handler !== "function") return () => {};
|
|
24
|
+
let latestRequestId = 0;
|
|
25
|
+
const listener = (_event, visible, requestId) => {
|
|
26
|
+
if (!Number.isInteger(requestId) || requestId <= 0) return;
|
|
27
|
+
latestRequestId = Math.max(latestRequestId, requestId);
|
|
28
|
+
const isCurrent = () => requestId === latestRequestId;
|
|
29
|
+
Promise.resolve()
|
|
30
|
+
.then(() => handler(Boolean(visible), isCurrent))
|
|
31
|
+
.then((applied) =>
|
|
32
|
+
ipcRenderer.send(
|
|
33
|
+
"pi-activity-strip:visibility-applied",
|
|
34
|
+
requestId,
|
|
35
|
+
Boolean(visible),
|
|
36
|
+
applied !== false,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
.catch(() => {});
|
|
40
|
+
};
|
|
41
|
+
ipcRenderer.on("pi-activity-strip:visibility", listener);
|
|
42
|
+
return () => ipcRenderer.removeListener("pi-activity-strip:visibility", listener);
|
|
43
|
+
},
|
|
4
44
|
subscribe(handler) {
|
|
5
45
|
if (typeof handler !== "function") return () => {};
|
|
6
46
|
const listener = (_event, snapshot) => handler(snapshot);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "acknowledges renderer conceal and reveal frames before native Niri lifecycle effects"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing renderer visibility handshakes or wrong-workspace flash prevention"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
export const VISIBILITY_CHANNEL = "pi-activity-strip:visibility";
|
|
8
|
+
export const VISIBILITY_APPLIED_CHANNEL = "pi-activity-strip:visibility-applied";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Coordinate one bounded main-to-renderer visibility request. The renderer
|
|
12
|
+
* acknowledges only after its CSS visibility change crosses two animation
|
|
13
|
+
* frames, so callers can keep stale cards concealed during native remaps.
|
|
14
|
+
* @param {{
|
|
15
|
+
* getWebContents: () => {send: (channel: string, ...args: unknown[]) => void} | null;
|
|
16
|
+
* setInputEnabled: (enabled: boolean) => void;
|
|
17
|
+
* timeoutMs: number;
|
|
18
|
+
* setTimer?: typeof setTimeout;
|
|
19
|
+
* clearTimer?: typeof clearTimeout;
|
|
20
|
+
* }} options
|
|
21
|
+
*/
|
|
22
|
+
export function createRendererVisibilityRuntime(options) {
|
|
23
|
+
const setTimer = options.setTimer ?? setTimeout;
|
|
24
|
+
const clearTimer = options.clearTimer ?? clearTimeout;
|
|
25
|
+
let nextRequestId = 0;
|
|
26
|
+
let latestRequestId = 0;
|
|
27
|
+
const pending = new Map();
|
|
28
|
+
|
|
29
|
+
function settle(requestId, applied) {
|
|
30
|
+
const request = pending.get(requestId);
|
|
31
|
+
if (!request) return false;
|
|
32
|
+
pending.delete(requestId);
|
|
33
|
+
clearTimer(request.timer);
|
|
34
|
+
request.resolve(applied);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function apply(visible, isCurrent = () => true) {
|
|
39
|
+
const webContents = options.getWebContents();
|
|
40
|
+
if (!webContents) return false;
|
|
41
|
+
if (!visible) options.setInputEnabled(false);
|
|
42
|
+
|
|
43
|
+
nextRequestId += 1;
|
|
44
|
+
const requestId = nextRequestId;
|
|
45
|
+
latestRequestId = requestId;
|
|
46
|
+
const applied = new Promise((resolve) => {
|
|
47
|
+
const timer = setTimer(() => settle(requestId, false), options.timeoutMs);
|
|
48
|
+
pending.set(requestId, { resolve, timer, webContents, visible: Boolean(visible) });
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
webContents.send(VISIBILITY_CHANNEL, Boolean(visible), requestId);
|
|
52
|
+
} catch {
|
|
53
|
+
settle(requestId, false);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const succeeded = await applied;
|
|
57
|
+
const appliedToCurrentRenderer =
|
|
58
|
+
succeeded &&
|
|
59
|
+
requestId === latestRequestId &&
|
|
60
|
+
isCurrent() &&
|
|
61
|
+
options.getWebContents() === webContents;
|
|
62
|
+
if (appliedToCurrentRenderer && visible) options.setInputEnabled(true);
|
|
63
|
+
return appliedToCurrentRenderer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function acknowledge(event, requestId, visible, applied = true) {
|
|
67
|
+
const request = pending.get(requestId);
|
|
68
|
+
if (!request || event?.sender !== request.webContents || Boolean(visible) !== request.visible) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return settle(requestId, Boolean(applied));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dispose() {
|
|
75
|
+
nextRequestId += 1;
|
|
76
|
+
latestRequestId = nextRequestId;
|
|
77
|
+
for (const requestId of [...pending.keys()]) settle(requestId, false);
|
|
78
|
+
options.setInputEnabled(false);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
acknowledge,
|
|
83
|
+
appliedChannel: VISIBILITY_APPLIED_CHANNEL,
|
|
84
|
+
apply,
|
|
85
|
+
dispose,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {() => import("electron").BrowserWindow | null} getWindow
|
|
91
|
+
* @param {boolean} interactive
|
|
92
|
+
*/
|
|
93
|
+
export function createBrowserWindowVisibilityRuntime(getWindow, interactive) {
|
|
94
|
+
return createRendererVisibilityRuntime({
|
|
95
|
+
getWebContents: () => {
|
|
96
|
+
const window = getWindow();
|
|
97
|
+
return window && !window.isDestroyed() ? window.webContents : null;
|
|
98
|
+
},
|
|
99
|
+
setInputEnabled: (enabled) => {
|
|
100
|
+
const window = getWindow();
|
|
101
|
+
if (!window || window.isDestroyed()) return;
|
|
102
|
+
window.setFocusable(enabled && interactive);
|
|
103
|
+
window.setIgnoreMouseEvents(!enabled || !interactive, { forward: true });
|
|
104
|
+
},
|
|
105
|
+
timeoutMs: 750,
|
|
106
|
+
});
|
|
107
|
+
}
|