From 76ac910b118147beba276748d2e2aaca0ee066de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Ekl=C3=B6f?= Date: Wed, 31 Jul 2024 16:22:17 +0200 Subject: [PATCH] osc: kitty notifications: buttons, icons, app-name, categories etc First, icons have been finalized in the specification. There were only three things we needed to adjust: * symbolic names are base64 encoded * there are a couple of OSC-99 defined symbolic names that need to be translated to the corresponding XDG icon name. * allow in-band icons without a cache ID (that is, allow applications to use p=icon without having to cache the icon first). Second, add support for the following new additions to the protocol: * 'f': custom app-name, overrides the terminal's app-id * 't': categories * 'p=alive': lets applications poll for currently active notifications * 'id' is now 'unset' by default, rather than "0" * 'w': expire time (i.e. notification timeout) * "buttons": aka actions. This lets applications add additional (to the terminal defined "default" action) actions. The 'activated' event has been updated to report which button/action was used to activate the notification. To support button/actions, desktop-notifications.command had to be reworked a bit. There's now a new config option: desktop-notifications.command-action-arg. It has two template arguments ${action-name} and ${action-label}. command-action-arg gets expanded for *each* action. ${action-name} and ${action-label} has been replaced by ${action-arg} in command. This is a somewhat special template, in that it gets replaced by *all* instances of the expanded actions. --- config.c | 17 ++- config.h | 1 + doc/foot.ini.5.scd | 120 ++++++++++++++--- foot.ini | 3 +- notify.c | 322 +++++++++++++++++++++++++++++++++++++------- notify.h | 13 ++ osc.c | 176 +++++++++++++++++++++++- terminal.c | 4 +- tests/test-config.c | 2 + 9 files changed, 580 insertions(+), 78 deletions(-) diff --git a/config.c b/config.c index 7b403e3c..e45a02a7 100644 --- a/config.c +++ b/config.c @@ -806,6 +806,11 @@ value_to_spawn_template(struct context *ctx, char **argv = NULL; + if (ctx->value[0] == '"' && ctx->value[1] == '"' && ctx->value[2] == '\0') { + template->argv.args = NULL; + return true; + } + if (!tokenize_cmdline(ctx->value, &argv)) { LOG_CONTEXTUAL_ERR("syntax error in command line"); return false; @@ -1110,6 +1115,9 @@ parse_section_desktop_notifications(struct context *ctx) if (streq(key, "command")) return value_to_spawn_template( ctx, &conf->desktop_notifications.command); + else if (streq(key, "command-action-arg")) + return value_to_spawn_template( + ctx, &conf->desktop_notifications.command_action_arg); else if (streq(key, "close")) return value_to_spawn_template( ctx, &conf->desktop_notifications.close); @@ -3189,6 +3197,9 @@ config_load(struct config *conf, const char *conf_path, .command = { .argv = {.args = NULL}, }, + .command_action_arg = { + .argv = {.args = NULL}, + }, .close = { .argv = {.args = NULL}, }, @@ -3231,8 +3242,9 @@ config_load(struct config *conf, const char *conf_path, parse_modifiers(XKB_MOD_NAME_SHIFT, 5, &conf->mouse.selection_override_modifiers); tokenize_cmdline( - "notify-send --wait --app-name ${app-id} --icon ${app-id} --urgency ${urgency} --hint STRING:image-path:${icon} --action ${action-name}=${action-label} --print-id -- ${title} ${body}", + "notify-send --wait --app-name ${app-id} --icon ${app-id} --category ${category} --urgency ${urgency} --expire-time ${expire-time} --hint STRING:image-path:${icon} --replace-id ${replace-id} ${action-arg} --print-id -- ${title} ${body}", &conf->desktop_notifications.command.argv.args); + tokenize_cmdline("--action ${action-name}=${action-label}", &conf->desktop_notifications.command_action_arg.argv.args); tokenize_cmdline("xdg-open ${url}", &conf->url.launch.argv.args); static const char32_t *url_protocols[] = { @@ -3487,6 +3499,8 @@ config_clone(const struct config *old) spawn_template_clone(&conf->bell.command, &old->bell.command); spawn_template_clone(&conf->desktop_notifications.command, &old->desktop_notifications.command); + spawn_template_clone(&conf->desktop_notifications.command_action_arg, + &old->desktop_notifications.command_action_arg); spawn_template_clone(&conf->desktop_notifications.close, &old->desktop_notifications.close); @@ -3571,6 +3585,7 @@ config_free(struct config *conf) spawn_template_free(&conf->bell.command); free(conf->scrollback.indicator.text); spawn_template_free(&conf->desktop_notifications.command); + spawn_template_free(&conf->desktop_notifications.command_action_arg); spawn_template_free(&conf->desktop_notifications.close); for (size_t i = 0; i < ALEN(conf->fonts); i++) config_font_list_destroy(&conf->fonts[i]); diff --git a/config.h b/config.h index 20fea3fc..246b479f 100644 --- a/config.h +++ b/config.h @@ -340,6 +340,7 @@ struct config { struct { struct config_spawn_template command; + struct config_spawn_template command_action_arg; struct config_spawn_template close; bool inhibit_when_focused; } desktop_notifications; diff --git a/doc/foot.ini.5.scd b/doc/foot.ini.5.scd index 85063088..f310e228 100644 --- a/doc/foot.ini.5.scd +++ b/doc/foot.ini.5.scd @@ -436,12 +436,37 @@ Note: do not set *TERM* here; use the *term* option in the main _${window-title}_ is replaced with the current window title. _${icon}_ is replaced by the icon specified in the - notification request, or _${app_id}_ if the notification did - not set an icon. Note that only symbolic icon names are - supported, not filenames. + notification request, or the empty string if no icon was + specified. Can be used with e.g. notify-send's *--icon* + option, or preferably, by setting the *image-path* hint (with + e.g. notify-send's *--hint* option). + + _${category}_ is replaced by the notification's catogory. Can + be used together with e.g. notify-send's *--category* option. _${urgency}_ is replaced with the notifications urgency; - *low*, *normal* or *critical*. + *low*, *normal* or *critical*. Can be used together with + e.g. notify-send's *--urgency* option. + + _${expire-time}_ is replaced with the notification specified + notification timeout. Can be used together with + e.g. notify-send's *--expire-time* option. + + _${replace-id}_ is replaced by the notification daemon + assigned ID that the notification replaces/updates. For this + to work, foot needs to know the externally assigned IDs of + previously emitted notifications, see the 'stdout' section + below. Can be used together with e.g. notify-send's + *--replace-id* option. + + _${action-arg}_ will be expanded to the *command-action-arg* + option, for each notification action. There will always be at + least one action, the "default" action. Foot uses this to + enable window focusing, and reporting notification activation + to applications that requested such events. + + Applications can also define their own custom notification + actions. See the *command-action-arg* option for details. Ways to trigger notifications Applications can trigger notifications in the following ways: @@ -469,12 +494,11 @@ Note: do not set *TERM* here; use the *term* option in the main activation token. There are two parts to handle this. First, the notification - must define an action. For this purpose, foot definse the - template parameters *${action-name}* and - *${action-label}*. They are intended to be used with - e.g. notify-send's *-A,--action* option. + must define an action. For this purpose, foot will add a + "default" action to the notification (see the + *command-action-arg* option). - Second, foot needs to know when the notification activated, + Second, foot needs to know when the notification is activated, and it needs to get hold of the XDG activation token. Both are expected to be printed on stdout. @@ -486,8 +510,8 @@ Note: do not set *TERM* here; use the *term* option in the main line, prefixed with *xdgtoken=*. Example: - default - xdgtoken=18179adf579a7a904ce73754964b1ec3 + default++ +xdgtoken=18179adf579a7a904ce73754964b1ec3 The expected format of stdout may change at any time. Please read the changelog when upgrading foot. @@ -501,17 +525,81 @@ Note: do not set *TERM* here; use the *term* option in the main helper's stdout: - _nnn_: integer in base 10, daemon assigned notification ID - - _id=nnn_: same as plain _nnn_. - - _default_: the 'default' action was triggered - - _action=default_: same as _default_ - - _xdgtoken=xyz_: XDG activation token. + - *id=*_nnn_: same as plain _nnn_. + - *default*: the 'default' action was triggered + - *action=*_default_: same as _default_ + - *action=*_n_: application custom action _n_ triggered + - *xdgtoken=*_xyz_: XDG activation token. Example: 17++ action=default++ xdgtoken=95ebdfe56e4f47ddb5bba9d7dc3a2c35 - Default: _notify-send --wait --app-name ${app-id} --icon ${app-id} --urgency ${urgency} --hint STRING:image-path:${icon} --action ${action-name}=${action-label} --print-id -- ${title} ${body}_. + Default: _notify-send++ + --wait++ + --app-name ${app-id}++ + --icon ${app-id}++ + --category ${category}++ + --urgency ${urgency}++ + --expire-time ${expire-time}++ + --hint STRING:image-path:${icon}++ + --replace-id ${replace-id}++ + ${action-arg}++ + --print-id++ + -- ${title} ${body}_. + +*command-action-arg* + String to use with *command* to enable passing action/button names + to the notification helper. + + Foot will always configure a "default" action that can be used to + "activate" the notification, which in turn can cause the foot + window to be focused, or an escape to be sent to the terminal + application (depending on how the application generated the + notification). + + Furhermore, the OSC-99 notifications protocol allows applications + to define their own actions. Foot uses a combination of the + *command* option, and the *command-action-arg* option to pass the + names of the actions to the notification helper. + + This option has the following template arguments: + + - _${action-name}_: the name of the action; *default* for the + default action configured by foot, and _n_, where _n_ is an + integer >= 1, for application defined actions. + - _${action-label}_: *Click to activate* for the default action, + and a free-form string for application defined actions. + + For each notification action (remember, there will always be at + least one), *command-action-arg* will be expanded with the + action's name and label. + + Then, _${action-arg}_ is expanded *command* to the full list of + actions. + + If *command-action-arg* is set to the empty string, no actions + will be passed to *command*. That is, _${action-arg}_ will be + replaced with the empty string. + + Example: + + *command-action-arg=--action ${action-name}=${action-label}* + *command=notify-send ${action-arg} ...* + + Assume the application defined two custom actions: *OK* and + *Cancel*. + + Given the above, foot will execute: + + notify-send++ + --action default='Click to activate'++ + --action 1=OK++ + --action 2=Cancel++ + ... + + Default: _--action ${action-name}=${action-label}_ *close* Command to execute to close an existing notification. diff --git a/foot.ini b/foot.ini index aeda18df..0ae0b52f 100644 --- a/foot.ini +++ b/foot.ini @@ -47,7 +47,8 @@ # command-focused=no [desktop-notifications] -# command=notify-send --wait --app-name ${app-id} --icon ${app-id} --urgency ${urgency} --hint STRING:image-path:${icon} --action ${action-name}=${action-label} --print-id -- ${title} ${body} +# command=notify-send --wait --app-name ${app-id} --icon ${app-id} --category ${category} --urgency ${urgency} --expire-time ${expire-time} --hint STRING:image-path:${icon} --replace-id ${replace-id} ${action-arg} --print-id -- ${title} ${body} +# command-action-arg=--action ${action-name}=${action-label} # close="" # inhibit-when-focused=yes diff --git a/notify.c b/notify.c index 15cbf531..e1d6b9d7 100644 --- a/notify.c +++ b/notify.c @@ -10,7 +10,7 @@ #include #define LOG_MODULE "notify" -#define LOG_ENABLE_DBG 0 +#define LOG_ENABLE_DBG 1 #include "log.h" #include "config.h" #include "spawn.h" @@ -27,11 +27,54 @@ notify_free(struct terminal *term, struct notification *notif) free(notif->id); free(notif->title); free(notif->body); + free(notif->category); + free(notif->app_id); free(notif->icon_id); free(notif->icon_symbolic_name); free(notif->icon_data); free(notif->xdg_token); free(notif->stdout_data); + + tll_free_and_free(notif->actions, free); + + if (notif->icon_path != NULL) { + unlink(notif->icon_path); + free(notif->icon_path); + + if (notif->icon_fd >= 0) + close(notif->icon_fd); + } +} + +static bool +write_icon_file(const void *data, size_t data_sz, int *fd, char **filename, + char **symbolic_name) +{ + xassert(*filename == NULL); + xassert(*symbolic_name == NULL); + + char name[64] = "/tmp/foot-notification-icon-XXXXXX"; + + *filename = NULL; + *symbolic_name = NULL; + *fd = mkostemp(name, O_CLOEXEC); + + if (*fd < 0) { + LOG_ERRNO("failed to create temporary file for icon cache"); + return false; + } + + if (write(*fd, data, data_sz) != (ssize_t)data_sz) { + LOG_ERRNO("failed to write icon data to temporary file"); + close(*fd); + *fd = -1; + return false; + } + + LOG_DBG("wrote icon data to %s", name); + *filename = xstrdup(name); + *symbolic_name = xasprintf("file://%s", *filename); + return true; } static bool @@ -94,6 +137,19 @@ consume_stdout(struct notification *notif, bool eof) LOG_DBG("notification's default action was triggered"); } + else if (len > 7 && memcmp(line, "action=", 7) == 0) { + notif->activated = true; + + uint32_t maybe_button_nr; + if (to_integer(&line[7], len - 7, &maybe_button_nr)) { + notif->activated_button = maybe_button_nr; + LOG_DBG("custom action %u triggered", notif->activated_button); + } else { + LOG_DBG("unrecognized action triggered: %.*s", + (int)(len - 7), &line[7]); + } + } + /* Check for XDG activation token, 'xdgtoken=xyz' */ else if (len > 9 && memcmp(line, "xdgtoken=", 9) == 0) { notif->xdg_token = xstrndup(&line[9], len - 9); @@ -170,7 +226,8 @@ notif_done(struct reaper *reaper, pid_t pid, int status, void *data) if (notif->pid != pid) continue; - LOG_DBG("notification %s closed", notif->id); + LOG_DBG("notification %s closed", + notif->id != NULL ? notif->id : ""); if (notif->activated && notif->focus) { LOG_DBG("focus window on notification activation: \"%s\"", @@ -183,22 +240,29 @@ notif_done(struct reaper *reaper, pid_t pid, int status, void *data) } if (notif->activated && notif->report_activated) { - xassert(notif->id != NULL); - LOG_DBG("sending notification activation event to client"); - char reply[7 + strlen(notif->id) + 1 + 2 + 1]; + const char *id = notif->id != NULL ? notif->id : "0"; + + char button_nr[16] = {0}; + if (notif->activated_button > 0) { + xsnprintf( + button_nr, sizeof(button_nr), "%u", notif->activated_button); + } + + char reply[7 + strlen(id) + 1 + strlen(button_nr) + 2 + 1]; int n = xsnprintf( - reply, sizeof(reply), "\033]99;i=%s;\033\\", notif->id); + reply, sizeof(reply), "\033]99;i=%s;%s\033\\", id, button_nr); term_to_slave(term, reply, n); } if (notif->report_closed) { LOG_DBG("sending notification close event to client"); - char reply[7 + strlen(notif->id) + 1 + 7 + 1 + 2 + 1]; + const char *id = notif->id != NULL ? notif->id : "0"; + char reply[7 + strlen(id) + 1 + 7 + 1 + 2 + 1]; int n = xsnprintf( - reply, sizeof(reply), "\033]99;i=%s:p=close;\033\\", notif->id); + reply, sizeof(reply), "\033]99;i=%s:p=close;\033\\", id); term_to_slave(term, reply, n); } @@ -212,14 +276,33 @@ bool notify_notify(struct terminal *term, struct notification *notif) { xassert(notif->xdg_token == NULL); + xassert(notif->external_id == 0); xassert(notif->pid == 0); xassert(notif->stdout_fd <= 0); xassert(notif->stdout_data == NULL); + xassert(notif->icon_path == NULL); + xassert(notif->icon_fd <= 0); notif->pid = -1; notif->stdout_fd = -1; + notif->icon_fd = -1; - /* Use body as title, if title is unset */ + if (term->conf->desktop_notifications.command.argv.args == NULL) + return false; + + if ((term->conf->desktop_notifications.inhibit_when_focused || + notif->when != NOTIFY_ALWAYS) + && term->kbd_focus) + { + /* No notifications while we're focused */ + return false; + } + + const char *app_id = notif->app_id != NULL + ? notif->app_id + : term->app_id != NULL + ? term->app_id + : term->conf->app_id; const char *title = notif->title != NULL ? notif->title : notif->body; const char *body = notif->title != NULL && notif->body != NULL ? notif->body : ""; @@ -231,40 +314,64 @@ notify_notify(struct terminal *term, struct notification *notif) const struct notification_icon *icon = &term->notification_icons[i]; if (icon->id != NULL && streq(icon->id, notif->icon_id)) { - icon_name_or_path = icon->symbolic_name != NULL - ? icon->symbolic_name - : icon->tmp_file_name; + /* For now, we set the symbolic name to 'file:///path' + * when using a file based icon. */ + xassert(icon->symbolic_name != NULL); + icon_name_or_path = icon->symbolic_name; + + LOG_DBG("using icon from cache (cache ID: %s): %s", + icon->id, icon_name_or_path); break; } } } else if (notif->icon_symbolic_name != NULL) { icon_name_or_path = notif->icon_symbolic_name; + LOG_DBG("using symbolic icon from notification: %s", icon_name_or_path); + } else if (notif->icon_data_sz > 0) { + xassert(notif->icon_data != NULL); + + if (write_icon_file( + notif->icon_data, notif->icon_data_sz, + ¬if->icon_fd, + ¬if->icon_path, + ¬if->icon_symbolic_name)) + icon_name_or_path = notif->icon_symbolic_name; + + LOG_DBG("using icon data from notification: %s", icon_name_or_path); } bool track_notification = notif->focus || notif->report_activated || notif->may_be_programatically_closed; - LOG_DBG("notify: title=\"%s\", body=\"%s\", icon=\"%s\" (tracking: %s)", - title, body, icon_name_or_path, track_notification ? "yes" : "no"); + uint32_t replaces_id = 0; + if (notif->id != NULL) { + tll_foreach(term->active_notifications, it) { + struct notification *existing = &it->item; - xassert(title != NULL); - if (title == NULL) - return false; + if (existing->id == NULL) + continue; - if ((term->conf->desktop_notifications.inhibit_when_focused || - notif->when != NOTIFY_ALWAYS) - && term->kbd_focus) - { - /* No notifications while we're focused */ - return false; + /* + * When replacing/updating a notificaton, we may have + * *multiple* notification helpers running for the "same" + * notification. Make sure only the *last* notification's + * report closed/activated are honored, to avoid sending + * multiple reports. + * + * This also means we cannot 'break' out of the loop - we + * must check *all* notifications. + */ + if (existing->external_id != 0 && streq(existing->id, notif->id)) { + replaces_id = existing->external_id; + existing->report_activated = false; + existing->report_closed = false; + } + } } - if (term->conf->desktop_notifications.command.argv.args == NULL) - return false; - - char **argv = NULL; - size_t argc = 0; + char replaces_id_str[16]; + xsnprintf(replaces_id_str, sizeof(replaces_id_str), "%u", replaces_id); const char *urgency_str = notif->urgency == NOTIFY_URGENCY_LOW @@ -272,19 +379,129 @@ notify_notify(struct terminal *term, struct notification *notif) : notif->urgency == NOTIFY_URGENCY_NORMAL ? "normal" : "critical"; + LOG_DBG("notify: title=\"%s\", body=\"%s\", app-id=\"%s\", category=\"%s\", " + "urgency=\"%s\", icon=\"%s\", expires=%d, replaces=%u (tracking: %s)", + title, body, app_id, notif->category, urgency_str, icon_name_or_path, + notif->expire_time, replaces_id, track_notification ? "yes" : "no"); + + xassert(title != NULL); + if (title == NULL) + return false; + + char **argv = NULL; + size_t argc = 0; + char **action_argv = NULL; + size_t action_argc = 0; + + char expire_time[16]; + xsnprintf(expire_time, sizeof(expire_time), "%d", notif->expire_time); + + if (term->conf->desktop_notifications.command_action_arg.argv.args) { + struct action { + const char *name; + const char *label; + }; + + tll(struct action) actions = tll_init(); + tll_push_back(actions, ((struct action){"default", "Click to activate"})); + + tll_foreach(notif->actions, it) { + tll_push_back(actions, ((struct action){NULL, it->item})); + } + + size_t action_idx = 0; + tll_foreach(actions, it) { + const char *name = it->item.name; + const char *label = it->item.label; + + /* + * Custom actions (buttons) start at 1. + * + * We always insert our own default action first, causing + * all custom actions to start at index 1 in our list. + */ + char numerical_name[16]; + xsnprintf(numerical_name, sizeof(numerical_name), "%zu", action_idx); + + if (name == NULL) + name = numerical_name; + + char **expanded = NULL; + size_t count = 0; + + if (!spawn_expand_template( + &term->conf->desktop_notifications.command_action_arg, 2, + (const char *[]){"action-name", "action-label"}, + (const char *[]){name, label}, + &count, &expanded)) + { + return false; + } + + /* Append to the "global" actions argv */ + action_argv = xrealloc( + action_argv, (action_argc + count) * sizeof(action_argv[0])); + + for (size_t i = 0; i < count; i++) + action_argv[action_argc + i] = expanded[i]; + + action_argc += count; + + free(expanded); + action_idx++; + tll_remove(actions, it); + } + } + if (!spawn_expand_template( - &term->conf->desktop_notifications.command, 8, + &term->conf->desktop_notifications.command, 10, (const char *[]){ - "app-id", "window-title", "icon", "title", "body", "urgency", "action-name", "action-label"}, + "app-id", "window-title", "icon", "title", "body", "category", + "urgency", "expire-time", "replace-id", "action-arg"}, (const char *[]){ - term->app_id ? term->app_id : term->conf->app_id, - term->window_title, icon_name_or_path, title, body, urgency_str, - "default", "Click to activate"}, + app_id, term->window_title, icon_name_or_path, title, body, + notif->category != NULL ? notif->category : "", urgency_str, + expire_time, replaces_id_str, + + /* Custom expansion below, since we need to expand to multiple arguments */ + "${action-arg}"}, &argc, &argv)) { return false; } + for (size_t i = 0; i < argc; i++) { + if (!streq(argv[i], "${action-arg}")) + continue; + + if (action_argc == 0) { + free(argv[i]); + memmove(&argv[i], &argv[i + 1], (argc - i - 1) * sizeof(argv[0])); + argv[argc--] = NULL; + break; + } + + /* Remove the "${action-arg}" entry, add all actions argument from earlier */ + argv = xrealloc(argv, (argc + action_argc - 1) * sizeof(argv[0])); + + /* Move remaining arguments to after the action arguments */ + memmove(&argv[i + action_argc], + &argv[i + 1], + (argc - (i + 1)) * sizeof(argv[0])); + + free(argv[i]); /* Free xstrdup("${action-arg}"); */ + + /* Insert the action arguments */ + for (size_t j = 0; j < action_argc; j++) { + argv[i + j] = action_argv[j]; + action_argv[j] = NULL; + } + + argc += action_argc; + argc--; /* The ${action-arg} option has been removed */ + break; + } + LOG_DBG("notify command:"); for (size_t i = 0; i < argc; i++) LOG_DBG(" argv[%zu] = \"%s\"", i, argv[i]); @@ -300,15 +517,23 @@ notify_notify(struct terminal *term, struct notification *notif) notif->id = NULL; notif->title = NULL; notif->body = NULL; + notif->category = NULL; + notif->app_id = NULL; notif->icon_id = NULL; notif->icon_symbolic_name = NULL; notif->icon_data = NULL; notif->icon_data_sz = 0; - notif = &tll_back(term->active_notifications); + notif->icon_path = NULL; + notif->icon_fd = -1; + notif->stdout_fd = -1; + struct notification *new_notif = &tll_back(term->active_notifications); + + /* We don't need these anymore. They'll be free:d by the caller */ + memset(&new_notif->actions, 0, sizeof(new_notif->actions)); + notif = new_notif; } } - if (stdout_fds[0] >= 0) { fdm_add(term->fdm, stdout_fds[0], EPOLLIN, &fdm_notify_stdout, (void *)term); @@ -336,6 +561,9 @@ notify_notify(struct terminal *term, struct notification *notif) for (size_t i = 0; i < argc; i++) free(argv[i]); free(argv); + for (size_t i = 0; i < action_argc; i++) + free(action_argv[i]); + free(action_argv); notif->pid = pid; notif->stdout_fd = stdout_fds[0]; @@ -345,11 +573,12 @@ notify_notify(struct terminal *term, struct notification *notif) void notify_close(struct terminal *term, const char *id) { + xassert(id != NULL); LOG_DBG("close notification %s", id); tll_foreach(term->active_notifications, it) { const struct notification *notif = &it->item; - if (notif->id == 0 || !streq(notif->id, id)) + if (notif->id == NULL || !streq(notif->id, id)) continue; if (term->conf->desktop_notifications.close.argv.args == NULL) { @@ -429,22 +658,11 @@ add_icon(struct notification_icon *icon, const char *id, const char *symbolic_na * have a symbolic name. */ if (symbolic_name == NULL && data_sz > 0) { - char name[64] = "/tmp/foot-notification-icon-cache-XXXXXX"; - int fd = mkostemp(name, O_CLOEXEC); - - if (fd < 0) { - LOG_ERRNO("failed to create temporary file for icon cache"); - return; - } - - if (write(fd, data, data_sz) != (ssize_t)data_sz) { - LOG_ERRNO("failed to write icon data to temporary file"); - close(fd); - } else { - LOG_DBG("wrote icon data to %s", name); - icon->tmp_file_name = xstrdup(name); - icon->tmp_file_fd = fd; - } + write_icon_file( + data, data_sz, + &icon->tmp_file_fd, + &icon->tmp_file_name, + &icon->symbolic_name); } LOG_DBG("added icon to cache: ID=%s: sym=%s, file=%s", diff --git a/notify.h b/notify.h index bf19d37c..2a7c5ca1 100644 --- a/notify.h +++ b/notify.h @@ -3,6 +3,8 @@ #include #include +#include + struct terminal; enum notify_when { @@ -30,7 +32,9 @@ struct notification { char *id; char *title; char *body; + char *category; + char *app_id; /* Custm app-id, overrides the terminal's app-id */ char *icon_id; char *icon_symbolic_name; uint8_t *icon_data; @@ -38,6 +42,9 @@ struct notification { enum notify_when when; enum notify_urgency urgency; + int32_t expire_time; + tll(char *) actions; + bool focus; bool may_be_programatically_closed; bool report_activated; @@ -49,6 +56,7 @@ struct notification { uint32_t external_id; /* Daemon assigned notification ID */ bool activated; /* User 'activated' the notification */ + uint32_t activated_button; /* User activated one of the custom actions */ char *xdg_token; /* XDG activation token, from daemon */ pid_t pid; /* Notifier command PID */ @@ -56,6 +64,11 @@ struct notification { char *stdout_data; /* Data we've reado from command's stdout */ size_t stdout_sz; + + /* Used when notification provides raw icon data, and it's + bypassing the icon cache */ + char *icon_path; + int icon_fd; }; struct notification_icon { diff --git a/osc.c b/osc.c index 34080467..360ea6ee 100644 --- a/osc.c +++ b/osc.c @@ -559,7 +559,9 @@ osc_notify(struct terminal *term, char *string) notify_notify(term, &(struct notification){ .title = (char *)title, - .body = (char *)msg}); + .body = (char *)msg, + .expire_time = -1, + }); } static void @@ -575,9 +577,11 @@ kitty_notification(struct terminal *term, char *string) *payload_raw = '\0'; payload_raw++; - char *id = xstrdup("0"); /* The 'i' parameter */ + char *id = NULL; /* The 'i' parameter */ + char *app_id = NULL; /* The 'f' parameter */ char *icon_id = NULL; /* The 'g' parameter */ char *symbolic_icon = NULL; /* The 'n' parameter */ + char *category = NULL; /* The 't' parameter */ char *payload = NULL; bool focus = true; /* The 'a' parameter */ @@ -586,12 +590,16 @@ kitty_notification(struct terminal *term, char *string) bool done = true; /* The 'd' parameter */ bool base64 = false; /* The 'e' parameter */ + int32_t expire_time = -1; /* The 'w' parameter */ + size_t payload_size; enum { PAYLOAD_TITLE, PAYLOAD_BODY, PAYLOAD_CLOSE, + PAYLOAD_ALIVE, PAYLOAD_ICON, + PAYLOAD_BUTTON, } payload_type = PAYLOAD_TITLE; /* The 'p' parameter */ enum notify_when when = NOTIFY_ALWAYS; @@ -601,6 +609,7 @@ kitty_notification(struct terminal *term, char *string) bool have_c = false; bool have_o = false; bool have_u = false; + bool have_w = false; char *ctx = NULL; for (char *param = strtok_r(parameters, ":", &ctx); @@ -675,8 +684,12 @@ kitty_notification(struct terminal *term, char *string) payload_type = PAYLOAD_BODY; else if (streq(value, "close")) payload_type = PAYLOAD_CLOSE; + else if (streq(value, "alive")) + payload_type = PAYLOAD_ALIVE; else if (streq(value, "icon")) payload_type = PAYLOAD_ICON; + else if (streq(value, "buttons")) + payload_type = PAYLOAD_BUTTON; else if (streq(value, "?")) { /* Query capabilities */ @@ -690,9 +703,10 @@ kitty_notification(struct terminal *term, char *string) char reply[128]; int n = xsnprintf( reply, sizeof(reply), - "\033]99;i=%s:p=?;p=title,body,close,icon:a=focus,report:o=%s:u=0,1,2:c=1%s", - id, when_str, terminator); + "\033]99;i=%s:p=?;p=title,body,?,close,alive,icon,buttons:a=focus,report:o=%s:u=0,1,2:c=1:w=1%s", + id != NULL ? id : "0", when_str, terminator); + xassert(n < sizeof(reply)); term_to_slave(term, reply, n); goto out; } @@ -720,6 +734,45 @@ kitty_notification(struct terminal *term, char *string) urgency = NOTIFY_URGENCY_CRITICAL; break; + case 'w': { + /* Notification timeout */ + errno = 0; + char *end = NULL; + long timeout = strtol(value, &end, 10); + + if (errno == 0 && *end == '\0' && timeout <= INT32_MAX) { + expire_time = timeout; + have_w = true; + } + break; + } + + case 'f': + free(app_id); + app_id = base64_decode(value, NULL); + break; + + case 't': { + /* Type (category) */ + char *decoded = base64_decode(value, NULL); + if (decoded != NULL) { + if (category == NULL) + category = decoded; + else { + const size_t old_len = strlen(category); + const size_t new_len = strlen(decoded); + + /* Append, comma separated */ + category = xrealloc(category, old_len + 1 + new_len + 1); + category[old_len] = ','; + memcpy(&category[old_len + 1], decoded, new_len); + category[old_len + 1 + new_len] = '\0'; + free(decoded); + } + } + break; + } + case 'g': /* graphical ID */ free(icon_id); @@ -729,7 +782,35 @@ kitty_notification(struct terminal *term, char *string) case 'n': /* Symbolic icon name, used with 'g' */ free(symbolic_icon); - symbolic_icon = xstrdup(value); + symbolic_icon = base64_decode(value, NULL); + + /* Translate OSC-99 "special" names */ + if (symbolic_icon != NULL) { + const char *translated_name = NULL; + + if (streq(symbolic_icon, "error")) + translated_name = "dialog-error"; + else if (streq(symbolic_icon, "warn") || + streq(symbolic_icon, "warning")) + translated_name = "dialog-warning"; + else if (streq(symbolic_icon, "info")) + translated_name = "dialog-information"; + else if (streq(symbolic_icon, "question")) + translated_name = "dialog-question"; + else if (streq(symbolic_icon, "help")) + translated_name = "system-help"; + else if (streq(symbolic_icon, "file-manager")) + translated_name = "system-file-manager"; + else if (streq(symbolic_icon, "system-monitor")) + translated_name = "utilities-system-monitor"; + else if (streq(symbolic_icon, "text-editor")) + translated_name = "text-editor"; + + if (translated_name != NULL) { + free(symbolic_icon); + symbolic_icon = xstrdup(translated_name); + } + } break; } } @@ -746,7 +827,9 @@ kitty_notification(struct terminal *term, char *string) /* Search for an existing (d=0) notification to update */ struct notification *notif = NULL; tll_foreach(term->kitty_notifications, it) { - if (streq(it->item.id, id)) { + if ((id == NULL && it->item.id == NULL) || + (id != NULL && it->item.id != NULL && streq(it->item.id, id))) + { /* Found existing notification */ notif = &it->item; break; @@ -758,6 +841,8 @@ kitty_notification(struct terminal *term, char *string) .id = id, .when = when, .urgency = urgency, + .expire_time = expire_time, + .actions = tll_init(), .focus = focus, .may_be_programatically_closed = true, .report_activated = report_activated, @@ -787,6 +872,8 @@ kitty_notification(struct terminal *term, char *string) notif->when = when; if (have_u) notif->urgency = urgency; + if (have_w) + notif->expire_time = expire_time; if (icon_id != NULL) { free(notif->icon_id); @@ -800,6 +887,29 @@ kitty_notification(struct terminal *term, char *string) symbolic_icon = NULL; } + if (app_id != NULL) { + free(notif->app_id); + notif->app_id = app_id; + app_id = NULL; /* Prevent double free */ + } + + if (category != NULL) { + if (notif->category == NULL) { + notif->category = category; + category = NULL; /* Prevent double free */ + } else { + const size_t old_len = strlen(notif->category); + const size_t new_len = strlen(category); + + /* Append, comma separated */ + notif->category = + xrealloc(notif->category, old_len + 1 + new_len + 1); + notif->category[old_len] = ','; + memcpy(¬if->category[old_len + 1], category, new_len); + notif->category[old_len + 1 + new_len] = '\0'; + } + } + /* Handled chunked payload - append to existing metadata */ switch (payload_type) { case PAYLOAD_TITLE: @@ -820,6 +930,7 @@ kitty_notification(struct terminal *term, char *string) } case PAYLOAD_CLOSE: + case PAYLOAD_ALIVE: /* Ignore payload */ break; @@ -835,6 +946,20 @@ kitty_notification(struct terminal *term, char *string) notif->icon_data_sz += payload_size; } break; + + case PAYLOAD_BUTTON: { + char *ctx = NULL; + for (const char *button = strtok_r(payload, "\u2028", &ctx); + button != NULL; + button = strtok_r(NULL, "\u2028", &ctx)) + { + if (button[0] != '\0') { + tll_push_back(notif->actions, xstrdup(button)); + } + } + + break; + } } if (done) { @@ -856,7 +981,42 @@ kitty_notification(struct terminal *term, char *string) } if (payload_type == PAYLOAD_CLOSE) { - notify_close(term, notif->id); + if (notif->id != NULL) + notify_close(term, notif->id); + } else if (payload_type == PAYLOAD_ALIVE) { + char *alive_ids = NULL; + size_t alive_ids_len = 0; + + tll_foreach(term->active_notifications, it) { + /* TODO: check with kitty: use "0" for all + notifications with no ID? */ + + const char *item_id = it->item.id != NULL ? it->item.id : "0"; + const size_t id_len = strlen(item_id); + + if (alive_ids == NULL) { + alive_ids = xstrdup(item_id); + alive_ids_len = id_len; + } else { + alive_ids = xrealloc(alive_ids, alive_ids_len + 1 + id_len + 1); + + /* Append "," */ + alive_ids[alive_ids_len] = ','; + memcpy(&alive_ids[alive_ids_len + 1], item_id, id_len); + + alive_ids_len += 1 + id_len; + alive_ids[alive_ids_len] = '\0'; + } + } + + char *reply = xasprintf( + "\033]99;i=%s:p=alive;%s\033\\", + notif->id != NULL ? notif->id : "0", + alive_ids != NULL ? alive_ids : ""); + + term_to_slave(term, reply, strlen(reply)); + free(reply); + free(alive_ids); } else { /* * Show notification. @@ -880,9 +1040,11 @@ kitty_notification(struct terminal *term, char *string) out: free(id); + free(app_id); free(icon_id); free(symbolic_icon); free(payload); + free(category); } void diff --git a/terminal.c b/terminal.c index e95a3615..bcd2651e 100644 --- a/terminal.c +++ b/terminal.c @@ -3601,7 +3601,9 @@ term_bell(struct terminal *term) if (term->conf->bell.notify) { notify_notify(term, &(struct notification){ .title = (char *)"Bell", - .body = (char *)"Bell in terminal"}); + .body = (char *)"Bell in terminal", + .expire_time = -1, + }); } if (term->conf->bell.flash) diff --git a/tests/test-config.c b/tests/test-config.c index ec718c24..ae111446 100644 --- a/tests/test-config.c +++ b/tests/test-config.c @@ -580,6 +580,8 @@ test_section_desktop_notifications(void) test_boolean(&ctx, &parse_section_desktop_notifications, "inhibit-when-focused", &conf.desktop_notifications.inhibit_when_focused); test_spawn_template(&ctx, &parse_section_desktop_notifications, "command", &conf.desktop_notifications.command); + test_spawn_template(&ctx, &parse_section_desktop_notifications, "command-action-arg", &conf.desktop_notifications.command_action_arg); + test_spawn_template(&ctx, &parse_section_desktop_notifications, "close", &conf.desktop_notifications.close); config_free(&conf); }