├── README.rst ├── notify-client ├── notify-is-focused ├── notify-last-command ├── notify-proxy ├── notify-proxy.desktop ├── tasks └── main.yml └── tmux.conf /README.rst: -------------------------------------------------------------------------------- 1 | ############################ 2 | Desktop notification proxy 3 | ############################ 4 | 5 | An TCP proxy and client to send libnotify desktop notification. This allow you 6 | te receive notification from remote computer, for example from persistent IRC 7 | client over SSH. 8 | 9 | 10 | Features 11 | ======== 12 | 13 | - Notify last command status. 14 | - Notify over SSH. 15 | - Inhibit notification when current bash shell is focused. 16 | 17 | 18 | Setup 19 | ===== 20 | 21 | Notify last command in bash 22 | --------------------------- 23 | 24 | - Install ``xdotool``. 25 | - Install ``notify-is-focused`` in your ``PATH``. 26 | - Put this in your ``.bashrc``:: 27 | 28 | . path/to/notify-last-command 29 | PROMPT_COMMAND='_EC=$? ; notify_last_command $_EC ;' 30 | - Open a new terminal to test it with:: 31 | 32 | sleep 11; true 33 | sleep 11; false 34 | 35 | Remember to focus another X11 window to see the notification. 36 | 37 | 38 | Detect focused byobu pane 39 | ------------------------- 40 | 41 | - Copy ``tmux.conf`` as ``~/.config/byobu/.tmux.conf``. 42 | - Recreate byobu session. 43 | - Test like bash above. 44 | 45 | 46 | Notify over SSH 47 | --------------- 48 | 49 | On your desktop station: 50 | 51 | - Install ``libnotify-bin`` and Python. 52 | - Install ``notify-proxy`` scripts in your ``PATH``. 53 | - Put ``notify-proxy.desktop`` in ``~/.config/autostart``. 54 | - Launch ``notify-proxy`` or open a new desktop session. 55 | - Setup ``RemoteForward 1216 127.0.0.1:1216`` in ``~/.ssh/config`` or use 56 | ``ssh -R 1216:localhost:1216``. 57 | 58 | 59 | On remote servers: 60 | 61 | - Install ``notify-last-command`` and setup bashrc and byobu as above. 62 | - You don't need ``xdotool`` or ``notify-is-focused``. 63 | - Install ``notify-client`` in your ``PATH``. 64 | - Test as above. 65 | 66 | Now, use ``notify-client`` just like ``notify-send``, long options are 67 | compatible. 68 | 69 | .. code-block:: console 70 | 71 | $ NOTIFY_TITLE=__unfocused__ notify-client Shown 72 | $ notify-client Inhibited 73 | 74 | 75 | IRSSI Setup 76 | ----------- 77 | 78 | - Install IRSSI perl script `ramnes/highlight_cmd 79 | `_. (Requires `CPAN 80 | Text::Sprintf::Named 81 | `_) 82 | - ``/set hilightcmd_systemcmd notify-client --hint int:transient:1 --hint string:category:im.received "%(message)s" &`` 83 | - Hilight from another IRC client to test it. 84 | 85 | That's it. 86 | 87 | 88 | References 89 | ------- 90 | 91 | - Initial idea stolen from `itsamenathan/libnotify-over-ssh 92 | `_. 93 | - `GNOME notifications specs `_ 94 | including hints and categories. 95 | -------------------------------------------------------------------------------- /notify-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import json 5 | import logging 6 | import os 7 | import re 8 | import socket 9 | import sys 10 | 11 | 12 | try: 13 | ConnectionRefusedError 14 | except NameError: 15 | ConnectionRefusedError = socket.error 16 | 17 | 18 | logger = logging.getLogger('notify-client') 19 | current_title = os.environ.get('NOTIFY_TITLE') 20 | 21 | 22 | logging.basicConfig( 23 | level=logging.DEBUG if os.environ.get('DEBUG') else logging.WARNING, 24 | format='%(message)s', 25 | ) 26 | 27 | parser = argparse.ArgumentParser() 28 | parser.add_argument( 29 | 'summary', metavar='SUMMARY', 30 | help="Titleline of the notification.", 31 | ) 32 | parser.add_argument( 33 | 'body', metavar='BODY', nargs='?', 34 | default="From %s" % current_title if current_title else None, 35 | help="Extendend information in the notification.", 36 | ) 37 | 38 | parser.add_argument( 39 | '-a', '--app-name', metavar='APP_NAME', 40 | help="Specifies the app name for the icon", 41 | ) 42 | parser.add_argument( 43 | '-c', '--category', metavar='TYPE[,TYPE...]', 44 | help="Specifies the notification category.", 45 | ) 46 | parser.add_argument( 47 | '-i', '--icon', metavar='ICON[,ICON...]', 48 | help="Specifies an icon filename or stock icon to display.", 49 | ) 50 | parser.add_argument( 51 | '--hint', metavar='TYPE:NAME:VALUE', action='append', 52 | help=( 53 | "Specifies basic extra data to pass. Valid types are int, double, " 54 | "string and byte." 55 | ), 56 | ) 57 | parser.add_argument( 58 | '-s', '--server', metavar='ADDR:PORT', default='127.0.0.1:1216', 59 | help="Host to send notification to.", 60 | ) 61 | parser.add_argument( 62 | '-t', '--expire-time', metavar='TIME', default=500, 63 | help="Specifies the timeout in milliseconds at which to expire.", 64 | ) 65 | parser.add_argument( 66 | '-u', '--urgency', metavar='LEVEL', 67 | choices=['low', 'normal', 'critical'], 68 | help="Specifies the urgency level.", 69 | ) 70 | 71 | args = parser.parse_args() 72 | 73 | socket.setdefaulttimeout(30) 74 | client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 75 | addr, port = args.server.split(':') 76 | logger.info("Sending to %s:%s.", addr, port) 77 | 78 | try: 79 | client.connect((addr, int(port))) 80 | except ConnectionRefusedError: 81 | logger.warn('Failed to notify %r. Connection refused.', args.summary) 82 | sys.exit(1) 83 | 84 | data = dict(args.__dict__) 85 | data.pop('server') 86 | data['window_title'] = current_title 87 | 88 | payload = json.dumps(data).encode('utf-8') 89 | 90 | logger.debug(">>> %r", payload) 91 | client.send(payload) 92 | 93 | client.close() 94 | -------------------------------------------------------------------------------- /notify-is-focused: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | if [ -n "${DEBUG-}" ] ; then 6 | set -x 7 | else 8 | exec 2>/dev/null 1>/dev/null 9 | fi 10 | 11 | NOTIFY_TITLE=${NOTIFY_TITLE-} 12 | test -n "${NOTIFY_TITLE}" 13 | focused_window=$(xdotool getwindowfocus) 14 | focused_title=$(xdotool getwindowname $focused_window) 15 | echo $focused_title | grep -q "${NOTIFY_TITLE}" 16 | echo OK 17 | -------------------------------------------------------------------------------- /notify-last-command: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Minimal duration of last commands to notify it. 4 | export NOTIFY_MIN_SECONDS=${NOTIFY_MIN_SECONDS-10} 5 | 6 | 7 | function _guess_window_title() { 8 | # If not on TMUX and we have an inherited title, use it. Because we don't control the terminal title. 9 | if [ -z "${TMUX-}" -a -n "${NOTIFY_INHERIT_TITLE}" ] ; then 10 | echo ${NOTIFY_INHERIT_TITLE} 11 | return 12 | fi 13 | 14 | fqdn=$(hostname --fqdn) 15 | if [ -n "${TMUX-}" ] ; then 16 | # Hack to manage old debian version. Can't tell which of byobu or tmux or else needs to be checked. 17 | if (echo -e '8.0 NEWER'; cat /etc/debian_version 2>/dev/null) | sort -V | head -1 | grep -q NEWER ; then 18 | # Let byobu update pane title 19 | echo "$fqdn ($$)"; 20 | else 21 | # This is the default byobu title, recomputed 22 | ip=$(ip addr show dev eth0 | grep -oP '(?<=inet )(.*)(?=/)') 23 | echo "${LOGNAME}@$fqdn ($ip) - byobu" 24 | fi 25 | else 26 | # Put this in ~/.config/byobu/.tmux.conf: 27 | # 28 | # set -g set-titles on 29 | # set -g set-titles-string '#(hostname --fqdn) (#{pane_pid})' 30 | echo "bash $fqdn ($$)" 31 | fi 32 | } 33 | 34 | function _reset_title() { 35 | echo -ne "\033]2;${NOTIFY_TITLE}\007" 36 | } 37 | 38 | function _notify_last_command() { 39 | last_entry=($@) 40 | 41 | # Wait this seconds so that Window manager can update the title. This way, 42 | # we avoid to consider current window as unfocused. 43 | sleep ${WAIT_TITLE-0} 44 | 45 | # If not desktop, use client. 46 | if [ -z "${DESKTOP_SESSION-}" ] ; then 47 | notify=notify-client 48 | elif notify-is-focused; then 49 | # bash is focused. skip. 50 | return 51 | else 52 | notify=notify-send 53 | fi 54 | 55 | last_start=${last_entry[2]} 56 | if [ $last_start -le ${_NOTIFY_LAST_TIME-0} ] ; then 57 | return 58 | fi 59 | 60 | now=$(date +%s) 61 | elapsed_seconds=$((now - last_start)) 62 | 63 | if [ $elapsed_seconds -lt ${NOTIFY_MIN_SECONDS} ] ; then 64 | return 65 | fi 66 | 67 | last_exit_code=${last_entry[0]} 68 | last_command=${last_entry[@]:3} 69 | 70 | args="--app-name $(basename $SHELL)" 71 | if [ ${last_exit_code} -eq 0 ] ; then 72 | $notify $args --icon utilites-terminal --hint int:transient:1 \ 73 | "Command exited on ${NOTIFY_TITLE}" "$last_command" &>/dev/null 74 | else 75 | $notify $args --icon gtk-dialog-error --urgency=critical \ 76 | "Command failed on ${NOTIFY_TITLE}" "$last_command" &>/dev/null 77 | fi 78 | } 79 | 80 | function _export_title() { 81 | title="$*" 82 | export NOTIFY_TITLE="$title" 83 | # Pass NOTIFY_TITLE to ssh connections through LC_* hack. 84 | export LC_TELEPHONE="notify_title:$title" 85 | } 86 | 87 | function notify_last_command() { 88 | last_exit_status=$1 89 | last_entry=($(HISTTIMEFORMAT="%s " history 1)) 90 | 91 | # Skip if history is empty 92 | if [ "${#last_entry[@]}" -eq 0 ] ; then 93 | return 94 | fi 95 | 96 | _export_title $(_guess_window_title) 97 | WAIT_TITLE=0 98 | # Loose list of command known to change window title. This can trigger a 99 | # bug in terminator where window title is updated from a background 100 | # tab. But most of the time, you are detaching a session, so you are 101 | # focusing this shell. 102 | if expr match "${last_entry[2]}" "\(byobu\|screen\|ssh\|tmux\)" &>/dev/null; then 103 | _reset_title 104 | WAIT_TITLE=1 105 | fi 106 | 107 | # Run in background 108 | (WAIT_TITLE=$WAIT_TITLE _notify_last_command $last_exit_status ${last_entry[*]} &) 109 | 110 | export _NOTIFY_LAST_TIME=$(date +%s) 111 | } 112 | 113 | if [[ ${BASH_SOURCE[0]} = $0 ]]; then 114 | set -eux 115 | _notify_last_command $@ 116 | else 117 | # sourced in .bashrc, bootstrap. 118 | # Ignore previous history. 119 | export _NOTIFY_LAST_TIME=$(date +%s) 120 | 121 | # On shell init, if on SSH connections, not within tmux, LC_TELEPHONE contains passthrough hack… 122 | if [[ -n "${SSH_CONNECTION-}" && -z "${NOTIFY_TITLE-}" && "${LC_TELEPHONE-}" =~ ^notify_title:* ]] ; 123 | then 124 | # Save the inherited title 125 | export NOTIFY_INHERIT_TITLE="${LC_TELEPHONE##notify_title:}" 126 | fi 127 | 128 | _export_title $(_guess_window_title) 129 | _reset_title 130 | fi 131 | -------------------------------------------------------------------------------- /notify-proxy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | from distutils.spawn import find_executable 5 | import os 6 | import logging 7 | import json 8 | 9 | 10 | logger = logging.getLogger('notify-proxy') 11 | 12 | 13 | notify_send_path = find_executable('notify-send') 14 | 15 | 16 | @asyncio.coroutine 17 | def is_focused(window_title): 18 | environ = dict(os.environ, NOTIFY_TITLE=window_title) 19 | child = yield from asyncio.create_subprocess_exec( 20 | 'notify-is-focused', 21 | stdout=asyncio.subprocess.PIPE, 22 | env=environ, 23 | ) 24 | 25 | stdout, _ = yield from child.communicate() 26 | return child.returncode == 0 27 | 28 | 29 | @asyncio.coroutine 30 | def notify_send( 31 | summary, app_name=None, body=None, urgency='normal', icon=None, window_title=None, 32 | expire_time=None, hint=None, 33 | **kwargs): 34 | 35 | if window_title: 36 | logger.debug("Checking focused window is %s", window_title) 37 | is_window_focused = yield from is_focused(window_title) 38 | if is_window_focused: 39 | return logger.info("Inhibited for focused window for %s.", summary) 40 | 41 | args = [notify_send_path] 42 | if app_name: 43 | args.extend(['--app-name', app_name]) 44 | if expire_time: 45 | args.extend(['--expire-time', str(expire_time)]) 46 | if icon: 47 | args.extend(['--icon', icon]) 48 | if urgency: 49 | args.extend(['--urgency', urgency]) 50 | if hint: 51 | if not isinstance(hint, list): 52 | hint = [hint] 53 | for item in hint: 54 | args.extend(['--hint', item]) 55 | 56 | args.append(summary) 57 | if body: 58 | args.append(body) 59 | 60 | logger.debug("%r", ' '.join(args)) 61 | child = yield from asyncio.create_subprocess_exec(*args) 62 | yield from child.communicate() 63 | logger.info("Notification sent: %r", summary) 64 | 65 | 66 | class NotifyProtocol(asyncio.Protocol): 67 | def data_received(self, payload): 68 | logger.debug("<<< %r", payload) 69 | try: 70 | data = json.loads(payload.decode('utf-8')) 71 | except Exception as e: 72 | logger.warn("Failed to parse payload:\n%s", e) 73 | return 74 | 75 | asyncio.ensure_future(notify_send(**data)) 76 | 77 | 78 | DEBUG = os.environ.get('DEBUG') 79 | logging.basicConfig( 80 | level=logging.DEBUG if DEBUG else logging.INFO, 81 | format='%(message)s', 82 | ) 83 | logger.info("Starting notify-proxy") 84 | 85 | loop = asyncio.get_event_loop() 86 | addr = os.environ.get('PROXY_ADDR', '127.0.0.1') 87 | port = int(os.environ.get('PROXY_PORT', '1216')) 88 | server = loop.run_until_complete(loop.create_server( 89 | NotifyProtocol, addr, port, 90 | reuse_address=True, reuse_port=True, 91 | )) 92 | 93 | logger.info("Listening on %s:%d", addr, port) 94 | 95 | if DEBUG: 96 | data = json.loads(DEBUG) 97 | logger.debug("Testing %r", data) 98 | loop.run_until_complete(asyncio.ensure_future(notify_send(**data))) 99 | else: 100 | try: 101 | loop.run_forever() 102 | except KeyboardInterrupt: 103 | logger.info('Done') 104 | 105 | server.close() 106 | loop.run_until_complete(server.wait_closed()) 107 | loop.close() 108 | -------------------------------------------------------------------------------- /notify-proxy.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Notification proxy 4 | NoDisplay=true 5 | Exec=notify-proxy 6 | -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install dependencies 3 | when: ansible_env.DESKTOP_SESSION is defined 4 | become: yes 5 | package: 6 | name: 7 | - libnotify-bin 8 | - xdotool 9 | 10 | - name: Directories 11 | loop: 12 | - ~/.config/autostart 13 | - ~/.config/byobu 14 | - ~/.local/bin 15 | file: 16 | state: directory 17 | path: "{{ item }}" 18 | 19 | - name: Install scripts 20 | loop: 21 | - notify-client 22 | - notify-is-focused 23 | - notify-last-command 24 | copy: 25 | src: ../{{ item }} 26 | dest: ~/.local/bin/{{ item }} 27 | mode: 0750 28 | 29 | - name: Install notify-proxy script 30 | when: ansible_env.DESKTOP_SESSION is defined 31 | copy: 32 | src: ../notify-proxy 33 | dest: ~/.local/bin/notify-proxy 34 | mode: 0750 35 | 36 | - name: Setup session autostart 37 | when: ansible_env.DESKTOP_SESSION is defined 38 | copy: 39 | src: ../notify-proxy.desktop 40 | dest: ~/.config/autostart/notify-proxy.desktop 41 | 42 | - name: .tmux.conf 43 | when: ansible_env.DESKTOP_SESSION is defined 44 | copy: 45 | src: ../tmux.conf 46 | dest: ~/.config/byobu/.tmux.conf 47 | -------------------------------------------------------------------------------- /tmux.conf: -------------------------------------------------------------------------------- 1 | set -gw automatic-rename off 2 | set -gw allow-rename on 3 | set -g set-titles on 4 | set -g set-titles-string '#W #(hostname --fqdn) (#{pane_pid})' 5 | --------------------------------------------------------------------------------