aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig
blob: 4019148250d48f72cf2515e68826f1a70d7866a0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
//! Use DBus to call the XDG Desktop Portal to open an URI.
//! See: https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html#org-freedesktop-portal-openuri-openuri
const OpenURI = @This();

const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;

const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const global = @import("../../../global.zig");

const App = @import("../App.zig");
const portal = @import("../portal.zig");
const apprt = @import("../../../apprt.zig");

const log = std.log.scoped(.openuri);

/// The GTK app that we "belong" to.
app: *App,

/// Connection to the D-Bus session bus that we'll use for all of our messaging.
dbus: ?*gio.DBusConnection = null,

/// Mutex to protect modification of the entries map or the cleanup timer.
mutex: std.Io.Mutex = .init,

/// Map to store data about any in-flight calls to the portal.
entries: std.AutoArrayHashMapUnmanaged(usize, *Entry) = .empty,

/// Used to manage a timer to clean up any orphan entries in the map.
cleanup_timer: ?c_uint = null,

/// Set to false during shutdown so callbacks stop touching internal state.
alive: bool = true,

const RequestData = struct {
    open_uri: *OpenURI,
    token: usize,
    kind: apprt.action.OpenUrl.Kind,
    uri: [:0]const u8,
    request_path: [:0]const u8,

    pub fn init(
        alloc: Allocator,
        open_uri: *OpenURI,
        token: usize,
        kind: apprt.action.OpenUrl.Kind,
        uri: []const u8,
        request_path: []const u8,
    ) Allocator.Error!*RequestData {
        const uri_copy = try alloc.dupeZ(u8, uri);
        errdefer alloc.free(uri_copy);

        const request_path_copy = try alloc.dupeZ(u8, request_path);
        errdefer alloc.free(request_path_copy);

        const data = try alloc.create(RequestData);
        errdefer alloc.destroy(data);

        data.* = .{
            .open_uri = open_uri,
            .token = token,
            .kind = kind,
            .uri = uri_copy,
            .request_path = request_path_copy,
        };

        return data;
    }

    pub fn deinit(self: *const RequestData, alloc: Allocator) void {
        alloc.free(self.uri);
        alloc.free(self.request_path);
    }
};

/// Data about any in-flight calls to the portal.
pub const Entry = struct {
    /// When the request started.
    start: std.Io.Timestamp,
    /// A token used by the portal to identify requests and responses. The
    /// actual format of the token does not really matter as long as it can be
    /// used as part of a D-Bus object path. `usize` was chosen since it's easy
    /// to hash and to generate random tokens.
    token: usize,
    /// The "kind" of URI. Unused here, but we may need to pass it on to the
    /// fallback URL opener if the D-Bus method fails.
    kind: apprt.action.OpenUrl.Kind,
    /// A copy of the URI that we are opening. We need our own copy since the
    /// method calls are asynchronous and the original may have been freed by
    /// the time we need it.
    uri: [:0]const u8,
    /// Used to manage a subscription to a D-Bus signal, which is how the XDG
    /// Portal reports results of the method call.
    subscription: ?c_uint = null,

    pub fn deinit(self: *const Entry, alloc: Allocator) void {
        alloc.free(self.uri);
    }
};

pub const Errors = error{
    /// Could not get a D-Bus connection
    DBusConnectionRequired,
    /// The D-Bus connection did not have a unique name. This _should_ be
    /// impossible, but is handled for safety's sake.
    NoDBusUniqueName,
    /// The system was unable to give us the time.
    TimerUnavailable,
};

pub fn init(app: *App) OpenURI {
    return .{
        .app = app,
    };
}

pub fn setDbusConnection(self: *OpenURI, dbus: ?*gio.DBusConnection) void {
    self.dbus = dbus;
}

pub fn deinit(self: *OpenURI) void {
    const alloc = self.app.app.allocator();

    self.mutex.lockUncancelable(global.io());
    defer self.mutex.unlock(global.io());

    if (!self.alive) return;
    self.alive = false;

    self.stopCleanupTimer();

    for (self.entries.entries.items(.value)) |entry| {
        self.unsubscribeFromResponse(entry);
        destroyEntry(alloc, entry);
    }

    self.entries.deinit(alloc);
    self.entries = .empty;
    self.dbus = null;
}

/// Send the D-Bus method call to the XDG Desktop portal. The result of the
/// method call will be reported asynchronously.
pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || Errors)!void {
    const alloc = self.app.app.allocator();
    const dbus = self.dbus orelse return error.DBusConnectionRequired;

    const token = portal.generateToken();
    const request_path = try portal.getRequestPath(alloc, dbus, token);
    defer alloc.free(request_path);

    const request = try RequestData.init(alloc, self, token, value.kind, value.url, request_path);
    errdefer {
        request.deinit(alloc);
        alloc.destroy(request);
    }

    self.mutex.lockUncancelable(global.io());
    defer self.mutex.unlock(global.io());

    // Create an entry that is used to track the results of the D-Bus method
    // call.
    const entry = entry: {
        const entry = try alloc.create(Entry);
        errdefer alloc.destroy(entry);
        entry.* = .{
            .start = std.Io.Timestamp.now(global.io(), .awake),
            .token = token,
            .kind = value.kind,
            .uri = try alloc.dupeZ(u8, value.url),
        };
        errdefer entry.deinit(alloc);
        try self.entries.putNoClobber(alloc, token, entry);
        break :entry entry;
    };

    errdefer {
        _ = self.entries.swapRemove(token);
        destroyEntry(alloc, entry);
    }

    self.startCleanupTimer();
    self.subscribeToResponse(entry, dbus, request_path.ptr);
    self.sendRequest(entry, dbus, request);
}

/// Subscribe to the D-Bus signal that will contain the results of our method
/// call to the portal. This must be called with the mutex locked.
fn subscribeToResponse(
    self: *OpenURI,
    entry: *Entry,
    dbus: *gio.DBusConnection,
    request_path: [*:0]const u8,
) void {
    assert(!self.mutex.tryLock());

    if (entry.subscription != null) return;

    entry.subscription = dbus.signalSubscribe(
        null,
        "org.freedesktop.portal.Request",
        "Response",
        request_path,
        null,
        .{},
        responseReceived,
        self,
        null,
    );
}

/// Unsubscribe to the D-Bus signal that contains the result of the method call.
/// This will prevent a response from being processed multiple times. This must
/// be called when the mutex is locked.
fn unsubscribeFromResponse(self: *OpenURI, entry: *Entry) void {
    assert(!self.mutex.tryLock());

    // Unsubscribe from the response signal
    if (entry.subscription) |subscription| {
        const dbus = self.dbus orelse {
            entry.subscription = null;
            log.warn("unable to unsubscribe open uri response without dbus connection", .{});
            return;
        };
        dbus.signalUnsubscribe(subscription);
        entry.subscription = null;
    }
}

fn destroyEntry(alloc: Allocator, entry: *Entry) void {
    entry.deinit(alloc);
    alloc.destroy(entry);
}

fn failRequest(self: *OpenURI, token: usize) ?*Entry {
    self.mutex.lockUncancelable(global.io());
    defer self.mutex.unlock(global.io());

    if (!self.alive) return null;

    const entry = (self.entries.fetchSwapRemove(token) orelse return null).value;
    self.unsubscribeFromResponse(entry);
    return entry;
}

fn failRequestAndFallback(self: *OpenURI, request: *const RequestData) void {
    const alloc = self.app.app.allocator();
    const entry = self.failRequest(request.token) orelse return;
    defer destroyEntry(alloc, entry);

    self.app.app.openUrlFallback(request.kind, request.uri);
}

/// Send the D-Bus method call to the portal. The mutex must be locked when this
/// is called.
fn sendRequest(
    self: *OpenURI,
    entry: *Entry,
    dbus: *gio.DBusConnection,
    request: *RequestData,
) void {
    assert(!self.mutex.tryLock());

    const payload = payload: {
        const builder_type = glib.VariantType.new("(ssa{sv})");
        defer builder_type.free();

        // Initialize our builder to build up our parameters
        var builder: glib.VariantBuilder = undefined;
        builder.init(builder_type);

        // parent window - empty string means we have no window
        builder.add("s", "");

        // URI to open
        builder.add("s", entry.uri.ptr);

        // Options
        {
            const options = glib.VariantType.new("a{sv}");
            defer options.free();

            builder.open(options);
            defer builder.close();

            {
                const option = glib.VariantType.new("{sv}");
                defer option.free();

                builder.open(option);
                defer builder.close();

                builder.add("s", "handle_token");

                var token_buf: portal.TokenBuffer = undefined;
                const token = portal.formatToken(&token_buf, entry.token);

                const handle_token = glib.Variant.newString(token.ptr);
                builder.add("v", handle_token);
            }
            {
                const option = glib.VariantType.new("{sv}");
                defer option.free();

                builder.open(option);
                defer builder.close();

                builder.add("s", "writable");

                const writable = glib.Variant.newBoolean(@intFromBool(false));
                builder.add("v", writable);
            }
            {
                const option = glib.VariantType.new("{sv}");
                defer option.free();

                builder.open(option);
                defer builder.close();

                builder.add("s", "ask");

                const ask = glib.Variant.newBoolean(@intFromBool(false));
                builder.add("v", ask);
            }
        }

        break :payload builder.end();
    };

    // We're expecting an object path back from the method call.
    const reply_type = glib.VariantType.new("(o)");
    defer reply_type.free();

    dbus.call(
        "org.freedesktop.portal.Desktop",
        "/org/freedesktop/portal/desktop",
        "org.freedesktop.portal.OpenURI",
        "OpenURI",
        payload,
        reply_type,
        .{},
        -1,
        null,
        requestCallback,
        request,
    );
}

/// Process the result of the original method call. Receiving this result does
/// not indicate that the that the method call succeeded but it may contain an
/// error message that is useful to log for debugging purposes.
fn requestCallback(
    source: ?*gobject.Object,
    result: *gio.AsyncResult,
    ud: ?*anyopaque,
) callconv(.c) void {
    const request: *RequestData = @ptrCast(@alignCast(ud orelse return));
    const self = request.open_uri;
    const alloc = self.app.app.allocator();
    defer {
        request.deinit(alloc);
        alloc.destroy(request);
    }

    const dbus = gobject.ext.cast(gio.DBusConnection, source orelse {
        log.err("Open URI request finished without a D-Bus source object", .{});
        self.failRequestAndFallback(request);
        return;
    }) orelse {
        log.err("Open URI request finished with an unexpected source object", .{});
        self.failRequestAndFallback(request);
        return;
    };

    var err_: ?*glib.Error = null;
    defer if (err_) |err| err.free();

    const reply_ = dbus.callFinish(result, &err_);

    if (err_) |err| {
        log.err("Open URI request failed={s} ({})", .{
            err.f_message orelse "(unknown)",
            err.f_code,
        });
        self.failRequestAndFallback(request);
        return;
    }

    const reply = reply_ orelse {
        log.err("D-Bus method call returned a null value!", .{});
        self.failRequestAndFallback(request);
        return;
    };
    defer reply.unref();

    const reply_type = glib.VariantType.new("(o)");
    defer reply_type.free();

    if (reply.isOfType(reply_type) == 0) {
        log.warn("Reply from D-Bus method call does not contain an object path!", .{});
        self.failRequestAndFallback(request);
        return;
    }

    var object_path: [*:0]const u8 = undefined;
    reply.get("(&o)", &object_path);

    const token = portal.parseRequestPathToken(std.mem.span(object_path)) orelse {
        log.warn("Unable to parse token from the object path {s}", .{object_path});
        self.failRequestAndFallback(request);
        return;
    };

    if (token != request.token) {
        log.warn("Open URI request returned mismatched token expected={x} actual={x}", .{
            request.token,
            token,
        });
        self.failRequestAndFallback(request);
        return;
    }

    self.mutex.lockUncancelable(global.io());
    defer self.mutex.unlock(global.io());

    if (!self.alive) return;

    const entry = self.entries.get(token) orelse return;
    if (std.mem.eql(u8, request.request_path, std.mem.span(object_path))) return;

    log.debug("updating open uri request path old={s} new={s}", .{
        request.request_path,
        object_path,
    });
    self.unsubscribeFromResponse(entry);
    self.subscribeToResponse(entry, dbus, object_path);
}

/// Handle the response signal from the portal. This should contain the actual
/// results of the method call (success or failure).
fn responseReceived(
    _: *gio.DBusConnection,
    _: ?[*:0]const u8,
    object_path: [*:0]const u8,
    _: [*:0]const u8,
    _: [*:0]const u8,
    params: *glib.Variant,
    ud: ?*anyopaque,
) callconv(.c) void {
    const self: *OpenURI = @ptrCast(@alignCast(ud orelse {
        log.err("OpenURI response received with null userdata", .{});
        return;
    }));

    const alloc = self.app.app.allocator();

    const token = portal.parseRequestPathToken(std.mem.span(object_path)) orelse {
        log.warn("invalid object path: {s}", .{std.mem.span(object_path)});
        return;
    };

    self.mutex.lockUncancelable(global.io());
    defer self.mutex.unlock(global.io());

    if (!self.alive) return;

    const entry = (self.entries.fetchSwapRemove(token) orelse {
        log.warn("no entry for token {x}", .{token});
        return;
    }).value;

    defer destroyEntry(alloc, entry);

    self.unsubscribeFromResponse(entry);

    var response: u32 = 0;
    var results: ?*glib.Variant = null;
    defer if (results) |variant| variant.unref();
    params.get("(u@a{sv})", &response, &results);

    switch (response) {
        0 => {
            log.debug("open uri successful", .{});
        },
        1 => {
            log.debug("open uri request was cancelled by the user", .{});
        },
        2 => {
            log.warn("open uri request ended unexpectedly", .{});
            self.app.app.openUrlFallback(entry.kind, entry.uri);
        },
        else => {
            log.err("unrecognized response code={}", .{response});
            self.app.app.openUrlFallback(entry.kind, entry.uri);
        },
    }
}

/// Wait this number of seconds and then clean up any orphaned entries.
const cleanup_timeout = 30;

/// If there is an active cleanup timer, cancel it. This must be called with the
/// mutex locked
fn stopCleanupTimer(self: *OpenURI) void {
    assert(!self.mutex.tryLock());

    if (self.cleanup_timer) |timer| {
        if (glib.Source.remove(timer) == 0) {
            log.warn("unable to remove cleanup timer source={d}", .{timer});
        }
        self.cleanup_timer = null;
    }
}

/// Start a timer to clean up any entries that have not received a timely
/// response. If there is already a timer it will be stopped and replaced with a
/// new one. This must be called with the mutex locked.
fn startCleanupTimer(self: *OpenURI) void {
    assert(!self.mutex.tryLock());

    self.stopCleanupTimer();
    self.cleanup_timer = glib.timeoutAddSeconds(cleanup_timeout + 1, cleanup, self);
}

/// The cleanup timer is used to free up any entries that may have failed
/// to get a response in a timely manner.
fn cleanup(ud: ?*anyopaque) callconv(.c) c_int {
    const self: *OpenURI = @ptrCast(@alignCast(ud orelse {
        log.warn("cleanup called with null userdata", .{});
        return @intFromBool(glib.SOURCE_REMOVE);
    }));

    const alloc = self.app.app.allocator();

    self.mutex.lockUncancelable(global.io());
    defer self.mutex.unlock(global.io());

    self.cleanup_timer = null;
    if (!self.alive) return @intFromBool(glib.SOURCE_REMOVE);

    loop: while (true) {
        for (self.entries.entries.items(.value)) |entry| {
            if (entry.start.untilNow(
                global.io(),
                .awake,
            ).toSeconds() > cleanup_timeout) {
                log.warn("open uri request timed out token={x}", .{entry.token});
                self.unsubscribeFromResponse(entry);
                _ = self.entries.swapRemove(entry.token);
                self.app.app.openUrlFallback(entry.kind, entry.uri);
                destroyEntry(alloc, entry);
                continue :loop;
            }
        }
        break :loop;
    }

    return @intFromBool(glib.SOURCE_REMOVE);
}