osc: kitty notifications: implement focus|report

This patch adds support for window focusing, and sending events back
to the client application when a notification is closed.

* Refactor notification related configuration options:
    - add desktop-notifications sub-section
    - deprecate 'notify' in favor of 'desktop-notifications.command'
    - deprecate 'notify-focus-inhibit' in favor of
      'desktop-notifications.inhibit-when-focused'
* Refactor: rename 'struct kitty_notification' to 'struct
  notification'
* Pass a 'struct notification' to notify_notify(), instead of many
  arguments.
* notify_notify() now registers a reaper callback. When the notifier
  process has terminated, the notification is considered closed, and we
  either try to focus (activate) the window, or send an event to the
  client application, depending on the notification setting.
* For the window activation, we need an XDG activation token. For now,
  assume *everything* written on stdout is part of the token.
* Refactor: remove much of the warnings from OSC-99; we don't
  typically log anything when an OSC/CSI has invalid values.
* Add icon support to OSC-99. This isn't part of the upstream
  spec. Foot's implementation:
    - uses the 'I' parameter
    - the value is expected to be a symbolic icon name
    - a quick check for absolute paths is done, and such icon requests
      are ignored.
* Added ${icon} to the 'desktop-notifications.command' template. Uses
  the icon specified in the notification, or ${app-id} if not set.
This commit is contained in:
Daniel Eklöf 2024-07-23 06:59:46 +02:00
parent 12152a8ae4
commit 5905ea0d84
No known key found for this signature in database
GPG key ID: 5BBD4992C116573F
11 changed files with 410 additions and 137 deletions

View file

@ -1024,11 +1024,29 @@ parse_section_main(struct context *ctx)
else if (streq(key, "word-delimiters")) else if (streq(key, "word-delimiters"))
return value_to_wchars(ctx, &conf->word_delimiters); return value_to_wchars(ctx, &conf->word_delimiters);
else if (streq(key, "notify")) else if (streq(key, "notify")) {
return value_to_spawn_template(ctx, &conf->notify); user_notification_add(
&conf->notifications, USER_NOTIFICATION_DEPRECATED,
xstrdup("notify: use desktop-notifications.command instead"));
log_msg(
LOG_CLASS_WARNING, LOG_MODULE, __FILE__, __LINE__,
"deprecated: notify: use desktop-notifications.command instead");
return value_to_spawn_template(
ctx, &conf->desktop_notifications.command);
}
else if (streq(key, "notify-focus-inhibit")) else if (streq(key, "notify-focus-inhibit")) {
return value_to_bool(ctx, &conf->notify_focus_inhibit); user_notification_add(
&conf->notifications, USER_NOTIFICATION_DEPRECATED,
xstrdup("notify-focus-inhibit: "
"use desktop-notifications.inhibit-when-focused instead"));
log_msg(
LOG_CLASS_WARNING, LOG_MODULE, __FILE__, __LINE__,
"deprecrated: notify-focus-inhibit: "
"use desktop-notifications.inhibit-when-focused instead");
return value_to_bool(
ctx, &conf->desktop_notifications.inhibit_when_focused);
}
else if (streq(key, "selection-target")) { else if (streq(key, "selection-target")) {
_Static_assert(sizeof(conf->selection_target) == sizeof(int), _Static_assert(sizeof(conf->selection_target) == sizeof(int),
@ -1083,6 +1101,24 @@ parse_section_bell(struct context *ctx)
} }
} }
static bool
parse_section_desktop_notifications(struct context *ctx)
{
struct config *conf = ctx->conf;
const char *key = ctx->key;
if (streq(key, "command"))
return value_to_spawn_template(
ctx, &conf->desktop_notifications.command);
else if (streq(key, "inhibit-when-focused"))
return value_to_bool(
ctx, &conf->desktop_notifications.inhibit_when_focused);
else {
LOG_CONTEXTUAL_ERR("not a valid option: %s", key);
return false;
}
}
static bool static bool
parse_section_scrollback(struct context *ctx) parse_section_scrollback(struct context *ctx)
{ {
@ -2662,6 +2698,7 @@ parse_key_value(char *kv, const char **section, const char **key, const char **v
enum section { enum section {
SECTION_MAIN, SECTION_MAIN,
SECTION_BELL, SECTION_BELL,
SECTION_DESKTOP_NOTIFICATIONS,
SECTION_SCROLLBACK, SECTION_SCROLLBACK,
SECTION_URL, SECTION_URL,
SECTION_COLORS, SECTION_COLORS,
@ -2688,6 +2725,7 @@ static const struct {
} section_info[] = { } section_info[] = {
[SECTION_MAIN] = {&parse_section_main, "main"}, [SECTION_MAIN] = {&parse_section_main, "main"},
[SECTION_BELL] = {&parse_section_bell, "bell"}, [SECTION_BELL] = {&parse_section_bell, "bell"},
[SECTION_DESKTOP_NOTIFICATIONS] = {&parse_section_desktop_notifications, "desktop-notifications"},
[SECTION_SCROLLBACK] = {&parse_section_scrollback, "scrollback"}, [SECTION_SCROLLBACK] = {&parse_section_scrollback, "scrollback"},
[SECTION_URL] = {&parse_section_url, "url"}, [SECTION_URL] = {&parse_section_url, "url"},
[SECTION_COLORS] = {&parse_section_colors, "colors"}, [SECTION_COLORS] = {&parse_section_colors, "colors"},
@ -3144,10 +3182,12 @@ config_load(struct config *conf, const char *conf_path,
.presentation_timings = false, .presentation_timings = false,
.selection_target = SELECTION_TARGET_PRIMARY, .selection_target = SELECTION_TARGET_PRIMARY,
.hold_at_exit = false, .hold_at_exit = false,
.notify = { .desktop_notifications = {
.argv = {.args = NULL}, .command = {
.argv = {.args = NULL},
},
.inhibit_when_focused = true,
}, },
.notify_focus_inhibit = true,
.tweak = { .tweak = {
.fcft_filter = FCFT_SCALING_FILTER_LANCZOS3, .fcft_filter = FCFT_SCALING_FILTER_LANCZOS3,
@ -3185,8 +3225,8 @@ 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 -a ${app-id} -i ${app-id} -u ${urgency} ${title} ${body}", "notify-send --wait --app-name ${app-id} --icon ${icon} --urgency ${urgency} -- ${title} ${body}",
&conf->notify.argv.args); &conf->desktop_notifications.command.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[] = {
@ -3439,7 +3479,8 @@ config_clone(const struct config *old)
conf->scrollback.indicator.text = xc32dup(old->scrollback.indicator.text); conf->scrollback.indicator.text = xc32dup(old->scrollback.indicator.text);
conf->server_socket_path = xstrdup(old->server_socket_path); conf->server_socket_path = xstrdup(old->server_socket_path);
spawn_template_clone(&conf->bell.command, &old->bell.command); spawn_template_clone(&conf->bell.command, &old->bell.command);
spawn_template_clone(&conf->notify, &old->notify); spawn_template_clone(&conf->desktop_notifications.command,
&old->desktop_notifications.command);
for (size_t i = 0; i < ALEN(conf->fonts); i++) for (size_t i = 0; i < ALEN(conf->fonts); i++)
config_font_list_clone(&conf->fonts[i], &old->fonts[i]); config_font_list_clone(&conf->fonts[i], &old->fonts[i]);
@ -3521,7 +3562,7 @@ config_free(struct config *conf)
free(conf->word_delimiters); free(conf->word_delimiters);
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->notify); spawn_template_free(&conf->desktop_notifications.command);
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]);
free(conf->server_socket_path); free(conf->server_socket_path);

View file

@ -338,8 +338,10 @@ struct config {
SELECTION_TARGET_BOTH SELECTION_TARGET_BOTH
} selection_target; } selection_target;
struct config_spawn_template notify; struct {
bool notify_focus_inhibit; struct config_spawn_template command;
bool inhibit_when_focused;
} desktop_notifications;
env_var_list_t env_vars; env_var_list_t env_vars;

View file

@ -718,6 +718,10 @@ All _OSC_ sequences begin with *\\E]*, sometimes abbreviated _OSC_.
: Copy _Pd_ (base64 encoded text) to the clipboard. _Pc_ denotes the : Copy _Pd_ (base64 encoded text) to the clipboard. _Pc_ denotes the
target: *c* targets the clipboard and *s* and *p* the primary target: *c* targets the clipboard and *s* and *p* the primary
selection. selection.
| \\E] 99 ; _params_ ; _payload_ \\E\\
: kitty
: Desktop notification; uses *desktop-notifications.command* in
*foot.ini*(5).
| \\E] 104 ; _c_ \\E\\ | \\E] 104 ; _c_ \\E\\
: xterm : xterm
: Reset color number _c_ (multiple semicolon separated _c_ values may : Reset color number _c_ (multiple semicolon separated _c_ values may
@ -757,7 +761,8 @@ All _OSC_ sequences begin with *\\E]*, sometimes abbreviated _OSC_.
: Flash the entire terminal (foot extension) : Flash the entire terminal (foot extension)
| \\E] 777;notify;_title_;_msg_ \\E\\ | \\E] 777;notify;_title_;_msg_ \\E\\
: urxvt : urxvt
: Desktop notification, uses *notify* in *foot.ini*(5). : Desktop notification, uses *desktop-notifications.command* in
*foot.ini*(5).
# DCS # DCS

View file

@ -342,32 +342,6 @@ empty string to be set, but it must be quoted: *KEY=""*)
text. Note that whitespace characters are _always_ word text. Note that whitespace characters are _always_ word
delimiters, regardless of this setting. Default: _,│`|:"'()[]{}<>_ delimiters, regardless of this setting. Default: _,│`|:"'()[]{}<>_
*notify*
Command to execute to display a notification. _${title}_ and
_${body}_ will be replaced with the notification's actual _title_
and _body_ (message content).
_${app-id}_ is replaced with the value of the command line option
_--app-id_, and defaults to *foot* (normal mode), or
*footclient* (server mode).
_${window-title}_ is replaced with the current window title.
Applications can trigger notifications in the following ways:
- OSC 777: *\\e]777;notify;<title>;<body>\\e\\\\*
By default, notifications are *inhibited* if the foot window
has keyboard focus. See _notify-focus-inhibit_.
Default: _notify-send -a ${app-id} -i ${app-id} -u ${urgency} ${title} ${body}_.
*notify-focus-inhibit*
Boolean. If enabled, foot will not display notifications if the
terminal window has keyboard focus.
Default: _yes_
*selection-target* *selection-target*
Clipboard target to automatically copy selected text to. One of Clipboard target to automatically copy selected text to. One of
*none*, *primary*, *clipboard* or *both*. Default: _primary_. *none*, *primary*, *clipboard* or *both*. Default: _primary_.
@ -426,10 +400,11 @@ Note: do not set *TERM* here; use the *term* option in the main
Default: _no_ Default: _no_
*notify* *notify*
When set to _yes_, foot will emit a desktop notification using When set to _yes_, foot will emit a desktop notification using the
the command specified in the *notify* option whenever *BEL* is command specified in the *notify* option whenever *BEL* is
received. By default, bell notifications are shown only when the received. By default, bell notifications are shown only when the
window does *not* have keyboard focus. See _notify-focus-inhibit_. window does *not* have keyboard focus. See
_desktop-notifications.inhibit-when-focused_.
Default: _no_ Default: _no_
@ -445,6 +420,82 @@ Note: do not set *TERM* here; use the *term* option in the main
Whether to run the command on *BEL* even while focused. Default: Whether to run the command on *BEL* even while focused. Default:
_no_ _no_
# SECTION: desktop-notifications
*command*
Command to execute to display a notification.
Template arguments
_${title}_ and _${body}_ will be replaced with the
notification's actual _title_ and _body_ (message content).
_${app-id}_ is replaced with the value of the command line
option _--app-id_, and defaults to *foot* (normal mode), or
*footclient* (server mode).
_${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.
_${urgency}_ is replaced with the notifications urgency;
*low*, *normal* or *critical*.
Ways to trigger notifications
Applications can trigger notifications in the following ways:
- OSC 777: *\\e]777;notify;<title>;<body>\\e\\\\*
- OSC 99: *\\e]99;;<title>\\e\\\\* (this is just a bare bones
example; this protocol has lots of features, see
https://sw.kovidgoyal.net/kitty/desktop-notifications)
By default, notifications are *inhibited* if the foot window
has keyboard focus. See
_desktop-notifications.inhibit-when-focused_.
Window activation (focusing)
Foot can focus the window when the notification is
"activated". This typically happens when the default action is
invoked, and/or when the notification is clicked, but exact
behavior depends on the notification daemon in use, and how it
has been configured.
For this to work, foot needs an XDG activation token. To this
end, foot will read the command's stdout; everything printed
there, not including trailing newlines, are assumed to be part
of the activation token. There is no harm in printing
something else on stdout - it will simply result in the
activation failing (i.e. the window will not be focused).
*Note*: notify-send does not, out of the box, support
reporting the XDG activation token in any way. This means
window activation will not work by default.
Notification dismissal
The kitty desktop notifications protocol (OSC-99) allows the
terminal application to request an event be sent to it when
the notification has been dismissed (by setting *a=report* in
the notification request).
To be able to send this event, foot needs to know when the
notification is dismissed. This is handled in a very simple
manner; the command signals notification dismissal by
exiting. That is, as soon as the command returns, foot
considers the notification dismissed.
For *notify-send*, this can be achieved with the *--wait*
option.
Default: _notify-send --wait --app-name ${app-id} --icon ${icon} --urgency ${urgency} -- ${title} ${body}_.
*inhibit-when-focused*
Boolean. If enabled, foot will not display notifications if the
terminal window has keyboard focus.
Default: _yes_
# SECTION: scrollback # SECTION: scrollback
*lines* *lines*

View file

@ -29,8 +29,6 @@
# resize-by-cells=yes # resize-by-cells=yes
# resize-delay-ms=100 # resize-delay-ms=100
# notify=notify-send -a ${app-id} -i ${app-id} -u ${urgency} ${title} ${body}
# bold-text-in-bright=no # bold-text-in-bright=no
# word-delimiters=,│`|:"'()[]{}<> # word-delimiters=,│`|:"'()[]{}<>
# selection-target=primary # selection-target=primary
@ -48,6 +46,11 @@
# command= # command=
# command-focused=no # command-focused=no
[desktop-notifications]
# command=notify-send --wait --app-name ${app-id} --icon ${icon} --urgency ${urgency} -- ${title} ${body}
# inhibit-when-focused=yes
[scrollback] [scrollback]
# lines=1000 # lines=1000
# multiplier=3.0 # multiplier=3.0

185
notify.c
View file

@ -1,9 +1,11 @@
#include "notify.h" #include "notify.h"
#include <errno.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include <sys/epoll.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <fcntl.h> #include <fcntl.h>
@ -13,50 +15,189 @@
#include "config.h" #include "config.h"
#include "spawn.h" #include "spawn.h"
#include "terminal.h" #include "terminal.h"
#include "wayland.h"
#include "xmalloc.h" #include "xmalloc.h"
#include "xsnprintf.h"
void void
notify_notify(const struct terminal *term, const char *title, const char *body, notify_free(struct terminal *term, struct notification *notif)
enum notify_when when, enum notify_urgency urgency)
{ {
LOG_DBG("notify: title=\"%s\", msg=\"%s\"", title, body); fdm_del(term->fdm, notif->stdout_fd);
free(notif->id);
free(notif->title);
free(notif->body);
free(notif->icon);
free(notif->xdg_token);
}
if ((term->conf->notify_focus_inhibit || when != NOTIFY_ALWAYS) static bool
fdm_notify_stdout(struct fdm *fdm, int fd, int events, void *data)
{
const struct terminal *term = data;
struct notification *notif = NULL;
/* Find notification */
tll_foreach(term->notifications, it) {
if (it->item.stdout_fd == fd) {
notif = &it->item;
break;
}
}
if (events & EPOLLIN) {
char buf[512];
ssize_t count = read(fd, buf, sizeof(buf) - 1);
if (count < 0) {
if (errno == EINTR)
return true;
LOG_ERRNO("failed to read notification activation token");
return false;
}
if (count > 0) {
buf[count - 1] = '\0';
if (notif != NULL) {
if (notif->xdg_token == NULL) {
notif->xdg_token = xstrdup(buf);
} else {
char *new_token = xstrjoin(notif->xdg_token, buf);
free(notif->xdg_token);
notif->xdg_token = new_token;
}
}
}
}
if (events & EPOLLHUP) {
fdm_del(fdm, fd);
if (notif != NULL)
notif->stdout_fd = -1;
/* Strip trailing newlines */
if (notif != NULL && notif->xdg_token != NULL) {
size_t len = strlen(notif->xdg_token);
while (len > 0 && notif->xdg_token[len - 1] == '\n')
len--;
notif->xdg_token[len] = '\0';
}
}
return true;
}
static void
notif_done(struct reaper *reaper, pid_t pid, int status, void *data)
{
struct terminal *term = data;
tll_foreach(term->notifications, it) {
struct notification *notif = &it->item;
if (notif->pid != pid)
continue;
LOG_DBG("notification %s dismissed", notif->id);
if (notif->focus) {
LOG_DBG("focus window on notification activation: \"%s\"", notif->xdg_token);
wayl_activate(term->wl, term->window, notif->xdg_token);
}
if (notif->report) {
xassert(notif->id != NULL);
LOG_DBG("sending notification report to client");
char reply[5 + strlen(notif->id) + 1 + 2 + 1];
int n = xsnprintf(
reply, sizeof(reply), "\033]99;%s;\033\\", notif->id);
term_to_slave(term, reply, n);
}
notify_free(term, notif);
tll_remove(term->notifications, it);
return;
}
}
bool
notify_notify(const struct terminal *term, struct notification *notif)
{
xassert(notif->xdg_token == NULL);
xassert(notif->pid == 0);
xassert(notif->stdout_fd == 0);
notif->pid = -1;
notif->stdout_fd = -1;
/* Use body as title, if title is unset */
const char *title = notif->title != NULL ? notif->title : notif->body;
const char *body = notif->title != NULL && notif->body != NULL ? notif->body : "";
/* Icon: use symbolic name from notification, if present,
otherwise fallback to the application ID */
const char *icon = notif->icon != NULL
? notif->icon
: term->app_id != NULL
? term->app_id
: term->conf->app_id;
LOG_DBG("notify: title=\"%s\", body=\"%s\"", title, body);
xassert(title != NULL);
if (title == NULL)
return false;
if ((term->conf->desktop_notifications.inhibit_when_focused ||
notif->when != NOTIFY_ALWAYS)
&& term->kbd_focus) && term->kbd_focus)
{ {
/* No notifications while we're focused */ /* No notifications while we're focused */
return; return false;
} }
if (title == NULL || body == NULL) if (term->conf->desktop_notifications.command.argv.args == NULL)
return; return false;
if (term->conf->notify.argv.args == NULL)
return;
char **argv = NULL; char **argv = NULL;
size_t argc = 0; size_t argc = 0;
const char *urgency_str = const char *urgency_str =
urgency == NOTIFY_URGENCY_LOW notif->urgency == NOTIFY_URGENCY_LOW
? "low" ? "low"
: urgency == NOTIFY_URGENCY_NORMAL : notif->urgency == NOTIFY_URGENCY_NORMAL
? "normal" : "critical"; ? "normal" : "critical";
if (!spawn_expand_template( if (!spawn_expand_template(
&term->conf->notify, 5, &term->conf->desktop_notifications.command, 6,
(const char *[]){"app-id", "window-title", "title", "body", "urgency"}, (const char *[]){"app-id", "window-title", "icon", "title", "body", "urgency"},
(const char *[]){term->app_id ? term->app_id : term->conf->app_id, (const char *[]){term->app_id ? term->app_id : term->conf->app_id,
term->window_title, title, body, urgency_str}, term->window_title, icon, title, body, urgency_str},
&argc, &argv)) &argc, &argv))
{ {
return; return false;
} }
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]);
int stdout_fds[2] = {-1, -1};
if (notif->focus && pipe2(stdout_fds, O_CLOEXEC | O_NONBLOCK) < 0) {
LOG_WARN("failed to create stdout pipe");
/* Non-fatal */
}
if (stdout_fds[0] >= 0) {
xassert(notif->xdg_token == NULL);
fdm_add(term->fdm, stdout_fds[0], EPOLLIN,
&fdm_notify_stdout, (void *)term);
}
/* Redirect stdin to /dev/null, but ignore failure to open */ /* Redirect stdin to /dev/null, but ignore failure to open */
int devnull = open("/dev/null", O_RDONLY); int devnull = open("/dev/null", O_RDONLY);
pid_t pid = spawn( pid_t pid = spawn(
@ -64,6 +205,14 @@ notify_notify(const struct terminal *term, const char *title, const char *body,
&notif_done, (void *)term, NULL); &notif_done, (void *)term, NULL);
if (stdout_fds[1] >= 0) { if (stdout_fds[1] >= 0) {
/* Close write-end of stdout pipe */
close(stdout_fds[1]);
}
if (pid < 0 && stdout_fds[0] >= 0) {
/* Remove FDM callback if we failed to spawn */
fdm_del(term->fdm, stdout_fds[0]);
}
if (devnull >= 0) if (devnull >= 0)
close(devnull); close(devnull);
@ -71,4 +220,8 @@ notify_notify(const struct terminal *term, const char *title, const char *body,
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);
notif->pid = pid;
notif->stdout_fd = stdout_fds[0];
return true;
} }

View file

@ -1,30 +1,41 @@
#pragma once #pragma once
#include <stdbool.h> #include <stdbool.h>
#include <unistd.h>
struct terminal; struct terminal;
enum notify_when { enum notify_when {
/* First, so that it can be left out of initializer and still be
the default */
NOTIFY_ALWAYS, NOTIFY_ALWAYS,
NOTIFY_UNFOCUSED, NOTIFY_UNFOCUSED,
NOTIFY_INVISIBLE NOTIFY_INVISIBLE
}; };
enum notify_urgency { enum notify_urgency {
NOTIFY_URGENCY_LOW, /* First, so that it can be left out of initializer and still be
the default */
NOTIFY_URGENCY_NORMAL, NOTIFY_URGENCY_NORMAL,
NOTIFY_URGENCY_LOW,
NOTIFY_URGENCY_CRITICAL, NOTIFY_URGENCY_CRITICAL,
}; };
struct kitty_notification { struct notification {
char *id; char *id;
char *title; char *title;
char *body; char *body;
char *icon;
char *xdg_token;
enum notify_when when; enum notify_when when;
enum notify_urgency urgency; enum notify_urgency urgency;
bool focus; bool focus;
bool report; bool report;
pid_t pid;
int stdout_fd;
}; };
void notify_notify( bool notify_notify(const struct terminal *term, struct notification *notif);
const struct terminal *term, const char *title, const char *body, void notify_free(struct terminal *term, struct notification *notif);
enum notify_when when, enum notify_urgency urgency);

103
osc.c
View file

@ -560,9 +560,9 @@ osc_notify(struct terminal *term, char *string)
return; return;
} }
notify_notify( notify_notify(term, &(struct notification){
term, title, msg != NULL ? msg : "", .title = (char *)title,
NOTIFY_ALWAYS, NOTIFY_URGENCY_NORMAL); .body = (char *)msg});
} }
static void static void
@ -571,16 +571,15 @@ kitty_notification(struct terminal *term, char *string)
/* https://sw.kovidgoyal.net/kitty/desktop-notifications */ /* https://sw.kovidgoyal.net/kitty/desktop-notifications */
char *payload = strchr(string, ';'); char *payload = strchr(string, ';');
if (payload == NULL) { if (payload == NULL)
LOG_ERR("OSC-99: payload missing");
return; return;
}
char *parameters = string; char *parameters = string;
*payload = '\0'; *payload = '\0';
payload++; payload++;
char *id = xstrdup("0"); /* The 'i' parameter */ char *id = xstrdup("0"); /* The 'i' parameter */
char *icon = NULL; /* The 'I' parameter */
bool focus = true; /* The 'a' parameter */ bool focus = true; /* The 'a' parameter */
bool report = false; /* The 'a' parameter */ bool report = false; /* The 'a' parameter */
bool done = true; /* The 'd' parameter */ bool done = true; /* The 'd' parameter */
@ -601,10 +600,8 @@ kitty_notification(struct terminal *term, char *string)
{ {
/* All parameters are on the form X=value, where X is always /* All parameters are on the form X=value, where X is always
exactly one character */ exactly one character */
if (param[0] == '\0' || param[1] != '=') { if (param[0] == '\0' || param[1] != '=')
LOG_WARN("OSC-99: invalid parameter: \"%s\"", param);
continue; continue;
}
char *value = &param[2]; char *value = &param[2];
@ -613,25 +610,19 @@ kitty_notification(struct terminal *term, char *string)
/* notification activation action: focus|report|-focus|-report */ /* notification activation action: focus|report|-focus|-report */
have_a = true; have_a = true;
char *a_ctx = NULL; char *a_ctx = NULL;
for (const char *v = strtok_r(value, ",", &a_ctx); for (const char *v = strtok_r(value, ",", &a_ctx);
v != NULL; v != NULL;
v = strtok_r(NULL, ",", &a_ctx)) v = strtok_r(NULL, ",", &a_ctx))
{ {
LOG_WARN(" a: \"%s\"", v);
bool reverse = v[0] == '-'; bool reverse = v[0] == '-';
if (reverse) if (reverse)
v++; v++;
if (strcmp(v, "focus") == 0) { if (strcmp(v, "focus") == 0)
focus = !reverse; focus = !reverse;
if (focus) else if (strcmp(v, "report") == 0)
LOG_WARN("unimplemented: OSC-99: focus on notification activation");
} else if (strcmp(v, "report") == 0) {
report = !reverse; report = !reverse;
if (report)
LOG_WARN("unimplemented: OSC-99: report on notification activation");
} else
LOG_WARN("OSC-99: unrecognized value for 'a': \"%s\", ignoring", v);
} }
break; break;
@ -643,8 +634,6 @@ kitty_notification(struct terminal *term, char *string)
done = false; done = false;
else if (value[0] == '1' && value[1] == '\0') else if (value[0] == '1' && value[1] == '\0')
done = true; done = true;
else
LOG_WARN("OSC-99: unrecognized value for 'd': \"%s\", ignoring", value);
break; break;
case 'e': case 'e':
@ -653,8 +642,6 @@ kitty_notification(struct terminal *term, char *string)
base64 = false; base64 = false;
else if (value[0] == '1' && value[1] == '\0') else if (value[0] == '1' && value[1] == '\0')
base64 = true; base64 = true;
else
LOG_WARN("OSC-99: unrecognized value for 'e': \"%s\", ignoring", value);
break; break;
case 'i': case 'i':
@ -669,8 +656,6 @@ kitty_notification(struct terminal *term, char *string)
payload_is_title = true; payload_is_title = true;
else if (strcmp(value, "body") == 0) else if (strcmp(value, "body") == 0)
payload_is_title = false; payload_is_title = false;
else
LOG_WARN("OSC-99: unrecognized value for 'p': \"%s\", ignoring", value);
break; break;
case 'o': case 'o':
@ -682,8 +667,6 @@ kitty_notification(struct terminal *term, char *string)
when = NOTIFY_UNFOCUSED; when = NOTIFY_UNFOCUSED;
else if (strcmp(value, "invisible") == 0) else if (strcmp(value, "invisible") == 0)
when = NOTIFY_INVISIBLE; when = NOTIFY_INVISIBLE;
else
LOG_WARN("OSC-99: unrecognized value for 'o': \"%s\", ignoring", value);
break; break;
case 'u': case 'u':
@ -695,13 +678,16 @@ kitty_notification(struct terminal *term, char *string)
urgency = NOTIFY_URGENCY_NORMAL; urgency = NOTIFY_URGENCY_NORMAL;
else if (value[0] == '2' && value[1] == '\0') else if (value[0] == '2' && value[1] == '\0')
urgency = NOTIFY_URGENCY_CRITICAL; urgency = NOTIFY_URGENCY_CRITICAL;
else
LOG_WARN("OSC-99: unrecognized value for 'u': \"%s\", ignoring", value);
break; break;
default: /*
LOG_WARN("OSC-99: unrecognized parameter: \"%s\", ignoring", param); * The options below are not (yet) part of the official spec.
break; */
case 'I':
/* icon: only symbolic names allowed; absolute paths are ignored */
if (value[0] != '/')
icon = xstrdup(value);
break;
} }
} }
@ -710,9 +696,9 @@ kitty_notification(struct terminal *term, char *string)
else else
payload = xstrdup(payload); payload = xstrdup(payload);
LOG_DBG("id=%s, done=%d, focus=%d, report=%d, base64=%d, payload: %s, " LOG_DBG("id=%s, done=%d, focus=%d, report=%d, base64=%d, icon=%s, payload: %s, "
"honor: %s, urgency: %s, %s: %s", "honor: %s, urgency: %s, %s: %s",
id, done, focus, report, base64, id, done, focus, report, base64, icon != NULL ? icon : "<not set>",
payload_is_title ? "title" : "body", payload_is_title ? "title" : "body",
(when == NOTIFY_ALWAYS (when == NOTIFY_ALWAYS
? "always" ? "always"
@ -726,11 +712,10 @@ kitty_notification(struct terminal *term, char *string)
payload_is_title ? "title" : "body", payload); payload_is_title ? "title" : "body", payload);
/* Search for an existing (d=0) notification to update */ /* Search for an existing (d=0) notification to update */
struct kitty_notification *notif = NULL; struct notification *notif = NULL;
tll_foreach(term->kitty_notifications, it) { tll_foreach(term->notifications, it) {
if (strcmp(it->item.id, id) == 0) { if (strcmp(it->item.id, id) == 0) {
/* Found existing notification */ /* Found existing notification */
LOG_WARN("found existing kitty notification");
notif = &it->item; notif = &it->item;
break; break;
} }
@ -739,8 +724,9 @@ kitty_notification(struct terminal *term, char *string)
if (notif == NULL) { if (notif == NULL) {
/* Somewhat unoptimized... this will be free:d and removed /* Somewhat unoptimized... this will be free:d and removed
immediately if d=1 */ immediately if d=1 */
tll_push_front(term->kitty_notifications, ((struct kitty_notification){ tll_push_front(term->notifications, ((struct notification){
.id = id, .id = id,
.icon = NULL,
.title = NULL, .title = NULL,
.body = NULL, .body = NULL,
.when = when, .when = when,
@ -749,8 +735,13 @@ kitty_notification(struct terminal *term, char *string)
.report = report, .report = report,
})); }));
id = NULL; /* Prevent double free */ id = NULL; /* Prevent double free */
notif = &tll_front(term->kitty_notifications); notif = &tll_front(term->notifications);
}
if (notif->pid > 0) {
/* Notification has already been completed, ignore new metadata */
goto out;
} }
/* Update notification metadata */ /* Update notification metadata */
@ -764,6 +755,12 @@ kitty_notification(struct terminal *term, char *string)
if (have_u) if (have_u)
notif->urgency = urgency; notif->urgency = urgency;
if (icon != NULL) {
free(notif->icon);
notif->icon = icon;
icon = NULL; /* Prevent double free */
}
if (payload_is_title) { if (payload_is_title) {
if (notif->title == NULL) { if (notif->title == NULL) {
notif->title = payload; notif->title = payload;
@ -784,26 +781,22 @@ kitty_notification(struct terminal *term, char *string)
} }
} }
free(id);
free(payload);
if (done) { if (done) {
notify_notify( if (!notify_notify(term, notif)) {
term, tll_foreach(term->notifications, it) {
notif->title != NULL ? notif->title : notif->body, if (&it->item == notif) {
notif->title != NULL && notif->body != NULL ? notif->body : "", notify_free(term, &it->item);
notif->when, notif->urgency); tll_remove(term->notifications, it);
break;
tll_foreach(term->kitty_notifications, it) { }
if (&it->item == notif) {
free(it->item.id);
free(it->item.title);
free(it->item.body);
tll_remove(term->kitty_notifications, it);
break;
} }
} }
} }
out:
free(id);
free(icon);
free(payload);
} }
void void

View file

@ -1313,7 +1313,7 @@ term_init(const struct config *conf, struct fdm *fdm, struct reaper *reaper,
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED #if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
.ime_enabled = true, .ime_enabled = true,
#endif #endif
.kitty_notifications = tll_init(), .notifications = tll_init(),
}; };
pixman_region32_init(&term->render.last_overlay_clip); pixman_region32_init(&term->render.last_overlay_clip);
@ -1818,11 +1818,9 @@ term_destroy(struct terminal *term)
tll_remove(term->ptmx_paste_buffers, it); tll_remove(term->ptmx_paste_buffers, it);
} }
tll_foreach(term->kitty_notifications, it) { tll_foreach(term->notifications, it) {
free(it->item.id); notify_free(term, &it->item);
free(it->item.body); tll_remove(term->notifications, it);
free(it->item.title);
tll_remove(term->kitty_notifications, it);
} }
sixel_fini(term); sixel_fini(term);
@ -2030,11 +2028,9 @@ term_reset(struct terminal *term, bool hard)
tll_remove(term->alt.sixel_images, it); tll_remove(term->alt.sixel_images, it);
} }
tll_foreach(term->kitty_notifications, it) { tll_foreach(term->notifications, it) {
free(it->item.id); notify_free(term, &it->item);
free(it->item.title); tll_remove(term->notifications, it);
free(it->item.body);
tll_remove(term->kitty_notifications, it);
} }
term->grapheme_shaping = term->conf->tweak.grapheme_shaping; term->grapheme_shaping = term->conf->tweak.grapheme_shaping;
@ -3582,8 +3578,9 @@ term_bell(struct terminal *term)
} }
if (term->conf->bell.notify) { if (term->conf->bell.notify) {
notify_notify(term, "Bell", "Bell in terminal", notify_notify(term, &(struct notification){
NOTIFY_ALWAYS, NOTIFY_URGENCY_NORMAL); .title = (char *)"Bell",
.body = (char *)"Bell in terminal"});
} }
if (term->conf->bell.flash) if (term->conf->bell.flash)

View file

@ -799,7 +799,9 @@ struct terminal {
void *cb_data; void *cb_data;
} shutdown; } shutdown;
tll(struct kitty_notification) kitty_notifications; /* Notifications that either haven't been sent yet, or have been
sent but not yet dismissed */
tll(struct notification) notifications;
char *foot_exe; char *foot_exe;
char *cwd; char *cwd;

View file

@ -511,7 +511,7 @@ test_section_main(void)
test_boolean(&ctx, &parse_section_main, "login-shell", &conf.login_shell); test_boolean(&ctx, &parse_section_main, "login-shell", &conf.login_shell);
test_boolean(&ctx, &parse_section_main, "box-drawings-uses-font-glyphs", &conf.box_drawings_uses_font_glyphs); test_boolean(&ctx, &parse_section_main, "box-drawings-uses-font-glyphs", &conf.box_drawings_uses_font_glyphs);
test_boolean(&ctx, &parse_section_main, "locked-title", &conf.locked_title); test_boolean(&ctx, &parse_section_main, "locked-title", &conf.locked_title);
test_boolean(&ctx, &parse_section_main, "notify-focus-inhibit", &conf.notify_focus_inhibit); test_boolean(&ctx, &parse_section_main, "notify-focus-inhibit", &conf.desktop_notifications.inhibit_when_focused); /* Deprecated */
test_boolean(&ctx, &parse_section_main, "dpi-aware", &conf.dpi_aware); test_boolean(&ctx, &parse_section_main, "dpi-aware", &conf.dpi_aware);
test_pt_or_px(&ctx, &parse_section_main, "font-size-adjustment", &conf.font_size_adjustment.pt_or_px); /* TODO: test N% values too */ test_pt_or_px(&ctx, &parse_section_main, "font-size-adjustment", &conf.font_size_adjustment.pt_or_px); /* TODO: test N% values too */
@ -524,7 +524,7 @@ test_section_main(void)
test_uint16(&ctx, &parse_section_main, "resize-delay-ms", &conf.resize_delay_ms); test_uint16(&ctx, &parse_section_main, "resize-delay-ms", &conf.resize_delay_ms);
test_uint16(&ctx, &parse_section_main, "workers", &conf.render_worker_count); test_uint16(&ctx, &parse_section_main, "workers", &conf.render_worker_count);
test_spawn_template(&ctx, &parse_section_main, "notify", &conf.notify); test_spawn_template(&ctx, &parse_section_main, "notify", &conf.desktop_notifications.command); /* Deprecated */
test_enum(&ctx, &parse_section_main, "selection-target", test_enum(&ctx, &parse_section_main, "selection-target",
4, 4,
@ -570,6 +570,20 @@ test_section_bell(void)
config_free(&conf); config_free(&conf);
} }
static void
test_section_desktop_notifications(void)
{
struct config conf = {0};
struct context ctx = {.conf = &conf, .section = "desktop-notifications", .path = "unittest"};
test_invalid_key(&ctx, &parse_section_desktop_notifications, "invalid-key");
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);
config_free(&conf);
}
static void static void
test_section_scrollback(void) test_section_scrollback(void)
{ {
@ -1391,6 +1405,7 @@ main(int argc, const char *const *argv)
log_init(LOG_COLORIZE_AUTO, false, 0, LOG_CLASS_ERROR); log_init(LOG_COLORIZE_AUTO, false, 0, LOG_CLASS_ERROR);
test_section_main(); test_section_main();
test_section_bell(); test_section_bell();
test_section_desktop_notifications();
test_section_scrollback(); test_section_scrollback();
test_section_url(); test_section_url();
test_section_cursor(); test_section_cursor();