diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts
index acd9a29b32..c80aad57de 100644
--- a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts
+++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts
@@ -5,29 +5,36 @@ import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeReadline from "node:readline";
-import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next";
+import { Message, MessageType, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next";
import { expect, it, vi } from "vite-plus/test";
import { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts";
it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)(
- "registers, rebinds saved keys, receives activations, and replaces sessions over real D-Bus",
+ "registers, rebinds saved keys, recovers returning portal owners, and releases sessions over real D-Bus",
async () => {
const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-shortcut-dbus-"));
let daemon: NodeChildProcess.ChildProcess | undefined;
- let server: MessageBus | undefined;
+ const servers: MessageBus[] = [];
const clients: PortalCaptureShortcut[] = [];
try {
vi.stubEnv("FLATPAK_ID", "");
vi.stubEnv("SNAP", "");
+ const config = NodePath.join(dir, "bus.conf");
+ await NodeFSP.writeFile(
+ config,
+ `
+ session
+ unix:path=${NodePath.join(dir, "bus")}
+
+
+
+
+
+`,
+ );
daemon = NodeChildProcess.spawn(
"dbus-daemon",
- [
- "--session",
- "--nofork",
- "--nopidfile",
- "--print-address",
- `--address=unix:path=${NodePath.join(dir, "bus")}`,
- ],
+ [`--config-file=${config}`, "--nofork", "--nopidfile", "--print-address"],
{ stdio: ["ignore", "pipe", "pipe"] },
);
const lines = NodeReadline.createInterface({ input: daemon.stdout! });
@@ -39,70 +46,84 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)(
]);
lines.close();
const connect = () => sessionBus({ busAddress: String(address) });
- server = connect();
+ let server = connect();
+ servers.push(server);
server.on("error", () => undefined);
- await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE);
+ await server.requestName("org.freedesktop.portal.Desktop", NameFlag.ALLOW_REPLACEMENT);
const root = "/org/freedesktop/portal/desktop";
const iface = "org.freedesktop.portal.GlobalShortcuts";
const sessions = new Map();
const bindings = new Map();
- const identities = new Set();
const closed = new Set();
const closeWaiters = new Map void>();
let bindCount = 0;
- server.addMethodHandler((message: Message) => {
- if (message.member === "Register") {
- expect(message.body[0]).toBe("com.t3tools.T3Code");
- identities.add(message.sender);
- server!.send(Message.newMethodReturn(message));
- } else if (message.member === "Get") {
- server!.send(Message.newMethodReturn(message, "v", [new Variant("u", 2)]));
- } else if (message.member === "Close") {
- closed.add(message.path);
- closeWaiters.get(message.path)?.();
- } else if (["CreateSession", "BindShortcuts"].includes(message.member)) {
- expect(identities.has(message.sender)).toBe(true);
- const options = message.body.at(-1) as Record>;
- const sender = message.sender.slice(1).replaceAll(".", "_");
- const handle = `${root}/request/${sender}/${options.handle_token!.value}`;
- let results: Record>;
- if (message.member === "CreateSession") {
- const session = `${root}/session/${sender}/${options.session_handle_token!.value}`;
- sessions.set(message.sender, session);
- results = { session_handle: new Variant("s", session) };
- } else {
- bindCount++;
- const shortcuts = message.body[1] as Array<
- [string, { preferred_trigger: Variant }]
- >;
- expect(shortcuts).toHaveLength(1);
- const [id, properties] = shortcuts[0]!;
- expect(properties.preferred_trigger.signature).toBe("s");
- bindings.set(message.sender, id);
- const bound = [
- [
- id,
- {
- description: new Variant("s", "Capture a window"),
- trigger_description: new Variant("s", properties.preferred_trigger.value),
- },
- ],
- ];
- results = { shortcuts: new Variant("a(sa{sv})", bound) };
- }
- const response = Message.newSignal(
- handle,
- "org.freedesktop.portal.Request",
- "Response",
- "ua{sv}",
- [0, results],
- );
- response.destination = message.sender;
- server!.send(response);
- server!.send(Message.newMethodReturn(message, "o", [handle]));
- } else return false;
- return true;
- });
+ const serve = (bus: MessageBus) => {
+ const identities = new Set();
+ const ownedSessions = new Set();
+ bus.addMethodHandler((message: Message) => {
+ if (message.member === "Register") {
+ expect(message.body[0]).toBe("com.t3tools.T3Code");
+ if (identities.has(message.sender)) {
+ const reply = Message.newMethodReturn(message, "s", ["Already registered"]);
+ reply.type = MessageType.ERROR;
+ reply.errorName = "org.freedesktop.portal.Error.Failed";
+ bus.send(reply);
+ return true;
+ }
+ identities.add(message.sender);
+ bus.send(Message.newMethodReturn(message));
+ } else if (message.member === "Get") {
+ bus.send(Message.newMethodReturn(message, "v", [new Variant("u", 2)]));
+ } else if (message.member === "Close") {
+ expect(ownedSessions.delete(message.path)).toBe(true);
+ closed.add(message.path);
+ closeWaiters.get(message.path)?.();
+ } else if (["CreateSession", "BindShortcuts"].includes(message.member)) {
+ expect(identities.has(message.sender)).toBe(true);
+ const options = message.body.at(-1) as Record>;
+ const sender = message.sender.slice(1).replaceAll(".", "_");
+ const handle = `${root}/request/${sender}/${options.handle_token!.value}`;
+ let results: Record>;
+ if (message.member === "CreateSession") {
+ const session = `${root}/session/${sender}/${options.session_handle_token!.value}`;
+ ownedSessions.add(session);
+ sessions.set(message.sender, session);
+ results = { session_handle: new Variant("s", session) };
+ } else {
+ bindCount++;
+ const shortcuts = message.body[1] as Array<
+ [string, { preferred_trigger: Variant }]
+ >;
+ expect(shortcuts).toHaveLength(1);
+ const [id, properties] = shortcuts[0]!;
+ expect(properties.preferred_trigger.signature).toBe("s");
+ bindings.set(message.sender, id);
+ const bound = [
+ [
+ id,
+ {
+ description: new Variant("s", "Capture a window"),
+ trigger_description: new Variant("s", properties.preferred_trigger.value),
+ },
+ ],
+ ];
+ results = { shortcuts: new Variant("a(sa{sv})", bound) };
+ }
+ const response = Message.newSignal(
+ handle,
+ "org.freedesktop.portal.Request",
+ "Response",
+ "ua{sv}",
+ [0, results],
+ );
+ response.destination = message.sender;
+ bus.send(response);
+ bus.send(Message.newMethodReturn(message, "o", [handle]));
+ } else return false;
+ return true;
+ });
+ };
+ serve(server);
const shortcut = {
key: "2",
@@ -113,17 +134,19 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)(
metaKey: false,
};
const start = (key = "2") => {
- const received = Promise.withResolvers();
- const capture = vi.fn(() => received.resolve());
+ const events = new NodeEvents.EventEmitter();
+ const capture = vi.fn(() => events.emit("capture"));
const client = new PortalCaptureShortcut(
"com.t3tools.T3Code",
{ ...shortcut, key },
capture,
- () => {},
+ () => {
+ if (!client.state.shortcutPending) events.emit("settled");
+ },
connect(),
);
clients.push(client);
- return { client, capture, received: received.promise };
+ return { client, capture, events };
};
const activate = (sender: string) => {
const signal = Message.newSignal(root, iface, "Activated", "osta{sv}", [
@@ -139,8 +162,9 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)(
await first.client.ready;
expect(first.client.state.shortcutRegistered).toBe(true);
const firstSender = [...bindings.keys()][0]!;
+ const firstCapture = NodeEvents.EventEmitter.once(first.events, "capture");
activate(firstSender);
- await first.received;
+ await firstCapture;
expect(first.capture).toHaveBeenCalledOnce();
const sessionClosed = new Promise((resolve) =>
closeWaiters.set(sessions.get(firstSender)!, resolve),
@@ -160,12 +184,52 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)(
expect(bindCount).toBe(3);
const lastSender = [...bindings.keys()].at(-1)!;
expect(bindings.get(lastSender)).not.toBe(bindings.get(firstSender));
+ const nextCapture = NodeEvents.EventEmitter.once(next.events, "capture");
activate(lastSender);
- await next.received;
+ await nextCapture;
expect(next.capture).toHaveBeenCalledOnce();
+ restoredClient.client.close();
+
+ const original = server;
+ const replacement = connect();
+ servers.push(replacement);
+ replacement.on("error", () => undefined);
+ serve(replacement);
+ for (const owner of [replacement, original]) {
+ const previous = server;
+ const oldSession = sessions.get(lastSender)!;
+ const released = new Promise((resolve) => closeWaiters.set(oldSession, resolve));
+ const settled = NodeEvents.EventEmitter.once(next.events, "settled");
+ server = owner;
+ await owner.requestName(
+ "org.freedesktop.portal.Desktop",
+ NameFlag.ALLOW_REPLACEMENT | NameFlag.REPLACE_EXISTING,
+ );
+ await settled;
+ expect(next.client.state.shortcutRegistered, next.client.state.shortcutMessage ?? "").toBe(
+ true,
+ );
+ await released;
+ expect(sessions.get(lastSender)).not.toBe(oldSession);
+ expect(next.client.state.shortcutLabel).toBe("CTRL+SHIFT+8");
+
+ const stale = Message.newSignal(root, iface, "Activated", "osta{sv}", [
+ oldSession,
+ bindings.get(lastSender),
+ 123,
+ {},
+ ]);
+ stale.destination = lastSender;
+ previous.send(stale);
+ const captured = NodeEvents.EventEmitter.once(next.events, "capture");
+ activate(lastSender);
+ await captured;
+ }
+ expect(next.capture).toHaveBeenCalledTimes(3);
+ expect(bindCount).toBe(5);
} finally {
clients.forEach((client) => client.close());
- server?.disconnect();
+ servers.forEach((bus) => bus.disconnect());
if (daemon && daemon.exitCode === null) {
const exited = NodeEvents.EventEmitter.once(daemon, "exit");
daemon.kill();
diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts
index eb20a4a041..f586e2dfb2 100644
--- a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts
+++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts
@@ -22,6 +22,11 @@ class FakeBus extends NodeEvents.EventEmitter {
sends: Message[] = [];
disconnect = vi.fn();
registryError: Error | undefined;
+ registryResponse: Promise | undefined;
+ registeredOwners = new Set();
+ activationError: Error | undefined;
+ ownerReply: Promise | undefined;
+ bindReply: Promise | undefined;
version = 2;
autoBind = true;
actualLabel = "Ctrl+Shift+2";
@@ -30,6 +35,20 @@ class FakeBus extends NodeEvents.EventEmitter {
requestPath = "";
boundId = "";
foreignHandle = false;
+ owner = ":1.2";
+
+ changeOwner(owner: string) {
+ const previous = this.owner;
+ this.owner = owner;
+ this.signal(
+ "org.freedesktop.DBus",
+ "NameOwnerChanged",
+ "/org/freedesktop/DBus",
+ ["org.freedesktop.portal.Desktop", previous, owner],
+ "org.freedesktop.DBus",
+ "sss",
+ );
+ }
send(message: Message) {
this.sends.push(message);
@@ -39,7 +58,7 @@ class FakeBus extends NodeEvents.EventEmitter {
member: string,
path: string,
body: unknown[],
- sender = ":1.2",
+ sender = this.owner,
signature = "",
) {
this.emit(
@@ -71,7 +90,7 @@ class FakeBus extends NodeEvents.EventEmitter {
]),
});
}
- activate(id = this.boundId, session = this.session, sender = ":1.2") {
+ activate(id = this.boundId, session = this.session, sender = this.owner) {
this.signal(portal, "Activated", root, [session, id, 1, {}], sender, "osta{sv}");
}
async call(message: Message) {
@@ -85,9 +104,28 @@ class FakeBus extends NodeEvents.EventEmitter {
});
if (message.member === "Register") {
if (this.registryError) throw this.registryError;
+ const owner =
+ message.destination === "org.freedesktop.portal.Desktop" ? this.owner : message.destination;
+ if (this.registeredOwners.has(owner))
+ throw new DBusError("org.freedesktop.portal.Error.Failed", "Already registered");
+ this.registeredOwners.add(owner);
+ this.emit("register");
+ await this.registryResponse;
return reply();
}
- if (message.member === "GetNameOwner") return reply([":1.2"]);
+ if (message.member === "GetNameOwner") {
+ if (!this.owner)
+ throw new DBusError("org.freedesktop.DBus.Error.NameHasNoOwner", "No portal owner");
+ const owner = this.owner;
+ this.emit("owner");
+ await this.ownerReply;
+ return reply([owner]);
+ }
+ if (message.member === "StartServiceByName") {
+ if (this.activationError) throw this.activationError;
+ this.changeOwner(":1.3");
+ return reply([1]);
+ }
if (message.member === "Get") return reply([new Variant("u", this.version)]);
if (message.member === "AddMatch" || message.member === "ConfigureShortcuts") return reply();
const options = message.body.at(-1) as Record>;
@@ -100,11 +138,13 @@ class FakeBus extends NodeEvents.EventEmitter {
this.foreignHandle ? `${root}/session/9_9/other` : this.session,
),
});
+ this.emit("create");
} else if (message.member === "BindShortcuts") {
this.boundId = (message.body[1] as Shortcut[])[0]![0];
this.requestPath = path;
this.emit("bind");
if (this.autoBind) this.respondBind();
+ await this.bindReply;
} else throw new Error(`Unexpected call: ${message.member}`);
return reply([path]);
}
@@ -134,7 +174,13 @@ afterEach(() => {
it("accepts Hyprland's action-only binding without claiming the keys are reserved", async () => {
const bus = new FakeBus();
bus.actualLabel = "";
+ bus.owner = "";
+ bus.autoBind = false;
+ const binding = NodeEvents.EventEmitter.once(bus, "bind");
const { client, capture } = start(bus, chord, true);
+ await binding;
+ expect(client.state.shortcutMessage).toBe("Connecting to Hyprland shortcuts…");
+ bus.respondBind();
await client.ready;
expect(client.state).toMatchObject({
shortcutRegistered: false,
@@ -231,7 +277,9 @@ it("binds only the requested shortcut with a stable ID across sessions", async (
]);
bus.activate();
expect(capture).toHaveBeenCalledOnce();
- expect(bus.calls[0]?.member).toBe("Register");
+ expect(bus.calls.find((call) => call.destination !== "org.freedesktop.DBus")?.member).toBe(
+ "Register",
+ );
});
it("ignores other shortcuts, sessions, and forged activations", async () => {
@@ -376,19 +424,195 @@ it("bounds unanswered consent and cleans up the pending request", async () => {
expect(vi.getTimerCount()).toBe(0);
});
-it("handles a disappearing portal without keeping a false registered state", async () => {
- const { bus, client } = start();
+it("uses the replacement owner when an earlier owner lookup is still pending", async () => {
+ const bus = new FakeBus();
+ const lookup = Promise.withResolvers();
+ bus.ownerReply = lookup.promise;
+ const resolving = NodeEvents.EventEmitter.once(bus, "owner");
+ const { client, capture } = start(bus);
+ await resolving;
+ bus.ownerReply = undefined;
+ const registering = NodeEvents.EventEmitter.once(bus, "register");
+ bus.changeOwner(":1.3");
+ lookup.resolve();
+ await registering;
+ expect(bus.calls.find((call) => call.member === "Register")?.destination).toBe(":1.3");
+ await client.ready;
+ bus.activate();
+ expect(client.state.shortcutRegistered).toBe(true);
+ expect(capture).toHaveBeenCalledOnce();
+});
+
+it.each([false, true])(
+ "restores a lost portal shortcut and keeps listening after activation fails (Hyprland: %s)",
+ async (hyprland) => {
+ const { bus, client, capture } = start(new FakeBus(), chord, hyprland);
+ await client.ready;
+ const originalSession = bus.session;
+ const bound = NodeEvents.EventEmitter.once(bus, "bind");
+ bus.changeOwner("");
+ expect(client.state).toMatchObject({ shortcutRegistered: false, shortcutPending: true });
+ expect(client.state.shortcutActionRegistered).not.toBe(true);
+ expect(client.hasSession).toBe(false);
+ bus.activate(bus.boundId, originalSession, ":1.2");
+ expect(capture).not.toHaveBeenCalled();
+ await bound;
+ await client.ready;
+ bus.activate();
+ expect(capture).toHaveBeenCalledOnce();
+
+ bus.activationError = new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "No portal");
+ bus.changeOwner("");
+ await client.ready;
+ expect(client.state).toMatchObject({
+ shortcutRegistered: false,
+ shortcutPending: false,
+ shortcutCanRetry: !hyprland,
+ });
+ expect(client.state.shortcutMessage).toContain("No portal");
+ expect(bus.disconnect).not.toHaveBeenCalled();
+ bus.changeOwner(":1.4");
+ await client.ready;
+ bus.activate();
+ expect(capture).toHaveBeenCalledTimes(2);
+ expect(client.state.shortcutPending).toBe(false);
+ },
+);
+
+it("releases a created session when its response races an owner replacement", async () => {
+ const { bus, client, capture } = start();
await client.ready;
- bus.signal(
- "org.freedesktop.DBus",
- "NameOwnerChanged",
- "/org/freedesktop/DBus",
- ["org.freedesktop.portal.Desktop", ":1.2", ""],
- "org.freedesktop.DBus",
+ const creating = NodeEvents.EventEmitter.once(bus, "create");
+ bus.changeOwner(":1.3");
+ await creating;
+ const oldSession = bus.session;
+ bus.changeOwner(":1.4");
+ await client.ready;
+ expect(bus.sends).toContainEqual(
+ expect.objectContaining({ destination: ":1.3", path: oldSession, member: "Close" }),
);
- expect(client.state.shortcutRegistered).toBe(false);
- expect(client.state.shortcutMessage).toContain("restarted");
+ bus.activate();
+ expect(capture).toHaveBeenCalledOnce();
+});
+
+it("recovers approved consent when its response races an owner replacement", async () => {
+ const bus = new FakeBus();
+ bus.autoBind = false;
+ const binding = NodeEvents.EventEmitter.once(bus, "bind");
+ const { client, capture } = start(bus);
+ await binding;
+ bus.respondBind();
+ bus.autoBind = true;
+ bus.changeOwner(":1.3");
+ await client.ready;
+ bus.activate();
+ expect(client.state.shortcutRegistered).toBe(true);
+ expect(capture).toHaveBeenCalledOnce();
+});
+
+it("discards interrupted replies, closes old portal objects, and stays disabled after close", async () => {
+ const { bus, client, capture } = start();
+ await client.ready;
+ bus.autoBind = false;
+ const reply = Promise.withResolvers();
+ bus.bindReply = reply.promise;
+ const binding = NodeEvents.EventEmitter.once(bus, "bind");
+ bus.changeOwner(":1.3");
+ await binding;
+ const oldSession = bus.session;
+ const oldRequest = bus.requestPath;
+ const interrupted = client.ready;
+
+ bus.bindReply = undefined;
+ const replacement = NodeEvents.EventEmitter.once(bus, "bind");
+ bus.changeOwner(":1.4");
+ await replacement;
+ await interrupted;
+ expect(bus.sends).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ destination: ":1.3", path: oldRequest, member: "Close" }),
+ expect.objectContaining({ destination: ":1.3", path: oldSession, member: "Close" }),
+ ]),
+ );
+ reply.resolve();
+ bus.signal("org.freedesktop.portal.Request", "Response", oldRequest, [2, {}], ":1.3");
+ bus.activate(bus.boundId, oldSession, ":1.3");
+ expect(capture).not.toHaveBeenCalled();
+ expect(client.state.shortcutPending).toBe(true);
+ bus.respondBind();
+ await client.ready;
+ expect(bus.sends.some((message) => message.path === bus.requestPath)).toBe(false);
+ bus.activate();
+ expect(capture).toHaveBeenCalledOnce();
+
+ const disabling = NodeEvents.EventEmitter.once(bus, "bind");
+ bus.changeOwner(":1.5");
+ await disabling;
+ const disabledRequest = bus.requestPath;
+ const disabledSession = bus.session;
+ client.close();
+ bus.respondBind();
+ bus.changeOwner(":1.6");
+ await client.ready;
+ bus.activate();
+ expect(capture).toHaveBeenCalledOnce();
+ expect(client.hasSession).toBe(false);
expect(bus.disconnect).toHaveBeenCalledOnce();
+ expect(bus.calls.filter((call) => call.member === "BindShortcuts")).toHaveLength(4);
+ expect(bus.sends).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ destination: ":1.5", path: disabledRequest, member: "Close" }),
+ expect.objectContaining({ destination: ":1.5", path: disabledSession, member: "Close" }),
+ ]),
+ );
+});
+
+it.each([true, false])(
+ "honors an interrupted portal identity result when its owner returns (allowed: %s)",
+ async (allowed) => {
+ const { bus, client, capture } = start();
+ await client.ready;
+ const identity = Promise.withResolvers();
+ bus.registryResponse = identity.promise;
+ const registering = NodeEvents.EventEmitter.once(bus, "register");
+ bus.changeOwner(":1.3");
+ await registering;
+ bus.registryResponse = undefined;
+ bus.changeOwner(":1.2");
+ await client.ready;
+ bus.activate();
+ expect(capture).toHaveBeenCalledOnce();
+
+ if (allowed) identity.resolve();
+ else
+ identity.reject(
+ new DBusError("org.freedesktop.portal.Error.NotAllowed", "App is not allowed"),
+ );
+ bus.changeOwner(":1.3");
+ await client.ready;
+ bus.activate();
+ expect(client.state.shortcutRegistered).toBe(allowed);
+ expect(capture).toHaveBeenCalledTimes(allowed ? 2 : 1);
+ if (!allowed) expect(client.state.shortcutMessage).toContain("App is not allowed");
+ expect(bus.calls.filter((call) => call.member === "Register")).toHaveLength(2);
+ },
+);
+
+it("does not reopen consent after a refused recovery and immediate owner replacement", async () => {
+ const { bus, client, capture } = start();
+ await client.ready;
+ bus.autoBind = false;
+ const binding = NodeEvents.EventEmitter.once(bus, "bind");
+ bus.changeOwner(":1.3");
+ await binding;
+ bus.respondBind(1);
+ bus.changeOwner(":1.4");
+ await client.ready;
+ bus.activate();
+ expect(client.state.shortcutRegistered).toBe(false);
+ expect(client.state.shortcutPending).toBe(false);
+ expect(capture).not.toHaveBeenCalled();
+ expect(bus.calls.filter((call) => call.member === "BindShortcuts")).toHaveLength(2);
});
it("accepts old portals without Registry but does not ignore permission denial", async () => {
diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts
index ec42af46a3..f90a59071c 100644
--- a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts
+++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts
@@ -65,8 +65,9 @@ export class PortalCaptureShortcut {
shortcutPending: true,
shortcutMessage: "Waiting for shortcut permission. Approve the desktop prompt if one appears.",
};
- readonly ready: Promise;
+ ready: Promise;
private closed = false;
+ private reconnecting = false;
private owner = "";
private namespace = "";
private session = "";
@@ -74,12 +75,14 @@ export class PortalCaptureShortcut {
private version = 0;
private pending: { path: string; resolve: (body: unknown) => void } | undefined;
private responses = new Map();
- private readonly stopped: Promise;
- private rejectStopped!: (reason: Error) => void;
+ private registrations = new Map>();
+ private stopped = Promise.withResolvers();
private readonly onCapture: () => void;
private readonly onStateChanged: () => void;
private readonly bus: MessageBus;
private readonly managedByHyprland: boolean;
+ private readonly appId: string;
+ private readonly shortcut: SnapShotKeyChord;
constructor(
appId: string,
@@ -93,33 +96,83 @@ export class PortalCaptureShortcut {
this.onStateChanged = onStateChanged;
this.bus = bus;
this.managedByHyprland = managedByHyprland;
+ this.appId = appId;
+ this.shortcut = shortcut;
if (managedByHyprland)
this.state = {
shortcutRegistered: false,
shortcutPending: true,
shortcutMessage: "Connecting to Hyprland shortcuts…",
};
- this.stopped = new Promise((_, reject) => {
- this.rejectStopped = reject;
- });
- void this.stopped.catch(() => undefined);
+ void this.stopped.promise.catch(() => undefined);
bus.on("error", this.failed);
bus.on("message", this.message);
- this.ready = this.initialize(appId, shortcut).catch(this.failed);
+ this.ready = this.subscribe();
+ this.register();
+ }
+
+ private async subscribe() {
+ for (const rule of [
+ `type='signal',sender='${PORTAL}',path_namespace='${PATH}'`,
+ `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`,
+ ])
+ await this.call({
+ destination: DBUS,
+ path: "/org/freedesktop/DBus",
+ interface: DBUS,
+ member: "AddMatch",
+ signature: "s",
+ body: [rule],
+ });
+ }
+
+ private register() {
+ const stopped = this.stopped;
+ // Drain the interrupted registration before starting another on this connection.
+ this.ready = this.ready
+ .then(async () => {
+ if (this.closed || stopped !== this.stopped) return;
+ await this.initialize(stopped);
+ this.assertCurrent(stopped);
+ this.reconnecting = false;
+ })
+ .catch((error) => {
+ if (this.closed || stopped !== this.stopped) return;
+ if (this.reconnecting && !this.owner) {
+ this.update({
+ shortcutRegistered: false,
+ shortcutPending: false,
+ shortcutCanRetry: !this.managedByHyprland,
+ shortcutMessage:
+ (error instanceof Error ? `${error.message} ` : "") +
+ "Waiting for the desktop shortcut service to return.",
+ });
+ return;
+ }
+ this.failed(error);
+ });
}
close = () => {
if (this.closed) return;
this.closed = true;
- this.rejectStopped(new Error("Capture shortcut registration closed."));
+ this.stopped.reject(new Error("Capture shortcut registration closed."));
+ this.releaseSession();
+ this.bus.removeListener("message", this.message);
+ this.bus.disconnect();
+ };
+
+ private releaseSession() {
try {
if (this.pending) this.closeObject(this.pending.path, REQUEST);
if (this.session) this.closeObject(this.session, SESSION);
- } catch {}
- this.bus.removeListener("message", this.message);
+ } catch {
+ // A broken bus is already closing its sessions; local cleanup must still run.
+ }
+ this.session = "";
+ this.pending = undefined;
this.responses.clear();
- this.bus.disconnect();
- };
+ }
get hasSession() {
return !this.closed && Boolean(this.session);
@@ -166,12 +219,21 @@ export class PortalCaptureShortcut {
this.close();
};
- private async wait(promise: Promise, timeoutMs = 5_000): Promise {
+ private assertCurrent(stopped: PromiseWithResolvers) {
+ if (this.closed) throw new Error("Capture shortcut registration closed.");
+ if (stopped !== this.stopped) throw new Error("The desktop shortcut service restarted.");
+ }
+
+ private async wait(
+ promise: Promise,
+ timeoutMs = 5_000,
+ stopped = this.stopped,
+ ): Promise {
let timer: ReturnType | undefined;
try {
- return await Promise.race([
+ const result = await Promise.race([
promise,
- this.stopped,
+ stopped.promise,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Shortcut permission request timed out. Try again.")),
@@ -179,22 +241,25 @@ export class PortalCaptureShortcut {
);
}),
]);
+ this.assertCurrent(stopped);
+ return result;
} finally {
clearTimeout(timer);
}
}
- private async call(message: MessageLike) {
- if (this.closed) throw new Error("Capture shortcut registration closed.");
- const reply = await this.wait(this.bus.call(new Message(message)));
+ private async call(message: MessageLike, stopped = this.stopped) {
+ this.assertCurrent(stopped);
+ const reply = await this.wait(this.bus.call(new Message(message)), 5_000, stopped);
+ this.assertCurrent(stopped);
if (!reply) throw new Error("Missing shortcut portal reply.");
return reply;
}
- private closeObject(path: string, iface: string) {
+ private closeObject(path: string, iface: string, owner = this.owner) {
this.bus.send(
new Message({
- destination: this.owner,
+ destination: owner,
path,
interface: iface,
member: "Close",
@@ -209,10 +274,35 @@ export class PortalCaptureShortcut {
message.sender === "org.freedesktop.DBus" &&
message.interface === DBUS &&
message.member === "NameOwnerChanged" &&
+ message.signature === "sss" &&
message.body[0] === PORTAL &&
- message.body[1] === this.owner
+ (!this.owner || message.body[1] === this.owner)
) {
- this.failed(new Error("The desktop shortcut service restarted. Retry the shortcut request."));
+ const recovering =
+ this.reconnecting ||
+ this.state.shortcutRegistered ||
+ this.state.shortcutActionRegistered === true;
+ if (!recovering && this.namespace) {
+ this.failed(
+ new Error("The desktop shortcut service restarted. Retry the shortcut request."),
+ );
+ return;
+ }
+ this.reconnecting = recovering;
+ this.stopped.reject(new Error("The desktop shortcut service restarted."));
+ this.stopped = Promise.withResolvers();
+ void this.stopped.promise.catch(() => undefined);
+ // A replaced owner can still be alive. Release its objects before switching owners.
+ this.releaseSession();
+ this.owner = string(message.body[2]);
+ if (recovering)
+ this.update({
+ shortcutRegistered: false,
+ shortcutPending: true,
+ shortcutCanRetry: false,
+ shortcutMessage: "Reconnecting to the desktop shortcut service…",
+ });
+ this.register();
return;
}
if (!this.owner || message.sender !== this.owner) return;
@@ -262,53 +352,83 @@ export class PortalCaptureShortcut {
member: string,
signature: string,
body: unknown[],
- options: Record> = {},
+ options: Record>,
+ stopped: PromiseWithResolvers,
) {
+ this.assertCurrent(stopped);
+ const owner = this.owner;
+ const namespace = this.namespace;
const token = `t3_${NodeCrypto.randomUUID().replaceAll("-", "")}`;
- const expectedPath = this.namespace + token;
- let resolve!: (body: unknown) => void;
- const response = new Promise((done) => {
- resolve = done;
- });
- this.pending = { path: expectedPath, resolve };
+ const expectedPath = namespace + token;
+ const response = Promise.withResolvers>();
+ void response.promise.catch(() => undefined);
+ const pending = {
+ path: expectedPath,
+ resolve: (body: unknown) => {
+ try {
+ const result = decodeResponse(body);
+ // Owner changes can arrive before the request continuation consumes this response.
+ if (member === "CreateSession" && result[0] === 0) {
+ const session = decodeSession(result[1]).session_handle.value;
+ if (!session.startsWith(namespace.replace("/request/", "/session/")))
+ throw new Error("Invalid shortcut session handle.");
+ this.session = session;
+ }
+ if (member === "BindShortcuts") {
+ if (result[0] === 0) this.bound(decodeShortcutsResult(result[1]).shortcuts.value);
+ else {
+ this.reconnecting = false;
+ this.update({
+ shortcutRegistered: false,
+ shortcutPending: false,
+ shortcutMessage:
+ this.version >= 2
+ ? "Shortcut permission wasn't granted. Open shortcut permissions to allow it."
+ : "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.",
+ });
+ }
+ }
+ response.resolve(result);
+ } catch (error) {
+ response.reject(error);
+ }
+ },
+ };
+ this.pending = pending;
this.responses.clear();
let completed = false;
try {
- const reply = await this.call({
- destination: this.owner,
- path: PATH,
- interface: SHORTCUTS,
- member,
- signature: signature + "a{sv}",
- body: [...body, { ...options, handle_token: new Variant("s", token) }],
- });
+ const reply = await this.call(
+ {
+ destination: owner,
+ path: PATH,
+ interface: SHORTCUTS,
+ member,
+ signature: signature + "a{sv}",
+ body: [...body, { ...options, handle_token: new Variant("s", token) }],
+ },
+ stopped,
+ );
+ this.assertCurrent(stopped);
const handle = string(reply.body[0]);
- if (!handle.startsWith(this.namespace)) throw new Error("Invalid shortcut request handle.");
- this.pending.path = handle;
- if (this.responses.has(handle)) resolve(this.responses.get(handle));
- const [status, results] = decodeResponse(
- await this.wait(response, member === "BindShortcuts" ? 120_000 : 5_000),
+ if (!handle.startsWith(namespace)) throw new Error("Invalid shortcut request handle.");
+ pending.path = handle;
+ if (this.responses.has(handle)) pending.resolve(this.responses.get(handle));
+ const [status] = await this.wait(
+ response.promise,
+ member === "BindShortcuts" ? 120_000 : 5_000,
+ stopped,
);
+ this.assertCurrent(stopped);
completed = true;
- if (status !== 0) {
- if (member === "BindShortcuts") {
- this.update({
- shortcutRegistered: false,
- shortcutPending: false,
- shortcutMessage:
- this.version >= 2
- ? "Shortcut permission wasn't granted. Open shortcut permissions to allow it."
- : "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.",
- });
- return undefined;
- }
+ if (status !== 0 && member !== "BindShortcuts")
throw new Error("Your desktop could not create a capture shortcut session.");
- }
- return results;
} finally {
- if (!completed && !this.closed && this.pending) this.closeObject(this.pending.path, REQUEST);
- this.pending = undefined;
- this.responses.clear();
+ if (this.pending === pending) {
+ if (!completed && !this.closed) this.closeObject(pending.path, REQUEST, owner);
+ this.pending = undefined;
+ this.responses.clear();
+ }
}
}
@@ -338,86 +458,119 @@ export class PortalCaptureShortcut {
});
}
- private async initialize(appId: string, shortcut: SnapShotKeyChord) {
- const trigger = portalShortcutTrigger(shortcut);
- if (!process.env.FLATPAK_ID && !process.env.SNAP) {
- await this.call({
- destination: PORTAL,
- path: PATH,
- interface: "org.freedesktop.host.portal.Registry",
- member: "Register",
- signature: "sa{sv}",
- body: [appId, {}],
- }).catch((error: unknown) => {
- if (
- !(
- error instanceof DBusError &&
- ["UnknownMethod", "UnknownInterface"].some(
- (name) => error.type === `${DBUS}.Error.${name}`,
- )
- )
- )
- throw error;
- });
- }
- const owner = await this.call({
+ private async initialize(stopped: PromiseWithResolvers) {
+ const trigger = portalShortcutTrigger(this.shortcut);
+ const ownerRequest = {
destination: DBUS,
path: "/org/freedesktop/DBus",
interface: DBUS,
member: "GetNameOwner",
signature: "s",
body: [PORTAL],
+ };
+ const owner = await this.call(ownerRequest, stopped).catch(async (error: unknown) => {
+ this.assertCurrent(stopped);
+ if (!(error instanceof DBusError && error.type === `${DBUS}.Error.NameHasNoOwner`))
+ throw error;
+ await this.call(
+ {
+ destination: DBUS,
+ path: "/org/freedesktop/DBus",
+ interface: DBUS,
+ member: "StartServiceByName",
+ signature: "su",
+ body: [PORTAL, 0],
+ },
+ stopped,
+ );
+ this.assertCurrent(stopped);
+ return this.call(ownerRequest, stopped);
});
+ this.assertCurrent(stopped);
this.owner = string(owner.body[0]);
this.namespace = `${PATH}/request/${owner.destination.slice(1).replaceAll(".", "_")}/`;
- const version = await this.call({
- destination: this.owner,
- path: PATH,
- interface: "org.freedesktop.DBus.Properties",
- member: "Get",
- signature: "ss",
- body: [SHORTCUTS, "version"],
- });
+ if (!process.env.FLATPAK_ID && !process.env.SNAP) {
+ let registration = this.registrations.get(this.owner);
+ if (!registration) {
+ // Registry permits one registration per connection and portal, even across owner changes.
+ registration = this.bus
+ .call(
+ new Message({
+ destination: this.owner,
+ path: PATH,
+ interface: "org.freedesktop.host.portal.Registry",
+ member: "Register",
+ signature: "sa{sv}",
+ body: [this.appId, {}],
+ }),
+ )
+ .then((reply) => {
+ if (!reply) throw new Error("Missing shortcut portal reply.");
+ })
+ .catch((error: unknown) => {
+ if (
+ !(
+ error instanceof DBusError &&
+ ["UnknownMethod", "UnknownInterface"].some(
+ (name) => error.type === `${DBUS}.Error.${name}`,
+ )
+ )
+ )
+ throw error;
+ });
+ this.registrations.set(this.owner, registration);
+ }
+ await this.wait(registration, 5_000, stopped);
+ this.assertCurrent(stopped);
+ }
+ const version = await this.call(
+ {
+ destination: this.owner,
+ path: PATH,
+ interface: "org.freedesktop.DBus.Properties",
+ member: "Get",
+ signature: "ss",
+ body: [SHORTCUTS, "version"],
+ },
+ stopped,
+ );
+ this.assertCurrent(stopped);
this.version = decodeVersion(version.body[0]).value;
- for (const rule of [
- `type='signal',sender='${this.owner}',path_namespace='${PATH}'`,
- `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`,
- ])
- await this.call({
- destination: DBUS,
- path: "/org/freedesktop/DBus",
- interface: DBUS,
- member: "AddMatch",
- signature: "s",
- body: [rule],
- });
- const created = await this.request("CreateSession", "", [], {
- session_handle_token: new Variant(
- "s",
- `t3_capture_${NodeCrypto.randomUUID().replaceAll("-", "")}`,
- ),
- });
- const session = decodeSession(created).session_handle.value;
- const sessionNamespace = this.namespace.replace("/request/", "/session/");
- if (!session.startsWith(sessionNamespace)) throw new Error("Invalid shortcut session handle.");
- this.session = session;
+ await this.request(
+ "CreateSession",
+ "",
+ [],
+ {
+ session_handle_token: new Variant(
+ "s",
+ `t3_capture_${NodeCrypto.randomUUID().replaceAll("-", "")}`,
+ ),
+ },
+ stopped,
+ );
+ this.assertCurrent(stopped);
this.shortcutId = this.managedByHyprland
? HYPRLAND_CAPTURE_ACTION
: `t3-snap-shot-${NodeCrypto.createHash("sha256").update(trigger).digest("hex").slice(0, 16)}`;
// Every session must bind, even when the desktop remembers this shortcut's approval.
- const bound = await this.request("BindShortcuts", "oa(sa{sv})s", [
- this.session,
+ await this.request(
+ "BindShortcuts",
+ "oa(sa{sv})s",
[
+ this.session,
[
- this.shortcutId,
- {
- description: new Variant("s", "Capture a window"),
- ...(!this.managedByHyprland ? { preferred_trigger: new Variant("s", trigger) } : {}),
- },
+ [
+ this.shortcutId,
+ {
+ description: new Variant("s", "Capture a window"),
+ ...(!this.managedByHyprland ? { preferred_trigger: new Variant("s", trigger) } : {}),
+ },
+ ],
],
+ "",
],
- "",
- ]);
- if (bound) this.bound(decodeShortcutsResult(bound).shortcuts.value);
+ {},
+ stopped,
+ );
}
}