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.
This commit is contained in:
Daniel Eklöf 2024-07-31 16:22:17 +02:00
parent d87b81dd52
commit 76ac910b11
No known key found for this signature in database
GPG key ID: 5BBD4992C116573F
9 changed files with 580 additions and 78 deletions

322
notify.c
View file

@ -10,7 +10,7 @@
#include <fcntl.h>
#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 : "<unset>");
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,
&notif->icon_fd,
&notif->icon_path,
&notif->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",