1// @effect-diagnostics nodeBuiltinImport:off -- Stable shortcut IDs and unique portal request tokens.
2// @effect-diagnostics globalTimers:off -- Bounded D-Bus calls and user-consent requests at the native boundary.
3import * as NodeCrypto from "node:crypto";
4import {
5 DBusError,
6 Message,
7 MessageFlag,
8 MessageType,
9 Variant,
10 sessionBus,
11 type MessageBus,
12 type MessageLike,
13} from "dbus-next";
14import * as Schema from "effect/Schema";
15import type { SnapShotKeyChord } from "@t3tools/contracts";
16import { HYPRLAND_CAPTURE_ACTION, portalShortcutTrigger } from "./linuxCaptureSession.ts";
17export { portalShortcutTrigger } from "./linuxCaptureSession.ts";
18
19const PORTAL = "org.freedesktop.portal.Desktop";
20const PATH = "/org/freedesktop/portal/desktop";
21const SHORTCUTS = "org.freedesktop.portal.GlobalShortcuts";
22const REQUEST = "org.freedesktop.portal.Request";
23const SESSION = "org.freedesktop.portal.Session";
24const DBUS = "org.freedesktop.DBus";
25const string = Schema.decodeUnknownSync(Schema.String);
26const StringVariant = Schema.Struct({ signature: Schema.Literal("s"), value: Schema.String });
27const Shortcuts = Schema.Array(
28 Schema.Tuple([
29 Schema.String,
30 Schema.Struct({
31 description: Schema.optional(StringVariant),
32 trigger_description: Schema.optional(StringVariant),
33 }),
34 ]),
35);
36const decodeShortcuts = Schema.decodeUnknownSync(Shortcuts);
37const decodeShortcutsResult = Schema.decodeUnknownSync(
38 Schema.Struct({
39 shortcuts: Schema.Struct({ signature: Schema.Literal("a(sa{sv})"), value: Shortcuts }),
40 }),
41);
42const decodeSession = Schema.decodeUnknownSync(Schema.Struct({ session_handle: StringVariant }));
43const decodeResponse = Schema.decodeUnknownSync(
44 Schema.Tuple([Schema.Int, Schema.Record(Schema.String, Schema.Unknown)]),
45);
46const decodeVersion = Schema.decodeUnknownSync(
47 Schema.Struct({
48 signature: Schema.Literal("u"),
49 value: Schema.Int,
50 }),
51);
52
53export interface PortalShortcutState {
54 readonly shortcutActionRegistered?: boolean;
55 readonly shortcutRegistered: boolean;
56 readonly shortcutPending: boolean;
57 /** Whether retry-shortcut can reopen permissions or start a new session. */
58 readonly shortcutCanRetry?: boolean;
59 readonly shortcutLabel?: string;
60 readonly shortcutMessage: string | null;
61}
62
63/** Own the portal session, including consent and release; Electron only reports submission. */
64export class PortalCaptureShortcut {
65 state: PortalShortcutState = {
66 shortcutRegistered: false,
67 shortcutPending: true,
68 shortcutMessage: "Waiting for shortcut permission. Approve the desktop prompt if one appears.",
69 };
70 readonly ready: Promise<void>;
71 private closed = false;
72 private owner = "";
73 private namespace = "";
74 private session = "";
75 private shortcutId = "";
76 private version = 0;
77 private pending: { path: string; resolve: (body: unknown) => void } | undefined;
78 private responses = new Map<string, unknown>();
79 private readonly stopped: Promise<never>;
80 private rejectStopped!: (reason: Error) => void;
81 private readonly onCapture: () => void;
82 private readonly onStateChanged: () => void;
83 private readonly bus: MessageBus;
84 private readonly managedByHyprland: boolean;
85
86 constructor(
87 appId: string,
88 shortcut: SnapShotKeyChord,
89 onCapture: () => void,
90 onStateChanged: () => void,
91 bus: MessageBus = sessionBus(),
92 managedByHyprland = false,
93 ) {
94 this.onCapture = onCapture;
95 this.onStateChanged = onStateChanged;
96 this.bus = bus;
97 this.managedByHyprland = managedByHyprland;
98 if (managedByHyprland)
99 this.state = {
100 shortcutRegistered: false,
101 shortcutPending: true,
102 shortcutMessage: "Connecting to Hyprland shortcuts…",
103 };
104 this.stopped = new Promise((_, reject) => {
105 this.rejectStopped = reject;
106 });
107 void this.stopped.catch(() => undefined);
108 bus.on("error", this.failed);
109 bus.on("message", this.message);
110 this.ready = this.initialize(appId, shortcut).catch(this.failed);
111 }
112
113 close = () => {
114 if (this.closed) return;
115 this.closed = true;
116 this.rejectStopped(new Error("Capture shortcut registration closed."));
117 try {
118 if (this.pending) this.closeObject(this.pending.path, REQUEST);
119 if (this.session) this.closeObject(this.session, SESSION);
120 } catch {
121 // A broken bus is already closing its sessions; local cleanup must still run.
122 }
123 this.bus.removeListener("message", this.message);
124 this.responses.clear();
125 this.bus.disconnect();
126 };
127
128 /** A previously denied binding is changed by the desktop, not by bypassing its decision. */
129 get hasSession() {
130 return !this.closed && Boolean(this.session);
131 }
132
133 async configure() {
134 if (this.managedByHyprland)
135 throw new Error("Change the capture binding in your Hyprland config, then save it.");
136 if (!this.hasSession || this.version < 2)
137 throw new Error(
138 "Open your desktop's shortcut settings and allow The Knife's capture shortcut.",
139 );
140 await this.call({
141 destination: this.owner,
142 path: PATH,
143 interface: SHORTCUTS,
144 member: "ConfigureShortcuts",
145 signature: "osa{sv}",
146 body: [this.session, "", {}],
147 });
148 }
149
150 private update(state: PortalShortcutState) {
151 if (this.closed) return;
152 this.state = {
153 shortcutCanRetry: !this.managedByHyprland && (!this.hasSession || this.version >= 2),
154 ...state,
155 };
156 this.onStateChanged();
157 }
158
159 private failed = (error: unknown) => {
160 if (this.closed) return;
161 this.update({
162 shortcutRegistered: false,
163 shortcutPending: false,
164 // This failed session is closing, so retry can register a fresh one.
165 shortcutCanRetry: !this.managedByHyprland,
166 shortcutMessage: this.managedByHyprland
167 ? "Couldn't connect to Hyprland shortcuts. Make sure xdg-desktop-portal-hyprland is running, then restart The Knife."
168 : error instanceof Error
169 ? error.message
170 : "Could not register the capture shortcut.",
171 });
172 this.close();
173 };
174
175 private async wait<T>(promise: Promise<T>, timeoutMs = 5_000): Promise<T> {
176 let timer: ReturnType<typeof setTimeout> | undefined;
177 try {
178 return await Promise.race([
179 promise,
180 this.stopped,
181 new Promise<never>((_, reject) => {
182 timer = setTimeout(
183 () => reject(new Error("Shortcut permission request timed out. Try again.")),
184 timeoutMs,
185 );
186 }),
187 ]);
188 } finally {
189 clearTimeout(timer);
190 }
191 }
192
193 private async call(message: MessageLike) {
194 if (this.closed) throw new Error("Capture shortcut registration closed.");
195 const reply = await this.wait(this.bus.call(new Message(message)));
196 if (!reply) throw new Error("Missing shortcut portal reply.");
197 return reply;
198 }
199
200 private closeObject(path: string, iface: string) {
201 this.bus.send(
202 new Message({
203 destination: this.owner,
204 path,
205 interface: iface,
206 member: "Close",
207 flags: MessageFlag.NO_REPLY_EXPECTED,
208 }),
209 );
210 }
211
212 private message = (message: Message) => {
213 if (this.closed || message.type !== MessageType.SIGNAL) return;
214 if (
215 message.sender === "org.freedesktop.DBus" &&
216 message.interface === DBUS &&
217 message.member === "NameOwnerChanged" &&
218 message.body[0] === PORTAL &&
219 message.body[1] === this.owner
220 ) {
221 this.failed(new Error("The desktop shortcut service restarted. Retry the shortcut request."));
222 return;
223 }
224 if (!this.owner || message.sender !== this.owner) return;
225 if (
226 message.interface === REQUEST &&
227 message.member === "Response" &&
228 message.path.startsWith(this.namespace)
229 ) {
230 if (message.path === this.pending?.path) this.pending.resolve(message.body);
231 else if (this.responses.size < 8) this.responses.set(message.path, message.body);
232 return;
233 }
234 if (
235 this.session &&
236 message.path === this.session &&
237 message.interface === SESSION &&
238 message.member === "Closed"
239 ) {
240 this.failed(
241 new Error("Your desktop closed the capture shortcut. Retry the shortcut request."),
242 );
243 return;
244 }
245 if (
246 message.path !== PATH ||
247 message.interface !== SHORTCUTS ||
248 message.body[0] !== this.session
249 )
250 return;
251 if (
252 message.member === "Activated" &&
253 message.signature === "osta{sv}" &&
254 message.body[1] === this.shortcutId &&
255 (this.state.shortcutRegistered || this.state.shortcutActionRegistered)
256 )
257 this.onCapture();
258 if (message.member === "ShortcutsChanged") {
259 try {
260 this.bound(decodeShortcuts(message.body[1]));
261 } catch (error) {
262 this.failed(error);
263 }
264 }
265 };
266
267 private async request(
268 member: string,
269 signature: string,
270 body: unknown[],
271 options: Record<string, Variant<unknown>> = {},
272 ) {
273 const token = `t3_${NodeCrypto.randomUUID().replaceAll("-", "")}`;
274 const expectedPath = this.namespace + token;
275 let resolve!: (body: unknown) => void;
276 const response = new Promise<unknown>((done) => {
277 resolve = done;
278 });
279 this.pending = { path: expectedPath, resolve };
280 this.responses.clear();
281 let completed = false;
282 try {
283 const reply = await this.call({
284 destination: this.owner,
285 path: PATH,
286 interface: SHORTCUTS,
287 member,
288 signature: signature + "a{sv}",
289 body: [...body, { ...options, handle_token: new Variant("s", token) }],
290 });
291 const handle = string(reply.body[0]);
292 if (!handle.startsWith(this.namespace)) throw new Error("Invalid shortcut request handle.");
293 this.pending.path = handle;
294 if (this.responses.has(handle)) resolve(this.responses.get(handle));
295 const [status, results] = decodeResponse(
296 await this.wait(response, member === "BindShortcuts" ? 120_000 : 5_000),
297 );
298 completed = true;
299 if (status !== 0) {
300 if (member === "BindShortcuts") {
301 this.update({
302 shortcutRegistered: false,
303 shortcutPending: false,
304 shortcutMessage:
305 this.version >= 2
306 ? "Shortcut permission wasn't granted. Open shortcut permissions to allow it."
307 : "Shortcut permission wasn't granted. Allow The Knife in your desktop's shortcut settings.",
308 });
309 return undefined;
310 }
311 throw new Error("Your desktop could not create a capture shortcut session.");
312 }
313 return results;
314 } finally {
315 if (!completed && !this.closed && this.pending) this.closeObject(this.pending.path, REQUEST);
316 this.pending = undefined;
317 this.responses.clear();
318 }
319 }
320
321 private bound(shortcuts: typeof Shortcuts.Type) {
322 const shortcut = shortcuts.find(([id]) => id === this.shortcutId);
323 if (this.managedByHyprland) {
324 this.update({
325 shortcutRegistered: false,
326 shortcutActionRegistered: Boolean(shortcut),
327 shortcutPending: false,
328 shortcutMessage: shortcut
329 ? "Managed by Hyprland. Add the binding to your config and save it."
330 : "Hyprland did not register the capture action. Check that xdg-desktop-portal-hyprland is running, then restart The Knife.",
331 });
332 return;
333 }
334 const label = shortcut?.[1].trigger_description?.value.trim();
335 this.update({
336 shortcutRegistered: Boolean(shortcut && label),
337 shortcutPending: false,
338 ...(label ? { shortcutLabel: label } : {}),
339 shortcutMessage: label
340 ? `Desktop shortcut: ${label}`
341 : this.version >= 2
342 ? "No shortcut is assigned. Open shortcut permissions to choose one."
343 : "No shortcut is assigned. Choose one in your desktop's shortcut settings.",
344 });
345 }
346
347 private async initialize(appId: string, shortcut: SnapShotKeyChord) {
348 const trigger = portalShortcutTrigger(shortcut);
349 if (!process.env.FLATPAK_ID && !process.env.SNAP) {
350 await this.call({
351 destination: PORTAL,
352 path: PATH,
353 interface: "org.freedesktop.host.portal.Registry",
354 member: "Register",
355 signature: "sa{sv}",
356 body: [appId, {}],
357 }).catch((error: unknown) => {
358 if (
359 !(
360 error instanceof DBusError &&
361 ["UnknownMethod", "UnknownInterface"].some(
362 (name) => error.type === `${DBUS}.Error.${name}`,
363 )
364 )
365 )
366 throw error;
367 });
368 }
369 const owner = await this.call({
370 destination: DBUS,
371 path: "/org/freedesktop/DBus",
372 interface: DBUS,
373 member: "GetNameOwner",
374 signature: "s",
375 body: [PORTAL],
376 });
377 this.owner = string(owner.body[0]);
378 this.namespace = `${PATH}/request/${owner.destination.slice(1).replaceAll(".", "_")}/`;
379 const version = await this.call({
380 destination: this.owner,
381 path: PATH,
382 interface: "org.freedesktop.DBus.Properties",
383 member: "Get",
384 signature: "ss",
385 body: [SHORTCUTS, "version"],
386 });
387 this.version = decodeVersion(version.body[0]).value;
388 for (const rule of [
389 `type='signal',sender='${this.owner}',path_namespace='${PATH}'`,
390 `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`,
391 ])
392 await this.call({
393 destination: DBUS,
394 path: "/org/freedesktop/DBus",
395 interface: DBUS,
396 member: "AddMatch",
397 signature: "s",
398 body: [rule],
399 });
400 const created = await this.request("CreateSession", "", [], {
401 session_handle_token: new Variant(
402 "s",
403 `t3_capture_${NodeCrypto.randomUUID().replaceAll("-", "")}`,
404 ),
405 });
406 const session = decodeSession(created).session_handle.value;
407 const sessionNamespace = this.namespace.replace("/request/", "/session/");
408 if (!session.startsWith(sessionNamespace)) throw new Error("Invalid shortcut session handle.");
409 this.session = session;
410 this.shortcutId = this.managedByHyprland
411 ? HYPRLAND_CAPTURE_ACTION
412 : `t3-snap-shot-${NodeCrypto.createHash("sha256").update(trigger).digest("hex").slice(0, 16)}`;
413 // Every session must bind, even when the desktop remembers this shortcut's approval.
414 const bound = await this.request("BindShortcuts", "oa(sa{sv})s", [
415 this.session,
416 [
417 [
418 this.shortcutId,
419 {
420 description: new Variant("s", "Capture a window"),
421 ...(!this.managedByHyprland ? { preferred_trigger: new Variant("s", trigger) } : {}),
422 },
423 ],
424 ],
425 "",
426 ]);
427 if (bound) this.bound(decodeShortcutsResult(bound).shortcuts.value);
428 }
429}