summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-20 16:25:39 +0300
committerPaul Buetow <paul@buetow.org>2026-07-20 16:25:39 +0300
commitdc430ce8ec427529c75e3527a0ff01fcf3858dd2 (patch)
tree0c5a79320b182e145f24420b67da9f01930a1101
parentf6517a56a6e4d92468dfdda1656e80d57f302816 (diff)
clipboard: offer text/uri-list (CRLF) + text/plain via union provider (iu0)
clipboard_copy_uris wrapped its URI text as a generic G_TYPE_STRING provider, so targets could not request text/uri-list and file-manager paste failed. Extracted clipboard_build_uri_provider builds a union of two byte providers: text/uri-list (RFC 2483 CRLF-terminated URI lines, trailing CRLF) and text/plain (newline-joined local paths, URI fallback for non-local files). clipboard_copy_uris sets the union on the GdkClipboard with correct refcounting (new_union steals the sub-provider refs; clipboard takes its own ref). Empty list returns NULL and leaves the clipboard untouched. New integration test tests/test_clipboard.c: serializes the provider's content per MIME type via gdk_content_provider_write_mime_type_async into a GMemoryOutputStream and memcmps the bytes. Covers formats (both MIME advertised, bogus type absent), single + multiple files (exact CRLF/newline-joined bytes), empty list (no crash), and a negative (unknown MIME rejected with GError).
-rw-r--r--src/clipboard.c69
-rw-r--r--src/clipboard.h5
-rw-r--r--tests/meson.build9
-rw-r--r--tests/test_clipboard.c278
4 files changed, 350 insertions, 11 deletions
diff --git a/src/clipboard.c b/src/clipboard.c
index 112e209..364a70b 100644
--- a/src/clipboard.c
+++ b/src/clipboard.c
@@ -63,21 +63,68 @@ clipboard_copy_image_finish(GAsyncResult *p_res, GError **p_err) {
return FALSE;
}
+/* Build a GdkContentProvider that offers the marked files as BOTH
+ * `text/uri-list` (RFC 2483: CRLF-terminated URI lines, the format file
+ * managers request) and `text/plain` (a newline-joined list of local PATHS
+ * so pasting into a text field yields readable paths rather than raw URIs;
+ * if a file has no local path, its URI is used for that line instead). The
+ * two byte-buffers are wrapped in a union so a target requesting either MIME
+ * type is satisfied. Returns a new ref the caller must unref, or NULL when
+ * the file list is empty/NULL.
+ *
+ * An empty list returns NULL rather than an empty provider: in practice
+ * clipboard_copy_uris is only invoked when marks exist, and returning NULL
+ * lets the caller leave the previous clipboard content untouched instead of
+ * replacing it with an empty payload (which GDK's bytes provider refuses to
+ * serialize anyway). */
+GdkContentProvider *
+clipboard_build_uri_provider(GList *p_files) {
+ if (p_files == NULL) {
+ return (NULL);
+ }
+ GString *p_uris = g_string_new(NULL); /* text/uri-list body */
+ GString *p_plain = g_string_new(NULL); /* text/plain body */
+ for (GList *it = p_files; it; it = it->next) {
+ GFile *p_f = G_FILE(it->data);
+ char *c_uri = g_file_get_uri(p_f);
+ char *c_path = g_file_get_path(p_f);
+ /* RFC 2483: each record terminated by CRLF, incl. the last. */
+ g_string_append_printf(p_uris, "%s\r\n", c_uri);
+ /* Prefer the local path for human-readable text/plain paste;
+ * fall back to the URI for non-local (e.g. trash://) files. */
+ g_string_append_printf(p_plain, "%s\n", c_path != NULL ? c_path : c_uri);
+ g_free(c_uri);
+ g_free(c_path);
+ }
+ GBytes *p_uri_bytes = g_bytes_new_take(p_uris->str, p_uris->len);
+ GBytes *p_plain_bytes = g_bytes_new_take(p_plain->str, p_plain->len);
+ /* g_bytes_new_take freed the GString buffers; only the structs remain. */
+ g_string_free(p_uris, FALSE);
+ g_string_free(p_plain, FALSE);
+ GdkContentProvider *p_provs[2] = {
+ gdk_content_provider_new_for_bytes("text/uri-list", p_uri_bytes),
+ gdk_content_provider_new_for_bytes("text/plain", p_plain_bytes),
+ };
+ /* new_for_bytes refs/copies the bytes; we can release our ref now. */
+ g_bytes_unref(p_uri_bytes);
+ g_bytes_unref(p_plain_bytes);
+ GdkContentProvider *p_union =
+ gdk_content_provider_new_union(p_provs, G_N_ELEMENTS(p_provs));
+ /* new_union "takes ownership" of the sub-providers: it steals our refs
+ * and frees them when the union is disposed, so we must NOT unref them
+ * here (doing so would double-free them on the union's dispose). */
+ return (p_union);
+}
+
void
clipboard_copy_uris(GdkClipboard *p_clip, GList *p_files) {
g_return_if_fail(GDK_IS_CLIPBOARD(p_clip));
- GString *p_str = g_string_new(NULL);
- for (GList *it = p_files; it; it = it->next) {
- char *c_uri = g_file_get_uri(G_FILE(it->data));
- g_string_append_printf(p_str, "%s\n", c_uri);
- g_free(c_uri);
+ GdkContentProvider *p_prov = clipboard_build_uri_provider(p_files);
+ if (p_prov == NULL) {
+ return; /* empty list: leave the clipboard untouched. */
}
- GValue st_val = G_VALUE_INIT;
- g_value_init(&st_val, G_TYPE_STRING);
- g_value_take_string(&st_val, g_strdup(p_str->str));
- GdkContentProvider *p_prov = gdk_content_provider_new_for_value(&st_val);
+ /* gdk_clipboard_set_content takes its own ref on the provider; we still
+ * own our initial ref from clipboard_build_uri_provider, so drop it. */
gdk_clipboard_set_content(p_clip, p_prov);
- g_value_unset(&st_val);
g_object_unref(p_prov);
- g_string_free(p_str, TRUE);
} \ No newline at end of file
diff --git a/src/clipboard.h b/src/clipboard.h
index e972875..805ee24 100644
--- a/src/clipboard.h
+++ b/src/clipboard.h
@@ -16,6 +16,11 @@ gboolean clipboard_copy_image_finish(GAsyncResult *p_res, GError **p_err);
/* Copy a list of files as text/uri-list to the clipboard. */
void clipboard_copy_uris(GdkClipboard *p_clip, GList *p_files);
+/* Build (but do not set) a content provider offering the given files as
+ * text/uri-list (CRLF) and text/plain (newline-joined local paths). Returns
+ * a new ref; caller must unref. Useful for testing without a clipboard. */
+GdkContentProvider *clipboard_build_uri_provider(GList *p_files);
+
G_END_DECLS
#endif \ No newline at end of file
diff --git a/tests/meson.build b/tests/meson.build
index b60475c..6a93277 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -254,4 +254,13 @@ test_shortcut = executable(
test('shortcut', test_shortcut,
suite : 'integration', env : fixtures_env)
+test_clipboard = executable(
+ 'test_clipboard', ['test_clipboard.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : ggaze_deps,
+ link_with : ggaze_lib, install : false,
+)
+test('clipboard', test_clipboard,
+ suite : 'integration', env : fixtures_env)
+
subdir('integration')
diff --git a/tests/test_clipboard.c b/tests/test_clipboard.c
new file mode 100644
index 0000000..48e5834
--- /dev/null
+++ b/tests/test_clipboard.c
@@ -0,0 +1,278 @@
+/*:*
+ * ggaze — clipboard URI-list integration test
+ *
+ * Verifies that clipboard_build_uri_provider (the helper clipboard_copy_uris
+ * sets on the GdkClipboard) advertises BOTH text/uri-list and text/plain and
+ * serializes standards-compliant bytes for each:
+ * - text/uri-list : RFC 2483 CRLF-terminated URI lines (trailing CRLF).
+ * - text/plain : newline-joined local PATH list (trailing newline), with
+ * a URI fallback for non-local files.
+ *
+ * It also exercises a negative target (application/x-bogus) to prove the
+ * provider does not accidentally offer arbitrary MIME types, and an empty
+ * file list (must not crash and must yield a valid, empty-bytes provider).
+ *
+ * The fake-target technique: rather than driving a real clipboard daemon, we
+ * serialize the provider's content for a chosen MIME type directly through
+ * gdk_content_provider_write_mime_type_async into a GMemoryOutputStream, then
+ * compare the captured bytes against the expected payload. Needs a display (the
+ * GdkContentProvider/GdkContentFormats APIs require Gdk to be initialized;
+ * integration suite; CI uses xvfb-run).
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "clipboard.h"
+
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+#include <gtk/gtk.h>
+
+/* --- helpers ------------------------------------------------------------ */
+
+static void
+drain_main(guint u_ms) {
+ for (guint u = 0; u < u_ms; u++) {
+ g_main_context_iteration(g_main_context_default(), FALSE);
+ g_usleep(1000);
+ }
+}
+
+/* Async-write completion context. */
+typedef struct {
+ GMainLoop *p_loop;
+ GError *p_err;
+ gboolean b_ok;
+} WriteCtx;
+
+static void
+_write_done_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) {
+ WriteCtx *p_ctx = (WriteCtx *)p_data;
+ p_ctx->b_ok = gdk_content_provider_write_mime_type_finish(
+ GDK_CONTENT_PROVIDER(p_src), p_res, &p_ctx->p_err);
+ g_main_loop_quit(p_ctx->p_loop);
+}
+
+/* Serialize p_prov's content for c_mime into freshly-allocated bytes.
+ * Returns NULL (and sets *p_err) if the provider cannot supply c_mime.
+ * Caller frees with g_free. The provider is ref'd here so the caller need
+ * not keep it alive across the call. */
+static guchar *
+serialize_mime(GdkContentProvider *p_prov, const char *c_mime, gsize *p_len,
+ GError **p_err) {
+ GMemoryOutputStream *p_mem =
+ G_MEMORY_OUTPUT_STREAM(g_memory_output_stream_new_resizable());
+ GMainLoop *p_loop = g_main_loop_new(NULL, FALSE);
+ WriteCtx st_ctx = {.p_loop = p_loop, .p_err = NULL, .b_ok = FALSE};
+
+ gdk_content_provider_write_mime_type_async(
+ p_prov, c_mime, G_OUTPUT_STREAM(p_mem), G_PRIORITY_DEFAULT, NULL,
+ _write_done_cb, &st_ctx);
+ g_main_loop_run(p_loop);
+
+ /* The content write fills the buffer but does not close the stream;
+ * g_memory_output_stream_steal_data requires a closed stream. */
+ g_output_stream_close(G_OUTPUT_STREAM(p_mem), NULL, NULL);
+
+ guchar *c_out = NULL;
+ if (st_ctx.b_ok) {
+ gsize u_size = 0;
+ c_out = g_memory_output_stream_steal_data(p_mem);
+ /* steal_data returns the buffer but not its length; query it. */
+ u_size = g_memory_output_stream_get_data_size(p_mem);
+ if (p_len != NULL) {
+ *p_len = u_size;
+ }
+ } else {
+ if (p_err != NULL) {
+ *p_err = st_ctx.p_err;
+ } else {
+ g_clear_error(&st_ctx.p_err);
+ }
+ }
+ g_object_unref(p_mem);
+ g_main_loop_unref(p_loop);
+ return (c_out);
+}
+
+/* Build a GList of GFile* from NULL-terminated path arguments. */
+static GList *
+build_file_list(const char *c_first, ...) {
+ GList *p_list = NULL;
+ if (c_first != NULL) {
+ p_list = g_list_append(p_list, g_file_new_for_path(c_first));
+ va_list ap;
+ va_start(ap, c_first);
+ const char *c_path;
+ while ((c_path = va_arg(ap, const char *)) != NULL) {
+ p_list = g_list_append(p_list, g_file_new_for_path(c_path));
+ }
+ va_end(ap);
+ }
+ return (p_list);
+}
+
+static void
+free_file_list(GList *p_list) {
+ g_list_free_full(p_list, (GDestroyNotify)g_object_unref);
+}
+
+/* --- subtests ----------------------------------------------------------- */
+
+/* Both MIME types must be in the provider's offered formats. */
+static void
+test_clipboard_formats(void) {
+ GList *p_files = build_file_list("/tmp/a.jpg", "/tmp/b.jpg", NULL);
+ GdkContentProvider *p_prov = clipboard_build_uri_provider(p_files);
+ g_assert_nonnull(p_prov);
+
+ GdkContentFormats *p_fmts = gdk_content_provider_ref_formats(p_prov);
+ g_assert_nonnull(p_fmts);
+ g_assert_true(
+ gdk_content_formats_contain_mime_type(p_fmts, "text/uri-list"));
+ g_assert_true(gdk_content_formats_contain_mime_type(p_fmts, "text/plain"));
+ /* Negative: a type we never offered must NOT be present. */
+ g_assert_false(
+ gdk_content_formats_contain_mime_type(p_fmts, "application/x-bogus"));
+ gdk_content_formats_unref(p_fmts);
+
+ g_object_unref(p_prov);
+ free_file_list(p_files);
+ drain_main(50);
+}
+
+/* Single local file: uri-list has one CRLF-terminated line; plain has the
+ * path followed by a newline. */
+static void
+test_clipboard_single_file(void) {
+ GList *p_files = build_file_list("/home/u/pic.jpg", NULL);
+ GdkContentProvider *p_prov = clipboard_build_uri_provider(p_files);
+ g_assert_nonnull(p_prov);
+
+ /* text/uri-list: file:// URI + trailing CRLF. */
+ {
+ gsize u_len = 0;
+ GError *p_err = NULL;
+ guchar *c_buf = serialize_mime(p_prov, "text/uri-list", &u_len, &p_err);
+ g_assert_no_error(p_err);
+ g_assert_nonnull(c_buf);
+ char *c_expect = g_strdup_printf("file:///home/u/pic.jpg\r\n");
+ g_assert_cmpuint(u_len, ==, strlen(c_expect));
+ g_assert_cmpint(memcmp(c_buf, c_expect, u_len), ==, 0);
+ g_free(c_expect);
+ g_free(c_buf);
+ }
+ /* text/plain: local path + trailing newline. */
+ {
+ gsize u_len = 0;
+ GError *p_err = NULL;
+ guchar *c_buf = serialize_mime(p_prov, "text/plain", &u_len, &p_err);
+ g_assert_no_error(p_err);
+ g_assert_nonnull(c_buf);
+ char *c_expect = g_strdup_printf("/home/u/pic.jpg\n");
+ g_assert_cmpuint(u_len, ==, strlen(c_expect));
+ g_assert_cmpint(memcmp(c_buf, c_expect, u_len), ==, 0);
+ g_free(c_expect);
+ g_free(c_buf);
+ }
+
+ g_object_unref(p_prov);
+ free_file_list(p_files);
+ drain_main(50);
+}
+
+/* Multiple local files: each line terminated correctly, order preserved. */
+static void
+test_clipboard_multiple_files(void) {
+ GList *p_files = build_file_list("/a/1.jpg", "/a/2.png", "/a/3.webp", NULL);
+ GdkContentProvider *p_prov = clipboard_build_uri_provider(p_files);
+ g_assert_nonnull(p_prov);
+
+ {
+ gsize u_len = 0;
+ GError *p_err = NULL;
+ guchar *c_buf = serialize_mime(p_prov, "text/uri-list", &u_len, &p_err);
+ g_assert_no_error(p_err);
+ g_assert_nonnull(c_buf);
+ const char *c_expect =
+ "file:///a/1.jpg\r\nfile:///a/2.png\r\nfile:///a/3.webp\r\n";
+ g_assert_cmpuint(u_len, ==, strlen(c_expect));
+ g_assert_cmpint(memcmp(c_buf, c_expect, u_len), ==, 0);
+ g_free(c_buf);
+ }
+ {
+ gsize u_len = 0;
+ GError *p_err = NULL;
+ guchar *c_buf = serialize_mime(p_prov, "text/plain", &u_len, &p_err);
+ g_assert_no_error(p_err);
+ g_assert_nonnull(c_buf);
+ const char *c_expect = "/a/1.jpg\n/a/2.png\n/a/3.webp\n";
+ g_assert_cmpuint(u_len, ==, strlen(c_expect));
+ g_assert_cmpint(memcmp(c_buf, c_expect, u_len), ==, 0);
+ g_free(c_buf);
+ }
+
+ g_object_unref(p_prov);
+ free_file_list(p_files);
+ drain_main(50);
+}
+
+/* Empty list: must not crash. clipboard_build_uri_provider returns NULL
+ * (leaving the clipboard untouched rather than replacing it with an empty
+ * payload), and clipboard_copy_uris must handle that without crashing. */
+static void
+test_clipboard_empty_list(void) {
+ GdkContentProvider *p_prov = clipboard_build_uri_provider(NULL);
+ g_assert_null(p_prov);
+
+ /* Driving clipboard_copy_uris with an empty list must not crash; it
+ * should be a no-op against the default display's clipboard. */
+ GdkDisplay *p_disp = gdk_display_get_default();
+ if (p_disp != NULL) {
+ GdkClipboard *p_clip = gdk_display_get_clipboard(p_disp);
+ clipboard_copy_uris(p_clip, NULL);
+ }
+
+ drain_main(50);
+}
+
+/* Negative: requesting a MIME type the provider does NOT offer must fail the
+ * write (write_finish returns FALSE with a GError), proving the union does
+ * not accidentally advertise arbitrary content. */
+static void
+test_clipboard_rejects_unknown_mime(void) {
+ GList *p_files = build_file_list("/tmp/x.jpg", NULL);
+ GdkContentProvider *p_prov = clipboard_build_uri_provider(p_files);
+ g_assert_nonnull(p_prov);
+
+ gsize u_len = 0;
+ GError *p_err = NULL;
+ guchar *c_buf =
+ serialize_mime(p_prov, "application/x-bogus", &u_len, &p_err);
+ g_assert_null(c_buf);
+ g_assert_nonnull(p_err);
+ g_clear_error(&p_err);
+
+ g_object_unref(p_prov);
+ free_file_list(p_files);
+ drain_main(50);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_test_init(&i_argc, &c_argv, NULL);
+ g_log_set_always_fatal(G_LOG_LEVEL_ERROR | G_LOG_LEVEL_CRITICAL);
+ if (!gtk_init_check()) {
+ g_test_skip("no display available (run under xvfb)");
+ return (g_test_run());
+ }
+ g_test_add_func("/clipboard/formats", test_clipboard_formats);
+ g_test_add_func("/clipboard/single_file", test_clipboard_single_file);
+ g_test_add_func("/clipboard/multiple_files", test_clipboard_multiple_files);
+ g_test_add_func("/clipboard/empty_list", test_clipboard_empty_list);
+ g_test_add_func("/clipboard/rejects_unknown_mime",
+ test_clipboard_rejects_unknown_mime);
+ return (g_test_run());
+} \ No newline at end of file