SDL: misc: Added SDL_EncodeURL.

From 507179ee74eeb7e549d47d15ac15fdc97486632c Mon Sep 17 00:00:00 2001
From: "Ryan C. Gordon" <[EMAIL REDACTED]>
Date: Mon, 7 Sep 2026 16:28:58 -0400
Subject: [PATCH] misc: Added SDL_EncodeURL.

Fixes #16254.
---
 include/SDL3/SDL_misc.h           | 43 ++++++++++++++++++++++++++++++
 src/dynapi/SDL_dynapi.exports     |  1 +
 src/dynapi/SDL_dynapi.sym         |  1 +
 src/dynapi/SDL_dynapi_overrides.h |  1 +
 src/dynapi/SDL_dynapi_procs.h     |  1 +
 src/misc/SDL_url.c                | 44 +++++++++++++++++++++++++++++++
 test/testurl.c                    | 31 ++++++++++++++++++----
 7 files changed, 117 insertions(+), 5 deletions(-)

diff --git a/include/SDL3/SDL_misc.h b/include/SDL3/SDL_misc.h
index 654c0055807ce..e202b2d366920 100644
--- a/include/SDL3/SDL_misc.h
+++ b/include/SDL3/SDL_misc.h
@@ -71,6 +71,49 @@ extern "C" {
  */
 extern SDL_DECLSPEC bool SDLCALL SDL_OpenURL(const char *url);
 
+
+/**
+ * Encode a string for use in a URL.
+ *
+ * This encodes a string according to RFC3986 section 2, using
+ * "percent-encoding," which is the act of turning a single byte into a
+ * three-character sequence. For example, a space character (ASCII 32, or 0x20)
+ * is converted into the string "%20".
+ *
+ * Only certain bytes are encoded. What RFC3986 calls "unreserved characters"
+ * are not: low-ASCII alphanumeric chars, and four others: `=._~`.
+ *
+ * While using UTF-8 is encouraged, this function does not care if the string
+ * is well-formed UTF-8; it will treat the string as individual bytes to
+ * encode, with some of those bytes being unreserved characters.
+ *
+ * This function's primary use is encoding strings for use as parameters
+ * appended to a URL passed to SDL_OpenURL(), however, this can _also_ be
+ * useful on some systems for passing a filesystem path with a "file://"
+ * prefix to that function to launch a file manager. In such cases, it would
+ * be better if '/' and '\\' aren't encoded, so one can pass a null-terminated
+ * list of characters to `no_encode_chars` to add to the list of characters
+ * that aren't encoded:
+ *
+ * ```c
+ * char *encoded = SDL_EncodeURL(MyFilePath, "/\\");
+ * ```
+ *
+ * These characters will be added to the normal "unreserved characters" list
+ * and not encoded. Passing NULL is valid and adds no characters to the list.
+ *
+ * \param str the string to encode.
+ * \param no_encode_chars a string of extra characters that are not to be encoded.
+ * \returns the encoded string or NULL on failure; call SDL_GetError() for more
+ *          information. This should be freed with SDL_free() when it is no
+ *          longer needed.
+ *
+ * \threadsafety It is safe to call this function from any thread.
+ *
+ * \since This function is available since SDL 3.6.0.
+ */
+extern SDL_DECLSPEC char * SDLCALL SDL_EncodeURL(const char *str, const char *no_encode_chars);
+
 /* Ends C function definitions when using C++ */
 #ifdef __cplusplus
 }
diff --git a/src/dynapi/SDL_dynapi.exports b/src/dynapi/SDL_dynapi.exports
index b223f40a89972..657e43332bae7 100644
--- a/src/dynapi/SDL_dynapi.exports
+++ b/src/dynapi/SDL_dynapi.exports
@@ -1317,3 +1317,4 @@ _SDL_RequestOpenHarmonyPermission
 _SDL_GetAudioDeviceProperties
 _SDL_SetSoftwareRendererWindow
 _SDL_SetSoftwareRendererSurface
+_SDL_EncodeURL
diff --git a/src/dynapi/SDL_dynapi.sym b/src/dynapi/SDL_dynapi.sym
index 5d699b2f3202b..9ea8c9d7336c0 100644
--- a/src/dynapi/SDL_dynapi.sym
+++ b/src/dynapi/SDL_dynapi.sym
@@ -1318,6 +1318,7 @@ SDL3_0.0.0 {
     SDL_GetAudioDeviceProperties;
     SDL_SetSoftwareRendererWindow;
     SDL_SetSoftwareRendererSurface;
+    SDL_EncodeURL;
     # extra symbols go here (don't modify this line)
   local: *;
 };
diff --git a/src/dynapi/SDL_dynapi_overrides.h b/src/dynapi/SDL_dynapi_overrides.h
index 8ca5f71b8478d..2df1ee9216d2e 100644
--- a/src/dynapi/SDL_dynapi_overrides.h
+++ b/src/dynapi/SDL_dynapi_overrides.h
@@ -1344,3 +1344,4 @@
 #define SDL_GetAudioDeviceProperties SDL_GetAudioDeviceProperties_REAL
 #define SDL_SetSoftwareRendererWindow SDL_SetSoftwareRendererWindow_REAL
 #define SDL_SetSoftwareRendererSurface SDL_SetSoftwareRendererSurface_REAL
+#define SDL_EncodeURL SDL_EncodeURL_REAL
diff --git a/src/dynapi/SDL_dynapi_procs.h b/src/dynapi/SDL_dynapi_procs.h
index d34980d2f4b7a..d84e8166acfa2 100644
--- a/src/dynapi/SDL_dynapi_procs.h
+++ b/src/dynapi/SDL_dynapi_procs.h
@@ -1352,3 +1352,4 @@ SDL_DYNAPI_PROC(bool,SDL_RequestOpenHarmonyPermission,(const char *a,SDL_Request
 SDL_DYNAPI_PROC(SDL_PropertiesID,SDL_GetAudioDeviceProperties,(SDL_AudioDeviceID a),(a),return)
 SDL_DYNAPI_PROC(bool,SDL_SetSoftwareRendererWindow,(SDL_Renderer *a,SDL_Window *b),(a,b),return)
 SDL_DYNAPI_PROC(bool,SDL_SetSoftwareRendererSurface,(SDL_Renderer *a,SDL_Surface *b),(a,b),return)
+SDL_DYNAPI_PROC(char*,SDL_EncodeURL,(const char *a,const char *b),(a,b),return)
diff --git a/src/misc/SDL_url.c b/src/misc/SDL_url.c
index 7ec2883074b67..3b814f784690f 100644
--- a/src/misc/SDL_url.c
+++ b/src/misc/SDL_url.c
@@ -29,3 +29,47 @@ bool SDL_OpenURL(const char *url)
     }
     return SDL_SYS_OpenURL(url);
 }
+
+char *SDL_EncodeURL(const char *str, const char *no_encode_chars)
+{
+    const size_t slen = SDL_strlen(str) + 1;
+    size_t allocation = slen + 64;   // at least this long plus a little more, in case one allocation covers it.
+    size_t dsti = 0;
+    char *retval = (char *) SDL_malloc(allocation);
+    if (!retval) {
+        return NULL;
+    }
+
+    for (size_t i = 0; i < slen; i++) {
+        if (dsti >= (allocation - 4)) {
+            allocation += 64;
+            char *ptr = (char *) SDL_realloc(retval, allocation);
+            if (!ptr) {
+                SDL_free(retval);
+                return NULL;
+            }
+            retval = ptr;
+        }
+
+        const char ch = str[i];
+
+        if ( ((ch >= 'A') && (ch <= 'Z')) || ((ch >= 'a') && (ch <= 'z')) ||
+             ((ch >= '0') && (ch <= '9')) ||
+             ((ch == '=') || (ch == '.') || (ch == '_') || (ch == '~') || (ch == '\0')) ||
+             (no_encode_chars && (SDL_strchr(no_encode_chars, ch) != NULL)) ) {
+            retval[dsti++] = ch;  // unreserved char, null terminator char, or explicitly requested not to encode.
+        } else {
+            SDL_snprintf(&retval[dsti], 4, "%%%02X", (unsigned int) ch);
+            dsti += 3;
+        }
+    }
+
+    // shrink the allocation, if possible.
+    char *ptr = (char *) SDL_realloc(retval, SDL_strlen(retval) + 1);
+    if (ptr) {
+        retval = ptr;
+    }
+
+    return retval;
+}
+
diff --git a/test/testurl.c b/test/testurl.c
index ef61eb2f6264f..5be870bf91167 100644
--- a/test/testurl.c
+++ b/test/testurl.c
@@ -13,14 +13,25 @@
 #include <SDL3/SDL_main.h>
 #include <SDL3/SDL_test.h>
 
-static void tryOpenURL(const char *url)
+static void tryOpenURL(const char *url, bool urlencode, const char *no_encode_chars)
 {
+    char *encoded = NULL;
+    if (urlencode) {
+        encoded = SDL_EncodeURL(url, no_encode_chars);
+        if (!encoded) {
+            SDL_Log("URL encoding failed! %s", SDL_GetError());
+        }
+        url = encoded;
+    }
+
     SDL_Log("Opening '%s' ...", url);
     if (SDL_OpenURL(url)) {
         SDL_Log("  success!");
     } else {
         SDL_Log("  failed! %s", SDL_GetError());
     }
+
+    SDL_free(encoded);
 }
 
 int main(int argc, char **argv)
@@ -28,6 +39,8 @@ int main(int argc, char **argv)
     const char *url = NULL;
     SDLTest_CommonState *state = SDLTest_CommonCreateState(argv, 0);
     bool use_gui = false;
+    bool urlencode = false;
+    const char *no_encode_chars = NULL;
 
     /* Parse commandline */
     for (int i = 1; i < argc;) {
@@ -41,11 +54,19 @@ int main(int argc, char **argv)
             } else if (SDL_strcasecmp(argv[i], "--gui") == 0) {
                 use_gui = true;
                 consumed = 1;
+            } else if (SDL_strcasecmp(argv[i], "--urlencode") == 0) {
+                urlencode = true;
+                consumed = 1;
+            } else if (SDL_strcasecmp(argv[i], "--no-encode-chars") == 0) {
+                no_encode_chars = argv[i + 1];
+                consumed = 2;
             }
         }
         if (consumed <= 0) {
             static const char *options[] = {
-                "[--gui]"
+                "[--gui]",
+                "[--urlencode]",
+                "[--no-encode-chars ...]",
                 "[URL [...]]",
                 NULL,
             };
@@ -61,7 +82,7 @@ int main(int argc, char **argv)
     }
 
     if (!use_gui) {
-        tryOpenURL(url);
+        tryOpenURL(url, urlencode, no_encode_chars);
     } else {
         SDL_Event event;
         bool quit = false;
@@ -69,10 +90,10 @@ int main(int argc, char **argv)
         while (!quit) {
             while (SDL_PollEvent(&event)) {
                 if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
-                    tryOpenURL(url);
+                    tryOpenURL(url, urlencode, no_encode_chars);
                 } else if (event.type == SDL_EVENT_KEY_DOWN) {
                     if (event.key.key == SDLK_SPACE) {
-                        tryOpenURL(url);
+                        tryOpenURL(url, urlencode, no_encode_chars);
                     } else if (event.key.key == SDLK_ESCAPE) {
                         quit = true;
                     }