SDL: misc: Use the OpenURI D-Bus portal for opening URLs

From 682da4ee9885c28bccf0dee832f3089b0abca547 Mon Sep 17 00:00:00 2001
From: Frank Praznik <[EMAIL REDACTED]>
Date: Sat, 4 Apr 2026 22:28:06 -0400
Subject: [PATCH] misc: Use the OpenURI D-Bus portal for opening URLs

This works inside of containers, and supports passing an activation token with the request, which is needed on Wayland to transfer focus to the browser.
---
 src/core/linux/SDL_dbus.c           |  57 ++++++++++++
 src/core/linux/SDL_dbus.h           |   2 +
 src/misc/unix/SDL_sysurl.c          |  39 ++++++++
 src/video/wayland/SDL_waylandutil.c | 134 ++++++++++++++++++++++++++++
 src/video/wayland/SDL_waylandutil.h |  34 +++++++
 test/testurl.c                      |  52 ++++++++---
 6 files changed, 307 insertions(+), 11 deletions(-)
 create mode 100644 src/video/wayland/SDL_waylandutil.c
 create mode 100644 src/video/wayland/SDL_waylandutil.h

diff --git a/src/core/linux/SDL_dbus.c b/src/core/linux/SDL_dbus.c
index 2d083e8f29bdc..df114e0e19189 100644
--- a/src/core/linux/SDL_dbus.c
+++ b/src/core/linux/SDL_dbus.c
@@ -471,6 +471,63 @@ static bool SDL_DBus_AppendDictWithKeyValue(DBusMessageIter *iterInit, const cha
    return SDL_DBus_AppendDictWithKeysAndValues(iterInit, keys, values, 1);
 }
 
+bool SDL_DBus_OpenURI(const char *uri, const char *window_id, const char *activation_token)
+{
+    const char *bus_name = "org.freedesktop.portal.Desktop";
+    const char *path = "/org/freedesktop/portal/desktop";
+    const char *interface = "org.freedesktop.portal.OpenURI";
+    DBusMessageIter iterInit;
+    bool ret = false;
+
+    if (!dbus.session_conn) {
+        /* We either lost connection to the session bus or were not able to
+         * load the D-Bus library at all.
+         */
+        return false;
+    }
+
+    DBusMessage *msg = dbus.message_new_method_call(bus_name, path, interface, "OpenURI");
+    if (!msg) {
+        return false;
+    }
+
+    if (!window_id) {
+        window_id = "";
+    }
+    if (!dbus.message_append_args(msg, DBUS_TYPE_STRING, &window_id, DBUS_TYPE_STRING, &uri, DBUS_TYPE_INVALID)) {
+        goto done;
+    }
+
+    dbus.message_iter_init_append(msg, &iterInit);
+
+    if (activation_token) {
+        if (!SDL_DBus_AppendDictWithKeyValue(&iterInit, "activation_token", activation_token)) {
+            goto done;
+        }
+    } else {
+        // The array must be in the parameter list, even if empty.
+        DBusMessageIter iterArray;
+        if (!dbus.message_iter_open_container(&iterInit, DBUS_TYPE_ARRAY, "{sv}", &iterArray)) {
+            goto done;
+        }
+        if (!dbus.message_iter_close_container(&iterInit, &iterArray)) {
+            goto done;
+        }
+    }
+
+    {
+        DBusMessage *reply = dbus.connection_send_with_reply_and_block(dbus.session_conn, msg, -1, NULL);
+        if (reply) {
+            ret = true;
+            dbus.message_unref(reply);
+        }
+    }
+
+done:
+    dbus.message_unref(msg);
+    return ret;
+}
+
 bool SDL_DBus_ScreensaverInhibit(bool inhibit)
 {
     const char *default_inhibit_reason = "Playing a game";
diff --git a/src/core/linux/SDL_dbus.h b/src/core/linux/SDL_dbus.h
index e6f81b48acea2..568dd74bb2c20 100644
--- a/src/core/linux/SDL_dbus.h
+++ b/src/core/linux/SDL_dbus.h
@@ -119,6 +119,8 @@ extern void SDL_DBus_FreeReply(DBusMessage **saved_reply);
 extern void SDL_DBus_ScreensaverTickle(void);
 extern bool SDL_DBus_ScreensaverInhibit(bool inhibit);
 
+extern bool SDL_DBus_OpenURI(const char *uri, const char *window_id, const char *activation_token);
+
 extern void SDL_DBus_PumpEvents(void);
 extern char *SDL_DBus_GetLocalMachineId(void);
 
diff --git a/src/misc/unix/SDL_sysurl.c b/src/misc/unix/SDL_sysurl.c
index 8745576e2ca1a..32e2d476ead98 100644
--- a/src/misc/unix/SDL_sysurl.c
+++ b/src/misc/unix/SDL_sysurl.c
@@ -33,8 +33,42 @@
 extern char **environ;
 #endif
 
+#ifdef HAVE_DBUS_DBUS_H
+#include "../../core/linux/SDL_dbus.h"
+#endif
+
+#ifdef SDL_VIDEO_DRIVER_WAYLAND
+#include "../../video/wayland/SDL_waylandutil.h"
+#endif
+
+// Wayland requires an activation token for the browser to take focus.
+static void GetActivationToken(char **token, char **window_id)
+{
+#ifdef SDL_VIDEO_DRIVER_WAYLAND
+    SDL_VideoDevice *vid = SDL_GetVideoDevice();
+
+    if (vid && SDL_strcmp(vid->name, "wayland") == 0) {
+        Wayland_GetActivationTokenForExport(vid, token, window_id);
+    }
+#endif
+}
+
 bool SDL_SYS_OpenURL(const char *url)
 {
+    char *activation_token = NULL;
+    char *window_id = NULL;
+
+    GetActivationToken(&activation_token, &window_id);
+
+    // Prefer the D-Bus portal, if available.
+#ifdef HAVE_DBUS_DBUS_H
+    if (SDL_DBus_OpenURI(url, window_id, activation_token)) {
+        SDL_free(activation_token);
+        SDL_free(window_id);
+        return true;
+    }
+#endif
+
     const char *args[] = { "xdg-open", url, NULL };
     SDL_Environment *env = NULL;
     SDL_Process *process = NULL;
@@ -47,6 +81,9 @@ bool SDL_SYS_OpenURL(const char *url)
 
     // Clear LD_PRELOAD so Chrome opens correctly when this application is launched by Steam
     SDL_UnsetEnvironmentVariable(env, "LD_PRELOAD");
+    if (activation_token) {
+        SDL_SetEnvironmentVariable(env, "XDG_ACTIVATION_TOKEN", activation_token, false);
+    }
 
     SDL_PropertiesID props = SDL_CreateProperties();
     if (!props) {
@@ -64,6 +101,8 @@ bool SDL_SYS_OpenURL(const char *url)
     result = true;
 
 done:
+    SDL_free(activation_token);
+    SDL_free(window_id);
     SDL_DestroyEnvironment(env);
     SDL_DestroyProcess(process);
 
diff --git a/src/video/wayland/SDL_waylandutil.c b/src/video/wayland/SDL_waylandutil.c
new file mode 100644
index 0000000000000..d6461cb07130a
--- /dev/null
+++ b/src/video/wayland/SDL_waylandutil.c
@@ -0,0 +1,134 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+#include "SDL_internal.h"
+
+#ifdef SDL_VIDEO_DRIVER_WAYLAND
+
+#include "SDL_waylandevents_c.h"
+#include "SDL_waylandutil.h"
+#include "xdg-activation-v1-client-protocol.h"
+
+#define WAYLAND_HANDLE_PREFIX "wayland:"
+
+typedef struct Wayland_ActivationParams
+{
+    char **token;
+    bool done;
+} Wayland_ActivationParams;
+
+static void handle_xdg_activation_done(void *data, struct xdg_activation_token_v1 *xdg_activation_token_v1, const char *token)
+{
+    Wayland_ActivationParams *activation_params = (Wayland_ActivationParams *)data;
+    *activation_params->token = SDL_strdup(token);
+    activation_params->done = true;
+
+    xdg_activation_token_v1_destroy(xdg_activation_token_v1);
+}
+
+static const struct xdg_activation_token_v1_listener xdg_activation_listener = {
+    handle_xdg_activation_done
+};
+
+bool Wayland_GetActivationTokenForExport(SDL_VideoDevice *_this, char **token, char **window_id)
+{
+    if (!_this || !token) {
+        return false;
+    }
+
+    SDL_VideoData *viddata = _this->internal;
+
+    SDL_WaylandSeat *seat = viddata->last_implicit_grab_seat;
+    SDL_WindowData *focus = NULL;
+
+    if (seat) {
+        focus = seat->keyboard.focus;
+        if (!focus) {
+            focus = seat->pointer.focus;
+        }
+    }
+
+    const char *xdg_activation_token = SDL_getenv("XDG_ACTIVATION_TOKEN");
+    if (xdg_activation_token) {
+        *token = SDL_strdup(xdg_activation_token);
+        if (!*token) {
+            return false;
+        }
+
+        // Unset the envvar after claiming the token.
+        SDL_unsetenv_unsafe("XDG_ACTIVATION_TOKEN");
+    } else if (viddata->activation_manager) {
+        struct wl_surface *requesting_surface = focus ? focus->surface : NULL;
+        Wayland_ActivationParams params = {
+            .token = token,
+            .done = false
+        };
+
+        struct wl_event_queue *activation_token_queue = Wayland_DisplayCreateQueue(viddata->display, "SDL Activation Token Generation Queue");
+
+        struct wl_proxy *activation_manager_wrapper = WAYLAND_wl_proxy_create_wrapper(viddata->activation_manager);
+        WAYLAND_wl_proxy_set_queue(activation_manager_wrapper, activation_token_queue);
+        struct xdg_activation_token_v1 *activation_token = xdg_activation_v1_get_activation_token((struct xdg_activation_v1 *)activation_manager_wrapper);
+        xdg_activation_token_v1_add_listener(activation_token, &xdg_activation_listener, &params);
+
+        if (requesting_surface) {
+            // This specifies the surface from which the activation request is originating, not the activation target surface.
+            xdg_activation_token_v1_set_surface(activation_token, requesting_surface);
+        }
+        if (seat && seat->wl_seat) {
+            xdg_activation_token_v1_set_serial(activation_token, seat->last_implicit_grab_serial, seat->wl_seat);
+        }
+        if (focus && focus->app_id) {
+            // Set the app ID for external use.
+            xdg_activation_token_v1_set_app_id(activation_token, focus->app_id);
+        }
+        xdg_activation_token_v1_commit(activation_token);
+
+        while (!params.done) {
+            WAYLAND_wl_display_dispatch_queue(viddata->display, activation_token_queue);
+        }
+        WAYLAND_wl_proxy_wrapper_destroy(activation_manager_wrapper);
+        WAYLAND_wl_event_queue_destroy(activation_token_queue);
+
+        if (!*token) {
+            return false;
+        }
+    }
+
+    if (focus && window_id) {
+        const char *id = SDL_GetStringProperty(focus->sdlwindow->props, SDL_PROP_WINDOW_WAYLAND_XDG_TOPLEVEL_EXPORT_HANDLE_STRING, NULL);
+        if (id) {
+            const size_t len = SDL_strlen(id) + sizeof(WAYLAND_HANDLE_PREFIX) + 1;
+            *window_id = SDL_malloc(len);
+            if (!*window_id) {
+                SDL_free(*token);
+                *token = NULL;
+                return false;
+            }
+
+            SDL_strlcpy(*window_id, WAYLAND_HANDLE_PREFIX, len);
+            SDL_strlcat(*window_id, id, len);
+        }
+    }
+
+    return true;
+}
+
+#endif // SDL_VIDEO_DRIVER_WAYLAND
diff --git a/src/video/wayland/SDL_waylandutil.h b/src/video/wayland/SDL_waylandutil.h
new file mode 100644
index 0000000000000..daad6effb3479
--- /dev/null
+++ b/src/video/wayland/SDL_waylandutil.h
@@ -0,0 +1,34 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+#include "SDL_internal.h"
+
+#ifndef SDL_waylandutil_h_
+#define SDL_waylandutil_h_
+
+#include "../SDL_sysvideo.h"
+
+/**
+ * Generates an activation token that can be passed to external clients.
+ * The token and window_id parameters must be freed with SDL_free() when done.
+ */
+extern bool Wayland_GetActivationTokenForExport(SDL_VideoDevice *_this, char **token, char **window_id);
+
+#endif // SDL_waylandutil_h_
diff --git a/test/testurl.c b/test/testurl.c
index 56b1904e6adea..a6fd9c5ec41a5 100644
--- a/test/testurl.c
+++ b/test/testurl.c
@@ -25,29 +25,27 @@ static void tryOpenURL(const char *url)
 
 int main(int argc, char **argv)
 {
-    int i;
-    SDLTest_CommonState *state;
-
-    state = SDLTest_CommonCreateState(argv, 0);
-
-    if (!SDL_Init(SDL_INIT_VIDEO)) {
-        SDL_Log("SDL_Init failed: %s", SDL_GetError());
-        return 1;
-    }
+    const char *url = NULL;
+    SDLTest_CommonState *state = SDLTest_CommonCreateState(argv, 0);
+    bool use_gui = false;
 
     /* Parse commandline */
-    for (i = 1; i < argc;) {
+    for (int i = 1; i < argc;) {
         int consumed;
 
         consumed = SDLTest_CommonArg(state, i);
         if (consumed == 0) {
             if (argv[i][0] != '-') {
-                tryOpenURL(argv[i]);
+                url = argv[i];
+                consumed = 1;
+            } else if (SDL_strcasecmp(argv[i], "--gui") == 0) {
+                use_gui = true;
                 consumed = 1;
             }
         }
         if (consumed <= 0) {
             static const char *options[] = {
+                "[--gui]"
                 "[URL [...]]",
                 NULL,
             };
@@ -57,6 +55,38 @@ int main(int argc, char **argv)
         i += consumed;
     }
 
+    state->flags = SDL_INIT_VIDEO;
+    if (!SDLTest_CommonInit(state)) {
+        return SDL_APP_FAILURE;
+    }
+
+    if (!use_gui) {
+        tryOpenURL(url);
+    } else {
+        SDL_Event event;
+        bool quit = false;
+
+        while (!quit) {
+            while (SDL_PollEvent(&event)) {
+                if (event.type == SDL_EVENT_KEY_DOWN) {
+                    if (event.key.key == SDLK_SPACE) {
+                        tryOpenURL(url);
+                    } else if (event.key.key == SDLK_ESCAPE) {
+                        quit = true;
+                    }
+                } else if (event.type == SDL_EVENT_QUIT) {
+                    quit = true;
+                }
+            }
+
+            SDL_SetRenderDrawColor(state->renderers[0], 0, 0, 0, 255);
+            SDL_RenderClear(state->renderers[0]);
+            SDL_SetRenderDrawColor(state->renderers[0], 255, 255, 255, 255);
+            SDL_RenderDebugTextFormat(state->renderers[0], 8.f, 16.f, "Press space to open %s", url);
+            SDL_RenderPresent(state->renderers[0]);
+        }
+    }
+
     SDL_Quit();
     SDLTest_CommonDestroyState(state);
     return 0;