/* hooks.c * * Copyright 2026 Eva M * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * SPDX-License-Identifier: GPL-3.0-or-later */ #define G_LOG_DOMAIN "BAZAAR::HOOKS" #include #include "env.h" #include "error.h" #include "hooks.h" #include "util.h" BZ_DEFINE_DATA ( dialog, Dialog, { char *id; AdwDialog *dialog; }, BZ_RELEASE_DATA (id, g_free); BZ_RELEASE_DATA (dialog, g_object_unref)); static DexFuture * execute_hook_fiber (BzHook *hook, BzHookTransactionType ts_type, const char *ts_appid, BzEntryGroup *group); static DexFuture * run_emission_fiber (GListModel *hooks, BzHookSignal signal, BzHookTransactionType ts_type, const char *ts_appid, BzEntryGroup *group); DexFuture * bz_execute_hook (BzHook *hook, BzHookTransactionType ts_type, const char *ts_appid, BzEntryGroup *group) { dex_return_error_if_fail (BZ_IS_HOOK (hook)); return dex_scheduler_spawnv ( dex_scheduler_get_default (), bz_get_dex_stack_size (), G_CALLBACK (execute_hook_fiber), 4, BZ_TYPE_HOOK, hook, BZ_TYPE_HOOK_TRANSACTION_TYPE, ts_type, G_TYPE_STRING, ts_appid, BZ_TYPE_ENTRY_GROUP, group); } DexFuture * bz_run_hook_emission (GListModel *hooks, BzHookSignal signal, BzHookTransactionType ts_type, const char *ts_appid, BzEntryGroup *group) { dex_return_error_if_fail (G_IS_LIST_MODEL (hooks)); return dex_scheduler_spawnv ( dex_scheduler_get_default (), bz_get_dex_stack_size (), G_CALLBACK (run_emission_fiber), 5, G_TYPE_LIST_MODEL, hooks, BZ_TYPE_HOOK_SIGNAL, signal, BZ_TYPE_HOOK_TRANSACTION_TYPE, ts_type, G_TYPE_STRING, ts_appid, BZ_TYPE_ENTRY_GROUP, group); } static DexFuture * execute_hook_fiber (BzHook *hook, BzHookTransactionType ts_type, const char *ts_appid, BzEntryGroup *group) { BzHookSignal signal = 0; g_autoptr (GEnumClass) signal_enum_class = NULL; g_autoptr (GEnumClass) ts_type_enum_class = NULL; GEnumValue *signal_enum = NULL; GEnumValue *ts_type_enum = NULL; g_autoptr (GDateTime) date = NULL; g_autofree char *timestamp_sec = NULL; g_autofree char *timestamp_usec = NULL; const char *id = NULL; const char *shell = NULL; g_autoptr (GPtrArray) dialogs = NULL; g_autoptr (DialogData) current_dialog = NULL; gboolean hook_aborted = FALSE; gboolean finish = FALSE; signal = bz_hook_get_when (hook); switch (signal) { case BZ_HOOK_SIGNAL_BEFORE_TRANSACTION: case BZ_HOOK_SIGNAL_AFTER_TRANSACTION: dex_return_error_if_fail (ts_appid != NULL); break; case BZ_HOOK_SIGNAL_VIEW_APP: case BZ_HOOK_SIGNAL_ARTICLE_APP: default: dex_return_error_if_fail (BZ_IS_ENTRY_GROUP (group)); break; } signal_enum_class = g_type_class_ref (BZ_TYPE_HOOK_SIGNAL); ts_type_enum_class = g_type_class_ref (BZ_TYPE_HOOK_TRANSACTION_TYPE); signal_enum = g_enum_get_value (signal_enum_class, signal); ts_type_enum = g_enum_get_value (ts_type_enum_class, ts_type); date = g_date_time_new_now_utc (); timestamp_sec = g_strdup_printf ("%zu", g_date_time_to_unix (date)); timestamp_usec = g_strdup_printf ("%zu", g_date_time_to_unix_usec (date)); id = bz_hook_get_id (hook); shell = bz_hook_get_shell (hook); if (shell == NULL) { g_warning ("Main Config: hook definition must have shell code, skipping this hook"); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } dialogs = g_ptr_array_new_with_free_func (dialog_data_unref); if (bz_hook_get_dialogs (hook) != NULL) { GListModel *config_dialogs = NULL; guint n_dialogs = 0; config_dialogs = bz_hook_get_dialogs (hook); n_dialogs = g_list_model_get_n_items (config_dialogs); for (guint i = 0; i < n_dialogs; i++) { g_autoptr (BzHookDialog) config_dialog = NULL; const char *dialog_id = NULL; const char *dialog_title = NULL; const char *dialog_body = NULL; gboolean dialog_body_use_markup = FALSE; const char *dialog_default_response = NULL; g_autoptr (AdwDialog) dialog = NULL; guint n_opts = 0; g_autoptr (DialogData) dialog_data = NULL; config_dialog = g_list_model_get_item (config_dialogs, i); dialog_id = bz_hook_dialog_get_id (config_dialog); dialog_title = bz_hook_dialog_get_title (config_dialog); dialog_body = bz_hook_dialog_get_body (config_dialog); dialog_body_use_markup = bz_hook_dialog_get_body_use_markup (config_dialog); dialog_default_response = bz_hook_dialog_get_default_response_id (config_dialog); if (dialog_title == NULL || dialog_body == NULL) { g_warning ("Main Config: dialog definition must have a title and body, skipping this hook"); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } if (dialog_default_response == NULL) { g_warning ("Main Config: dialog definition must have a default response, skipping this hook"); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } dialog = g_object_ref_sink (adw_alert_dialog_new (dialog_title, dialog_body)); if (bz_hook_dialog_get_options (config_dialog) != NULL) { GListModel *config_opts = NULL; guint n_options = 0; config_opts = bz_hook_dialog_get_options (config_dialog); n_options = g_list_model_get_n_items (config_opts); for (guint j = 0; j < n_options; j++) { g_autoptr (BzHookDialogOption) config_opt = NULL; const char *opt_id = NULL; const char *opt_string = NULL; const char *opt_style = NULL; config_opt = g_list_model_get_item (config_opts, j); opt_id = bz_hook_dialog_option_get_id (config_opt); if (opt_id == NULL) { g_warning ("Main Config: dialog option definition must have an id, skipping this hook"); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } opt_string = bz_hook_dialog_option_get_string (config_opt); if (opt_string == NULL) { g_warning ("Main Config: dialog option definition must have a string, skipping this hook"); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } opt_style = bz_hook_dialog_option_get_style (config_opt); adw_alert_dialog_add_response (ADW_ALERT_DIALOG (dialog), opt_id, opt_string); if (opt_style != NULL) { AdwResponseAppearance appearance = ADW_RESPONSE_DEFAULT; if (g_strcmp0 (opt_style, "suggested") == 0) appearance = ADW_RESPONSE_SUGGESTED; else if (g_strcmp0 (opt_style, "destructive") == 0) appearance = ADW_RESPONSE_DESTRUCTIVE; else g_warning ("Main Config: dialog option definition appearance can be " "\"suggested\" or \"destructive\". \"%s\" is invalid.", opt_style); adw_alert_dialog_set_response_appearance ( ADW_ALERT_DIALOG (dialog), opt_id, appearance); } n_opts++; } } if (n_opts == 0) { g_warning ("Main Config: dialog definition must have options, skipping this hook"); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } adw_alert_dialog_set_body_use_markup ( ADW_ALERT_DIALOG (dialog), dialog_body_use_markup); adw_alert_dialog_set_default_response ( ADW_ALERT_DIALOG (dialog), dialog_default_response); dialog_data = dialog_data_new (); dialog_data->id = dialog_id != NULL ? g_strdup (dialog_id) : NULL; dialog_data->dialog = g_steal_pointer (&dialog); g_ptr_array_add (dialogs, g_steal_pointer (&dialog_data)); } } for (guint stage = 0;; stage++) { g_autoptr (GError) local_error = NULL; g_autoptr (GSubprocessLauncher) launcher = NULL; g_autofree char *stage_str = NULL; const char *hook_stage = NULL; g_autoptr (GSubprocess) subprocess = NULL; gboolean result = FALSE; GInputStream *stdout_pipe = NULL; g_autoptr (GBytes) stdout_bytes = NULL; gsize stdout_size = 0; gconstpointer stdout_data = NULL; g_autofree char *stdout_str = NULL; char *stdout_newline = NULL; launcher = g_subprocess_launcher_new (G_SUBPROCESS_FLAGS_STDOUT_PIPE); g_subprocess_launcher_set_cwd (launcher, g_get_home_dir ()); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_INITIATED_UNIX_STAMP", timestamp_sec, TRUE); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_INITIATED_UNIX_STAMP_USEC", timestamp_usec, TRUE); stage_str = g_strdup_printf ("%d", stage); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_STAGE_IDX", stage_str, TRUE); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_ID", id, TRUE); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_TYPE", signal_enum->value_nick, TRUE); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_WAS_ABORTED", hook_aborted ? "true" : "false", TRUE); switch (signal) { case BZ_HOOK_SIGNAL_BEFORE_TRANSACTION: case BZ_HOOK_SIGNAL_AFTER_TRANSACTION: g_subprocess_launcher_setenv (launcher, "BAZAAR_TS_APPID", ts_appid, TRUE); g_subprocess_launcher_setenv (launcher, "BAZAAR_TS_TYPE", ts_type_enum->value_nick, TRUE); break; case BZ_HOOK_SIGNAL_VIEW_APP: case BZ_HOOK_SIGNAL_ARTICLE_APP: default: g_subprocess_launcher_setenv (launcher, "BAZAAR_APPID", bz_entry_group_get_id (group), TRUE); break; } if (finish) hook_stage = "teardown"; else if (hook_aborted) hook_stage = "catch"; else if (stage == 0) hook_stage = "setup"; else if (current_dialog != NULL) { GApplication *application = NULL; GtkWindow *window = NULL; g_autofree char *response = NULL; hook_stage = "teardown-dialog"; application = g_application_get_default (); window = gtk_application_get_active_window (GTK_APPLICATION (application)); if (window != NULL) { adw_dialog_present (current_dialog->dialog, GTK_WIDGET (window)); response = dex_await_string ( bz_make_alert_dialog_future (ADW_ALERT_DIALOG (current_dialog->dialog)), &local_error); if (response == NULL) g_warning ("Failed to resolve response from dialog " "\"%s\", assuming default response \"%s\": %s", current_dialog->id, adw_alert_dialog_get_default_response ( ADW_ALERT_DIALOG (current_dialog->dialog)), local_error->message); g_clear_pointer (&local_error, g_error_free); } else g_warning ("A window was not available to present dialog " "\"%s\" on, assuming default response \"%s\"", current_dialog->id, adw_alert_dialog_get_default_response ( ADW_ALERT_DIALOG (current_dialog->dialog))); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_DIALOG_ID", current_dialog->id, TRUE); g_subprocess_launcher_setenv ( launcher, "BAZAAR_HOOK_DIALOG_RESPONSE_ID", response != NULL ? response : adw_alert_dialog_get_default_response ( ADW_ALERT_DIALOG (current_dialog->dialog)), TRUE); g_clear_pointer (¤t_dialog, dialog_data_unref); } else if (dialogs->len > 0) { hook_stage = "setup-dialog"; current_dialog = g_ptr_array_steal_index (dialogs, 0); g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_DIALOG_ID", current_dialog->id, TRUE); } else hook_stage = "action"; g_subprocess_launcher_setenv (launcher, "BAZAAR_HOOK_STAGE", hook_stage, TRUE); subprocess = g_subprocess_launcher_spawn ( launcher, &local_error, "/bin/sh", "-c", shell, NULL); if (subprocess == NULL) { g_warning ("Hook failed to spawn, abandoning it now: %s", local_error->message); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } result = dex_await ( dex_subprocess_wait_check (subprocess), &local_error); if (!result) { g_warning ("Hook failed to exit cleanly, abandoning it now: %s", local_error->message); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } stdout_pipe = g_subprocess_get_stdout_pipe (subprocess); stdout_bytes = g_input_stream_read_bytes (stdout_pipe, 1024, NULL, &local_error); if (!stdout_bytes) { g_warning ("Failed to read stdout pipe of hook, abandoning it now: %s", local_error->message); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } stdout_data = g_bytes_get_data (stdout_bytes, &stdout_size); stdout_str = g_malloc (stdout_size + 1); memcpy (stdout_str, stdout_data, stdout_size); stdout_str[stdout_size] = '\0'; stdout_newline = strchr (stdout_str, '\n'); if (stdout_newline != NULL) *stdout_newline = '\0'; if (g_strcmp0 (hook_stage, "setup") == 0) { if (g_strcmp0 (stdout_str, "ok") == 0) continue; else if (g_strcmp0 (stdout_str, "pass") == 0) return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } else if (g_strcmp0 (hook_stage, "setup-dialog") == 0) { if (g_strcmp0 (stdout_str, "ok") == 0) continue; else if (g_strcmp0 (stdout_str, "pass") == 0) { g_clear_pointer (¤t_dialog, dialog_data_unref); continue; } } else if (g_strcmp0 (hook_stage, "teardown-dialog") == 0) { if (g_strcmp0 (stdout_str, "ok") == 0) continue; else if (g_strcmp0 (stdout_str, "abort") == 0) { hook_aborted = TRUE; continue; } } else if (g_strcmp0 (hook_stage, "catch") == 0) { if (g_strcmp0 (stdout_str, "recover") == 0) { hook_aborted = FALSE; continue; } else if (g_strcmp0 (stdout_str, "abort") == 0) { finish = TRUE; continue; } } else if (g_strcmp0 (hook_stage, "action") == 0) { finish = TRUE; continue; } else if (g_strcmp0 (hook_stage, "teardown") == 0) { if (g_strcmp0 (stdout_str, "continue") == 0) return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); else if (g_strcmp0 (stdout_str, "stop") == 0) return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_STOP); else if (g_strcmp0 (stdout_str, "confirm") == 0) return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONFIRM); else if (g_strcmp0 (stdout_str, "deny") == 0) return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_DENY); } else g_assert_not_reached (); g_warning ("Received invalid response from hook for stage \"%s\", abandoning it now", hook_stage); return dex_future_new_for_int (BZ_HOOK_RETURN_STATUS_CONTINUE); } } static DexFuture * run_emission_fiber (GListModel *hooks, BzHookSignal signal, BzHookTransactionType ts_type, const char *ts_appid, BzEntryGroup *group) { guint n_hooks = 0; n_hooks = g_list_model_get_n_items (hooks); for (guint i = 0; i < n_hooks; i++) { g_autoptr (BzHook) hook = NULL; BzHookSignal when = 0; hook = g_list_model_get_item (hooks, i); when = bz_hook_get_when (hook); if (when == signal) { const char *check_appid_regex = NULL; BzHookReturnStatus hook_result = BZ_HOOK_RETURN_STATUS_CONTINUE; check_appid_regex = bz_hook_get_check_appid_regex (hook); if (check_appid_regex != NULL) { GRegex *regex = NULL; regex = g_object_get_data (G_OBJECT (hook), "check-appid-regex"); if (regex == NULL) { g_autoptr (GError) local_error = NULL; regex = g_regex_new ( check_appid_regex, G_REGEX_OPTIMIZE, G_REGEX_MATCH_DEFAULT, &local_error); if (regex != NULL) g_object_set_data_full ( G_OBJECT (hook), "check-appid-regex", regex, (GDestroyNotify) g_regex_unref); else g_warning ("Hook check-appid-regex property '%s' " "is an invalid regex string: %s", check_appid_regex, local_error->message); } if (regex != NULL && !g_regex_match (regex, ts_appid, G_REGEX_MATCH_DEFAULT, NULL)) continue; } hook_result = dex_await_int ( bz_execute_hook (hook, ts_type, ts_appid, group), NULL); if (hook_result == BZ_HOOK_RETURN_STATUS_CONFIRM || hook_result == BZ_HOOK_RETURN_STATUS_STOP) break; else if (hook_result == BZ_HOOK_RETURN_STATUS_DENY) return dex_future_new_reject ( G_IO_ERROR, G_IO_ERROR_UNKNOWN, "Prevented by a configured hook"); } } return dex_future_new_true (); }