1// @effect-diagnostics nodeBuiltinImport:off -- Event-driven fake of the portal transport.
2import * as NodeEvents from "node:events";
3import { DBusError, Message, MessageType, Variant, type MessageBus } from "dbus-next";
4import { afterEach, expect, it, vi } from "vite-plus/test";
5import { PortalCaptureShortcut, portalShortcutTrigger } from "./PortalCaptureShortcut.ts";
6
7const chord = {
8 key: "2",
9 modKey: true,
10 ctrlKey: false,
11 altKey: false,
12 shiftKey: true,
13 metaKey: false,
14};
15const root = "/org/freedesktop/portal/desktop";
16const portal = "org.freedesktop.portal.GlobalShortcuts";
17const clients: PortalCaptureShortcut[] = [];
18type Shortcut = [string, { description?: Variant<string>; trigger_description?: Variant<string> }];
19
20class FakeBus extends NodeEvents.EventEmitter {
21 calls: Message[] = [];
22 sends: Message[] = [];
23 disconnect = vi.fn();
24 registryError: Error | undefined;
25 version = 2;
26 autoBind = true;
27 actualLabel = "Ctrl+Shift+2";
28 bindStatus = 0;
29 session = "";
30 requestPath = "";
31 boundId = "";
32 foreignHandle = false;
33
34 send(message: Message) {
35 this.sends.push(message);
36 }
37 signal(
38 iface: string,
39 member: string,
40 path: string,
41 body: unknown[],
42 sender = ":1.2",
43 signature = "",
44 ) {
45 this.emit(
46 "message",
47 new Message({
48 type: MessageType.SIGNAL,
49 sender,
50 interface: iface,
51 member,
52 path,
53 body,
54 signature,
55 }),
56 );
57 }
58 response(path: string, status: number, results: Record<string, Variant<unknown>>) {
59 this.signal("org.freedesktop.portal.Request", "Response", path, [status, results]);
60 }
61 respondBind(status = this.bindStatus) {
62 this.response(this.requestPath, status, {
63 shortcuts: new Variant("a(sa{sv})", [
64 [
65 this.boundId,
66 {
67 description: new Variant("s", "Capture a window"),
68 trigger_description: new Variant("s", this.actualLabel),
69 },
70 ],
71 ]),
72 });
73 }
74 activate(id = this.boundId, session = this.session, sender = ":1.2") {
75 this.signal(portal, "Activated", root, [session, id, 1, {}], sender, "osta{sv}");
76 }
77 async call(message: Message) {
78 this.calls.push(message);
79 const reply = (body: unknown[] = []) =>
80 new Message({
81 type: MessageType.METHOD_RETURN,
82 replySerial: "1",
83 destination: ":1.23",
84 body,
85 });
86 if (message.member === "Register") {
87 if (this.registryError) throw this.registryError;
88 return reply();
89 }
90 if (message.member === "GetNameOwner") return reply([":1.2"]);
91 if (message.member === "Get") return reply([new Variant("u", this.version)]);
92 if (message.member === "AddMatch" || message.member === "ConfigureShortcuts") return reply();
93 const options = message.body.at(-1) as Record<string, Variant<string>>;
94 const path = `${root}/request/1_23/${options.handle_token!.value}`;
95 if (message.member === "CreateSession") {
96 this.session = `${root}/session/1_23/${options.session_handle_token!.value}`;
97 this.response(path, 0, {
98 session_handle: new Variant(
99 "s",
100 this.foreignHandle ? `${root}/session/9_9/other` : this.session,
101 ),
102 });
103 } else if (message.member === "BindShortcuts") {
104 this.boundId = (message.body[1] as Shortcut[])[0]![0];
105 this.requestPath = path;
106 this.emit("bind");
107 if (this.autoBind) this.respondBind();
108 } else throw new Error(`Unexpected call: ${message.member}`);
109 return reply([path]);
110 }
111}
112
113function start(bus = new FakeBus(), shortcut = chord, managedByHyprland = false) {
114 const capture = vi.fn();
115 const changed = vi.fn();
116 const client = new PortalCaptureShortcut(
117 "com.t3tools.T3Code",
118 shortcut,
119 capture,
120 changed,
121 bus as unknown as MessageBus,
122 managedByHyprland,
123 );
124 clients.push(client);
125 return { bus, client, capture, changed };
126}
127
128afterEach(() => {
129 for (const client of clients.splice(0)) client.close();
130 vi.useRealTimers();
131 vi.unstubAllEnvs();
132});
133
134it("accepts Hyprland's action-only binding without claiming the keys are reserved", async () => {
135 const bus = new FakeBus();
136 bus.actualLabel = "";
137 const { client, capture } = start(bus, chord, true);
138 await client.ready;
139 expect(client.state).toMatchObject({
140 shortcutRegistered: false,
141 shortcutActionRegistered: true,
142 shortcutPending: false,
143 shortcutCanRetry: false,
144 });
145 expect(bus.boundId).toBe("capture-window");
146 expect(bus.calls.find((call) => call.member === "BindShortcuts")?.body[1]).toEqual([
147 ["capture-window", { description: new Variant("s", "Capture a window") }],
148 ]);
149 bus.activate("wrong");
150 bus.activate(bus.boundId, bus.session, ":1.999");
151 expect(capture).not.toHaveBeenCalled();
152 bus.activate();
153 expect(capture).toHaveBeenCalledOnce();
154 await expect(client.configure()).rejects.toThrow("Hyprland config");
155 bus.signal(portal, "ShortcutsChanged", root, [bus.session, []]);
156 bus.activate();
157 expect(capture).toHaveBeenCalledOnce();
158 expect(client.state.shortcutActionRegistered).toBe(false);
159});
160
161it("keeps the Hyprland action ID stable regardless of a saved key chord", async () => {
162 const first = start(new FakeBus(), chord, true);
163 const second = start(new FakeBus(), { ...chord, key: "7" }, true);
164 await Promise.all([first.client.ready, second.client.ready]);
165 expect(first.bus.boundId).toBe(second.bus.boundId);
166});
167
168it("does not accept an empty trigger as approval on other desktops", async () => {
169 const bus = new FakeBus();
170 bus.actualLabel = "";
171 const { client, capture } = start(bus);
172 await client.ready;
173 expect(client.state.shortcutRegistered).toBe(false);
174 expect(client.state.shortcutActionRegistered).not.toBe(true);
175 bus.activate();
176 expect(capture).not.toHaveBeenCalled();
177});
178
179it.each([
180 ["2", "CTRL+SHIFT+2"],
181 ["f12", "CTRL+SHIFT+F12"],
182 ["pageup", "CTRL+SHIFT+Page_Up"],
183 ["+", "CTRL+SHIFT+plus"],
184 ["arrowleft", "CTRL+SHIFT+Left"],
185 [" ", "CTRL+SHIFT+space"],
186])("encodes %s as an XDG shortcut", (key, expected) => {
187 expect(portalShortcutTrigger({ ...chord, key })).toBe(expected);
188});
189it("normalizes Ctrl/Mod and rejects unsupported keys before requesting access", () => {
190 expect(portalShortcutTrigger({ ...chord, ctrlKey: true, metaKey: true })).toBe(
191 "CTRL+SHIFT+LOGO+2",
192 );
193 expect(() => portalShortcutTrigger({ ...chord, key: "unknown-key" })).toThrow("isn't supported");
194});
195
196it("does not call submission approval, then reads the actual assigned shortcut", async () => {
197 const bus = new FakeBus();
198 bus.autoBind = false;
199 bus.actualLabel = "Ctrl+Shift+7";
200 const binding = NodeEvents.EventEmitter.once(bus, "bind");
201 const { client } = start(bus);
202 await binding;
203 expect(client.state.shortcutRegistered).toBe(false);
204 expect(client.state.shortcutPending).toBe(true);
205 bus.respondBind();
206 await client.ready;
207 expect(client.state).toMatchObject({
208 shortcutRegistered: true,
209 shortcutPending: false,
210 shortcutLabel: "Ctrl+Shift+7",
211 });
212});
213
214it("binds only the requested shortcut with a stable ID across sessions", async () => {
215 const first = start();
216 await first.client.ready;
217 first.client.close();
218 const { bus, client, capture } = start();
219 await client.ready;
220 expect(bus.boundId).toBe(first.bus.boundId);
221 const binds = bus.calls.filter((call) => call.member === "BindShortcuts");
222 expect(binds).toHaveLength(1);
223 expect(binds[0]!.body[1]).toEqual([
224 [
225 bus.boundId,
226 {
227 description: new Variant("s", "Capture a window"),
228 preferred_trigger: new Variant("s", "CTRL+SHIFT+2"),
229 },
230 ],
231 ]);
232 bus.activate();
233 expect(capture).toHaveBeenCalledOnce();
234 expect(bus.calls[0]?.member).toBe("Register");
235});
236
237it("ignores other shortcuts, sessions, and forged activations", async () => {
238 const { bus, client, capture } = start();
239 await client.ready;
240 bus.activate("stale-shortcut");
241 bus.activate(bus.boundId, `${root}/session/9_9/other`);
242 bus.activate(bus.boundId, bus.session, ":1.999");
243 expect(capture).not.toHaveBeenCalled();
244 bus.activate();
245 expect(capture).toHaveBeenCalledOnce();
246});
247
248it("replaces a binding without restarting the application and stops the old callback", async () => {
249 const first = start();
250 await first.client.ready;
251 first.client.close();
252 const next = start(new FakeBus(), { ...chord, key: "8" });
253 await next.client.ready;
254 expect(next.bus.boundId).not.toBe(first.bus.boundId);
255 expect(next.bus.calls.find((call) => call.member === "BindShortcuts")?.body[1]).toEqual([
256 [
257 next.bus.boundId,
258 {
259 description: new Variant("s", "Capture a window"),
260 preferred_trigger: new Variant("s", "CTRL+SHIFT+8"),
261 },
262 ],
263 ]);
264 first.bus.activate();
265 next.bus.activate();
266 expect(first.capture).not.toHaveBeenCalled();
267 expect(next.capture).toHaveBeenCalledOnce();
268 expect(
269 first.bus.sends.some(
270 (message) =>
271 message.interface === "org.freedesktop.portal.Session" && message.member === "Close",
272 ),
273 ).toBe(true);
274});
275
276it.each([1, 2])(
277 "reports denied/cancelled response %s and offers the desktop permissions UI",
278 async (status) => {
279 const bus = new FakeBus();
280 bus.bindStatus = status;
281 const { client, capture } = start(bus);
282 await client.ready;
283 expect(client.state.shortcutRegistered).toBe(false);
284 expect(client.state.shortcutPending).toBe(false);
285 expect(client.state.shortcutCanRetry).toBe(true);
286 expect(client.state.shortcutMessage).toContain("wasn't granted");
287 bus.activate();
288 expect(capture).not.toHaveBeenCalled();
289 await client.configure();
290 expect(bus.calls.at(-1)?.member).toBe("ConfigureShortcuts");
291 },
292);
293
294it("treats a remembered empty binding as unassigned, then observes desktop-side edits", async () => {
295 const bus = new FakeBus();
296 bus.actualLabel = "";
297 const { client, capture } = start(bus);
298 await client.ready;
299 expect(client.state.shortcutRegistered).toBe(false);
300 bus.activate();
301 expect(capture).not.toHaveBeenCalled();
302 bus.signal(portal, "ShortcutsChanged", root, [
303 bus.session,
304 [[bus.boundId, { trigger_description: new Variant("s", "Meta+F12") }]],
305 ]);
306 expect(client.state.shortcutLabel).toBe("Meta+F12");
307 expect(client.state.shortcutRegistered).toBe(true);
308 bus.activate();
309 expect(capture).toHaveBeenCalledOnce();
310});
311
312it("guides users to manual desktop settings when the portal cannot open them", async () => {
313 const bus = new FakeBus();
314 bus.version = 1;
315 bus.bindStatus = 1;
316 const { client } = start(bus);
317 await client.ready;
318 expect(client.hasSession).toBe(true);
319 expect(client.state.shortcutCanRetry).toBe(false);
320 expect(client.state.shortcutMessage).toBe(
321 "Shortcut permission wasn't granted. Allow The Knife in your desktop's shortcut settings.",
322 );
323 await expect(client.configure()).rejects.toThrow("Open your desktop's shortcut settings");
324 expect(bus.calls.some((message) => message.member === "ConfigureShortcuts")).toBe(false);
325 client.close();
326 expect(client.hasSession).toBe(false);
327});
328
329it("captures on older portals without offering an unsupported permission dialog", async () => {
330 const bus = new FakeBus();
331 bus.version = 1;
332 const { client, capture } = start(bus);
333 await client.ready;
334 expect(client.state).toMatchObject({ shortcutRegistered: true, shortcutCanRetry: false });
335 bus.activate();
336 expect(capture).toHaveBeenCalledOnce();
337 bus.signal(portal, "ShortcutsChanged", root, [bus.session, []]);
338 expect(client.state).toMatchObject({
339 shortcutRegistered: false,
340 shortcutCanRetry: false,
341 shortcutMessage: "No shortcut is assigned. Choose one in your desktop's shortcut settings.",
342 });
343 bus.signal("org.freedesktop.portal.Session", "Closed", bus.session, []);
344 expect(client.hasSession).toBe(false);
345 expect(client.state.shortcutCanRetry).toBe(true);
346});
347
348it("closes an outstanding consent request and ignores its late response", async () => {
349 const bus = new FakeBus();
350 bus.autoBind = false;
351 const binding = NodeEvents.EventEmitter.once(bus, "bind");
352 const { client, capture, changed } = start(bus);
353 await binding;
354 client.close();
355 bus.respondBind();
356 await client.ready;
357 bus.activate();
358 expect(capture).not.toHaveBeenCalled();
359 expect(changed).not.toHaveBeenCalled();
360 expect(bus.sends.filter((message) => message.member === "Close")).toHaveLength(2);
361 expect(bus.disconnect).toHaveBeenCalledOnce();
362});
363
364it("bounds unanswered consent and cleans up the pending request", async () => {
365 vi.useFakeTimers();
366 const bus = new FakeBus();
367 bus.autoBind = false;
368 const binding = NodeEvents.EventEmitter.once(bus, "bind");
369 const { client } = start(bus);
370 await binding;
371 await vi.advanceTimersByTimeAsync(120_000);
372 await client.ready;
373 expect(client.state.shortcutPending).toBe(false);
374 expect(client.state.shortcutMessage).toContain("timed out");
375 expect(bus.disconnect).toHaveBeenCalledOnce();
376 expect(vi.getTimerCount()).toBe(0);
377});
378
379it("handles a disappearing portal without keeping a false registered state", async () => {
380 const { bus, client } = start();
381 await client.ready;
382 bus.signal(
383 "org.freedesktop.DBus",
384 "NameOwnerChanged",
385 "/org/freedesktop/DBus",
386 ["org.freedesktop.portal.Desktop", ":1.2", ""],
387 "org.freedesktop.DBus",
388 );
389 expect(client.state.shortcutRegistered).toBe(false);
390 expect(client.state.shortcutMessage).toContain("restarted");
391 expect(bus.disconnect).toHaveBeenCalledOnce();
392});
393
394it("accepts old portals without Registry but does not ignore permission denial", async () => {
395 const bus = new FakeBus();
396 bus.registryError = new DBusError("org.freedesktop.DBus.Error.UnknownMethod", "Old portal");
397 const first = start(bus);
398 await first.client.ready;
399 expect(first.client.state.shortcutRegistered).toBe(true);
400 const denied = new FakeBus();
401 denied.registryError = new DBusError(
402 "org.freedesktop.portal.Error.NotAllowed",
403 "App is not allowed",
404 );
405 const second = start(denied);
406 await second.client.ready;
407 expect(second.client.state.shortcutRegistered).toBe(false);
408 expect(denied.calls.some((call) => call.member === "CreateSession")).toBe(false);
409});
410
411it("rejects a foreign session without trying to close someone else's session", async () => {
412 const bus = new FakeBus();
413 bus.foreignHandle = true;
414 const { client } = start(bus);
415 await client.ready;
416 expect(client.state.shortcutRegistered).toBe(false);
417 expect(bus.sends.some((message) => message.path.includes("9_9"))).toBe(false);
418});