diff --git a/.config/dunst/dunstrc b/.config/dunst/dunstrc index 1af5a9c..f4fe550 100644 --- a/.config/dunst/dunstrc +++ b/.config/dunst/dunstrc @@ -300,7 +300,7 @@ # * context_all: Open context menu for all notifications. # These values can be strung together for each mouse event, and # will be executed in sequence. - mouse_left_click = close_current + mouse_left_click = do_action, close_current mouse_middle_click = do_action, close_current mouse_right_click = close_all diff --git a/.config/systemd/user/calcurse-relogin.service b/.config/systemd/user/calcurse-relogin.service new file mode 100644 index 0000000..d265564 --- /dev/null +++ b/.config/systemd/user/calcurse-relogin.service @@ -0,0 +1,7 @@ +[Unit] +Description=Ask to renew the calcurse Google Calendar login + +[Service] +# simple: the prompt waits for a click without a start timeout +Type=simple +ExecStart=%h/.local/bin/calcurse-relogin --prompt diff --git a/.local/bin/calcurse-relogin b/.local/bin/calcurse-relogin new file mode 100755 index 0000000..882b87f --- /dev/null +++ b/.local/bin/calcurse-relogin @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Renew calcurse-caldav's Google login: catch the OAuth redirect on 127.0.0.1 and hand the code to calcurse-sync. + +--prompt shows a notification first and logs in when it is clicked. +""" + +import argparse +import configparser +import os +import subprocess +import sys +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "calcurse" / "caldav" / "config" +WAIT_SECONDS = 300 + + +def notify(summary: str, body: str = "", *extra: str) -> str: + """Send a calcurse notification; with actions in extra, wait and return the chosen action's name.""" + cmd = ["notify-send", "-a", "calcurse", "-i", "x-office-calendar", *extra, summary, body] + return subprocess.run(cmd, capture_output=True, text=True).stdout.strip() + + +def redirect_port() -> int: + parser = configparser.RawConfigParser() + parser.read(CONFIG) + uri = urllib.parse.urlparse(parser.get("OAuth2", "RedirectURI", fallback="")) + if uri.hostname != "127.0.0.1" or uri.port is None: + sys.exit(f"RedirectURI in {CONFIG} must be http://127.0.0.1:") + return uri.port + + +def ui_running() -> bool: + ttys = subprocess.run(["ps", "-C", "calcurse", "-o", "tty="], capture_output=True, text=True).stdout.split() + return any(tty != "?" for tty in ttys) + + +class RedirectHandler(BaseHTTPRequestHandler): + result: dict[str, str] = {} + + def do_GET(self) -> None: + query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + for key in ("code", "error"): + if key in query: + RedirectHandler.result[key] = query[key][0] + done = bool(RedirectHandler.result) + self.send_response(200 if done else 404) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + if done: + self.wfile.write(b"calcurse: login received, you can close this tab.\n") + + def log_message(self, format: str, *args: object) -> None: + pass + + +def login() -> int: + if ui_running(): + notify("Google Calendar login", "Close calcurse first, then log in again.", "-u", "critical") + return 1 + server = HTTPServer(("127.0.0.1", redirect_port()), RedirectHandler) + # With an expired login calcurse-caldav opens the consent page and exits; with a valid one it just syncs. + if subprocess.run(["calcurse-caldav"], capture_output=True).returncode == 0: + notify("Google Calendar login", "Already logged in.") + return 0 + deadline = time.monotonic() + WAIT_SECONDS + while not RedirectHandler.result and (left := deadline - time.monotonic()) > 0: + server.timeout = left + server.handle_request() + server.server_close() + code = RedirectHandler.result.get("code") + if not code: + reason = RedirectHandler.result.get("error", "no answer from the browser") + notify("Google Calendar login failed", reason, "-u", "critical") + return 1 + # The user manager's PATH lacks ~/.local/bin: call calcurse-sync from this script's folder. + calcurse_sync = Path(__file__).resolve().with_name("calcurse-sync") + sync = subprocess.run([calcurse_sync, "--authcode", code], capture_output=True, text=True) + if sync.returncode != 0: + last_line = (sync.stdout.strip().splitlines() or [""])[-1] + notify("Google Calendar login failed", last_line, "-u", "critical") + return 1 + notify("Google Calendar login renewed", "Synced again.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--prompt", action="store_true", help="ask with a notification before logging in") + if parser.parse_args().prompt: + body = "Click to log in again, then click Allow in the browser." + if notify("Google Calendar login expired", body, "-u", "critical", "-A", "default=Log in") != "default": + return 0 + return login() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.local/bin/calcurse-sync b/.local/bin/calcurse-sync index c786ce8..456f6a3 100755 --- a/.local/bin/calcurse-sync +++ b/.local/bin/calcurse-sync @@ -1,13 +1,18 @@ #!/bin/bash -# Sync calcurse with Google Calendar via calcurse-caldav; notify once when the weekly OAuth login expires. +# Sync calcurse with Google Calendar via calcurse-caldav; start the login prompt when the weekly OAuth login expires. # --now: called from the UI's pre-load hook (skip the UI check, shorter timeout, no daemon restart). +# --authcode CODE: finish a login (from calcurse-relogin, which checks for the UI itself). set -uo pipefail data=${XDG_DATA_HOME:-$HOME/.local/share}/calcurse state=${XDG_STATE_HOME:-$HOME/.local/state}/calcurse-sync expired_flag=$state/login-expired now=false -[[ ${1:-} == --now ]] && now=true +authcode=() +case ${1:-} in +--now) now=true ;; +--authcode) authcode=(--authcode "$2") ;; +esac # The UI keeps the data in memory, so syncing under it would lose changes; its post-save hook syncs after it exits. ui_running() { @@ -15,22 +20,22 @@ ui_running() { ps -C calcurse -o tty= | grep -qv '?' } -if ! $now && ui_running; then +if ! $now && ((${#authcode[@]} == 0)) && ui_running; then echo "calcurse UI is running, skipping" exit 0 fi mkdir -p "$state" # BROWSER=true: an expired login must not open browser tabs on its own. -out=$(BROWSER=true timeout "$($now && echo 15 || echo 60)" calcurse-caldav 2>&1) +out=$(BROWSER=true timeout "$($now && echo 15 || echo 60)" calcurse-caldav "${authcode[@]}" 2>&1) rc=$? printf '%s\n' "$out" if ((rc != 0)); then if grep -q 'Access token is missing or refresh token is expired' <<<"$out"; then - if [[ ! -e $expired_flag ]]; then - notify-send -a calcurse -u critical -i x-office-calendar "Google Calendar login expired" \ - "Run calcurse-caldav in a terminal, allow access in the browser, then calcurse-caldav --authcode ''." + # Prompt via calcurse-relogin, again daily if the prompt was dismissed. + if [[ ! -e $expired_flag || -n $(find "$expired_flag" -mmin +1440) ]]; then + systemctl --user start --no-block calcurse-relogin.service touch "$expired_flag" fi elif grep -q 'Another synchronization instance' <<<"$out"; then