From 8f99f1650e4a7363af1d83190b4082fb72a9f73f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 19:02:35 +0200 Subject: ttym 3.0: fix terminal and config bugs, fold terminal-only patches into base Correctness fixes: - restore the terminal on SIGQUIT and SIGHUP. atexit never runs on an uncaught fatal signal, so Ctrl+\ left the cursor hidden and autowrap disabled. - drop the unconditional newline in restore_tty. Every run emitted a stray blank line, including piped output. - treat EINTR as an interruption rather than a poll() failure, which delayed Ctrl+C by up to one 100ms frame. config-file patch: - make comment stripping quote-aware so a value may contain '#', and strip one matched pair of quotes. bar_fill = "#" was silently dropped, despite '#' being the glyph of the hash style this build ships. - resolve bar_fill/bar_empty inside resolve_bar and clear them on --bar, so command-line flags beat the config file as documented. Previously config glyphs silently overrode --bar. - report unknown keys, unparseable booleans and malformed lines on stderr. flash = enabled silently meant off. - never write to disk. --dump-config prints a commented template on stdout instead of the binary creating ~/.config/ttym/config on first run. Patch set: - fold persist-alert, flash and bar-styles into the base. The base is now terminal-only: it opens /dev/tty, draws and exits, with no file writes and no subprocesses. Everything still in patches/ changes what the program touches. - seven patches become four; config-file's prerequisites drop from six patches to one (notify), so it is regenerable mechanically. - regenerate every patch. All apply with zero fuzz and compile. Source: - rewrite argv in place rather than into a malloc'd copy (C99 5.1.2.2.1), removing die(), four free() calls and . - fold the duplicated elapsed-time expression into elapsed(). - drop dead code: have_any, (void)cfg, and an unreachable branch in parse_duration. - replace hand-counted padding with \033[K. Docs and build: - timer.1: -c is the third hook argument, not the fourth. Add EXIT STATUS (a stopwatch always exits 130 -- it has no natural end) and ENVIRONMENT. Document config precedence and quoting. - wire VERSION into a dist target; it was defined and never used. - track .gitignore, which previously ignored itself, so a fresh clone had no ignore rules. Claude-Session: https://claude.ai/code/session_01APLBs8RB1FcUaC4viVkzbP --- patches/ttym-notify-3.0.diff | 259 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 patches/ttym-notify-3.0.diff (limited to 'patches/ttym-notify-3.0.diff') diff --git a/patches/ttym-notify-3.0.diff b/patches/ttym-notify-3.0.diff new file mode 100644 index 0000000..9ccfd5c --- /dev/null +++ b/patches/ttym-notify-3.0.diff @@ -0,0 +1,259 @@ +diff -ruN a/config.def.h b/config.def.h +--- a/config.def.h 2026-08-25 17:18:15.698123000 +0200 ++++ b/config.def.h 2026-08-25 17:18:15.702123073 +0200 +@@ -4,6 +4,19 @@ + 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 = ""; ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +diff -ruN a/config.h b/config.h +--- a/config.h 2026-08-25 17:18:15.698123000 +0200 ++++ b/config.h 2026-08-25 17:18:15.702123073 +0200 +@@ -4,6 +4,19 @@ + 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 = ""; ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +Binary files a/timer and b/timer differ +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-08-25 17:18:15.698123000 +0200 ++++ b/ttym.c 2026-08-25 17:18:15.702123073 +0200 +@@ -13,6 +13,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -24,6 +25,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]; + int alert_persist; /* alert loop until keypress; --no-persist disables */ + int flash; /* reverse-video flash during alert loop */ + char bar_style[32]; /* --bar STYLE */ +@@ -236,6 +241,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); ++ } ++ } ++ } ++} ++ + /* ===== alert loop ===== + * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap. + */ +@@ -340,8 +481,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" +@@ -471,6 +612,7 @@ + printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb); + 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); +@@ -491,6 +633,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"); + cfg.alert_persist = ALERT_PERSIST; + cfg.flash = FLASH; + +@@ -539,6 +685,16 @@ + fprintf(stderr, "bad duration: %s\n", rem_argv[0]); + 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; ++ } ++ } + } + + return run(total, stopwatch, &cfg); -- cgit v1.3