🏡 index : quine-global-labs/bazaar.git

/* 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 <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

#define G_LOG_DOMAIN "BAZAAR::HOOKS"

#include <adwaita.h>

#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 (&current_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 (&current_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 ();
}