1// @effect-diagnostics nodeBuiltinImport:off -- Private D-Bus integration fixture, never the user's session bus.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeEvents from "node:events";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeReadline from "node:readline";
8import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next";
9import { expect, it, vi } from "vite-plus/test";
10import { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts";
11
12it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)(
13 "registers, rebinds saved keys, receives activations, and replaces sessions over real D-Bus",
14 async () => {
15 const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-shortcut-dbus-"));
16 let daemon: NodeChildProcess.ChildProcess | undefined;
17 let server: MessageBus | undefined;
18 const clients: PortalCaptureShortcut[] = [];
19 try {
20 vi.stubEnv("FLATPAK_ID", "");
21 vi.stubEnv("SNAP", "");
22 daemon = NodeChildProcess.spawn(
23 "dbus-daemon",
24 [
25 "--session",
26 "--nofork",
27 "--nopidfile",
28 "--print-address",
29 `--address=unix:path=${NodePath.join(dir, "bus")}`,
30 ],
31 { stdio: ["ignore", "pipe", "pipe"] },
32 );
33 const lines = NodeReadline.createInterface({ input: daemon.stdout! });
34 const [address] = await Promise.race([
35 NodeEvents.EventEmitter.once(lines, "line"),
36 NodeEvents.EventEmitter.once(daemon, "exit").then(() => {
37 throw new Error("Private bus failed to start");
38 }),
39 ]);
40 lines.close();
41 const connect = () => sessionBus({ busAddress: String(address) });
42 server = connect();
43 server.on("error", () => undefined);
44 await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE);
45 const root = "/org/freedesktop/portal/desktop";
46 const iface = "org.freedesktop.portal.GlobalShortcuts";
47 const sessions = new Map<string, string>();
48 const bindings = new Map<string, string>();
49 const identities = new Set<string>();
50 const closed = new Set<string>();
51 const closeWaiters = new Map<string, () => void>();
52 let bindCount = 0;
53 server.addMethodHandler((message: Message) => {
54 if (message.member === "Register") {
55 expect(message.body[0]).toBe("com.t3tools.T3Code");
56 identities.add(message.sender);
57 server!.send(Message.newMethodReturn(message));
58 } else if (message.member === "Get") {
59 server!.send(Message.newMethodReturn(message, "v", [new Variant("u", 2)]));
60 } else if (message.member === "Close") {
61 closed.add(message.path);
62 closeWaiters.get(message.path)?.();
63 } else if (["CreateSession", "BindShortcuts"].includes(message.member)) {
64 expect(identities.has(message.sender)).toBe(true);
65 const options = message.body.at(-1) as Record<string, Variant<string>>;
66 const sender = message.sender.slice(1).replaceAll(".", "_");
67 const handle = `${root}/request/${sender}/${options.handle_token!.value}`;
68 let results: Record<string, Variant<unknown>>;
69 if (message.member === "CreateSession") {
70 const session = `${root}/session/${sender}/${options.session_handle_token!.value}`;
71 sessions.set(message.sender, session);
72 results = { session_handle: new Variant("s", session) };
73 } else {
74 bindCount++;
75 const shortcuts = message.body[1] as Array<
76 [string, { preferred_trigger: Variant<string> }]
77 >;
78 expect(shortcuts).toHaveLength(1);
79 const [id, properties] = shortcuts[0]!;
80 expect(properties.preferred_trigger.signature).toBe("s");
81 bindings.set(message.sender, id);
82 const bound = [
83 [
84 id,
85 {
86 description: new Variant("s", "Capture a window"),
87 trigger_description: new Variant("s", properties.preferred_trigger.value),
88 },
89 ],
90 ];
91 results = { shortcuts: new Variant("a(sa{sv})", bound) };
92 }
93 // Exercise the fast-portal race: Response is delivered before the method's handle reply.
94 const response = Message.newSignal(
95 handle,
96 "org.freedesktop.portal.Request",
97 "Response",
98 "ua{sv}",
99 [0, results],
100 );
101 response.destination = message.sender;
102 server!.send(response);
103 server!.send(Message.newMethodReturn(message, "o", [handle]));
104 } else return false;
105 return true;
106 });
107
108 const shortcut = {
109 key: "2",
110 modKey: true,
111 ctrlKey: false,
112 shiftKey: true,
113 altKey: false,
114 metaKey: false,
115 };
116 const start = (key = "2") => {
117 const received = Promise.withResolvers<void>();
118 const capture = vi.fn(() => received.resolve());
119 const client = new PortalCaptureShortcut(
120 "com.t3tools.T3Code",
121 { ...shortcut, key },
122 capture,
123 () => {},
124 connect(),
125 );
126 clients.push(client);
127 return { client, capture, received: received.promise };
128 };
129 const activate = (sender: string) => {
130 const signal = Message.newSignal(root, iface, "Activated", "osta{sv}", [
131 sessions.get(sender),
132 bindings.get(sender),
133 123,
134 {},
135 ]);
136 signal.destination = sender;
137 server!.send(signal);
138 };
139 const first = start();
140 await first.client.ready;
141 expect(first.client.state.shortcutRegistered).toBe(true);
142 const firstSender = [...bindings.keys()][0]!;
143 activate(firstSender);
144 await first.received;
145 expect(first.capture).toHaveBeenCalledOnce();
146 const sessionClosed = new Promise<void>((resolve) =>
147 closeWaiters.set(sessions.get(firstSender)!, resolve),
148 );
149 first.client.close();
150 await sessionClosed;
151 expect(closed.has(sessions.get(firstSender)!)).toBe(true);
152
153 const restoredClient = start();
154 await restoredClient.client.ready;
155 expect(restoredClient.client.state.shortcutRegistered).toBe(true);
156 expect(bindCount).toBe(2);
157 expect([...bindings.values()][1]).toBe(bindings.get(firstSender));
158 const next = start("8");
159 await next.client.ready;
160 expect(next.client.state.shortcutLabel).toBe("CTRL+SHIFT+8");
161 expect(bindCount).toBe(3);
162 const lastSender = [...bindings.keys()].at(-1)!;
163 expect(bindings.get(lastSender)).not.toBe(bindings.get(firstSender));
164 activate(lastSender);
165 await next.received;
166 expect(next.capture).toHaveBeenCalledOnce();
167 } finally {
168 clients.forEach((client) => client.close());
169 server?.disconnect();
170 if (daemon && daemon.exitCode === null) {
171 const exited = NodeEvents.EventEmitter.once(daemon, "exit");
172 daemon.kill();
173 await exited;
174 }
175 vi.unstubAllEnvs();
176 await NodeFSP.rm(dir, { recursive: true, force: true });
177 }
178 },
179);