From dc430ce8ec427529c75e3527a0ff01fcf3858dd2 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 20 Jul 2026 16:25:39 +0300 Subject: 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). --- src/clipboard.c | 69 ++++++++++-- src/clipboard.h | 5 + tests/meson.build | 9 ++ tests/test_clipboard.c | 278 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 tests/test_clipboard.c 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 +#include +#include +#include + +/* --- 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 -- cgit v1.2.3