| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Private D-Bus integration fixture, never the user's session bus. |
| 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeEvents from "node:events"; |
| 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeOS from "node:os"; |
| 6 | import * as NodePath from "node:path"; |
| 7 | import * as NodeReadline from "node:readline"; |
| 8 | import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next"; |
| 9 | import { expect, it, vi } from "vite-plus/test"; |
| 10 | import { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts"; |
| 11 | |
| 12 | it.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 | ); |