Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions docs/design/2026-07-10-actionable-notifications-design.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion eufy_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions eufy_sync/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 39 additions & 2 deletions eufy_sync/cli/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import json
import os
import shlex
import shutil
import subprocess
from pathlib import Path

Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion eufy_sync/cli/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
67 changes: 67 additions & 0 deletions tests/test_notify.py
Original file line number Diff line number Diff line change
@@ -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
Loading