mirror of
https://github.com/turtlebasket/env.git
synced 2026-03-07 04:54:27 -08:00
254 lines
6.6 KiB
Python
Executable File
254 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
#
|
|
# ntfy-bell - run a command, forwarding OSC 9 notifications to ntfy
|
|
#
|
|
# Usage:
|
|
# ntfy-bell <command> [args...]
|
|
|
|
import fcntl
|
|
import os
|
|
import pty
|
|
import select
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import sys
|
|
import termios
|
|
import time
|
|
import tty
|
|
import urllib.request
|
|
from typing import Optional
|
|
|
|
BEL = 0x07
|
|
OSC9_PREFIX = b"\x1b]9;" # ESC ] 9 ;
|
|
ST = b"\x1b\\" # ESC \\ (string terminator)
|
|
|
|
|
|
def notify_ntfy(topic: str, title: str, message: str) -> None:
|
|
url = f"https://ntfy.sh/{topic}"
|
|
data = message.encode("utf-8", errors="replace")
|
|
|
|
req = urllib.request.Request(url, data=data, method="POST")
|
|
req.add_header("Title", title)
|
|
|
|
with urllib.request.urlopen(req, timeout=5):
|
|
pass
|
|
|
|
|
|
def _extract_osc9_from_buffer(buf: bytearray) -> Optional[str]:
|
|
start = buf.find(OSC9_PREFIX)
|
|
if start == -1:
|
|
if len(buf) > 65536:
|
|
del buf[:-4096]
|
|
return None
|
|
|
|
if start > 0:
|
|
del buf[:start]
|
|
|
|
payload_start = len(OSC9_PREFIX)
|
|
bel_pos = buf.find(bytes([BEL]), payload_start)
|
|
st_pos = buf.find(ST, payload_start)
|
|
|
|
end_pos: Optional[int] = None
|
|
term_len = 0
|
|
|
|
if bel_pos != -1 and (st_pos == -1 or bel_pos < st_pos):
|
|
end_pos = bel_pos
|
|
term_len = 1
|
|
elif st_pos != -1:
|
|
end_pos = st_pos
|
|
term_len = len(ST)
|
|
|
|
if end_pos is None:
|
|
if len(buf) > 65536:
|
|
del buf[:-4096]
|
|
return None
|
|
|
|
payload = bytes(buf[payload_start:end_pos])
|
|
del buf[: end_pos + term_len]
|
|
|
|
return payload.decode("utf-8", errors="replace").strip("\r\n")
|
|
|
|
|
|
def _build_child_argv(cmd: list[str]) -> list[str]:
|
|
cmd0 = cmd[0]
|
|
|
|
if "/" in cmd0:
|
|
return cmd
|
|
|
|
if shutil.which(cmd0):
|
|
return cmd
|
|
|
|
shell = os.environ.get("SHELL") or "/bin/sh"
|
|
shell_name = os.path.basename(shell)
|
|
joined = shlex.join(cmd)
|
|
|
|
if shell_name == "zsh":
|
|
shell_cmd = (
|
|
'source "${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1; '
|
|
f"eval {shlex.quote(joined)}"
|
|
)
|
|
return [shell, "-c", shell_cmd]
|
|
|
|
return [shell, "-ic", joined]
|
|
|
|
|
|
def _copy_winsize(src_fd: int, dst_fd: int) -> None:
|
|
try:
|
|
winsize = fcntl.ioctl(src_fd, termios.TIOCGWINSZ, b"\0" * 8)
|
|
fcntl.ioctl(dst_fd, termios.TIOCSWINSZ, winsize)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def main() -> int:
|
|
topic = os.environ.get("NTFY_ME_TOPIC")
|
|
if not topic:
|
|
print("Error: environment variable NTFY_ME_TOPIC not set", file=sys.stderr)
|
|
return 2
|
|
|
|
if len(sys.argv) < 2:
|
|
print(f"usage: {sys.argv[0]} <command> [args...]", file=sys.stderr)
|
|
return 2
|
|
|
|
cmd = sys.argv[1:]
|
|
child_argv = _build_child_argv(cmd)
|
|
|
|
pid, master_fd = pty.fork()
|
|
if pid == 0:
|
|
try:
|
|
os.execvp(child_argv[0], child_argv)
|
|
except FileNotFoundError:
|
|
print(f"{cmd[0]}: command not found", file=sys.stderr)
|
|
os._exit(127)
|
|
except PermissionError:
|
|
print(f"{cmd[0]}: permission denied", file=sys.stderr)
|
|
os._exit(126)
|
|
|
|
stdin_fd = None
|
|
stdin_is_tty = sys.stdin.isatty()
|
|
old_tty_attrs = None
|
|
old_winch_handler = None
|
|
|
|
try:
|
|
stdin_fd = sys.stdin.fileno()
|
|
except OSError:
|
|
stdin_fd = None
|
|
|
|
if stdin_fd is not None and stdin_is_tty:
|
|
try:
|
|
old_tty_attrs = termios.tcgetattr(stdin_fd)
|
|
tty.setraw(stdin_fd)
|
|
except termios.error:
|
|
old_tty_attrs = None
|
|
|
|
_copy_winsize(stdin_fd, master_fd)
|
|
|
|
def _on_winch(_signum: int, _frame: object) -> None:
|
|
_copy_winsize(stdin_fd, master_fd)
|
|
try:
|
|
os.kill(pid, signal.SIGWINCH)
|
|
except OSError:
|
|
pass
|
|
|
|
old_winch_handler = signal.getsignal(signal.SIGWINCH)
|
|
signal.signal(signal.SIGWINCH, _on_winch)
|
|
|
|
osc_last_sent = 0.0
|
|
cooldown_s = 1.5
|
|
parse_buf = bytearray()
|
|
stdin_eof_sent = False
|
|
|
|
try:
|
|
while True:
|
|
read_fds = [master_fd]
|
|
if stdin_fd is not None:
|
|
read_fds.append(stdin_fd)
|
|
|
|
try:
|
|
rlist, _, _ = select.select(read_fds, [], [])
|
|
except InterruptedError:
|
|
continue
|
|
|
|
if stdin_fd is not None and stdin_fd in rlist:
|
|
try:
|
|
user_data = os.read(stdin_fd, 4096)
|
|
except OSError:
|
|
user_data = b""
|
|
|
|
if user_data:
|
|
try:
|
|
os.write(master_fd, user_data)
|
|
except OSError:
|
|
break
|
|
else:
|
|
if not stdin_is_tty and not stdin_eof_sent:
|
|
try:
|
|
os.write(master_fd, b"\x04")
|
|
except OSError:
|
|
pass
|
|
stdin_eof_sent = True
|
|
stdin_fd = None
|
|
|
|
if master_fd in rlist:
|
|
try:
|
|
data = os.read(master_fd, 4096)
|
|
except OSError:
|
|
break
|
|
|
|
if not data:
|
|
break
|
|
|
|
try:
|
|
os.write(sys.stdout.fileno(), data)
|
|
except OSError:
|
|
break
|
|
|
|
parse_buf.extend(data)
|
|
|
|
while True:
|
|
msg = _extract_osc9_from_buffer(parse_buf)
|
|
if msg is None:
|
|
break
|
|
|
|
now = time.time()
|
|
if now - osc_last_sent >= cooldown_s:
|
|
osc_last_sent = now
|
|
try:
|
|
notify_ntfy(
|
|
topic=topic,
|
|
title="Terminal notification",
|
|
message=msg if msg else f"OSC 9 notification from: {' '.join(cmd)}",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
finally:
|
|
if stdin_fd is not None and stdin_is_tty and old_tty_attrs is not None:
|
|
try:
|
|
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tty_attrs)
|
|
except termios.error:
|
|
pass
|
|
|
|
if old_winch_handler is not None:
|
|
signal.signal(signal.SIGWINCH, old_winch_handler)
|
|
|
|
try:
|
|
os.close(master_fd)
|
|
except OSError:
|
|
pass
|
|
|
|
_, status = os.waitpid(pid, 0)
|
|
|
|
if os.WIFEXITED(status):
|
|
return os.WEXITSTATUS(status)
|
|
|
|
if os.WIFSIGNALED(status):
|
|
return 128 + os.WTERMSIG(status)
|
|
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|