calcurse: one-click Google login renewal

On expiry calcurse-sync starts calcurse-relogin, a notification that opens the consent page when clicked and catches the OAuth code on 127.0.0.1:8749. dunst's left-click now runs a notification's action before closing it.
This commit is contained in:
Daniele Fucini committed 2026-11-12 14:35:56 +01:00
1 parent 3bb25669ea
commit 566ac562bd
4 files changed
+121 -8

No files matched your search

+1 -1
View File
@@ -300,7 +300,7 @@
# * context_all: Open context menu for all notifications. # * context_all: Open context menu for all notifications.
# These values can be strung together for each mouse event, and # These values can be strung together for each mouse event, and
# will be executed in sequence. # 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_middle_click = do_action, close_current
mouse_right_click = close_all mouse_right_click = close_all
@@ -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
+101
View File
@@ -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:<port>")
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())
+12 -7
View File
@@ -1,13 +1,18 @@
#!/bin/bash #!/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). # --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 set -uo pipefail
data=${XDG_DATA_HOME:-$HOME/.local/share}/calcurse data=${XDG_DATA_HOME:-$HOME/.local/share}/calcurse
state=${XDG_STATE_HOME:-$HOME/.local/state}/calcurse-sync state=${XDG_STATE_HOME:-$HOME/.local/state}/calcurse-sync
expired_flag=$state/login-expired expired_flag=$state/login-expired
now=false 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. # The UI keeps the data in memory, so syncing under it would lose changes; its post-save hook syncs after it exits.
ui_running() { ui_running() {
@@ -15,22 +20,22 @@ ui_running() {
ps -C calcurse -o tty= | grep -qv '?' 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" echo "calcurse UI is running, skipping"
exit 0 exit 0
fi fi
mkdir -p "$state" mkdir -p "$state"
# BROWSER=true: an expired login must not open browser tabs on its own. # 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=$? rc=$?
printf '%s\n' "$out" printf '%s\n' "$out"
if ((rc != 0)); then if ((rc != 0)); then
if grep -q 'Access token is missing or refresh token is expired' <<<"$out"; then if grep -q 'Access token is missing or refresh token is expired' <<<"$out"; then
if [[ ! -e $expired_flag ]]; then # Prompt via calcurse-relogin, again daily if the prompt was dismissed.
notify-send -a calcurse -u critical -i x-office-calendar "Google Calendar login expired" \ if [[ ! -e $expired_flag || -n $(find "$expired_flag" -mmin +1440) ]]; then
"Run calcurse-caldav in a terminal, allow access in the browser, then calcurse-caldav --authcode '<code>'." systemctl --user start --no-block calcurse-relogin.service
touch "$expired_flag" touch "$expired_flag"
fi fi
elif grep -q 'Another synchronization instance' <<<"$out"; then elif grep -q 'Another synchronization instance' <<<"$out"; then