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

View file

@ -806,6 +806,11 @@ value_to_spawn_template(struct context *ctx,
char **argv = NULL; 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)) { if (!tokenize_cmdline(ctx->value, &argv)) {
LOG_CONTEXTUAL_ERR("syntax error in command line"); LOG_CONTEXTUAL_ERR("syntax error in command line");
return false; return false;
@ -1110,6 +1115,9 @@ parse_section_desktop_notifications(struct context *ctx)
if (streq(key, "command")) if (streq(key, "command"))
return value_to_spawn_template( return value_to_spawn_template(
ctx, &conf->desktop_notifications.command); 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")) else if (streq(key, "close"))
return value_to_spawn_template( return value_to_spawn_template(
ctx, &conf->desktop_notifications.close); ctx, &conf->desktop_notifications.close);
@ -3189,6 +3197,9 @@ config_load(struct config *conf, const char *conf_path,
.command = { .command = {
.argv = {.args = NULL}, .argv = {.args = NULL},
}, },
.command_action_arg = {
.argv = {.args = NULL},
},
.close = { .close = {
.argv = {.args = NULL}, .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); parse_modifiers(XKB_MOD_NAME_SHIFT, 5, &conf->mouse.selection_override_modifiers);
tokenize_cmdline( 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); &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); tokenize_cmdline("xdg-open ${url}", &conf->url.launch.argv.args);
static const char32_t *url_protocols[] = { 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->bell.command, &old->bell.command);
spawn_template_clone(&conf->desktop_notifications.command, spawn_template_clone(&conf->desktop_notifications.command,
&old->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, spawn_template_clone(&conf->desktop_notifications.close,
&old->desktop_notifications.close); &old->desktop_notifications.close);
@ -3571,6 +3585,7 @@ config_free(struct config *conf)
spawn_template_free(&conf->bell.command); spawn_template_free(&conf->bell.command);
free(conf->scrollback.indicator.text); free(conf->scrollback.indicator.text);
spawn_template_free(&conf->desktop_notifications.command); spawn_template_free(&conf->desktop_notifications.command);
spawn_template_free(&conf->desktop_notifications.command_action_arg);
spawn_template_free(&conf->desktop_notifications.close); spawn_template_free(&conf->desktop_notifications.close);
for (size_t i = 0; i < ALEN(conf->fonts); i++) for (size_t i = 0; i < ALEN(conf->fonts); i++)
config_font_list_destroy(&conf->fonts[i]); config_font_list_destroy(&conf->fonts[i]);

View file

@ -340,6 +340,7 @@ struct config {
struct { struct {
struct config_spawn_template command; struct config_spawn_template command;
struct config_spawn_template command_action_arg;
struct config_spawn_template close; struct config_spawn_template close;
bool inhibit_when_focused; bool inhibit_when_focused;
} desktop_notifications; } desktop_notifications;

View file

@ -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. _${window-title}_ is replaced with the current window title.
_${icon}_ is replaced by the icon specified in the _${icon}_ is replaced by the icon specified in the
notification request, or _${app_id}_ if the notification did notification request, or the empty string if no icon was
not set an icon. Note that only symbolic icon names are specified. Can be used with e.g. notify-send's *--icon*
supported, not filenames. 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; _${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 Ways to trigger notifications
Applications can trigger notifications in the following ways: 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. activation token.
There are two parts to handle this. First, the notification There are two parts to handle this. First, the notification
must define an action. For this purpose, foot definse the must define an action. For this purpose, foot will add a
template parameters *${action-name}* and "default" action to the notification (see the
*${action-label}*. They are intended to be used with *command-action-arg* option).
e.g. notify-send's *-A,--action* 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. and it needs to get hold of the XDG activation token.
Both are expected to be printed on stdout. 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=*. line, prefixed with *xdgtoken=*.
Example: Example:
default default++
xdgtoken=18179adf579a7a904ce73754964b1ec3 xdgtoken=18179adf579a7a904ce73754964b1ec3
The expected format of stdout may change at any time. Please The expected format of stdout may change at any time. Please
read the changelog when upgrading foot. 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: helper's stdout:
- _nnn_: integer in base 10, daemon assigned notification ID - _nnn_: integer in base 10, daemon assigned notification ID
- _id=nnn_: same as plain _nnn_. - *id=*_nnn_: same as plain _nnn_.
- _default_: the 'default' action was triggered - *default*: the 'default' action was triggered
- _action=default_: same as _default_ - *action=*_default_: same as _default_
- _xdgtoken=xyz_: XDG activation token. - *action=*_n_: application custom action _n_ triggered
- *xdgtoken=*_xyz_: XDG activation token.
Example: Example:
17++ 17++
action=default++ action=default++
xdgtoken=95ebdfe56e4f47ddb5bba9d7dc3a2c35 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* *close*
Command to execute to close an existing notification. Command to execute to close an existing notification.

View file

@ -47,7 +47,8 @@
# command-focused=no # command-focused=no
[desktop-notifications] [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="" # close=""
# inhibit-when-focused=yes # inhibit-when-focused=yes

322
notify.c
View file

@ -10,7 +10,7 @@
#include <fcntl.h> #include <fcntl.h>
#define LOG_MODULE "notify" #define LOG_MODULE "notify"
#define LOG_ENABLE_DBG 0 #define LOG_ENABLE_DBG 1
#include "log.h" #include "log.h"
#include "config.h" #include "config.h"
#include "spawn.h" #include "spawn.h"
@ -27,11 +27,54 @@ notify_free(struct terminal *term, struct notification *notif)
free(notif->id); free(notif->id);
free(notif->title); free(notif->title);
free(notif->body); free(notif->body);
free(notif->category);
free(notif->app_id);
free(notif->icon_id); free(notif->icon_id);
free(notif->icon_symbolic_name); free(notif->icon_symbolic_name);
free(notif->icon_data); free(notif->icon_data);
free(notif->xdg_token); free(notif->xdg_token);
free(notif->stdout_data); 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 static bool
@ -94,6 +137,19 @@ consume_stdout(struct notification *notif, bool eof)
LOG_DBG("notification's default action was triggered"); 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' */ /* Check for XDG activation token, 'xdgtoken=xyz' */
else if (len > 9 && memcmp(line, "xdgtoken=", 9) == 0) { else if (len > 9 && memcmp(line, "xdgtoken=", 9) == 0) {
notif->xdg_token = xstrndup(&line[9], len - 9); 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) if (notif->pid != pid)
continue; continue;
LOG_DBG("notification %s closed", notif->id); LOG_DBG("notification %s closed",
notif->id != NULL ? notif->id : "<unset>");
if (notif->activated && notif->focus) { if (notif->activated && notif->focus) {
LOG_DBG("focus window on notification activation: \"%s\"", 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) { if (notif->activated && notif->report_activated) {
xassert(notif->id != NULL);
LOG_DBG("sending notification activation event to client"); 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( 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); term_to_slave(term, reply, n);
} }
if (notif->report_closed) { if (notif->report_closed) {
LOG_DBG("sending notification close event to client"); 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( 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); term_to_slave(term, reply, n);
} }
@ -212,14 +276,33 @@ bool
notify_notify(struct terminal *term, struct notification *notif) notify_notify(struct terminal *term, struct notification *notif)
{ {
xassert(notif->xdg_token == NULL); xassert(notif->xdg_token == NULL);
xassert(notif->external_id == 0);
xassert(notif->pid == 0); xassert(notif->pid == 0);
xassert(notif->stdout_fd <= 0); xassert(notif->stdout_fd <= 0);
xassert(notif->stdout_data == NULL); xassert(notif->stdout_data == NULL);
xassert(notif->icon_path == NULL);
xassert(notif->icon_fd <= 0);
notif->pid = -1; notif->pid = -1;
notif->stdout_fd = -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 *title = notif->title != NULL ? notif->title : notif->body;
const char *body = notif->title != NULL && notif->body != NULL ? 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]; const struct notification_icon *icon = &term->notification_icons[i];
if (icon->id != NULL && streq(icon->id, notif->icon_id)) { if (icon->id != NULL && streq(icon->id, notif->icon_id)) {
icon_name_or_path = icon->symbolic_name != NULL /* For now, we set the symbolic name to 'file:///path'
? icon->symbolic_name * when using a file based icon. */
: icon->tmp_file_name; 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; break;
} }
} }
} else if (notif->icon_symbolic_name != NULL) { } else if (notif->icon_symbolic_name != NULL) {
icon_name_or_path = notif->icon_symbolic_name; 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 || bool track_notification = notif->focus ||
notif->report_activated || notif->report_activated ||
notif->may_be_programatically_closed; notif->may_be_programatically_closed;
LOG_DBG("notify: title=\"%s\", body=\"%s\", icon=\"%s\" (tracking: %s)", uint32_t replaces_id = 0;
title, body, icon_name_or_path, track_notification ? "yes" : "no"); if (notif->id != NULL) {
tll_foreach(term->active_notifications, it) {
struct notification *existing = &it->item;
xassert(title != NULL); if (existing->id == NULL)
if (title == NULL) continue;
return false;
if ((term->conf->desktop_notifications.inhibit_when_focused || /*
notif->when != NOTIFY_ALWAYS) * When replacing/updating a notificaton, we may have
&& term->kbd_focus) * *multiple* notification helpers running for the "same"
{ * notification. Make sure only the *last* notification's
/* No notifications while we're focused */ * report closed/activated are honored, to avoid sending
return false; * 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) char replaces_id_str[16];
return false; xsnprintf(replaces_id_str, sizeof(replaces_id_str), "%u", replaces_id);
char **argv = NULL;
size_t argc = 0;
const char *urgency_str = const char *urgency_str =
notif->urgency == NOTIFY_URGENCY_LOW notif->urgency == NOTIFY_URGENCY_LOW
@ -272,19 +379,129 @@ notify_notify(struct terminal *term, struct notification *notif)
: notif->urgency == NOTIFY_URGENCY_NORMAL : notif->urgency == NOTIFY_URGENCY_NORMAL
? "normal" : "critical"; ? "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( if (!spawn_expand_template(
&term->conf->desktop_notifications.command, 8, &term->conf->desktop_notifications.command, 10,
(const char *[]){ (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 *[]){ (const char *[]){
term->app_id ? term->app_id : term->conf->app_id, app_id, term->window_title, icon_name_or_path, title, body,
term->window_title, icon_name_or_path, title, body, urgency_str, notif->category != NULL ? notif->category : "", urgency_str,
"default", "Click to activate"}, expire_time, replaces_id_str,
/* Custom expansion below, since we need to expand to multiple arguments */
"${action-arg}"},
&argc, &argv)) &argc, &argv))
{ {
return false; 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:"); LOG_DBG("notify command:");
for (size_t i = 0; i < argc; i++) for (size_t i = 0; i < argc; i++)
LOG_DBG(" argv[%zu] = \"%s\"", i, argv[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->id = NULL;
notif->title = NULL; notif->title = NULL;
notif->body = NULL; notif->body = NULL;
notif->category = NULL;
notif->app_id = NULL;
notif->icon_id = NULL; notif->icon_id = NULL;
notif->icon_symbolic_name = NULL; notif->icon_symbolic_name = NULL;
notif->icon_data = NULL; notif->icon_data = NULL;
notif->icon_data_sz = 0; 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) { if (stdout_fds[0] >= 0) {
fdm_add(term->fdm, stdout_fds[0], EPOLLIN, fdm_add(term->fdm, stdout_fds[0], EPOLLIN,
&fdm_notify_stdout, (void *)term); &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++) for (size_t i = 0; i < argc; i++)
free(argv[i]); free(argv[i]);
free(argv); free(argv);
for (size_t i = 0; i < action_argc; i++)
free(action_argv[i]);
free(action_argv);
notif->pid = pid; notif->pid = pid;
notif->stdout_fd = stdout_fds[0]; notif->stdout_fd = stdout_fds[0];
@ -345,11 +573,12 @@ notify_notify(struct terminal *term, struct notification *notif)
void void
notify_close(struct terminal *term, const char *id) notify_close(struct terminal *term, const char *id)
{ {
xassert(id != NULL);
LOG_DBG("close notification %s", id); LOG_DBG("close notification %s", id);
tll_foreach(term->active_notifications, it) { tll_foreach(term->active_notifications, it) {
const struct notification *notif = &it->item; const struct notification *notif = &it->item;
if (notif->id == 0 || !streq(notif->id, id)) if (notif->id == NULL || !streq(notif->id, id))
continue; continue;
if (term->conf->desktop_notifications.close.argv.args == NULL) { 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. * have a symbolic name.
*/ */
if (symbolic_name == NULL && data_sz > 0) { if (symbolic_name == NULL && data_sz > 0) {
char name[64] = "/tmp/foot-notification-icon-cache-XXXXXX"; write_icon_file(
int fd = mkostemp(name, O_CLOEXEC); data, data_sz,
&icon->tmp_file_fd,
if (fd < 0) { &icon->tmp_file_name,
LOG_ERRNO("failed to create temporary file for icon cache"); &icon->symbolic_name);
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;
}
} }
LOG_DBG("added icon to cache: ID=%s: sym=%s, file=%s", LOG_DBG("added icon to cache: ID=%s: sym=%s, file=%s",

View file

@ -3,6 +3,8 @@
#include <stdint.h> #include <stdint.h>
#include <unistd.h> #include <unistd.h>
#include <tllist.h>
struct terminal; struct terminal;
enum notify_when { enum notify_when {
@ -30,7 +32,9 @@ struct notification {
char *id; char *id;
char *title; char *title;
char *body; char *body;
char *category;
char *app_id; /* Custm app-id, overrides the terminal's app-id */
char *icon_id; char *icon_id;
char *icon_symbolic_name; char *icon_symbolic_name;
uint8_t *icon_data; uint8_t *icon_data;
@ -38,6 +42,9 @@ struct notification {
enum notify_when when; enum notify_when when;
enum notify_urgency urgency; enum notify_urgency urgency;
int32_t expire_time;
tll(char *) actions;
bool focus; bool focus;
bool may_be_programatically_closed; bool may_be_programatically_closed;
bool report_activated; bool report_activated;
@ -49,6 +56,7 @@ struct notification {
uint32_t external_id; /* Daemon assigned notification ID */ uint32_t external_id; /* Daemon assigned notification ID */
bool activated; /* User 'activated' the notification */ 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 */ char *xdg_token; /* XDG activation token, from daemon */
pid_t pid; /* Notifier command PID */ pid_t pid; /* Notifier command PID */
@ -56,6 +64,11 @@ struct notification {
char *stdout_data; /* Data we've reado from command's stdout */ char *stdout_data; /* Data we've reado from command's stdout */
size_t stdout_sz; 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 { struct notification_icon {

176
osc.c
View file

@ -559,7 +559,9 @@ osc_notify(struct terminal *term, char *string)
notify_notify(term, &(struct notification){ notify_notify(term, &(struct notification){
.title = (char *)title, .title = (char *)title,
.body = (char *)msg}); .body = (char *)msg,
.expire_time = -1,
});
} }
static void static void
@ -575,9 +577,11 @@ kitty_notification(struct terminal *term, char *string)
*payload_raw = '\0'; *payload_raw = '\0';
payload_raw++; 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 *icon_id = NULL; /* The 'g' parameter */
char *symbolic_icon = NULL; /* The 'n' parameter */ char *symbolic_icon = NULL; /* The 'n' parameter */
char *category = NULL; /* The 't' parameter */
char *payload = NULL; char *payload = NULL;
bool focus = true; /* The 'a' parameter */ bool focus = true; /* The 'a' parameter */
@ -586,12 +590,16 @@ kitty_notification(struct terminal *term, char *string)
bool done = true; /* The 'd' parameter */ bool done = true; /* The 'd' parameter */
bool base64 = false; /* The 'e' parameter */ bool base64 = false; /* The 'e' parameter */
int32_t expire_time = -1; /* The 'w' parameter */
size_t payload_size; size_t payload_size;
enum { enum {
PAYLOAD_TITLE, PAYLOAD_TITLE,
PAYLOAD_BODY, PAYLOAD_BODY,
PAYLOAD_CLOSE, PAYLOAD_CLOSE,
PAYLOAD_ALIVE,
PAYLOAD_ICON, PAYLOAD_ICON,
PAYLOAD_BUTTON,
} payload_type = PAYLOAD_TITLE; /* The 'p' parameter */ } payload_type = PAYLOAD_TITLE; /* The 'p' parameter */
enum notify_when when = NOTIFY_ALWAYS; enum notify_when when = NOTIFY_ALWAYS;
@ -601,6 +609,7 @@ kitty_notification(struct terminal *term, char *string)
bool have_c = false; bool have_c = false;
bool have_o = false; bool have_o = false;
bool have_u = false; bool have_u = false;
bool have_w = false;
char *ctx = NULL; char *ctx = NULL;
for (char *param = strtok_r(parameters, ":", &ctx); for (char *param = strtok_r(parameters, ":", &ctx);
@ -675,8 +684,12 @@ kitty_notification(struct terminal *term, char *string)
payload_type = PAYLOAD_BODY; payload_type = PAYLOAD_BODY;
else if (streq(value, "close")) else if (streq(value, "close"))
payload_type = PAYLOAD_CLOSE; payload_type = PAYLOAD_CLOSE;
else if (streq(value, "alive"))
payload_type = PAYLOAD_ALIVE;
else if (streq(value, "icon")) else if (streq(value, "icon"))
payload_type = PAYLOAD_ICON; payload_type = PAYLOAD_ICON;
else if (streq(value, "buttons"))
payload_type = PAYLOAD_BUTTON;
else if (streq(value, "?")) { else if (streq(value, "?")) {
/* Query capabilities */ /* Query capabilities */
@ -690,9 +703,10 @@ kitty_notification(struct terminal *term, char *string)
char reply[128]; char reply[128];
int n = xsnprintf( int n = xsnprintf(
reply, sizeof(reply), 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", "\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, when_str, terminator); id != NULL ? id : "0", when_str, terminator);
xassert(n < sizeof(reply));
term_to_slave(term, reply, n); term_to_slave(term, reply, n);
goto out; goto out;
} }
@ -720,6 +734,45 @@ kitty_notification(struct terminal *term, char *string)
urgency = NOTIFY_URGENCY_CRITICAL; urgency = NOTIFY_URGENCY_CRITICAL;
break; 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': case 'g':
/* graphical ID */ /* graphical ID */
free(icon_id); free(icon_id);
@ -729,7 +782,35 @@ kitty_notification(struct terminal *term, char *string)
case 'n': case 'n':
/* Symbolic icon name, used with 'g' */ /* Symbolic icon name, used with 'g' */
free(symbolic_icon); 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; break;
} }
} }
@ -746,7 +827,9 @@ kitty_notification(struct terminal *term, char *string)
/* Search for an existing (d=0) notification to update */ /* Search for an existing (d=0) notification to update */
struct notification *notif = NULL; struct notification *notif = NULL;
tll_foreach(term->kitty_notifications, it) { 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 */ /* Found existing notification */
notif = &it->item; notif = &it->item;
break; break;
@ -758,6 +841,8 @@ kitty_notification(struct terminal *term, char *string)
.id = id, .id = id,
.when = when, .when = when,
.urgency = urgency, .urgency = urgency,
.expire_time = expire_time,
.actions = tll_init(),
.focus = focus, .focus = focus,
.may_be_programatically_closed = true, .may_be_programatically_closed = true,
.report_activated = report_activated, .report_activated = report_activated,
@ -787,6 +872,8 @@ kitty_notification(struct terminal *term, char *string)
notif->when = when; notif->when = when;
if (have_u) if (have_u)
notif->urgency = urgency; notif->urgency = urgency;
if (have_w)
notif->expire_time = expire_time;
if (icon_id != NULL) { if (icon_id != NULL) {
free(notif->icon_id); free(notif->icon_id);
@ -800,6 +887,29 @@ kitty_notification(struct terminal *term, char *string)
symbolic_icon = NULL; 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(&notif->category[old_len + 1], category, new_len);
notif->category[old_len + 1 + new_len] = '\0';
}
}
/* Handled chunked payload - append to existing metadata */ /* Handled chunked payload - append to existing metadata */
switch (payload_type) { switch (payload_type) {
case PAYLOAD_TITLE: case PAYLOAD_TITLE:
@ -820,6 +930,7 @@ kitty_notification(struct terminal *term, char *string)
} }
case PAYLOAD_CLOSE: case PAYLOAD_CLOSE:
case PAYLOAD_ALIVE:
/* Ignore payload */ /* Ignore payload */
break; break;
@ -835,6 +946,20 @@ kitty_notification(struct terminal *term, char *string)
notif->icon_data_sz += payload_size; notif->icon_data_sz += payload_size;
} }
break; 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) { if (done) {
@ -856,7 +981,42 @@ kitty_notification(struct terminal *term, char *string)
} }
if (payload_type == PAYLOAD_CLOSE) { 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 ",<id>" */
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 { } else {
/* /*
* Show notification. * Show notification.
@ -880,9 +1040,11 @@ kitty_notification(struct terminal *term, char *string)
out: out:
free(id); free(id);
free(app_id);
free(icon_id); free(icon_id);
free(symbolic_icon); free(symbolic_icon);
free(payload); free(payload);
free(category);
} }
void void

View file

@ -3601,7 +3601,9 @@ term_bell(struct terminal *term)
if (term->conf->bell.notify) { if (term->conf->bell.notify) {
notify_notify(term, &(struct notification){ notify_notify(term, &(struct notification){
.title = (char *)"Bell", .title = (char *)"Bell",
.body = (char *)"Bell in terminal"}); .body = (char *)"Bell in terminal",
.expire_time = -1,
});
} }
if (term->conf->bell.flash) if (term->conf->bell.flash)

View file

@ -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_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", &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); config_free(&conf);
} }