diff -ruN a/config.def.h b/config.def.h --- a/config.def.h 2026-05-20 12:03:30.597867999 +0200 +++ b/config.def.h 2026-05-20 12:03:30.597867999 +0200 @@ -3,3 +3,16 @@ /* progress bar glyphs (ASCII default) */ static const char *const BAR_FILL = ">"; static const char *const BAR_EMPTY = "-"; + +/* desktop notification on countdown completion. + * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. + * Set NULL or "" to disable. + */ +static const char *const TITLE = "Timer"; +static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}"; + +/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects + * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound. + * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD. + */ +static const char *const SOUND_CMD = ""; diff -ruN a/ttym.c b/ttym.c --- a/ttym.c 2026-05-20 12:03:30.597867999 +0200 +++ b/ttym.c 2026-05-20 12:03:53.438340036 +0200 @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,10 @@ typedef struct { int quiet; /* -q: suppress completion bell */ int silent; /* -s: no bell, no extras */ + const char *title; + const char *notify_cmd; + const char *sound_cmd; + char msg[1024]; } Config; static struct termios oldtios; @@ -226,6 +231,142 @@ fflush(stdout); } +/* ===== subprocess + placeholder expansion ===== + * Tokens split on whitespace; {title} and {msg} expanded within each token. + * No shell involved — argv stays argv. + */ + +static int +build_argv(const char *cmd, const Config *c, char ***out) +{ + char buf[2048]; + snprintf(buf, sizeof(buf), "%s", cmd); + char **argv = calloc(64, sizeof(char *)); + if (!argv) return -1; + int argc = 0; + char *p = buf; + while (*p && argc < 63) { + while (*p && isspace((unsigned char)*p)) p++; + if (!*p) break; + char *start = p; + while (*p && !isspace((unsigned char)*p)) p++; + if (*p) *p++ = '\0'; + char tok[2048]; size_t oi = 0; + for (char *q = start; *q && oi < sizeof(tok) - 1; ) { + const char *sub = NULL; size_t skip = 0; + if (!strncmp(q, "{msg}", 5)) { sub = c->msg; skip = 5; } + else if (!strncmp(q, "{title}", 7)) { sub = c->title; skip = 7; } + if (sub) { + size_t sl = strlen(sub); + if (oi + sl >= sizeof(tok)) sl = sizeof(tok) - 1 - oi; + memcpy(tok + oi, sub, sl); + oi += sl; q += skip; + } else { tok[oi++] = *q++; } + } + tok[oi] = '\0'; + argv[argc++] = strdup(tok); + } + argv[argc] = NULL; + *out = argv; + return argc; +} + +static void +free_argv(char **argv) +{ + if (!argv) return; + for (int i = 0; argv[i]; i++) free(argv[i]); + free(argv); +} + +static void +spawn_bg(char *const argv[]) +{ + pid_t pid = fork(); + if (pid == 0) { + setsid(); + int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2); + if (devnull > 2) close(devnull); + } + execvp(argv[0], argv); + _exit(127); + } + (void)pid; +} + +static int +command_exists(const char *name) +{ + const char *path = getenv("PATH"); + if (!path) return 0; + char buf[512]; + const char *p = path; + while (*p) { + const char *e = strchr(p, ':'); + size_t len = e ? (size_t)(e - p) : strlen(p); + if (len > 0 && len + strlen(name) + 2 < sizeof(buf)) { + snprintf(buf, sizeof(buf), "%.*s/%s", (int)len, p, name); + if (access(buf, X_OK) == 0) return 1; + } + if (!e) break; + p = e + 1; + } + return 0; +} + +static const char * +detect_sound_player(void) +{ + static const char *cands[] = { "paplay", "aplay", "mpv", "ffplay", NULL }; + for (int i = 0; cands[i]; i++) + if (command_exists(cands[i])) return cands[i]; + return NULL; +} + +static const char * +default_sound_path(void) +{ + static const char *paths[] = { + "/usr/share/sounds/freedesktop/stereo/complete.oga", + "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", + "/usr/share/sounds/alsa/Front_Center.wav", + NULL + }; + for (int i = 0; paths[i]; i++) + if (access(paths[i], R_OK) == 0) return paths[i]; + return NULL; +} + +static void +notify_and_sound(const Config *c) +{ + if (c->notify_cmd && c->notify_cmd[0] && command_exists("notify-send")) { + char **a; + if (build_argv(c->notify_cmd, c, &a) > 0) { + spawn_bg(a); + free_argv(a); + } + } + if (!c->quiet) { + if (c->sound_cmd && c->sound_cmd[0]) { + char **a; + if (build_argv(c->sound_cmd, c, &a) > 0) { + spawn_bg(a); + free_argv(a); + } + } else { + const char *pl = detect_sound_player(); + const char *sp = default_sound_path(); + if (pl && sp) { + char *a[] = { (char *)pl, (char *)sp, NULL }; + spawn_bg(a); + } + } + } +} + /* ===== usage ===== * Patches MUST update this in lockstep with new flags so -h always * reflects the build. @@ -236,8 +377,8 @@ { fprintf(f, "usage:\n" -" timer [FLAGS] DURATION countdown\n" -" timer [FLAGS] stopwatch\n" +" timer [FLAGS] DURATION [MESSAGE...] countdown\n" +" timer [FLAGS] stopwatch\n" "\n" "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" "FLAGS:\n" @@ -370,6 +511,7 @@ printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, ""); else printf("DONE %s 100%%\n", tb); + if (!cfg->silent) notify_and_sound(cfg); if (!cfg->silent && !cfg->quiet && out_tty) { fputc('\a', stdout); fflush(stdout); @@ -387,6 +529,10 @@ main(int argc, char **argv) { Config cfg = {0}; + cfg.title = TITLE; + cfg.notify_cmd = NOTIFY_CMD; + cfg.sound_cmd = SOUND_CMD; + snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up"); /* Pre-strip long-form flags before getopt. Stop at "--". */ int new_argc = 0; @@ -424,6 +570,16 @@ free(new_argv); return 1; } + if (rem_argc > 1) { + size_t off = 0; + cfg.msg[0] = '\0'; + for (int i = 1; i < rem_argc && off < sizeof(cfg.msg) - 2; i++) { + int n = snprintf(cfg.msg + off, sizeof(cfg.msg) - off, + "%s%s", (i > 1 ? " " : ""), rem_argv[i]); + if (n < 0) break; + off += (size_t)n; + } + } } int rc = run(total, stopwatch, &cfg);