summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-14 09:08:10 +0300
committerPaul Buetow <paul@buetow.org>2026-07-14 09:08:10 +0300
commit95858bc4a819c118cf76dd38d6da0365ead997a9 (patch)
tree739544e2314c3e0bab65fb9c75e2c17e89069017
parent12be1b9ae52829292d13cc2fcc6ff7571d050d62 (diff)
operate: mover, opener, runner, clipboard modules + tests (ut0)
M8 (partial): plain-C operate modules + unit tests. - src/mover.{c,h}: move files to configured dests (g_file_move + stem collision suffix -1/-2), one-level undo (move back), acts on marks-or-current. - src/opener.{c,h}: %f expand + detached GSubprocess launch (non-blocking). - src/runner.{c,h}: /bin/sh -c with single-quoted %f/%d (injection guard), async wait_check, rescan on completion. - src/clipboard.{c,h}: image/png via GdkContentProvider + uri-list. - meson: new sources added to libggae. - tests: unit test_mover (move/undo/collision), test_opener (true/false/weird filenames), test_runner (true/false/injection guard). 19/19 green, ASan clean. - Fixed: g_str_replace (not in GLib 2.88) → manual _str_replace; GdkContentProvider API (new_typed/new_for_value); mover collision stem-suffix; runner /bin/sh -c argv (single arg, not shell-parsed); test GFile leaks.
-rw-r--r--meson.build4
-rw-r--r--src/clipboard.c83
-rw-r--r--src/clipboard.h21
-rw-r--r--src/mover.c135
-rw-r--r--src/mover.h29
-rw-r--r--src/opener.c113
-rw-r--r--src/opener.h28
-rw-r--r--src/runner.c135
-rw-r--r--src/runner.h33
-rw-r--r--tests/meson.build24
-rw-r--r--tests/test_mover.c99
-rw-r--r--tests/test_opener.c85
-rw-r--r--tests/test_runner.c101
13 files changed, 890 insertions, 0 deletions
diff --git a/meson.build b/meson.build
index 78cb24a..434e599 100644
--- a/meson.build
+++ b/meson.build
@@ -65,6 +65,10 @@ ggaze_src = files(
'src/trash.c',
'src/gridview.c',
'src/info.c',
+ 'src/mover.c',
+ 'src/opener.c',
+ 'src/runner.c',
+ 'src/clipboard.c',
'src/loader/loader.c',
'src/loader/detect.c',
'src/loader/backends/pixbuf.c',
diff --git a/src/clipboard.c b/src/clipboard.c
new file mode 100644
index 0000000..112e209
--- /dev/null
+++ b/src/clipboard.c
@@ -0,0 +1,83 @@
+/* clipboard.c — copy image (PNG) or URIs to GdkClipboard. */
+#include "clipboard.h"
+#include "loader/loader.h"
+
+#include <gdk-pixbuf/gdk-pixbuf.h>
+
+static void
+_copy_image_thread(GTask *p_task, gpointer p_src, gpointer p_data,
+ GCancellable *p_cancel) {
+ (void)p_data;
+ GError *p_err = NULL;
+ GdkTexture *p_tex = loader_load((GFile *)p_src, p_cancel, &p_err);
+ if (p_tex == NULL) {
+ g_task_return_error(p_task, p_err);
+ } else {
+ g_task_return_pointer(p_task, p_tex, (GDestroyNotify)g_object_unref);
+ }
+}
+
+void
+clipboard_copy_image_async(GdkClipboard *p_clip, GFile *p_file,
+ GCancellable *p_cancel, GAsyncReadyCallback p_cb,
+ gpointer p_data) {
+ g_return_if_fail(GDK_IS_CLIPBOARD(p_clip));
+ g_return_if_fail(G_IS_FILE(p_file));
+ GTask *p_task = g_task_new(p_file, p_cancel, p_cb, p_data);
+ g_task_run_in_thread(p_task, _copy_image_thread);
+ g_object_unref(p_task);
+}
+
+static void
+_clip_image_finish_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) {
+ GdkClipboard *p_clip = GDK_CLIPBOARD(p_data);
+ GError *p_err = NULL;
+ GdkTexture *p_tex = g_task_propagate_pointer(G_TASK(p_res), &p_err);
+ if (p_tex != NULL) {
+ GdkContentProvider *p_prov =
+ gdk_content_provider_new_typed(GDK_TYPE_TEXTURE, p_tex);
+ gdk_clipboard_set_content(p_clip, p_prov);
+ g_object_unref(p_prov);
+ g_object_unref(p_tex);
+ } else {
+ g_clear_error(&p_err);
+ }
+ g_object_unref(p_data);
+}
+
+/* Convenience: start the copy and set the clipboard when done. */
+void
+ggaze_clipboard_copy_image(GdkClipboard *p_clip, GFile *p_file) {
+ clipboard_copy_image_async(p_clip, p_file, NULL, _clip_image_finish_cb,
+ g_object_ref(p_clip));
+}
+
+gboolean
+clipboard_copy_image_finish(GAsyncResult *p_res, GError **p_err) {
+ g_return_val_if_fail(G_IS_TASK(p_res), FALSE);
+ GdkTexture *p_tex = g_task_propagate_pointer(G_TASK(p_res), p_err);
+ if (p_tex) {
+ g_object_unref(p_tex);
+ return TRUE;
+ }
+ return FALSE;
+}
+
+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);
+ }
+ 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(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
new file mode 100644
index 0000000..e972875
--- /dev/null
+++ b/src/clipboard.h
@@ -0,0 +1,21 @@
+#ifndef GGAZE_CLIPBOARD_H
+#define GGAZE_CLIPBOARD_H
+
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+/* Copy a single image as PNG pixels to the clipboard (decodes in a GTask). */
+void clipboard_copy_image_async(GdkClipboard *p_clip, GFile *p_file,
+ GCancellable *p_cancel,
+ GAsyncReadyCallback p_cb, gpointer p_data);
+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);
+
+G_END_DECLS
+
+#endif \ No newline at end of file
diff --git a/src/mover.c b/src/mover.c
new file mode 100644
index 0000000..cc82ae3
--- /dev/null
+++ b/src/mover.c
@@ -0,0 +1,135 @@
+/* mover.c — configurable move destinations with undo. */
+#include "mover.h"
+
+struct Mover {
+ GPtrArray *p_dests; /* MoverDest* (owned) */
+ GPtrArray *p_last_src; /* GFile* (owned, for undo) */
+ GPtrArray *p_last_dst; /* GFile* (owned, for undo) */
+};
+
+static void
+_dest_free(gpointer p) {
+ MoverDest *d = (MoverDest *)p;
+ if (d != NULL) {
+ g_free(d->c_name);
+ g_free(d->c_path);
+ g_free(d);
+ }
+}
+
+Mover *
+mover_new(void) {
+ Mover *m = g_new0(Mover, 1);
+ m->p_dests = g_ptr_array_new_with_free_func(_dest_free);
+ m->p_last_src = g_ptr_array_new_with_free_func(g_object_unref);
+ m->p_last_dst = g_ptr_array_new_with_free_func(g_object_unref);
+ return m;
+}
+
+void
+mover_delete(Mover *m) {
+ if (m == NULL)
+ return;
+ g_ptr_array_unref(m->p_dests);
+ g_ptr_array_unref(m->p_last_src);
+ g_ptr_array_unref(m->p_last_dst);
+ g_free(m);
+}
+
+void
+mover_set_dests(Mover *m, const GPtrArray *p_dests) {
+ g_return_if_fail(m != NULL);
+ g_ptr_array_set_size(m->p_dests, 0);
+ if (p_dests != NULL) {
+ for (guint i = 0; i < p_dests->len; i++) {
+ const MoverDest *d =
+ (const MoverDest *)g_ptr_array_index((GPtrArray *)p_dests, i);
+ MoverDest *nd = g_new(MoverDest, 1);
+ nd->c_name = g_strdup(d->c_name);
+ nd->c_path = g_strdup(d->c_path);
+ g_ptr_array_add(m->p_dests, nd);
+ }
+ }
+}
+
+const GPtrArray *
+mover_get_dests(Mover *m) {
+ g_return_val_if_fail(m != NULL, NULL);
+ return m->p_dests;
+}
+
+gboolean
+mover_move(Mover *m, GList *p_files, const MoverDest *p_dest, GError **p_err) {
+ g_return_val_if_fail(m != NULL, FALSE);
+ g_return_val_if_fail(p_dest != NULL, FALSE);
+ GFile *p_ddir = g_file_new_for_path(p_dest->c_path);
+ GError *e = NULL;
+ g_file_make_directory_with_parents(p_ddir, NULL, &e);
+ if (e != NULL && !g_error_matches(e, G_IO_ERROR, G_IO_ERROR_EXISTS)) {
+ g_propagate_error(p_err, e);
+ g_object_unref(p_ddir);
+ return FALSE;
+ }
+ g_clear_error(&e);
+
+ g_ptr_array_set_size(m->p_last_src, 0);
+ g_ptr_array_set_size(m->p_last_dst, 0);
+
+ for (GList *it = p_files; it != NULL; it = it->next) {
+ GFile *src = G_FILE(it->data);
+ char *base = g_file_get_basename(src);
+ GFile *dst = g_file_get_child(p_ddir, base);
+ g_free(base);
+ for (guint n = 1; g_file_query_exists(dst, NULL); n++) {
+ g_object_unref(dst);
+ /* Suffix on the stem (before the extension): a.jpg -> a-1.jpg */
+ char *c_b = g_file_get_basename(src);
+ const char *c_d = strrchr(c_b, '.');
+ char *c_s =
+ (c_d && c_d != c_b) ? g_strndup(c_b, c_d - c_b) : g_strdup(c_b);
+ const char *c_e = (c_d && c_d != c_b) ? c_d : "";
+ char *nb = g_strdup_printf("%s-%u%s", c_s, n, c_e);
+ g_free(c_s);
+ g_free(c_b);
+ dst = g_file_get_child(p_ddir, nb);
+ g_free(nb);
+ }
+ if (!g_file_move(src, dst, G_FILE_COPY_NOFOLLOW_SYMLINKS, NULL, NULL,
+ NULL, &e)) {
+ g_propagate_error(p_err, e);
+ g_object_unref(dst);
+ g_object_unref(p_ddir);
+ return FALSE;
+ }
+ g_ptr_array_add(m->p_last_src, g_object_ref(src));
+ g_ptr_array_add(m->p_last_dst, dst);
+ }
+ g_object_unref(p_ddir);
+ return TRUE;
+}
+
+gboolean
+mover_undo_last(Mover *m, GError **p_err) {
+ g_return_val_if_fail(m != NULL, FALSE);
+ if (m->p_last_dst->len == 0)
+ return FALSE;
+ for (guint i = 0; i < m->p_last_dst->len; i++) {
+ GFile *dst = g_ptr_array_index(m->p_last_dst, i);
+ GFile *src = g_ptr_array_index(m->p_last_src, i);
+ GError *e = NULL;
+ if (!g_file_move(dst, src, G_FILE_COPY_NOFOLLOW_SYMLINKS, NULL, NULL,
+ NULL, &e)) {
+ g_propagate_error(p_err, e);
+ return FALSE;
+ }
+ }
+ g_ptr_array_set_size(m->p_last_src, 0);
+ g_ptr_array_set_size(m->p_last_dst, 0);
+ return TRUE;
+}
+
+gboolean
+mover_can_undo(Mover *m) {
+ g_return_val_if_fail(m != NULL, FALSE);
+ return m->p_last_dst->len > 0;
+} \ No newline at end of file
diff --git a/src/mover.h b/src/mover.h
new file mode 100644
index 0000000..03137fc
--- /dev/null
+++ b/src/mover.h
@@ -0,0 +1,29 @@
+#ifndef GGAZE_MOVER_H
+#define GGAZE_MOVER_H
+
+#include <gio/gio.h>
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+typedef struct {
+ char *c_name;
+ char *c_path;
+} MoverDest;
+
+typedef struct Mover Mover;
+
+Mover *mover_new(void);
+void mover_delete(Mover *p_m);
+
+void mover_set_dests(Mover *p_m, const GPtrArray *p_dests);
+const GPtrArray *mover_get_dests(Mover *p_m);
+
+gboolean mover_move(Mover *p_m, GList *p_files, const MoverDest *p_dest,
+ GError **p_err);
+gboolean mover_undo_last(Mover *p_m, GError **p_err);
+gboolean mover_can_undo(Mover *p_m);
+
+G_END_DECLS
+
+#endif \ No newline at end of file
diff --git a/src/opener.c b/src/opener.c
new file mode 100644
index 0000000..f9afb1b
--- /dev/null
+++ b/src/opener.c
@@ -0,0 +1,113 @@
+/* opener.c — launch external programs with %f expansion. Detached GSubprocess.
+ */
+#include "opener.h"
+
+struct Opener {
+ GPtrArray *p_progs; /* OpenerProg* */
+};
+
+static void
+_prog_free(gpointer p) {
+ OpenerProg *d = (OpenerProg *)p;
+ if (d) {
+ g_free(d->c_name);
+ g_free(d->c_command);
+ g_free(d);
+ }
+}
+
+Opener *
+opener_new(void) {
+ Opener *o = g_new0(Opener, 1);
+ o->p_progs = g_ptr_array_new_with_free_func(_prog_free);
+ return o;
+}
+
+void
+opener_delete(Opener *o) {
+ if (!o)
+ return;
+ g_ptr_array_unref(o->p_progs);
+ g_free(o);
+}
+
+void
+opener_set_progs(Opener *o, const GPtrArray *p) {
+ g_return_if_fail(o);
+ g_ptr_array_set_size(o->p_progs, 0);
+ if (!p)
+ return;
+ for (guint i = 0; i < p->len; i++) {
+ const OpenerProg *src = g_ptr_array_index((GPtrArray *)p, i);
+ OpenerProg *np = g_new(OpenerProg, 1);
+ np->c_name = g_strdup(src->c_name);
+ np->c_command = g_strdup(src->c_command);
+ g_ptr_array_add(o->p_progs, np);
+ }
+}
+
+const GPtrArray *
+opener_get_progs(Opener *o) {
+ return o ? o->p_progs : NULL;
+}
+
+/* Expand %f → shell-escaped path. Returns a newly-allocated argv vector
+ * (NULL-terminated). Caller frees with g_strfreev. */
+
+/* Replace all occurrences of c_old with c_new in c_str. Caller frees. */
+static char *
+_str_replace(const char *c_str, const char *c_old, const char *c_new) {
+ if (c_str == NULL || c_old == NULL || c_new == NULL)
+ return NULL;
+ GString *p_out = g_string_new(NULL);
+ const char *p = c_str;
+ gsize u_old_len = strlen(c_old);
+ while (*p) {
+ if (strncmp(p, c_old, u_old_len) == 0) {
+ g_string_append(p_out, c_new);
+ p += u_old_len;
+ } else {
+ g_string_append_c(p_out, *p);
+ p++;
+ }
+ }
+ return g_string_free(p_out, FALSE);
+}
+
+static char **
+_expand_command(const char *c_cmd, GFile *p_file) {
+ char *c_path = g_file_get_path(p_file);
+ /* Simple tokenisation: split on spaces, replace %f with path. */
+ char **parts = g_strsplit(c_cmd, " ", -1);
+ GPtrArray *argv = g_ptr_array_new();
+ for (guint i = 0; parts[i]; i++) {
+ if (g_str_equal(parts[i], "%f"))
+ g_ptr_array_add(argv, g_strdup(c_path));
+ else if (strstr(parts[i], "%f")) {
+ char *r = _str_replace(parts[i], "%f", c_path);
+ g_ptr_array_add(argv, r ? r : g_strdup(parts[i]));
+ } else
+ g_ptr_array_add(argv, g_strdup(parts[i]));
+ }
+ g_ptr_array_add(argv, NULL);
+ g_strfreev(parts);
+ g_free(c_path);
+ return (char **)g_ptr_array_free(argv, FALSE);
+}
+
+gboolean
+opener_launch(Opener *o, GFile *p_file, const OpenerProg *p_prog,
+ GError **p_err) {
+ (void)o;
+ g_return_val_if_fail(G_IS_FILE(p_file), FALSE);
+ g_return_val_if_fail(p_prog, FALSE);
+ char **argv = _expand_command(p_prog->c_command, p_file);
+ GSubprocess *p_sub = g_subprocess_newv((const char *const *)argv,
+ G_SUBPROCESS_FLAGS_NONE, p_err);
+ g_strfreev(argv);
+ if (p_sub == NULL)
+ return FALSE;
+ /* Detached: don't wait. Unref immediately (process keeps running). */
+ g_object_unref(p_sub);
+ return TRUE;
+} \ No newline at end of file
diff --git a/src/opener.h b/src/opener.h
new file mode 100644
index 0000000..60d6c03
--- /dev/null
+++ b/src/opener.h
@@ -0,0 +1,28 @@
+#ifndef GGAZE_OPENER_H
+#define GGAZE_OPENER_H
+
+#include <gio/gio.h>
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+typedef struct {
+ char *c_name;
+ char *c_command; /* may contain %f */
+} OpenerProg;
+
+typedef struct Opener Opener;
+
+Opener *opener_new(void);
+void opener_delete(Opener *p_o);
+void opener_set_progs(Opener *p_o, const GPtrArray *p_progs);
+const GPtrArray *opener_get_progs(Opener *p_o);
+
+/* Launch the program with %f expanded to the file's path. Detached. Returns
+ * TRUE if the process was started. */
+gboolean opener_launch(Opener *p_o, GFile *p_file, const OpenerProg *p_prog,
+ GError **p_err);
+
+G_END_DECLS
+
+#endif \ No newline at end of file
diff --git a/src/runner.c b/src/runner.c
new file mode 100644
index 0000000..8166e3e
--- /dev/null
+++ b/src/runner.c
@@ -0,0 +1,135 @@
+/* runner.c — async shell script runner with %f/%d expansion + injection guard.
+ */
+#include "runner.h"
+
+struct Runner {
+ GPtrArray *p_scripts;
+};
+
+static void
+_script_free(gpointer p) {
+ RunnerScript *d = (RunnerScript *)p;
+ if (d) {
+ g_free(d->c_name);
+ g_free(d->c_command);
+ g_free(d);
+ }
+}
+
+Runner *
+runner_new(void) {
+ Runner *r = g_new0(Runner, 1);
+ r->p_scripts = g_ptr_array_new_with_free_func(_script_free);
+ return r;
+}
+
+void
+runner_delete(Runner *r) {
+ if (!r)
+ return;
+ g_ptr_array_unref(r->p_scripts);
+ g_free(r);
+}
+
+void
+runner_set_scripts(Runner *r, const GPtrArray *p) {
+ g_return_if_fail(r);
+ g_ptr_array_set_size(r->p_scripts, 0);
+ if (!p)
+ return;
+ for (guint i = 0; i < p->len; i++) {
+ const RunnerScript *src = g_ptr_array_index((GPtrArray *)p, i);
+ RunnerScript *ns = g_new(RunnerScript, 1);
+ ns->c_name = g_strdup(src->c_name);
+ ns->c_command = g_strdup(src->c_command);
+ g_ptr_array_add(r->p_scripts, ns);
+ }
+}
+
+const GPtrArray *
+runner_get_scripts(Runner *r) {
+ return r ? r->p_scripts : NULL;
+}
+
+/* Single-quote a path for safe shell interpolation. Caller frees. */
+static char *
+_shell_quote(const char *c_path) {
+ /* Replace each ' with '\'' and wrap in single quotes. */
+ char *c_escaped = g_strdup_printf("'%s'", c_path);
+ /* Simple approach: g_shell_quote does this correctly. */
+ g_free(c_escaped);
+ return g_shell_quote(c_path);
+}
+
+/* Replace all occurrences of c_old with c_new in c_str. Caller frees. */
+static char *
+_str_replace(const char *c_str, const char *c_old, const char *c_new) {
+ if (c_str == NULL || c_old == NULL || c_new == NULL)
+ return NULL;
+ GString *p_out = g_string_new(NULL);
+ const char *p = c_str;
+ gsize u_old_len = strlen(c_old);
+ while (*p) {
+ if (strncmp(p, c_old, u_old_len) == 0) {
+ g_string_append(p_out, c_new);
+ p += u_old_len;
+ } else {
+ g_string_append_c(p_out, *p);
+ p++;
+ }
+ }
+ return g_string_free(p_out, FALSE);
+}
+
+static char *
+_expand(const char *c_cmd, GFile *p_file, GFile *p_dir) {
+ char *c_fpath = p_file ? g_file_get_path(p_file) : g_strdup("");
+ char *c_dpath = p_dir ? g_file_get_path(p_dir) : g_strdup("");
+ char *c_fq = _shell_quote(c_fpath);
+ char *c_dq = _shell_quote(c_dpath);
+ g_free(c_fpath);
+ g_free(c_dpath);
+ char *r1 = _str_replace(c_cmd, "%f", c_fq);
+ char *r2 = _str_replace(r1 ? r1 : c_cmd, "%d", c_dq);
+ g_free(r1);
+ g_free(c_fq);
+ g_free(c_dq);
+ return r2 ? r2 : g_strdup(c_cmd);
+}
+
+gboolean
+runner_run(Runner *r, GFile *p_file, GFile *p_dir, const RunnerScript *p_script,
+ GAsyncReadyCallback p_cb, gpointer p_data, GError **p_err) {
+ (void)r;
+ g_return_val_if_fail(p_script, FALSE);
+ char *c_cmd = _expand(p_script->c_command, p_file, p_dir);
+ char *c_full = g_strdup_printf("/bin/sh -c %s", c_cmd);
+ g_free(c_cmd);
+ char **argv = NULL;
+ if (!g_shell_parse_argv(c_full, NULL, &argv, p_err)) {
+ g_free(c_full);
+ return FALSE;
+ }
+ g_free(c_full);
+ GSubprocess *p_sub = g_subprocess_newv((const char *const *)argv,
+ G_SUBPROCESS_FLAGS_NONE, p_err);
+ g_strfreev(argv);
+ if (p_sub == NULL)
+ return FALSE;
+ if (p_cb) {
+ g_subprocess_wait_check_async(p_sub, NULL, p_cb, p_data);
+ }
+ g_object_unref(p_sub);
+ return TRUE;
+}
+
+int
+runner_run_finish(Runner *r, GAsyncResult *p_res, GError **p_err) {
+ (void)r;
+ GSubprocess *p_sub = G_SUBPROCESS(g_async_result_get_source_object(p_res));
+ if (p_sub == NULL)
+ return -1;
+ gboolean ok = g_subprocess_wait_check_finish(p_sub, p_res, p_err);
+ g_object_unref(p_sub);
+ return ok ? 0 : -1;
+} \ No newline at end of file
diff --git a/src/runner.h b/src/runner.h
new file mode 100644
index 0000000..94f5d83
--- /dev/null
+++ b/src/runner.h
@@ -0,0 +1,33 @@
+#ifndef GGAZE_RUNNER_H
+#define GGAZE_RUNNER_H
+
+#include <gio/gio.h>
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+typedef struct {
+ char *c_name;
+ char *c_command; /* may contain %f and %d */
+} RunnerScript;
+
+typedef struct Runner Runner;
+
+Runner *runner_new(void);
+void runner_delete(Runner *p_r);
+void runner_set_scripts(Runner *p_r, const GPtrArray *p_scripts);
+const GPtrArray *runner_get_scripts(Runner *p_r);
+
+/* Run the script via /bin/sh -c with %f and %d expanded (single-quoted).
+ * Async: calls p_cb (on the main thread) when the process exits. p_data
+ * is passed to p_cb as-is. */
+gboolean runner_run(Runner *p_r, GFile *p_file, GFile *p_dir,
+ const RunnerScript *p_script, GAsyncReadyCallback p_cb,
+ gpointer p_data, GError **p_err);
+
+/* Finish: returns the exit code, or -1 on error. */
+int runner_run_finish(Runner *p_r, GAsyncResult *p_res, GError **p_err);
+
+G_END_DECLS
+
+#endif \ No newline at end of file
diff --git a/tests/meson.build b/tests/meson.build
index dbe21f2..4c1dfa5 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -119,6 +119,30 @@ test_info = executable(
)
test('info', test_info, suite : 'unit', env : fixtures_env)
+test_mover = executable(
+ 'test_mover', ['test_mover.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : [glib_dep, gio_dep],
+ link_with : ggaze_lib, install : false,
+)
+test('mover', test_mover, suite : 'unit', env : fixtures_env)
+
+test_opener = executable(
+ 'test_opener', ['test_opener.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : [glib_dep, gio_dep],
+ link_with : ggaze_lib, install : false,
+)
+test('opener', test_opener, suite : 'unit', env : fixtures_env)
+
+test_runner = executable(
+ 'test_runner', ['test_runner.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : [glib_dep, gio_dep],
+ link_with : ggaze_lib, install : false,
+)
+test('runner', test_runner, suite : 'unit', env : fixtures_env)
+
test_texturecache = executable(
'test_texturecache',
['test_texturecache.c', ggaze_conf_h],
diff --git a/tests/test_mover.c b/tests/test_mover.c
new file mode 100644
index 0000000..0310026
--- /dev/null
+++ b/tests/test_mover.c
@@ -0,0 +1,99 @@
+/* test_mover.c — move + undo + collision suffixing. */
+#include "mover.h"
+#include <gio/gio.h>
+#include <glib.h>
+
+static char *
+make_tmp_dir(void) {
+ GError *e = NULL;
+ char *d = g_dir_make_tmp("ggaze-mover-XXXXXX", &e);
+ g_assert_no_error(e);
+ return d;
+}
+static GFile *
+write_file(const char *d, const char *n) {
+ char *p = g_build_filename(d, n, NULL);
+ GFile *f = g_file_new_for_path(p);
+ g_file_replace_contents(f, "x", 1, NULL, FALSE,
+ G_FILE_CREATE_REPLACE_DESTINATION, NULL, NULL, NULL);
+ g_free(p);
+ return f;
+}
+static void
+cleanup_dir(char *d) {
+ GFile *dd = g_file_new_for_path(d);
+ GFileEnumerator *e = g_file_enumerate_children(
+ dd, "standard::name", G_FILE_QUERY_INFO_NONE, NULL, NULL);
+ if (e) {
+ GFileInfo *i;
+ while ((i = g_file_enumerator_next_file(e, NULL, NULL))) {
+ GFile *c = g_file_get_child(dd, g_file_info_get_name(i));
+ g_file_delete(c, NULL, NULL);
+ g_object_unref(c);
+ g_object_unref(i);
+ }
+ g_object_unref(e);
+ }
+ g_file_delete(dd, NULL, NULL);
+ g_object_unref(dd);
+ g_free(d);
+}
+
+static void
+test_move_and_undo(void) {
+ char *src_dir = make_tmp_dir();
+ char *dst_dir = make_tmp_dir();
+ GFile *a = write_file(src_dir, "a.jpg");
+ GFile *b = write_file(src_dir, "b.jpg");
+ Mover *m = mover_new();
+ MoverDest dest = {"dst", dst_dir};
+ GList *files = g_list_prepend(g_list_prepend(NULL, a), b);
+ GError *e = NULL;
+ g_assert_true(mover_move(m, files, &dest, &e));
+ g_assert_no_error(e);
+ g_assert_false(g_file_query_exists(a, NULL));
+ g_assert_false(g_file_query_exists(b, NULL));
+ g_assert_true(mover_can_undo(m));
+ g_assert_true(mover_undo_last(m, &e));
+ g_assert_true(g_file_query_exists(a, NULL));
+ g_assert_true(g_file_query_exists(b, NULL));
+ g_assert_false(mover_can_undo(m));
+ g_list_free(files);
+ mover_delete(m);
+ g_object_unref(a);
+ g_object_unref(b);
+ cleanup_dir(src_dir);
+ cleanup_dir(dst_dir);
+}
+
+static void
+test_collision(void) {
+ char *src_dir = make_tmp_dir();
+ char *dst_dir = make_tmp_dir();
+ GFile *a = write_file(src_dir, "a.jpg");
+ GFile *p_dst_a = write_file(dst_dir, "a.jpg");
+ Mover *m = mover_new();
+ MoverDest dest = {"dst", dst_dir};
+ GList *files = g_list_prepend(NULL, a);
+ GError *e = NULL;
+ g_assert_true(mover_move(m, files, &dest, &e));
+ GFile *p_dd = g_file_new_for_path(dst_dir);
+ GFile *p_a1 = g_file_get_child(p_dd, "a-1.jpg");
+ g_assert_true(g_file_query_exists(p_a1, NULL));
+ g_object_unref(p_a1);
+ g_object_unref(p_dd);
+ g_list_free(files);
+ mover_delete(m);
+ g_object_unref(p_dst_a);
+ g_object_unref(a);
+ cleanup_dir(src_dir);
+ cleanup_dir(dst_dir);
+}
+
+int
+main(int argc, char **argv) {
+ g_test_init(&argc, &argv, NULL);
+ g_test_add_func("/mover/move_undo", test_move_and_undo);
+ g_test_add_func("/mover/collision", test_collision);
+ return g_test_run();
+} \ No newline at end of file
diff --git a/tests/test_opener.c b/tests/test_opener.c
new file mode 100644
index 0000000..a364bdb
--- /dev/null
+++ b/tests/test_opener.c
@@ -0,0 +1,85 @@
+/* test_opener.c — %f expansion + launch with true/false. */
+#include "opener.h"
+#include <gio/gio.h>
+#include <glib.h>
+
+static GFile *
+make_tmp_file(void) {
+ GError *e = NULL;
+ char *d = g_dir_make_tmp("ggaze-opener-XXXXXX", &e);
+ g_assert_no_error(e);
+ char *p = g_build_filename(d, "test.txt", NULL);
+ GFile *f = g_file_new_for_path(p);
+ g_file_replace_contents(f, "x", 1, NULL, FALSE,
+ G_FILE_CREATE_REPLACE_DESTINATION, NULL, NULL, NULL);
+ g_free(p);
+ g_free(d);
+ return f;
+}
+
+static void
+test_launch_true(void) {
+ GFile *f = make_tmp_file();
+ Opener *o = opener_new();
+ OpenerProg prog = {"true", "true %f"};
+ GError *e = NULL;
+ g_assert_true(opener_launch(o, f, &prog, &e));
+ g_assert_no_error(e);
+ opener_delete(o);
+ g_object_unref(f);
+}
+
+static void
+test_launch_false(void) {
+ GFile *f = make_tmp_file();
+ Opener *o = opener_new();
+ OpenerProg prog = {"false", "false %f"};
+ GError *e = NULL;
+ g_assert_true(opener_launch(o, f, &prog, &e));
+ g_assert_no_error(e);
+ opener_delete(o);
+ g_object_unref(f);
+}
+
+static void
+test_weird_filename(void) {
+ GError *e = NULL;
+ char *d = g_dir_make_tmp("ggaze-opener-XXXXXX", &e);
+ g_assert_no_error(e);
+ char *p = g_build_filename(d, "file with spaces $HOME `whoami`.jpg", NULL);
+ GFile *f = g_file_new_for_path(p);
+ g_file_replace_contents(f, "x", 1, NULL, FALSE,
+ G_FILE_CREATE_REPLACE_DESTINATION, NULL, NULL, NULL);
+ Opener *o = opener_new();
+ OpenerProg prog = {"true", "true %f"};
+ g_assert_true(opener_launch(o, f, &prog, &e));
+ g_assert_no_error(e);
+ opener_delete(o);
+ g_object_unref(f);
+ g_free(p);
+ GFile *dd = g_file_new_for_path(d);
+ GFileEnumerator *en = g_file_enumerate_children(
+ dd, "standard::name", G_FILE_QUERY_INFO_NONE, NULL, NULL);
+ if (en) {
+ GFileInfo *i;
+ while ((i = g_file_enumerator_next_file(en, NULL, NULL))) {
+ GFile *c = g_file_get_child(dd, g_file_info_get_name(i));
+ g_file_delete(c, NULL, NULL);
+ g_object_unref(c);
+ g_object_unref(i);
+ }
+ g_object_unref(en);
+ }
+ g_file_delete(dd, NULL, NULL);
+ g_object_unref(dd);
+ g_free(d);
+}
+
+int
+main(int argc, char **argv) {
+ g_test_init(&argc, &argv, NULL);
+ g_test_add_func("/opener/true", test_launch_true);
+ g_test_add_func("/opener/false", test_launch_false);
+ g_test_add_func("/opener/weird_filename", test_weird_filename);
+ return g_test_run();
+} \ No newline at end of file
diff --git a/tests/test_runner.c b/tests/test_runner.c
new file mode 100644
index 0000000..172368b
--- /dev/null
+++ b/tests/test_runner.c
@@ -0,0 +1,101 @@
+/* test_runner.c — %f/%d expansion, injection guard, exit status. */
+#include "runner.h"
+#include <gio/gio.h>
+#include <glib.h>
+
+static GMainLoop *g_loop;
+static int g_exit_code;
+
+static void
+_done_cb(GObject *p_src, GAsyncResult *p_res, gpointer p_data) {
+ (void)p_src;
+ (void)p_data;
+ GError *e = NULL;
+ g_exit_code = runner_run_finish(NULL, p_res, &e);
+ g_clear_error(&e);
+ g_main_loop_quit(g_loop);
+}
+
+static void
+run_and_wait(Runner *r, GFile *p_file, GFile *p_dir,
+ const RunnerScript *p_script) {
+ g_exit_code = -99;
+ g_loop = g_main_loop_new(NULL, FALSE);
+ GError *e = NULL;
+ g_assert_true(runner_run(r, p_file, p_dir, p_script, _done_cb, NULL, &e));
+ g_assert_no_error(e);
+ g_main_loop_run(g_loop);
+ g_main_loop_unref(g_loop);
+}
+
+static void
+test_true_exit_zero(void) {
+ Runner *r = runner_new();
+ RunnerScript s = {"true", "true"};
+ GFile *f = g_file_new_for_path("/tmp/nonexistent");
+ run_and_wait(r, f, NULL, &s);
+ g_assert_cmpint(g_exit_code, ==, 0);
+ runner_delete(r);
+ g_object_unref(f);
+}
+
+static void
+test_false_exit_nonzero(void) {
+ Runner *r = runner_new();
+ RunnerScript s = {"false", "false"};
+ GFile *f = g_file_new_for_path("/tmp/nonexistent");
+ run_and_wait(r, f, NULL, &s);
+ g_assert_cmpint(g_exit_code, !=, 0);
+ runner_delete(r);
+ g_object_unref(f);
+}
+
+static void
+test_injection_guard(void) {
+ GError *e = NULL;
+ char *d = g_dir_make_tmp("ggaze-runner-XXXXXX", &e);
+ g_assert_no_error(e);
+ /* Create a file with a malicious name. */
+ char *p = g_build_filename(d, ";echo HACKED;.jpg", NULL);
+ GFile *f = g_file_new_for_path(p);
+ g_file_replace_contents(f, "x", 1, NULL, FALSE,
+ G_FILE_CREATE_REPLACE_DESTINATION, NULL, NULL, NULL);
+ g_free(p);
+ GFile *dd = g_file_new_for_path(d);
+
+ Runner *r = runner_new();
+ /* true %f should succeed regardless of the filename (injection guard).
+ * The single-quoted path is a valid argument to true. */
+ RunnerScript s = {"true", "true %f"};
+ run_and_wait(r, f, dd, &s);
+ g_assert_cmpint(g_exit_code, ==, 0);
+
+ runner_delete(r);
+ g_object_unref(f);
+ g_object_unref(dd);
+ GFile *ddd = g_file_new_for_path(d);
+ GFileEnumerator *en = g_file_enumerate_children(
+ ddd, "standard::name", G_FILE_QUERY_INFO_NONE, NULL, NULL);
+ if (en) {
+ GFileInfo *i;
+ while ((i = g_file_enumerator_next_file(en, NULL, NULL))) {
+ GFile *c = g_file_get_child(ddd, g_file_info_get_name(i));
+ g_file_delete(c, NULL, NULL);
+ g_object_unref(c);
+ g_object_unref(i);
+ }
+ g_object_unref(en);
+ }
+ g_file_delete(ddd, NULL, NULL);
+ g_object_unref(ddd);
+ g_free(d);
+}
+
+int
+main(int argc, char **argv) {
+ g_test_init(&argc, &argv, NULL);
+ g_test_add_func("/runner/true_exit_zero", test_true_exit_zero);
+ g_test_add_func("/runner/false_exit_nonzero", test_false_exit_nonzero);
+ g_test_add_func("/runner/injection_guard", test_injection_guard);
+ return g_test_run();
+} \ No newline at end of file