osc/notify: add support for OSC-99, kitty desktop notifications

This adds limited support for OSC-99, kitty desktop notifications[^1]. We
support everything defined by the "protocol", except:

* 'a': action to perform on notification activation. Since we don't
  trigger the notification ourselves (over D-Bus), we don't know a)
  which ID the notification got, or b) when it is clicked.
* ... and that's it. Everything else is supported

To be explicit, we *do* support:

* Chunked notifications (d=0|1), allowing the application to append
  data to a notification in chunks, before it's finally displayed.
* Plain UTF-8, or base64-encoded UTF-8 payload (e=0|1).
* Notification identifier (i=xyz).
* Payload type (p=title|body).
* When to honor the notification (o=always|unfocused|invisible), with
  the following quirks:
    - we don't know when the window is invisible, thus it's treated as
      'unfocused'.
    - the foot option 'notify-focus-inhibit' overrides 'always'
* Urgency (u=0|1|2)

[^1]: https://sw.kovidgoyal.net/kitty/desktop-notifications/
This commit is contained in:
Daniel Eklöf 2024-07-19 15:04:28 +02:00
parent 45c7cd3f74
commit b0bf8ca5f7
No known key found for this signature in database
GPG key ID: 5BBD4992C116573F
10 changed files with 322 additions and 15 deletions

View file

@ -70,6 +70,8 @@
* Support for [in-band window resize
notifications](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83),
private mode `2048`.
* Support for OSC-99 [_"Kitty desktop
notifications"_](https://sw.kovidgoyal.net/kitty/desktop-notifications/).
[1707]: https://codeberg.org/dnkl/foot/issues/1707
[1738]: https://codeberg.org/dnkl/foot/issues/1738

View file

@ -3184,8 +3184,9 @@ config_load(struct config *conf, const char *conf_path,
memcpy(conf->colors.table, default_color_table, sizeof(default_color_table));
parse_modifiers(XKB_MOD_NAME_SHIFT, 5, &conf->mouse.selection_override_modifiers);
tokenize_cmdline("notify-send -a ${app-id} -i ${app-id} ${title} ${body}",
&conf->notify.argv.args);
tokenize_cmdline(
"notify-send -a ${app-id} -i ${app-id} -u ${urgency} ${title} ${body}",
&conf->notify.argv.args);
tokenize_cmdline("xdg-open ${url}", &conf->url.launch.argv.args);
static const char32_t *url_protocols[] = {

View file

@ -360,7 +360,7 @@ empty string to be set, but it must be quoted: *KEY=""*)
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} ${title} ${body}_.
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

View file

@ -29,7 +29,7 @@
# resize-by-cells=yes
# resize-delay-ms=100
# notify=notify-send -a ${app-id} -i ${app-id} ${title} ${body}
# notify=notify-send -a ${app-id} -i ${app-id} -u ${urgency} ${title} ${body}
# bold-text-in-bright=no
# word-delimiters=,│`|:"'()[]{}<>

View file

@ -12,14 +12,18 @@
#include "log.h"
#include "config.h"
#include "spawn.h"
#include "terminal.h"
#include "xmalloc.h"
void
notify_notify(const struct terminal *term, const char *title, const char *body)
notify_notify(const struct terminal *term, const char *title, const char *body,
enum notify_when when, enum notify_urgency urgency)
{
LOG_DBG("notify: title=\"%s\", msg=\"%s\"", title, body);
if (term->conf->notify_focus_inhibit && term->kbd_focus) {
if ((term->conf->notify_focus_inhibit || when != NOTIFY_ALWAYS)
&& term->kbd_focus)
{
/* No notifications while we're focused */
return;
}
@ -33,10 +37,17 @@ notify_notify(const struct terminal *term, const char *title, const char *body)
char **argv = NULL;
size_t argc = 0;
const char *urgency_str =
urgency == NOTIFY_URGENCY_LOW
? "low"
: urgency == NOTIFY_URGENCY_NORMAL
? "normal" : "critical";
if (!spawn_expand_template(
&term->conf->notify, 4,
(const char *[]){"app-id", "window-title", "title", "body"},
(const char *[]){term->app_id ? term->app_id : term->conf->app_id, term->window_title, title, body},
&term->conf->notify, 5,
(const char *[]){"app-id", "window-title", "title", "body", "urgency"},
(const char *[]){term->app_id ? term->app_id : term->conf->app_id,
term->window_title, title, body, urgency_str},
&argc, &argv))
{
return;

View file

@ -1,6 +1,30 @@
#pragma once
#include <stdbool.h>
#include "terminal.h"
struct terminal;
enum notify_when {
NOTIFY_ALWAYS,
NOTIFY_UNFOCUSED,
NOTIFY_INVISIBLE
};
enum notify_urgency {
NOTIFY_URGENCY_LOW,
NOTIFY_URGENCY_NORMAL,
NOTIFY_URGENCY_CRITICAL,
};
struct kitty_notification {
char *id;
char *title;
char *body;
enum notify_when when;
enum notify_urgency urgency;
bool focus;
bool report;
};
void notify_notify(
const struct terminal *term, const char *title, const char *body);
const struct terminal *term, const char *title, const char *body,
enum notify_when when, enum notify_urgency urgency);

249
osc.c
View file

@ -560,7 +560,250 @@ osc_notify(struct terminal *term, char *string)
return;
}
notify_notify(term, title, msg != NULL ? msg : "");
notify_notify(
term, title, msg != NULL ? msg : "",
NOTIFY_ALWAYS, NOTIFY_URGENCY_NORMAL);
}
static void
kitty_notification(struct terminal *term, char *string)
{
/* https://sw.kovidgoyal.net/kitty/desktop-notifications */
char *payload = strchr(string, ';');
if (payload == NULL) {
LOG_ERR("OSC-99: payload missing");
return;
}
char *parameters = string;
*payload = '\0';
payload++;
char *id = xstrdup("0"); /* The 'i' parameter */
bool focus = true; /* The 'a' parameter */
bool report = false; /* The 'a' parameter */
bool done = true; /* The 'd' parameter */
bool base64 = false; /* The 'e' parameter */
bool payload_is_title = true; /* The 'p' parameter */
enum notify_when when = NOTIFY_ALWAYS;
enum notify_urgency urgency = NOTIFY_URGENCY_NORMAL;
bool have_a = false;
bool have_o = false;
bool have_u = false;
char *ctx = NULL;
for (char *param = strtok_r(parameters, ":", &ctx);
param != NULL;
param = strtok_r(NULL, ":", &ctx))
{
/* All parameters are on the form X=value, where X is always
exactly one character */
if (param[0] == '\0' || param[1] != '=') {
LOG_WARN("OSC-99: invalid parameter: \"%s\"", param);
continue;
}
char *value = &param[2];
switch (param[0]) {
case 'a': {
/* notification activation action: focus|report|-focus|-report */
have_a = true;
char *a_ctx = NULL;
for (const char *v = strtok_r(value, ",", &a_ctx);
v != NULL;
v = strtok_r(NULL, ",", &a_ctx))
{
LOG_WARN(" a: \"%s\"", v);
bool reverse = v[0] == '-';
if (reverse)
v++;
if (strcmp(v, "focus") == 0) {
focus = !reverse;
if (focus)
LOG_WARN("unimplemented: OSC-99: focus on notification activation");
} else if (strcmp(v, "report") == 0) {
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;
}
case 'd':
/* done: 0|1 */
if (value[0] == '0' && value[1] == '\0')
done = false;
else if (value[0] == '1' && value[1] == '\0')
done = true;
else
LOG_WARN("OSC-99: unrecognized value for 'd': \"%s\", ignoring", value);
break;
case 'e':
/* base64: 0=utf8, 1=base64(utf8) */
if (value[0] == '0' && value[1] == '\0')
base64 = false;
else if (value[0] == '1' && value[1] == '\0')
base64 = true;
else
LOG_WARN("OSC-99: unrecognized value for 'e': \"%s\", ignoring", value);
break;
case 'i':
/* id */
free(id);
id = xstrdup(value);
break;
case 'p':
/* payload content: title|body */
if (strcmp(value, "title") == 0)
payload_is_title = true;
else if (strcmp(value, "body") == 0)
payload_is_title = false;
else
LOG_WARN("OSC-99: unrecognized value for 'p': \"%s\", ignoring", value);
break;
case 'o':
/* honor when: always|unfocused|invisible */
have_o = true;
if (strcmp(value, "always") == 0)
when = NOTIFY_ALWAYS;
else if (strcmp(value, "unfocused") == 0)
when = NOTIFY_UNFOCUSED;
else if (strcmp(value, "invisible") == 0)
when = NOTIFY_INVISIBLE;
else
LOG_WARN("OSC-99: unrecognized value for 'o': \"%s\", ignoring", value);
break;
case 'u':
/* urgency: 0=low, 1=normal, 2=critical */
have_u = true;
if (value[0] == '0' && value[1] == '\0')
urgency = NOTIFY_URGENCY_LOW;
else if (value[0] == '1' && value[1] == '\0')
urgency = NOTIFY_URGENCY_NORMAL;
else if (value[0] == '2' && value[1] == '\0')
urgency = NOTIFY_URGENCY_CRITICAL;
else
LOG_WARN("OSC-99: unrecognized value for 'u': \"%s\", ignoring", value);
break;
default:
LOG_WARN("OSC-99: unrecognized parameter: \"%s\", ignoring", param);
break;
}
}
if (base64)
payload = base64_decode(payload);
else
payload = xstrdup(payload);
LOG_DBG("id=%s, done=%d, focus=%d, report=%d, base64=%d, payload: %s, "
"honor: %s, urgency: %s, %s: %s",
id, done, focus, report, base64,
payload_is_title ? "title" : "body",
(when == NOTIFY_ALWAYS
? "always"
: when == NOTIFY_UNFOCUSED
? "unfocused"
: "invisible"),
(urgency == NOTIFY_URGENCY_LOW
? "low" : urgency == NOTIFY_URGENCY_NORMAL
? "normal"
: "critical"),
payload_is_title ? "title" : "body", payload);
/* Search for an existing (d=0) notification to update */
struct kitty_notification *notif = NULL;
tll_foreach(term->kitty_notifications, it) {
if (strcmp(it->item.id, id) == 0) {
/* Found existing notification */
LOG_WARN("found existing kitty notification");
notif = &it->item;
break;
}
}
if (notif == NULL) {
/* Somewhat unoptimized... this will be free:d and removed
immediately if d=1 */
tll_push_front(term->kitty_notifications, ((struct kitty_notification){
.id = id,
.title = NULL,
.body = NULL,
.when = when,
.urgency = urgency,
.focus = focus,
.report = report,
}));
id = NULL; /* Prevent double free */
notif = &tll_front(term->kitty_notifications);
}
/* Update notification metadata */
if (have_a) {
notif->focus = focus;
notif->report = report;
}
if (have_o)
notif->when = when;
if (have_u)
notif->urgency = urgency;
if (payload_is_title) {
if (notif->title == NULL) {
notif->title = payload;
payload = NULL;
} else {
char *new_title = xasprintf("%s%s", notif->title, payload);
free(notif->title);
notif->title = new_title;
}
} else {
if (notif->body == NULL) {
notif->body = payload;
payload = NULL;
} else {
char *new_body = xasprintf("%s%s", notif->body, payload);
free(notif->body);
notif->body = new_body;
}
}
free(id);
free(payload);
if (done) {
notify_notify(
term,
notif->title != NULL ? notif->title : "",
notif->body != NULL ? notif->body : "",
notif->when, notif->urgency);
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;
}
}
}
}
void
@ -780,6 +1023,10 @@ osc_dispatch(struct terminal *term)
osc_selection(term, string);
break;
case 99: /* Kitty notifications */
kitty_notification(term, string);
break;
case 104: {
/* Reset Color Number 'c' (whole table if no parameter) */

View file

@ -152,7 +152,9 @@ void ime_disable(struct seat *seat) {}
void ime_reset_preedit(struct seat *seat) {}
void
notify_notify(const struct terminal *term, const char *title, const char *body)
notify_notify(
const struct terminal *term, const char *title, const char *body,
enum notify_when when, enum notify_urgency urgency)
{
}

View file

@ -1313,6 +1313,7 @@ term_init(const struct config *conf, struct fdm *fdm, struct reaper *reaper,
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
.ime_enabled = true,
#endif
.kitty_notifications = tll_init(),
};
pixman_region32_init(&term->render.last_overlay_clip);
@ -1817,6 +1818,13 @@ term_destroy(struct terminal *term)
tll_remove(term->ptmx_paste_buffers, it);
}
tll_foreach(term->kitty_notifications, it) {
free(it->item.id);
free(it->item.body);
free(it->item.title);
tll_remove(term->kitty_notifications, it);
}
sixel_fini(term);
term_ime_reset(term);
@ -2022,6 +2030,13 @@ term_reset(struct terminal *term, bool hard)
tll_remove(term->alt.sixel_images, it);
}
tll_foreach(term->kitty_notifications, it) {
free(it->item.id);
free(it->item.title);
free(it->item.body);
tll_remove(term->kitty_notifications, it);
}
term->grapheme_shaping = term->conf->tweak.grapheme_shaping;
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
@ -3566,8 +3581,10 @@ term_bell(struct terminal *term)
}
}
if (term->conf->bell.notify)
notify_notify(term, "Bell", "Bell in terminal");
if (term->conf->bell.notify) {
notify_notify(term, "Bell", "Bell in terminal",
NOTIFY_ALWAYS, NOTIFY_URGENCY_NORMAL);
}
if (term->conf->bell.flash)
term_flash(term, 100);

View file

@ -20,6 +20,7 @@
#include "fdm.h"
#include "key-binding.h"
#include "macros.h"
#include "notify.h"
#include "reaper.h"
#include "shm.h"
#include "wayland.h"
@ -798,6 +799,8 @@ struct terminal {
void *cb_data;
} shutdown;
tll(struct kitty_notification) kitty_notifications;
char *foot_exe;
char *cwd;