diff --git a/README.md b/README.md index 5e3d060..d43f5e7 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ eufy-sync --update On first run you can opt into syncing every 4 hours. If you do, a macOS Launch Agent runs it in the background: weigh yourself, open your laptop later, and it syncs on its own. Logs go to `~/.garmin-sync/sync.log`, and you get a notification if something fails. Turn it off with `eufy-sync --uninstall-agent`. +If [terminal-notifier](https://github.com/julienXX/terminal-notifier) is installed (`brew install terminal-notifier`), clicking a failure notification opens Terminal with the fix command already running. Without it, notifications still appear; the click just does nothing useful. + ## Headless Linux (server or VPS) eufy-sync runs on Linux too, and a server is a good home for it: no laptop that has to be awake. Without a system keychain, credentials fall back to a file with `600` permissions. diff --git a/docs/design/2026-07-10-actionable-notifications-design.md b/docs/design/2026-07-10-actionable-notifications-design.md new file mode 100644 index 0000000..21d8922 --- /dev/null +++ b/docs/design/2026-07-10-actionable-notifications-design.md @@ -0,0 +1,28 @@ +# Actionable macOS notifications - Design + +**Date:** 2026-07-10 + +## Problem + +Failure notifications (Garmin re-login, Eufy password change, profile selection, update available) tell the user exactly what to run, but clicking one opens Script Editor. That happens because the tool posts notifications through `osascript`, and macOS attributes those to Script Editor. Plain `osascript` has no way to attach a click action, so the click is a dead end. + +## Design + +`_notify(title, message)` in `cli/shared.py` gains an optional `command` parameter carrying the fix command the notification already names in its text. + +When a command is present and [terminal-notifier](https://github.com/julienXX/terminal-notifier) is installed, the notification goes out through it with an `-execute` action: clicking tells Terminal to open a new window, run the command, and come to the front. The user lands directly in the interactive prompts (Garmin login, password entry, profile picker). + +In every other case, behavior is unchanged: no command, no terminal-notifier, or a non-macOS host all take the existing `osascript` path, which fails silently off-platform as before. + +terminal-notifier is looked up with `shutil.which` plus the two standard Homebrew locations (`/opt/homebrew/bin`, `/usr/local/bin`), because scheduled runs under launchd get a minimal PATH that may not include Homebrew. + +Call sites that gain a command: the three re-auth style failures in `cli/app.py` and the update notice in `cli/updater.py`. Success and generic-failure notifications stay plain; there is nothing useful for a click to do. + +## Non-goals + +- No new required dependency. terminal-notifier stays optional; the README mentions it in one line. +- No change to notification text or to which events notify. + +## Testing + +Unit tests on `_notify` with `shutil.which` and `subprocess.run` monkeypatched: the terminal-notifier invocation includes the command inside the `-execute` action; absence of terminal-notifier falls back to `osascript`; a notification without a command uses `osascript` even when terminal-notifier is available. diff --git a/eufy_sync/__init__.py b/eufy_sync/__init__.py index 1ebc56f..47ae70b 100644 --- a/eufy_sync/__init__.py +++ b/eufy_sync/__init__.py @@ -1,6 +1,6 @@ """Sync Eufy smart scale body composition data to Garmin Connect and Strava.""" -__version__ = "1.7.21" +__version__ = "1.7.22" # Public API for programmatic use from eufy_sync.garmin_auth import GarminAuth diff --git a/eufy_sync/cli/app.py b/eufy_sync/cli/app.py index e43ef35..c1b0589 100644 --- a/eufy_sync/cli/app.py +++ b/eufy_sync/cli/app.py @@ -245,13 +245,13 @@ def main() -> None: multiple_profiles = any("multiple Eufy profiles" in err for _, err in failures) all_transient = all(failure_notify.is_transient_network_error(err) for _, err in failures) if reauth_needed: - shared._notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth garmin") + shared._notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth garmin", command="eufy-sync --reauth garmin") failure_notify.clear_network_failures() elif eufy_password: - shared._notify("eufy-sync: Eufy login failed", "Run: eufy-sync --update-password") + shared._notify("eufy-sync: Eufy login failed", "Run: eufy-sync --update-password", command="eufy-sync --update-password") failure_notify.clear_network_failures() elif multiple_profiles: - shared._notify("eufy-sync: choose your profile", "Run: eufy-sync --select-profile") + shared._notify("eufy-sync: choose your profile", "Run: eufy-sync --select-profile", command="eufy-sync --select-profile") failure_notify.clear_network_failures() elif all_transient and args.headless and not args.dry_run: # A scheduled run that only hit network trouble. Stay quiet - the diff --git a/eufy_sync/cli/shared.py b/eufy_sync/cli/shared.py index d8c9239..72edb1a 100644 --- a/eufy_sync/cli/shared.py +++ b/eufy_sync/cli/shared.py @@ -3,6 +3,8 @@ import json import os +import shlex +import shutil import subprocess from pathlib import Path @@ -27,9 +29,44 @@ UPDATE_CHECK_INTERVAL = 604800 # check once per week -def _notify(title: str, message: str) -> None: - """Send a macOS notification. Fails silently on other platforms.""" +def _find_terminal_notifier() -> str | None: + """Locate terminal-notifier (optional Homebrew tool). Checked beyond + PATH because launchd runs with a minimal PATH that excludes Homebrew.""" + found = shutil.which("terminal-notifier") + if found: + return found + for candidate in ("/opt/homebrew/bin/terminal-notifier", "/usr/local/bin/terminal-notifier"): + if os.path.exists(candidate): + return candidate + return None + + +def _notify(title: str, message: str, command: str | None = None) -> None: + """Send a macOS notification. Fails silently on other platforms. + + When a fix command is given and terminal-notifier is installed, + clicking the notification opens a Terminal window running the command, + so the user lands in the interactive prompts instead of Script Editor + (plain osascript notifications belong to Script Editor and clicking + them just launches it). + """ try: + if command: + notifier = _find_terminal_notifier() + if notifier: + do_script = f'tell application "Terminal" to do script "{command}"' + activate = 'tell application "Terminal" to activate' + subprocess.run( + [ + notifier, + "-title", title, + "-message", message, + "-execute", f"osascript -e {shlex.quote(do_script)} -e {shlex.quote(activate)}", + ], + capture_output=True, + timeout=5, + ) + return safe_title = json.dumps(title) safe_msg = json.dumps(message) subprocess.run( diff --git a/eufy_sync/cli/updater.py b/eufy_sync/cli/updater.py index e7d46e8..5d35ad3 100644 --- a/eufy_sync/cli/updater.py +++ b/eufy_sync/cli/updater.py @@ -65,7 +65,7 @@ def _parse(v: str) -> tuple: if sys.stdin.isatty(): print(f"Update available: v{latest} (you have v{__version__}). Run: eufy-sync --update") else: - shared._notify("eufy-sync", f"Update available: v{latest}. Run: eufy-sync --update") + shared._notify("eufy-sync", f"Update available: v{latest}. Run: eufy-sync --update", command="eufy-sync --update") except Exception: pass # never let update check break a sync diff --git a/pyproject.toml b/pyproject.toml index 6d7e644..c137e55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "eufy-sync" -version = "1.7.21" +version = "1.7.22" description = "Sync Eufy smart scale body composition data to Garmin Connect and Strava" readme = "README.md" license = "MIT" diff --git a/tests/test_cli.py b/tests/test_cli.py index 95c93f6..1b6ba1e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -968,7 +968,8 @@ def boom_input(*a, **k): out = capsys.readouterr().out assert "eufy-sync --select-profile" in out _notify.assert_any_call( - "eufy-sync: choose your profile", "Run: eufy-sync --select-profile" + "eufy-sync: choose your profile", "Run: eufy-sync --select-profile", + command="eufy-sync --select-profile", ) diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..54b26b5 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,67 @@ +"""_notify click actions: with terminal-notifier installed, notifications +that carry a fix command open Terminal and run it when clicked; everything +else keeps the plain osascript path.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from eufy_sync.cli import shared + +# conftest's autouse _mute_notifications fixture replaces shared._notify with +# a MagicMock so tests never fire real notifications. These tests exercise the +# real function, captured here at import time, with subprocess.run mocked out. +_real_notify = shared._notify + + +def _run_notify(which_return, command=None): + run = MagicMock() + with patch("eufy_sync.cli.shared.shutil.which", return_value=which_return), \ + patch("eufy_sync.cli.shared.os.path.exists", return_value=False), \ + patch("eufy_sync.cli.shared.subprocess.run", run): + _real_notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth garmin", command=command) + return run + + +def test_command_with_terminal_notifier_attaches_click_action(): + run = _run_notify("/opt/homebrew/bin/terminal-notifier", command="eufy-sync --reauth garmin") + + argv = run.call_args.args[0] + assert argv[0] == "/opt/homebrew/bin/terminal-notifier" + assert "-execute" in argv + execute = argv[argv.index("-execute") + 1] + assert "eufy-sync --reauth garmin" in execute + assert "Terminal" in execute + + +def test_command_without_terminal_notifier_falls_back_to_osascript(): + run = _run_notify(None, command="eufy-sync --reauth garmin") + + argv = run.call_args.args[0] + assert argv[0] == "osascript" + assert "display notification" in argv[2] + + +def test_plain_notification_ignores_terminal_notifier(): + run = _run_notify("/opt/homebrew/bin/terminal-notifier", command=None) + + argv = run.call_args.args[0] + assert argv[0] == "osascript" + + +def test_homebrew_path_is_checked_when_which_misses(): + """launchd runs with a minimal PATH that excludes Homebrew, so the + lookup must also try the standard install locations directly.""" + run = MagicMock() + with patch("eufy_sync.cli.shared.shutil.which", return_value=None), \ + patch("eufy_sync.cli.shared.os.path.exists", + side_effect=lambda p: p == "/opt/homebrew/bin/terminal-notifier"), \ + patch("eufy_sync.cli.shared.subprocess.run", run): + _real_notify("t", "m", command="eufy-sync --update") + + argv = run.call_args.args[0] + assert argv[0] == "/opt/homebrew/bin/terminal-notifier" + + +def test_notify_still_fails_silently(): + with patch("eufy_sync.cli.shared.shutil.which", side_effect=RuntimeError("boom")): + _real_notify("t", "m", command="eufy-sync --update") # must not raise