From 85375dfacf9b2f391255165bca8839705f5f59b8 Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 10 Jul 2026 16:37:15 -0400 Subject: [PATCH 01/14] Chunk the keychain vault across entries when it outgrows one Windows Credential Manager caps a single credential at roughly 2,560 bytes stored as UTF-16, about 1,280 characters. The vault JSON holds Garmin's two OAuth tokens plus Strava's, which can exceed that and make keyring.set_password fail outright on Windows. When the serialized vault fits in CHUNK_LIMIT characters it keeps today's single-entry shape byte for byte, so existing installs never migrate. When it does not, the payload is split across numbered "vault:i" entries and the "vault" entry holds a {"__chunks__": N} header. Chunks are written before the header so a racing reader sees either the old vault or a complete new one, and a save probes and deletes stale higher-numbered chunks left by an earlier, larger vault. A read with a missing chunk is treated as malformed, same as malformed JSON: warn and return empty. Co-Authored-By: Claude Fable 5 --- eufy_sync/credentials.py | 56 +++++++++++++++++++++++++++++++++++--- tests/test_credentials.py | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/eufy_sync/credentials.py b/eufy_sync/credentials.py index f3a47ef..7a8e253 100644 --- a/eufy_sync/credentials.py +++ b/eufy_sync/credentials.py @@ -42,6 +42,14 @@ SERVICE_NAME = "eufy-garmin-sync" VAULT_ACCOUNT = "vault" +# Windows Credential Manager caps one entry at ~2,560 bytes stored as UTF-16, +# roughly 1,280 characters. A vault larger than CHUNK_LIMIT characters is split +# across numbered "vault:i" entries so set_password never fails on Windows. +# MAX_CHUNKS bounds how far a save probes for stale leftover chunks to delete, +# so a corrupt store can never make that scan run away. +CHUNK_LIMIT = 1200 +MAX_CHUNKS = 40 + CRED_FILE = Path.home() / ".garmin-sync" / "credentials.json" @@ -136,15 +144,57 @@ def _load_vault_from_keychain() -> dict: if raw is None: return _empty_vault() try: - return _normalize_vault(json.loads(raw)) - except (json.JSONDecodeError, TypeError): + parsed = json.loads(raw) + # An oversized vault is stored as a header pointing at numbered chunks; + # reassemble the payload before normalizing. A missing chunk means the + # header outlived its payload, which is as unusable as malformed JSON. + if isinstance(parsed, dict) and "__chunks__" in parsed: + count = parsed["__chunks__"] + pieces = [] + for i in range(1, count + 1): + piece = keyring.get_password(SERVICE_NAME, f"{VAULT_ACCOUNT}:{i}") + if piece is None: + raise ValueError("missing vault chunk") + pieces.append(piece) + parsed = json.loads("".join(pieces)) + return _normalize_vault(parsed) + except (ValueError, TypeError): logger.warning("Keychain vault contained malformed JSON; treating as empty") return _empty_vault() +def _delete_stale_chunks(start: int) -> None: + # A previous save may have used more chunks than this one. Delete numbered + # entries from `start` upward until the first gap, so a later read can never + # reassemble a stale tail. Bounded by MAX_CHUNKS. + import keyring + for i in range(start, MAX_CHUNKS + 1): + account = f"{VAULT_ACCOUNT}:{i}" + if keyring.get_password(SERVICE_NAME, account) is None: + break + try: + keyring.delete_password(SERVICE_NAME, account) + except Exception: + pass + + def _save_vault_to_keychain(vault: dict) -> None: import keyring - keyring.set_password(SERVICE_NAME, VAULT_ACCOUNT, json.dumps(vault)) + payload = json.dumps(vault) + if len(payload) <= CHUNK_LIMIT: + keyring.set_password(SERVICE_NAME, VAULT_ACCOUNT, payload) + _delete_stale_chunks(1) + return + chunks = [payload[i:i + CHUNK_LIMIT] for i in range(0, len(payload), CHUNK_LIMIT)] + # Chunks first, header last: a reader that races the write sees either the + # old vault or a complete new one, never a header pointing at a chunk that + # has not been written yet. + for i, chunk in enumerate(chunks, start=1): + keyring.set_password(SERVICE_NAME, f"{VAULT_ACCOUNT}:{i}", chunk) + keyring.set_password( + SERVICE_NAME, VAULT_ACCOUNT, json.dumps({"__chunks__": len(chunks)}) + ) + _delete_stale_chunks(len(chunks) + 1) def _load_vault_from_file() -> dict: diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 011d95e..18cd266 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -716,3 +716,60 @@ def report(status, label, detail, fix=None): doctor._check_keychain(report) assert lines[0][0] == "PASS" assert "file" in lines[0][2] + + +# --- Vault chunking ---------------------------------------------------------- +# +# Windows Credential Manager caps one entry at ~2,560 bytes (UTF-16). A vault +# holding Garmin's two OAuth tokens plus Strava's can exceed that, so the +# keychain backend splits an oversized vault across numbered entries. A vault +# that fits keeps today's single-entry shape, so existing installs never see +# a migration. + +from eufy_sync.credentials import ( + CHUNK_LIMIT, + SERVICE_NAME, + VAULT_ACCOUNT, + store_token, + get_token, +) + + +def _big_token(size: int) -> dict: + return {"access_token": "x" * size} + + +def test_small_vault_keeps_single_entry_shape(fake_keyring): + store_token("garmin", {"a": 1}) + raw = fake_keyring.get_password(SERVICE_NAME, VAULT_ACCOUNT) + data = json.loads(raw) + assert "__chunks__" not in data + assert data["tokens"]["garmin"] == {"a": 1} + assert fake_keyring.get_password(SERVICE_NAME, f"{VAULT_ACCOUNT}:1") is None + + +def test_oversized_vault_chunks_and_round_trips(fake_keyring): + store_token("garmin", _big_token(3 * CHUNK_LIMIT)) + header = json.loads(fake_keyring.get_password(SERVICE_NAME, VAULT_ACCOUNT)) + n = header["__chunks__"] + assert n >= 3 + for i in range(1, n + 1): + chunk = fake_keyring.get_password(SERVICE_NAME, f"{VAULT_ACCOUNT}:{i}") + assert chunk is not None + assert len(chunk) <= CHUNK_LIMIT + assert get_token("garmin") == _big_token(3 * CHUNK_LIMIT) + + +def test_shrinking_vault_deletes_stale_chunks(fake_keyring): + store_token("garmin", _big_token(3 * CHUNK_LIMIT)) + store_token("garmin", {"a": 1}) # replaces the big token; vault fits again + assert get_token("garmin") == {"a": 1} + raw = fake_keyring.get_password(SERVICE_NAME, VAULT_ACCOUNT) + assert "__chunks__" not in json.loads(raw) + assert fake_keyring.get_password(SERVICE_NAME, f"{VAULT_ACCOUNT}:1") is None + + +def test_missing_chunk_reads_as_empty_vault(fake_keyring, caplog): + store_token("garmin", _big_token(3 * CHUNK_LIMIT)) + fake_keyring.delete_password(SERVICE_NAME, f"{VAULT_ACCOUNT}:2") + assert get_token("garmin") is None # malformed vault treated as empty From b0db6fe0f07f87b7a75a30369cce5f7f45bfe39b Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 10 Jul 2026 16:58:11 -0400 Subject: [PATCH 02/14] Move OS-specific plumbing behind a platform layer Notifications and the scheduled-sync agent (install/uninstall/status/offer) were macOS-specific code scattered across cli/shared.py, cli/maintenance.py, and cli/doctor.py. Collect them behind a new eufy_sync/platform_support/ package with one small interface, chosen once from platform.system(): Darwin uses the macOS Launch Agent and osascript notifications, everything else uses a no-op generic layer. This is the structural step that lets a windows.py drop in next without touching call sites. macOS behavior is a move-only relocation: every string, plist byte, and launchctl/osascript/terminal-notifier invocation is byte-identical. Call sites in app.py, updater.py, doctor.py, and maintenance.py now route through the platform layer; the maintenance agent functions became one-line delegates so the CLI flag routing is unchanged. Tests that patched the moved symbols were retargeted to their new import paths with no assertion changes. Co-Authored-By: Claude Fable 5 --- eufy_sync/cli/app.py | 19 +- eufy_sync/cli/doctor.py | 42 +--- eufy_sync/cli/maintenance.py | 134 ++----------- eufy_sync/cli/shared.py | 66 +----- eufy_sync/cli/updater.py | 3 +- eufy_sync/platform_support/__init__.py | 62 ++++++ eufy_sync/platform_support/generic.py | 40 ++++ eufy_sync/platform_support/macos.py | 268 +++++++++++++++++++++++++ tests/conftest.py | 14 +- tests/test_cli.py | 90 ++++----- tests/test_doctor.py | 22 +- tests/test_notify.py | 25 +-- tests/test_platform_support.py | 43 ++++ 13 files changed, 520 insertions(+), 308 deletions(-) create mode 100644 eufy_sync/platform_support/__init__.py create mode 100644 eufy_sync/platform_support/generic.py create mode 100644 eufy_sync/platform_support/macos.py create mode 100644 tests/test_platform_support.py diff --git a/eufy_sync/cli/app.py b/eufy_sync/cli/app.py index c1b0589..b3b89cc 100644 --- a/eufy_sync/cli/app.py +++ b/eufy_sync/cli/app.py @@ -4,6 +4,7 @@ import sys from pathlib import Path +from eufy_sync import platform_support from eufy_sync.cli import doctor, maintenance, profiles, setup, shared, status, updater @@ -123,7 +124,7 @@ def main() -> None: if first_run and args.headless: msg = "No config found. Run eufy-sync in a terminal to set up." print(msg) - shared._notify("eufy-sync", msg) + platform_support.notify("eufy-sync", msg) sys.exit(1) try: @@ -143,7 +144,7 @@ def main() -> None: except Exception as e: msg = f"eufy-sync could not start: {e}" print(msg) - shared._notify("eufy-sync failed", str(e)[:200]) + platform_support.notify("eufy-sync failed", str(e)[:200]) sys.exit(1) has_garmin = any(u.garmin for u in config.users) @@ -192,7 +193,7 @@ def main() -> None: except Exception as e: msg = f"eufy-sync could not start: {e}" print(msg) - shared._notify("eufy-sync failed", str(e)[:200]) + platform_support.notify("eufy-sync failed", str(e)[:200]) sys.exit(1) try: @@ -245,13 +246,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", command="eufy-sync --reauth garmin") + platform_support.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", command="eufy-sync --update-password") + platform_support.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", command="eufy-sync --select-profile") + platform_support.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 @@ -259,14 +260,14 @@ def main() -> None: # points at a real outage worth one heads-up. count, hours = failure_notify.record_network_failure() if failure_notify.should_escalate(count): - shared._notify( + platform_support.notify( "eufy-sync: network still down", f"No network for ~{round(hours)}h ({count} runs). " "Measurements are waiting and will sync when it is back.", ) else: fail_msg = "; ".join(f"{name}: {err[:80]}" for name, err in failures) - shared._notify("eufy-sync failed", fail_msg) + platform_support.notify("eufy-sync failed", fail_msg) failure_notify.clear_network_failures() logger.error("Sync failed for: %s", "; ".join(f"{n}: {e[:80]}" for n, e in failures)) elif not args.dry_run: @@ -285,7 +286,7 @@ def main() -> None: if total > 0: target_label = " and ".join(n.capitalize() for n in total_counts if total_counts[n] > 0) - shared._notify("eufy-sync", f"Synced {total} measurement{'s' if total != 1 else ''} to {target_label}") + platform_support.notify("eufy-sync", f"Synced {total} measurement{'s' if total != 1 else ''} to {target_label}") if first_run: if failures: diff --git a/eufy_sync/cli/doctor.py b/eufy_sync/cli/doctor.py index fb7c4f3..a418668 100644 --- a/eufy_sync/cli/doctor.py +++ b/eufy_sync/cli/doctor.py @@ -8,14 +8,12 @@ """ from __future__ import annotations -import platform -import subprocess import time from datetime import datetime, timezone from pathlib import Path from eufy_sync import credentials -from eufy_sync.cli import shared +from eufy_sync import platform_support from eufy_sync.cli import updater from eufy_sync.config import load_config from eufy_sync.credentials import _keyring_available, active_store_label @@ -94,9 +92,10 @@ def report(status: str, label: str, detail: str, fix: str | None = None) -> None # 7. eufy cloud _check_eufy_cloud(report, eufy_client) - # 8. launch agent (macOS only) - if platform.system() == "Darwin": - _check_launch_agent(report) + # 8. scheduled sync agent (where the platform manages one) + agent = platform_support.agent_status() + if agent is not None: + report(agent["status"], agent["label"], agent["detail"], agent["fix"]) # 9. state db _check_state_db(report, db_path, user) @@ -231,37 +230,6 @@ def _check_eufy_cloud(report, eufy_client) -> None: pass -def _check_launch_agent(report) -> None: - try: - if not shared.LAUNCH_AGENT_PATH.exists(): - report("WARN", "launch agent", "not installed", "eufy-sync --install-agent") - return - - content = shared.LAUNCH_AGENT_PATH.read_text() - wrapper_name = shared.LAUNCH_WRAPPER_NAME - if wrapper_name not in content: - report( - "WARN", "launch agent", - "outdated registration (re-announces on every update)", - "eufy-sync --install-agent", - ) - return - - result = subprocess.run( - ["launchctl", "list"], - capture_output=True, - text=True, - timeout=5, - ) - if shared.LAUNCH_AGENT_LABEL not in (result.stdout or ""): - report("WARN", "launch agent", "installed but not loaded", "eufy-sync --install-agent") - return - - report("PASS", "launch agent", "loaded, runs every 4h") - except Exception as e: - report("WARN", "launch agent", f"could not check ({e})", "eufy-sync --install-agent") - - def _check_state_db(report, db_path: Path, user) -> None: try: state = SyncState(db_path) diff --git a/eufy_sync/cli/maintenance.py b/eufy_sync/cli/maintenance.py index db8eee9..504553c 100644 --- a/eufy_sync/cli/maintenance.py +++ b/eufy_sync/cli/maintenance.py @@ -1,15 +1,14 @@ -"""Password updates, re-auth, and the macOS Launch Agent lifecycle.""" +"""Password updates, re-auth, and the scheduled-sync agent lifecycle.""" from __future__ import annotations import getpass -import platform import shutil -import subprocess import sys from pathlib import Path import yaml +from eufy_sync import platform_support from eufy_sync.cli import shared @@ -136,127 +135,19 @@ def _reauth(config_path: Path, config: dict | None = None, force: bool = False, print("Done - Strava tokens saved.") -def _write_run_script(binary_path: str) -> Path: - """Write the stable wrapper script the Launch Agent runs. - - macOS re-announces "can run in the background" whenever a registered - background item's executable changes identity, and pipx/uv replace the - binary on every update. The agent therefore points at this script, whose - bytes never change across updates, so the announcement fires once, not - once per release. Skipping the rewrite when content is unchanged is what - keeps the file's identity stable. The filename is what macOS shows in that - announcement, so it is a recognizable "eufy-sync-agent", not an opaque one. - """ - script_path = shared.DATA_DIR / shared.LAUNCH_WRAPPER_NAME - content = f'#!/bin/sh\nexec "{binary_path}" --headless\n' - if script_path.exists() and script_path.read_text() == content: - return script_path - script_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - script_path.write_text(content) - script_path.chmod(0o755) - return script_path - - -def _generate_plist(program_path: str) -> str: - """Generate a Launch Agent plist that runs the given program every 4 hours.""" - log_path = str(shared.LOG_FILE) - return f""" - - - - Label - {shared.LAUNCH_AGENT_LABEL} - - ProgramArguments - - {program_path} - - - StartInterval - 14400 - - RunAtLoad - - - StandardOutPath - {log_path} - StandardErrorPath - {log_path} - - -""" - - def _install_launch_agent() -> None: - """Install the macOS Launch Agent for automatic sync.""" - if platform.system() != "Darwin": - print("Auto-sync is only supported on macOS.") - return - - binary = shutil.which("eufy-sync") - if not binary: - print("Warning: could not find eufy-sync on PATH. Skipping auto-sync setup.") - return - - already_installed = shared.LAUNCH_AGENT_PATH.exists() - - # Ensure the log directory exists with restricted permissions - shared.DATA_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) - - wrapper = _write_run_script(binary) - - # Drop the pre-1.7.17 wrapper name so it does not linger as an orphan next - # to the new one. - legacy_wrapper = shared.DATA_DIR / shared.LEGACY_LAUNCH_WRAPPER_NAME - if legacy_wrapper.exists(): - legacy_wrapper.unlink() - - shared.LAUNCH_AGENT_PATH.parent.mkdir(parents=True, exist_ok=True) - shared.LAUNCH_AGENT_PATH.write_text(_generate_plist(str(wrapper))) - - # Unload first in case an old version is loaded - subprocess.run( - ["launchctl", "unload", str(shared.LAUNCH_AGENT_PATH)], - capture_output=True, - ) - subprocess.run( - ["launchctl", "load", str(shared.LAUNCH_AGENT_PATH)], - capture_output=True, - ) - - if already_installed: - print(f"Launch Agent already installed (reloaded). Logs: {shared.LOG_FILE}") - else: - print(f"Automatic sync installed. Logs: {shared.LOG_FILE}") + """Install the scheduled-sync agent for the current platform.""" + platform_support.install_agent() def _offer_launch_agent() -> None: - """Offer to install a macOS Launch Agent after first-run setup.""" - if platform.system() != "Darwin": - return - if not sys.stdin.isatty(): - return - - print("") - answer = input("Set up automatic sync every 4 hours? [y/N] ").strip() - if not answer.lower().startswith("y"): - return - - _install_launch_agent() + """Offer to install the scheduled-sync agent after first-run setup.""" + platform_support.offer_agent() def _uninstall_launch_agent() -> None: - """Remove the macOS Launch Agent.""" - if not shared.LAUNCH_AGENT_PATH.exists(): - print("No Launch Agent installed.") - return - - subprocess.run( - ["launchctl", "unload", str(shared.LAUNCH_AGENT_PATH)], - capture_output=True, - ) - shared.LAUNCH_AGENT_PATH.unlink() - print("Launch Agent removed. Auto-sync disabled.") + """Remove the scheduled-sync agent for the current platform.""" + platform_support.uninstall_agent() def _uninstall(data_dir: Path, config_path: Path | None = None, db_path: Path | None = None) -> None: @@ -274,7 +165,7 @@ def _uninstall(data_dir: Path, config_path: Path | None = None, db_path: Path | print(f" - All saved credentials and tokens in {data_dir}/") print(f" - Keychain entries for eufy-sync") print(f" - Sync history database") - if shared.LAUNCH_AGENT_PATH.exists(): + if platform_support.agent_installed(): print(f" - Automatic sync Launch Agent") print("") @@ -295,10 +186,9 @@ def _uninstall(data_dir: Path, config_path: Path | None = None, db_path: Path | keep_answer = input("Keep sync history? Prevents duplicates if you reinstall later. [Y/n] ").strip() keep_db = not keep_answer.lower().startswith("n") - # Stop and remove Launch Agent - if shared.LAUNCH_AGENT_PATH.exists(): - subprocess.run(["launchctl", "unload", str(shared.LAUNCH_AGENT_PATH)], capture_output=True) - shared.LAUNCH_AGENT_PATH.unlink() + # Stop and remove the scheduled-sync agent, where the platform manages one. + if platform_support.agent_installed(): + platform_support.purge_agent() # Clear keychain entries for every user named in the config user_names = ["default"] diff --git a/eufy_sync/cli/shared.py b/eufy_sync/cli/shared.py index 72edb1a..9f8bbba 100644 --- a/eufy_sync/cli/shared.py +++ b/eufy_sync/cli/shared.py @@ -1,11 +1,7 @@ -"""Path/agent constants and small helpers shared across the cli package.""" +"""Path constants and small helpers shared across the cli package.""" from __future__ import annotations -import json import os -import shlex -import shutil -import subprocess from pathlib import Path import yaml @@ -14,70 +10,10 @@ DEFAULT_CONFIG = DATA_DIR / "config.yaml" DEFAULT_DB = DATA_DIR / "state.db" LOG_FILE = DATA_DIR / "sync.log" -LAUNCH_AGENT_LABEL = "com.sturimcode.eufy-garmin-sync" -LAUNCH_AGENT_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LAUNCH_AGENT_LABEL}.plist" - -# The Launch Agent points at this wrapper script, not the pipx/uv binary, -# so the registered executable's bytes stay stable across updates (macOS -# only re-announces "can run in the background" when they change). macOS -# labels the background-item notification by this filename, so it is named -# recognizably rather than something opaque. LEGACY_LAUNCH_WRAPPER_NAME is -# the pre-1.7.17 name, cleaned up on the next --install-agent. -LAUNCH_WRAPPER_NAME = "eufy-sync-agent" -LEGACY_LAUNCH_WRAPPER_NAME = "run-sync.sh" UPDATE_CHECK_INTERVAL = 604800 # check once per week -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( - ["osascript", "-e", f'display notification {safe_msg} with title {safe_title}'], - capture_output=True, - timeout=5, - ) - except Exception: - pass - - def _write_config(path: Path, config: dict) -> None: """Write config with restricted permissions.""" path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) diff --git a/eufy_sync/cli/updater.py b/eufy_sync/cli/updater.py index 5d35ad3..921061b 100644 --- a/eufy_sync/cli/updater.py +++ b/eufy_sync/cli/updater.py @@ -9,6 +9,7 @@ import time import urllib.request +from eufy_sync import platform_support from eufy_sync.cli import shared @@ -65,7 +66,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", command="eufy-sync --update") + platform_support.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/eufy_sync/platform_support/__init__.py b/eufy_sync/platform_support/__init__.py new file mode 100644 index 0000000..5b3ff57 --- /dev/null +++ b/eufy_sync/platform_support/__init__.py @@ -0,0 +1,62 @@ +"""OS-specific plumbing behind one small interface. + +Notifications and the scheduled-sync agent (install/uninstall/status/offer) +differ per platform. The active implementation is chosen once from +platform.system(): Darwin uses the macOS Launch Agent and osascript +notifications; every other platform (including Windows for now) uses a +no-op generic layer. Task 3 adds a Windows implementation and points the +selection at it. +""" +from __future__ import annotations + +import platform + +_active = None + + +def _select(): + """Pick the implementation module for the current OS (uncached).""" + system = platform.system() + if system == "Darwin": + from eufy_sync.platform_support import macos as impl + else: + # Windows falls back to generic for now; Task 3 flips it to windows. + from eufy_sync.platform_support import generic as impl + return impl + + +def _impl(): + """Return the active implementation module, selecting it once and caching + the choice. Tests override the selection by patching this function.""" + global _active + if _active is None: + _active = _select() + return _active + + +def notify(title: str, message: str, command: str | None = None) -> None: + _impl().notify(title, message, command) + + +def install_agent() -> None: + _impl().install_agent() + + +def uninstall_agent() -> None: + _impl().uninstall_agent() + + +def offer_agent() -> None: + _impl().offer_agent() + + +def agent_status() -> dict | None: + return _impl().agent_status() + + +def agent_installed() -> bool: + return _impl().agent_installed() + + +def purge_agent() -> None: + _impl().purge_agent() diff --git a/eufy_sync/platform_support/generic.py b/eufy_sync/platform_support/generic.py new file mode 100644 index 0000000..4710dde --- /dev/null +++ b/eufy_sync/platform_support/generic.py @@ -0,0 +1,40 @@ +"""Generic no-op layer for platforms without a managed scheduler. + +Used on Windows until Task 3, and on Linux and anything else. Notifications +are silently dropped; the scheduled-sync agent is not managed here, so +--doctor skips the agent check and --uninstall skips the agent removal. +""" +from __future__ import annotations + +_UNMANAGED = ( + "Auto-sync is not managed on this platform. See the Headless Linux " + "section of the README for a systemd timer recipe." +) + + +def notify(title: str, message: str, command: str | None = None) -> None: + pass + + +def install_agent() -> None: + print(_UNMANAGED) + + +def uninstall_agent() -> None: + print(_UNMANAGED) + + +def offer_agent() -> None: + pass + + +def agent_status() -> dict | None: + return None + + +def agent_installed() -> bool: + return False + + +def purge_agent() -> None: + pass diff --git a/eufy_sync/platform_support/macos.py b/eufy_sync/platform_support/macos.py new file mode 100644 index 0000000..e8e2450 --- /dev/null +++ b/eufy_sync/platform_support/macos.py @@ -0,0 +1,268 @@ +"""macOS implementation: osascript/terminal-notifier notifications and the +Launch Agent that runs the scheduled sync. + +This is a move of the code that lived in cli/shared.py, cli/maintenance.py, +and cli/doctor.py; the behavior is unchanged. The public entry points +(notify, install_agent, uninstall_agent, offer_agent, agent_status, +agent_installed, purge_agent) are what the platform_support package dispatches +to; the leading-underscore helpers are the original functions, moved intact. +""" +from __future__ import annotations + +import json +import os +import platform +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +from eufy_sync.cli import shared + +LAUNCH_AGENT_LABEL = "com.sturimcode.eufy-garmin-sync" +LAUNCH_AGENT_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LAUNCH_AGENT_LABEL}.plist" + +# The Launch Agent points at this wrapper script, not the pipx/uv binary, +# so the registered executable's bytes stay stable across updates (macOS +# only re-announces "can run in the background" when they change). macOS +# labels the background-item notification by this filename, so it is named +# recognizably rather than something opaque. LEGACY_LAUNCH_WRAPPER_NAME is +# the pre-1.7.17 name, cleaned up on the next --install-agent. +LAUNCH_WRAPPER_NAME = "eufy-sync-agent" +LEGACY_LAUNCH_WRAPPER_NAME = "run-sync.sh" + + +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( + ["osascript", "-e", f'display notification {safe_msg} with title {safe_title}'], + capture_output=True, + timeout=5, + ) + except Exception: + pass + + +def _write_run_script(binary_path: str) -> Path: + """Write the stable wrapper script the Launch Agent runs. + + macOS re-announces "can run in the background" whenever a registered + background item's executable changes identity, and pipx/uv replace the + binary on every update. The agent therefore points at this script, whose + bytes never change across updates, so the announcement fires once, not + once per release. Skipping the rewrite when content is unchanged is what + keeps the file's identity stable. The filename is what macOS shows in that + announcement, so it is a recognizable "eufy-sync-agent", not an opaque one. + """ + script_path = shared.DATA_DIR / LAUNCH_WRAPPER_NAME + content = f'#!/bin/sh\nexec "{binary_path}" --headless\n' + if script_path.exists() and script_path.read_text() == content: + return script_path + script_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + script_path.write_text(content) + script_path.chmod(0o755) + return script_path + + +def _generate_plist(program_path: str) -> str: + """Generate a Launch Agent plist that runs the given program every 4 hours.""" + log_path = str(shared.LOG_FILE) + return f""" + + + + Label + {LAUNCH_AGENT_LABEL} + + ProgramArguments + + {program_path} + + + StartInterval + 14400 + + RunAtLoad + + + StandardOutPath + {log_path} + StandardErrorPath + {log_path} + + +""" + + +def _install_launch_agent() -> None: + """Install the macOS Launch Agent for automatic sync.""" + if platform.system() != "Darwin": + print("Auto-sync is only supported on macOS.") + return + + binary = shutil.which("eufy-sync") + if not binary: + print("Warning: could not find eufy-sync on PATH. Skipping auto-sync setup.") + return + + already_installed = LAUNCH_AGENT_PATH.exists() + + # Ensure the log directory exists with restricted permissions + shared.DATA_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) + + wrapper = _write_run_script(binary) + + # Drop the pre-1.7.17 wrapper name so it does not linger as an orphan next + # to the new one. + legacy_wrapper = shared.DATA_DIR / LEGACY_LAUNCH_WRAPPER_NAME + if legacy_wrapper.exists(): + legacy_wrapper.unlink() + + LAUNCH_AGENT_PATH.parent.mkdir(parents=True, exist_ok=True) + LAUNCH_AGENT_PATH.write_text(_generate_plist(str(wrapper))) + + # Unload first in case an old version is loaded + subprocess.run( + ["launchctl", "unload", str(LAUNCH_AGENT_PATH)], + capture_output=True, + ) + subprocess.run( + ["launchctl", "load", str(LAUNCH_AGENT_PATH)], + capture_output=True, + ) + + if already_installed: + print(f"Launch Agent already installed (reloaded). Logs: {shared.LOG_FILE}") + else: + print(f"Automatic sync installed. Logs: {shared.LOG_FILE}") + + +def _offer_launch_agent() -> None: + """Offer to install a macOS Launch Agent after first-run setup.""" + if platform.system() != "Darwin": + return + if not sys.stdin.isatty(): + return + + print("") + answer = input("Set up automatic sync every 4 hours? [y/N] ").strip() + if not answer.lower().startswith("y"): + return + + _install_launch_agent() + + +def _uninstall_launch_agent() -> None: + """Remove the macOS Launch Agent.""" + if not LAUNCH_AGENT_PATH.exists(): + print("No Launch Agent installed.") + return + + subprocess.run( + ["launchctl", "unload", str(LAUNCH_AGENT_PATH)], + capture_output=True, + ) + LAUNCH_AGENT_PATH.unlink() + print("Launch Agent removed. Auto-sync disabled.") + + +def agent_status() -> dict | None: + """Report the Launch Agent's health for --doctor. + + Reshaped from the old doctor._check_launch_agent: it returns the same + status/detail/fix strings as a dict instead of calling report(). + """ + try: + if not LAUNCH_AGENT_PATH.exists(): + return {"status": "WARN", "label": "launch agent", "detail": "not installed", "fix": "eufy-sync --install-agent"} + + content = LAUNCH_AGENT_PATH.read_text() + wrapper_name = LAUNCH_WRAPPER_NAME + if wrapper_name not in content: + return { + "status": "WARN", + "label": "launch agent", + "detail": "outdated registration (re-announces on every update)", + "fix": "eufy-sync --install-agent", + } + + result = subprocess.run( + ["launchctl", "list"], + capture_output=True, + text=True, + timeout=5, + ) + if LAUNCH_AGENT_LABEL not in (result.stdout or ""): + return {"status": "WARN", "label": "launch agent", "detail": "installed but not loaded", "fix": "eufy-sync --install-agent"} + + return {"status": "PASS", "label": "launch agent", "detail": "loaded, runs every 4h", "fix": None} + except Exception as e: + return {"status": "WARN", "label": "launch agent", "detail": f"could not check ({e})", "fix": "eufy-sync --install-agent"} + + +def agent_installed() -> bool: + """Whether the Launch Agent plist is present on disk.""" + return LAUNCH_AGENT_PATH.exists() + + +def purge_agent() -> None: + """Unload and delete the Launch Agent without printing. Used by the full + --uninstall, which reports its own summary.""" + if LAUNCH_AGENT_PATH.exists(): + subprocess.run(["launchctl", "unload", str(LAUNCH_AGENT_PATH)], capture_output=True) + LAUNCH_AGENT_PATH.unlink() + + +def notify(title: str, message: str, command: str | None = None) -> None: + _notify(title, message, command) + + +def install_agent() -> None: + _install_launch_agent() + + +def uninstall_agent() -> None: + _uninstall_launch_agent() + + +def offer_agent() -> None: + _offer_launch_agent() diff --git a/tests/conftest.py b/tests/conftest.py index b03fedc..f28ed01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,7 @@ def _hermetic_machine(tmp_path, monkeypatch): monkeypatch.setattr("eufy_sync.cli.shared.DEFAULT_DB", data_dir / "state.db") monkeypatch.setattr("eufy_sync.cli.shared.LOG_FILE", data_dir / "sync.log") monkeypatch.setattr( - "eufy_sync.cli.shared.LAUNCH_AGENT_PATH", + "eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH", tmp_path / "LaunchAgents" / "com.sturimcode.eufy-garmin-sync.plist", ) # setup.py captures shared.DATA_DIR at import time into this module-level @@ -57,11 +57,11 @@ def delete_password(service, account): @pytest.fixture(autouse=True) def _mute_notifications(monkeypatch): - """Stub the macOS notifier for every test. + """Stub the notifier for every test. - _notify shells out to osascript, so an unmocked call from any code path - fires a real notification on the machine running the suite. Tests that - assert on notifications patch eufy_sync.cli.shared._notify themselves; that - patch layers over this stub and restores it on exit. + notify shells out to osascript on macOS, so an unmocked call from any code + path fires a real notification on the machine running the suite. Tests that + assert on notifications patch eufy_sync.platform_support.notify themselves; + that patch layers over this stub and restores it on exit. """ - monkeypatch.setattr("eufy_sync.cli.shared._notify", MagicMock()) + monkeypatch.setattr("eufy_sync.platform_support.notify", MagicMock()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1b6ba1e..1a6542c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,9 +7,9 @@ import pytest import yaml -from eufy_sync.cli.shared import _write_config, LAUNCH_AGENT_LABEL, LAUNCH_AGENT_PATH +from eufy_sync.cli.shared import _write_config +from eufy_sync.platform_support.macos import LAUNCH_AGENT_LABEL, LAUNCH_AGENT_PATH, _generate_plist from eufy_sync.cli.maintenance import ( - _generate_plist, _install_launch_agent, _uninstall, _uninstall_launch_agent, @@ -68,7 +68,7 @@ def test_generate_plist_points_at_given_program(): def test_write_run_script_creates_executable_wrapper(tmp_path): - from eufy_sync.cli.maintenance import _write_run_script + from eufy_sync.platform_support.macos import _write_run_script with patch("eufy_sync.cli.shared.DATA_DIR", tmp_path): script = _write_run_script("/home/user/.local/bin/eufy-sync") assert script == tmp_path / "eufy-sync-agent" @@ -82,7 +82,7 @@ def test_write_run_script_is_byte_stable_across_installs(tmp_path): # macOS re-announces background items when the registered file changes; # a second install with the same binary must not rewrite the script. import os - from eufy_sync.cli.maintenance import _write_run_script + from eufy_sync.platform_support.macos import _write_run_script with patch("eufy_sync.cli.shared.DATA_DIR", tmp_path): first = _write_run_script("/home/user/.local/bin/eufy-sync") mtime_before = os.stat(first).st_mtime_ns @@ -97,10 +97,10 @@ def test_generate_plist_contains_log_path(): assert ".garmin-sync/sync.log" in plist -@patch("eufy_sync.cli.maintenance.subprocess.run") -@patch("eufy_sync.cli.maintenance.shutil.which", return_value="/home/user/.local/bin/eufy-sync") -@patch("eufy_sync.cli.maintenance.platform.system", return_value="Darwin") -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") +@patch("eufy_sync.platform_support.macos.shutil.which", return_value="/home/user/.local/bin/eufy-sync") +@patch("eufy_sync.platform_support.macos.platform.system", return_value="Darwin") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") def test_install_launch_agent_writes_plist_and_loads(mock_path, mock_system, mock_which, mock_run, tmp_path): mock_path.parent.mkdir = MagicMock() mock_path.write_text = MagicMock() @@ -124,10 +124,10 @@ def test_install_launch_agent_writes_plist_and_loads(mock_path, mock_system, moc assert "load" in mock_run.call_args_list[1][0][0] -@patch("eufy_sync.cli.maintenance.subprocess.run") -@patch("eufy_sync.cli.maintenance.shutil.which", return_value="/home/user/.local/bin/eufy-sync") -@patch("eufy_sync.cli.maintenance.platform.system", return_value="Darwin") -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") +@patch("eufy_sync.platform_support.macos.shutil.which", return_value="/home/user/.local/bin/eufy-sync") +@patch("eufy_sync.platform_support.macos.platform.system", return_value="Darwin") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") def test_install_launch_agent_removes_legacy_wrapper(mock_path, mock_system, mock_which, mock_run, tmp_path): """Re-installing must delete the pre-1.7.17 run-sync.sh wrapper so it does not linger as an orphan next to the new eufy-sync-agent script.""" @@ -145,21 +145,21 @@ def test_install_launch_agent_removes_legacy_wrapper(mock_path, mock_system, moc assert (tmp_path / "eufy-sync-agent").exists() -@patch("eufy_sync.cli.maintenance.platform.system", return_value="Linux") +@patch("eufy_sync.platform_support.macos.platform.system", return_value="Linux") def test_install_launch_agent_skips_on_linux(mock_system, capsys): _install_launch_agent() assert "only supported on macOS" in capsys.readouterr().out -@patch("eufy_sync.cli.maintenance.shutil.which", return_value=None) -@patch("eufy_sync.cli.maintenance.platform.system", return_value="Darwin") +@patch("eufy_sync.platform_support.macos.shutil.which", return_value=None) +@patch("eufy_sync.platform_support.macos.platform.system", return_value="Darwin") def test_install_launch_agent_warns_if_binary_not_found(mock_system, mock_which, capsys): _install_launch_agent() assert "could not find eufy-sync" in capsys.readouterr().out -@patch("eufy_sync.cli.maintenance.subprocess.run") -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") def test_uninstall_launch_agent_removes_plist(mock_path, mock_run): mock_path.exists.return_value = True mock_path.unlink = MagicMock() @@ -171,7 +171,7 @@ def test_uninstall_launch_agent_removes_plist(mock_path, mock_run): mock_path.unlink.assert_called_once() -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") def test_uninstall_launch_agent_noop_if_not_installed(mock_path, capsys): mock_path.exists.return_value = False @@ -180,10 +180,10 @@ def test_uninstall_launch_agent_noop_if_not_installed(mock_path, capsys): assert "No Launch Agent installed" in capsys.readouterr().out -@patch("eufy_sync.cli.maintenance._install_launch_agent") +@patch("eufy_sync.platform_support.macos._install_launch_agent") @patch("builtins.input", return_value="y") -@patch("eufy_sync.cli.maintenance.sys.stdin") -@patch("eufy_sync.cli.maintenance.platform.system", return_value="Darwin") +@patch("eufy_sync.platform_support.macos.sys.stdin") +@patch("eufy_sync.platform_support.macos.platform.system", return_value="Darwin") def test_offer_launch_agent_installs_on_yes(mock_system, mock_stdin, mock_input, mock_install): mock_stdin.isatty.return_value = True @@ -192,10 +192,10 @@ def test_offer_launch_agent_installs_on_yes(mock_system, mock_stdin, mock_input, mock_install.assert_called_once() -@patch("eufy_sync.cli.maintenance._install_launch_agent") +@patch("eufy_sync.platform_support.macos._install_launch_agent") @patch("builtins.input", return_value="n") -@patch("eufy_sync.cli.maintenance.sys.stdin") -@patch("eufy_sync.cli.maintenance.platform.system", return_value="Darwin") +@patch("eufy_sync.platform_support.macos.sys.stdin") +@patch("eufy_sync.platform_support.macos.platform.system", return_value="Darwin") def test_offer_launch_agent_skips_on_no(mock_system, mock_stdin, mock_input, mock_install): mock_stdin.isatty.return_value = True @@ -204,9 +204,9 @@ def test_offer_launch_agent_skips_on_no(mock_system, mock_stdin, mock_input, moc mock_install.assert_not_called() -@patch("eufy_sync.cli.maintenance._install_launch_agent") -@patch("eufy_sync.cli.maintenance.sys.stdin") -@patch("eufy_sync.cli.maintenance.platform.system", return_value="Darwin") +@patch("eufy_sync.platform_support.macos._install_launch_agent") +@patch("eufy_sync.platform_support.macos.sys.stdin") +@patch("eufy_sync.platform_support.macos.platform.system", return_value="Darwin") def test_offer_launch_agent_skips_non_interactive(mock_system, mock_stdin, mock_install): mock_stdin.isatty.return_value = False @@ -218,8 +218,8 @@ def test_offer_launch_agent_skips_non_interactive(mock_system, mock_stdin, mock_ # --- _uninstall keychain cleanup --- -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") -@patch("eufy_sync.cli.maintenance.subprocess.run") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") @patch("eufy_sync.credentials._keyring_available", return_value=True) @patch("eufy_sync.credentials.delete_token") @patch("eufy_sync.credentials.delete_password") @@ -253,8 +253,8 @@ def test_uninstall_clears_keychain_for_configured_user_name( assert "elias:garmin" in deleted_accounts -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") -@patch("eufy_sync.cli.maintenance.subprocess.run") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") @patch("eufy_sync.credentials._keyring_available", return_value=True) @patch("eufy_sync.credentials.delete_password", side_effect=RuntimeError("keychain locked")) @patch("eufy_sync.cli.maintenance.sys.stdin") @@ -283,8 +283,8 @@ def test_uninstall_survives_locked_keychain( assert "keychain" in out.lower() -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") -@patch("eufy_sync.cli.maintenance.subprocess.run") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") @patch("eufy_sync.credentials._keyring_available", return_value=True) @patch("eufy_sync.credentials.delete_token") @patch("eufy_sync.credentials.delete_password") @@ -313,8 +313,8 @@ def test_uninstall_names_uv_for_uv_installs( assert "pipx uninstall" not in out -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") -@patch("eufy_sync.cli.maintenance.subprocess.run") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") @patch("eufy_sync.credentials._keyring_available", return_value=True) @patch("eufy_sync.credentials.delete_token") @patch("eufy_sync.credentials.delete_password") @@ -341,8 +341,8 @@ def test_uninstall_names_pipx_when_not_uv( assert "pipx uninstall eufy-sync" in out -@patch("eufy_sync.cli.shared.LAUNCH_AGENT_PATH") -@patch("eufy_sync.cli.maintenance.subprocess.run") +@patch("eufy_sync.platform_support.macos.LAUNCH_AGENT_PATH") +@patch("eufy_sync.platform_support.macos.subprocess.run") @patch("eufy_sync.credentials._keyring_available", return_value=True) @patch("eufy_sync.credentials.delete_token") @patch("eufy_sync.credentials.delete_password") @@ -706,7 +706,7 @@ def test_upgrade_notice_file_is_hermetic(tmp_path): assert str(setup.UPGRADE_NOTICE_FILE).startswith(str(tmp_path)) -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") def test_headless_first_run_refuses_wizard(mock_notify, tmp_path): """A headless run with no config must never call input() - it should print guidance, notify, and exit 1 instead of hanging in the wizard.""" @@ -729,7 +729,7 @@ def boom_input(*a, **k): mock_notify.assert_called() -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") def test_startup_failure_before_harness_notifies_and_exits(mock_notify, tmp_path): """A load_config failure (e.g. missing keychain entry -> ValueError) that happens before the sync try/except harness must still notify and exit 1, @@ -752,7 +752,7 @@ def test_startup_failure_before_harness_notifies_and_exits(mock_notify, tmp_path mock_notify.assert_called() -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") @patch("eufy_sync.cli.updater._check_for_updates") @patch("eufy_sync.cli.setup._show_upgrade_notice") @patch("eufy_sync.cli.setup._migrate_config_passwords") @@ -788,7 +788,7 @@ def fake_sync_user(user, state, **kwargs): @patch("eufy_sync.cli.status._print_summary") -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") @patch("eufy_sync.cli.updater._check_for_updates") @patch("eufy_sync.cli.setup._show_upgrade_notice") @patch("eufy_sync.cli.setup._migrate_config_passwords") @@ -827,7 +827,7 @@ def run_once(): @patch("eufy_sync.cli.status._print_summary") -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") @patch("eufy_sync.cli.updater._check_for_updates") @patch("eufy_sync.cli.setup._show_upgrade_notice") @patch("eufy_sync.cli.setup._migrate_config_passwords") @@ -861,7 +861,7 @@ def run_with(side): @patch("eufy_sync.cli.status._print_summary") -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") @patch("eufy_sync.cli.updater._check_for_updates") @patch("eufy_sync.cli.setup._show_upgrade_notice") @patch("eufy_sync.cli.setup._migrate_config_passwords") @@ -892,7 +892,7 @@ def fake_sync_user(user, state, **kwargs): @patch("eufy_sync.cli.status._print_summary") -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") @patch("eufy_sync.cli.updater._check_for_updates") @patch("eufy_sync.cli.setup._show_upgrade_notice") @patch("eufy_sync.cli.setup._migrate_config_passwords") @@ -933,7 +933,7 @@ def fake_sync_user(user, state, **kwargs): @patch("eufy_sync.cli.status._print_summary") -@patch("eufy_sync.cli.shared._notify") +@patch("eufy_sync.platform_support.notify") @patch("eufy_sync.cli.updater._check_for_updates") @patch("eufy_sync.cli.setup._show_upgrade_notice") @patch("eufy_sync.cli.setup._migrate_config_passwords") diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 3fe22e7..644f0d8 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -14,8 +14,10 @@ import pytest import yaml +from eufy_sync import platform_support from eufy_sync.cli import doctor from eufy_sync.config import AppConfig, EufyConfig, GarminConfig, StravaConfig, UserConfig +from eufy_sync.platform_support import generic, macos def _write_config(path: Path, *, garmin: bool = True, strava: bool = True, customer_id: str | None = "abc1234567890867f") -> None: @@ -73,19 +75,19 @@ def _patch_all_pass(tmp_path, monkeypatch): strava_client.token_status.return_value = {"state": "valid", "days_remaining": None, "hours_remaining": 5} monkeypatch.setattr(doctor, "StravaClient", MagicMock(return_value=strava_client)) - monkeypatch.setattr(doctor.platform, "system", MagicMock(return_value="Darwin")) + monkeypatch.setattr(platform_support, "_active", macos) wrapper = tmp_path / "eufy-sync-agent" wrapper.write_text("#!/bin/sh\n") - monkeypatch.setattr(doctor.shared, "LAUNCH_AGENT_PATH", tmp_path / "agent.plist") + monkeypatch.setattr(macos, "LAUNCH_AGENT_PATH", tmp_path / "agent.plist") plist = f""" ProgramArguments {wrapper} """ - doctor.shared.LAUNCH_AGENT_PATH.write_text(plist) + macos.LAUNCH_AGENT_PATH.write_text(plist) monkeypatch.setattr( - doctor.subprocess, "run", - MagicMock(return_value=MagicMock(stdout=doctor.shared.LAUNCH_AGENT_LABEL, returncode=0)), + macos.subprocess, "run", + MagicMock(return_value=MagicMock(stdout=macos.LAUNCH_AGENT_LABEL, returncode=0)), ) state = MagicMock() @@ -196,7 +198,7 @@ def test_launch_agent_pointing_at_raw_binary_warns_install_agent(tmp_path, monke ProgramArguments /Users/x/.local/bin/eufy-sync """ - doctor.shared.LAUNCH_AGENT_PATH.write_text(plist) + macos.LAUNCH_AGENT_PATH.write_text(plist) code = doctor._run_doctor(config_path, db_path) @@ -221,7 +223,7 @@ def test_launch_agent_pointing_at_legacy_wrapper_warns_install_agent(tmp_path, m ProgramArguments {legacy} """ - doctor.shared.LAUNCH_AGENT_PATH.write_text(plist) + macos.LAUNCH_AGENT_PATH.write_text(plist) code = doctor._run_doctor(config_path, db_path) @@ -395,7 +397,7 @@ def test_keychain_fallback_never_fails(tmp_path, monkeypatch, capsys): def test_launch_agent_not_installed_warns(tmp_path, monkeypatch, capsys): config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) - doctor.shared.LAUNCH_AGENT_PATH.unlink() + macos.LAUNCH_AGENT_PATH.unlink() code = doctor._run_doctor(config_path, db_path) @@ -409,7 +411,7 @@ def test_launch_agent_not_installed_warns(tmp_path, monkeypatch, capsys): def test_launch_agent_not_loaded_warns(tmp_path, monkeypatch, capsys): config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) - doctor.subprocess.run = MagicMock(return_value=MagicMock(stdout="", returncode=0)) + macos.subprocess.run = MagicMock(return_value=MagicMock(stdout="", returncode=0)) code = doctor._run_doctor(config_path, db_path) @@ -423,7 +425,7 @@ def test_launch_agent_not_loaded_warns(tmp_path, monkeypatch, capsys): def test_launch_agent_skipped_on_non_macos(tmp_path, monkeypatch, capsys): config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) - doctor.platform.system = MagicMock(return_value="Linux") + monkeypatch.setattr(platform_support, "_active", generic) code = doctor._run_doctor(config_path, db_path) diff --git a/tests/test_notify.py b/tests/test_notify.py index 54b26b5..e6fe89c 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -5,19 +5,20 @@ from unittest.mock import MagicMock, patch -from eufy_sync.cli import shared +from eufy_sync.platform_support import macos -# 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 +# conftest's autouse _mute_notifications fixture replaces platform_support.notify +# with a MagicMock so tests never fire real notifications. These tests exercise +# the real macOS function, captured here at import time, with subprocess.run +# mocked out. +_real_notify = macos._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): + with patch("eufy_sync.platform_support.macos.shutil.which", return_value=which_return), \ + patch("eufy_sync.platform_support.macos.os.path.exists", return_value=False), \ + patch("eufy_sync.platform_support.macos.subprocess.run", run): _real_notify("eufy-sync: re-login needed", "Run: eufy-sync --reauth garmin", command=command) return run @@ -52,10 +53,10 @@ 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", + with patch("eufy_sync.platform_support.macos.shutil.which", return_value=None), \ + patch("eufy_sync.platform_support.macos.os.path.exists", side_effect=lambda p: p == "/opt/homebrew/bin/terminal-notifier"), \ - patch("eufy_sync.cli.shared.subprocess.run", run): + patch("eufy_sync.platform_support.macos.subprocess.run", run): _real_notify("t", "m", command="eufy-sync --update") argv = run.call_args.args[0] @@ -63,5 +64,5 @@ def test_homebrew_path_is_checked_when_which_misses(): def test_notify_still_fails_silently(): - with patch("eufy_sync.cli.shared.shutil.which", side_effect=RuntimeError("boom")): + with patch("eufy_sync.platform_support.macos.shutil.which", side_effect=RuntimeError("boom")): _real_notify("t", "m", command="eufy-sync --update") # must not raise diff --git a/tests/test_platform_support.py b/tests/test_platform_support.py new file mode 100644 index 0000000..d39f260 --- /dev/null +++ b/tests/test_platform_support.py @@ -0,0 +1,43 @@ +"""The platform_support package: implementation selection and the generic +no-op layer, plus a spot-check that the macOS agent status is a faithful move +of the old doctor launch-agent check. + +The full macOS behavior (notifications, plist bytes, launchctl invocations) is +covered by test_notify.py and test_cli.py against the macos module directly. +""" +from __future__ import annotations + +from unittest.mock import patch + +from eufy_sync import platform_support +from eufy_sync.platform_support import generic, macos + + +def test_select_picks_macos_on_darwin(): + with patch("eufy_sync.platform_support.platform.system", return_value="Darwin"): + assert platform_support._select() is macos + + +def test_select_picks_generic_on_linux(): + with patch("eufy_sync.platform_support.platform.system", return_value="Linux"): + assert platform_support._select() is generic + + +def test_generic_notify_swallows_everything_and_runs_no_subprocess(): + with patch("subprocess.run") as run: + assert generic.notify("t", "m", command="eufy-sync --update") is None + run.assert_not_called() + + +def test_generic_agent_status_is_none(): + assert generic.agent_status() is None + + +def test_macos_agent_status_warns_when_not_installed(tmp_path): + with patch.object(macos, "LAUNCH_AGENT_PATH", tmp_path / "missing.plist"): + status = macos.agent_status() + + assert status["status"] == "WARN" + assert status["label"] == "launch agent" + assert status["detail"] == "not installed" + assert status["fix"] == "eufy-sync --install-agent" From 7480074e1ebf96a68d0404e4891f299839d8a4c3 Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 10 Jul 2026 17:00:16 -0400 Subject: [PATCH 03/14] Describe the Windows fallback by state, not by roadmap Comments naming future work go stale the moment it lands; say what the selection does today instead. Co-Authored-By: Claude Fable 5 --- eufy_sync/platform_support/__init__.py | 7 +++---- eufy_sync/platform_support/generic.py | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/eufy_sync/platform_support/__init__.py b/eufy_sync/platform_support/__init__.py index 5b3ff57..a1908e1 100644 --- a/eufy_sync/platform_support/__init__.py +++ b/eufy_sync/platform_support/__init__.py @@ -3,9 +3,8 @@ Notifications and the scheduled-sync agent (install/uninstall/status/offer) differ per platform. The active implementation is chosen once from platform.system(): Darwin uses the macOS Launch Agent and osascript -notifications; every other platform (including Windows for now) uses a -no-op generic layer. Task 3 adds a Windows implementation and points the -selection at it. +notifications; every other platform uses a no-op generic layer. A native +Windows implementation plugs into the selection below. """ from __future__ import annotations @@ -20,7 +19,7 @@ def _select(): if system == "Darwin": from eufy_sync.platform_support import macos as impl else: - # Windows falls back to generic for now; Task 3 flips it to windows. + # Windows falls back to generic until its native implementation lands. from eufy_sync.platform_support import generic as impl return impl diff --git a/eufy_sync/platform_support/generic.py b/eufy_sync/platform_support/generic.py index 4710dde..a0182e5 100644 --- a/eufy_sync/platform_support/generic.py +++ b/eufy_sync/platform_support/generic.py @@ -1,8 +1,8 @@ """Generic no-op layer for platforms without a managed scheduler. -Used on Windows until Task 3, and on Linux and anything else. Notifications -are silently dropped; the scheduled-sync agent is not managed here, so ---doctor skips the agent check and --uninstall skips the agent removal. +Covers every platform without a native implementation. Notifications are +silently dropped; the scheduled-sync agent is not managed here, so --doctor +skips the agent check and --uninstall skips the agent removal. """ from __future__ import annotations From 781ffe37c8d4b784a3f1f0e147efaf1fe41217fe Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 10 Jul 2026 17:06:15 -0400 Subject: [PATCH 04/14] Windows auto-sync via a per-user scheduled task Register a Task Scheduler entry that runs the sync every four hours through a small VBScript wrapper. schtasks would flash a console window on each run; launching the sync from WScript.Shell.Run with a window style of 0 keeps it invisible, and routing through the wrapper points the task at a filename whose bytes stay stable across pipx/uv upgrades that swap the underlying binary. The wrapper is written and compared as raw bytes so its CRLF line endings survive the round-trip on every platform, which keeps the stable-bytes rule honest instead of rewriting the file on every run. The uninstall summary line loses its macOS-specific "Launch Agent" wording now that the agent is one of several per-platform schedulers. Co-Authored-By: Claude Fable 5 --- eufy_sync/cli/maintenance.py | 2 +- eufy_sync/platform_support/__init__.py | 7 +- eufy_sync/platform_support/windows.py | 186 ++++++++++++++++++++ tests/test_platform_windows.py | 230 +++++++++++++++++++++++++ 4 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 eufy_sync/platform_support/windows.py create mode 100644 tests/test_platform_windows.py diff --git a/eufy_sync/cli/maintenance.py b/eufy_sync/cli/maintenance.py index 504553c..b257188 100644 --- a/eufy_sync/cli/maintenance.py +++ b/eufy_sync/cli/maintenance.py @@ -166,7 +166,7 @@ def _uninstall(data_dir: Path, config_path: Path | None = None, db_path: Path | print(f" - Keychain entries for eufy-sync") print(f" - Sync history database") if platform_support.agent_installed(): - print(f" - Automatic sync Launch Agent") + print(f" - Automatic sync") print("") answer = input("Are you sure? [y/N] ").strip() diff --git a/eufy_sync/platform_support/__init__.py b/eufy_sync/platform_support/__init__.py index a1908e1..b95ad29 100644 --- a/eufy_sync/platform_support/__init__.py +++ b/eufy_sync/platform_support/__init__.py @@ -3,8 +3,8 @@ Notifications and the scheduled-sync agent (install/uninstall/status/offer) differ per platform. The active implementation is chosen once from platform.system(): Darwin uses the macOS Launch Agent and osascript -notifications; every other platform uses a no-op generic layer. A native -Windows implementation plugs into the selection below. +notifications; Windows uses a Task Scheduler entry; every other platform uses +a no-op generic layer. """ from __future__ import annotations @@ -18,8 +18,9 @@ def _select(): system = platform.system() if system == "Darwin": from eufy_sync.platform_support import macos as impl + elif system == "Windows": + from eufy_sync.platform_support import windows as impl else: - # Windows falls back to generic until its native implementation lands. from eufy_sync.platform_support import generic as impl return impl diff --git a/eufy_sync/platform_support/windows.py b/eufy_sync/platform_support/windows.py new file mode 100644 index 0000000..a4107b1 --- /dev/null +++ b/eufy_sync/platform_support/windows.py @@ -0,0 +1,186 @@ +"""Windows implementation: a per-user scheduled task that runs the sync in a +hidden window, plus the --doctor status check. + +Task Scheduler runs the sync through a small VBScript wrapper rather than the +pipx/uv binary directly. schtasks would flash a console window every four hours; +launching the sync from VBScript with WScript.Shell.Run and a window style of 0 +keeps it invisible. The wrapper also points the task at a filename whose bytes +stay stable across updates, so a pipx/uv upgrade that swaps the underlying +binary does not require re-registering the task. + +The public entry points (notify, install_agent, uninstall_agent, offer_agent, +agent_status, agent_installed, purge_agent) are what the platform_support +package dispatches to. notify is a no-op stub here; native toast support lands +separately. +""" +from __future__ import annotations + +import platform +import shutil +import subprocess +import sys + +from eufy_sync.cli import shared + +TASK_NAME = "eufy-sync" +WRAPPER_NAME = "eufy-sync-agent.vbs" + + +def _wrapper_content(binary_path: str) -> str: + """Build the VBScript that runs the sync with no visible console. + + WScript.Shell.Run takes one command string and a window style; style 0 + hides the window. The command is a `cmd /c` line so its stdout and stderr + can be appended to the log with `>>`. VBScript has no backslash escaping: + a literal double quote inside a string is written as two double quotes. + The inner command already contains quotes (around the binary path and the + log path), so every one of them is doubled before it is embedded in the + Run string literal. + """ + log = str(shared.LOG_FILE) + inner = f'cmd /c ""{binary_path}" --headless >> "{log}" 2>&1"' + escaped = inner.replace('"', '""') + return ( + 'Set shell = CreateObject("WScript.Shell")\r\n' + f'shell.Run "{escaped}", 0, False\r\n' + ) + + +def _write_wrapper(binary_path: str): + """Write the hidden-window wrapper, but only when its content would change. + + The task registration points at this file, so leaving the bytes untouched + across updates avoids needless rewrites (the pipx/uv shim path is stable, so + the content normally never changes anyway). + """ + wrapper_path = shared.DATA_DIR / WRAPPER_NAME + # Write and compare as raw bytes: text mode would translate the CRLF line + # endings (dropping the carriage returns on read, and doubling them on write + # under Windows), so the round-trip would never match and the file would be + # rewritten on every run. + content = _wrapper_content(binary_path).encode("utf-8") + if wrapper_path.exists() and wrapper_path.read_bytes() == content: + return wrapper_path + wrapper_path.parent.mkdir(parents=True, exist_ok=True) + wrapper_path.write_bytes(content) + return wrapper_path + + +def _install_scheduled_task() -> None: + """Register the per-user scheduled task for automatic sync.""" + binary = shutil.which("eufy-sync") + if not binary: + print("Warning: could not find eufy-sync on PATH. Skipping auto-sync setup.") + return + + shared.DATA_DIR.mkdir(parents=True, exist_ok=True) + wrapper_path = _write_wrapper(binary) + + result = subprocess.run( + ["schtasks", "/Create", "/F", "/TN", TASK_NAME, "/SC", "HOURLY", "/MO", "4", + "/TR", f'wscript.exe "{wrapper_path}"'], + capture_output=True, text=True, timeout=15, + ) + + if result.returncode == 0: + print(f"Automatic sync installed (every 4 hours). Logs: {shared.LOG_FILE}") + else: + print(result.stderr.strip() or "Could not register the scheduled task.") + print( + "Register it manually with: " + f'schtasks /Create /F /TN {TASK_NAME} /SC HOURLY /MO 4 ' + f'/TR "wscript.exe {wrapper_path}"' + ) + + +def _uninstall_scheduled_task() -> None: + """Remove the scheduled task and its wrapper.""" + result = subprocess.run( + ["schtasks", "/Delete", "/F", "/TN", TASK_NAME], + capture_output=True, text=True, timeout=15, + ) + + # schtasks returns nonzero (a "cannot find" error) when nothing is + # registered; treat that as "nothing to remove" rather than a failure. + if result.returncode != 0: + print("No scheduled task installed.") + else: + print("Scheduled task removed. Auto-sync disabled.") + + wrapper_path = shared.DATA_DIR / WRAPPER_NAME + if wrapper_path.exists(): + wrapper_path.unlink() + + +def _offer_scheduled_task() -> None: + """Offer to install the scheduled task after first-run setup.""" + if platform.system() != "Windows": + return + if not sys.stdin.isatty(): + return + + print("") + answer = input("Set up automatic sync every 4 hours? [y/N] ").strip() + if not answer.lower().startswith("y"): + return + + _install_scheduled_task() + + +def agent_status() -> dict | None: + """Report the scheduled task's health for --doctor. + + Three states: the wrapper missing or the task query failing means it is not + installed; the wrapper present but out of date with the current binary means + an outdated registration; both good means it is installed and scheduled. + """ + try: + wrapper_path = shared.DATA_DIR / WRAPPER_NAME + if not wrapper_path.exists(): + return {"status": "WARN", "label": "scheduled task", "detail": "not installed", "fix": "eufy-sync --install-agent"} + + result = subprocess.run( + ["schtasks", "/Query", "/TN", TASK_NAME], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0: + return {"status": "WARN", "label": "scheduled task", "detail": "not installed", "fix": "eufy-sync --install-agent"} + + binary = shutil.which("eufy-sync") + if binary and wrapper_path.read_bytes() != _wrapper_content(binary).encode("utf-8"): + return {"status": "WARN", "label": "scheduled task", "detail": "outdated registration", "fix": "eufy-sync --install-agent"} + + return {"status": "PASS", "label": "scheduled task", "detail": "installed, runs every 4h", "fix": None} + except Exception as e: + return {"status": "WARN", "label": "scheduled task", "detail": f"could not check ({e})", "fix": "eufy-sync --install-agent"} + + +def agent_installed() -> bool: + """Whether the scheduled task's wrapper file is present on disk.""" + return (shared.DATA_DIR / WRAPPER_NAME).exists() + + +def purge_agent() -> None: + """Remove the scheduled task and wrapper without printing. Used by the full + --uninstall, which reports its own summary.""" + wrapper_path = shared.DATA_DIR / WRAPPER_NAME + if wrapper_path.exists(): + subprocess.run(["schtasks", "/Delete", "/F", "/TN", TASK_NAME], capture_output=True) + wrapper_path.unlink() + + +def notify(title: str, message: str, command: str | None = None) -> None: + # Toast notifications land separately; this is a no-op until then. + pass + + +def install_agent() -> None: + _install_scheduled_task() + + +def uninstall_agent() -> None: + _uninstall_scheduled_task() + + +def offer_agent() -> None: + _offer_scheduled_task() diff --git a/tests/test_platform_windows.py b/tests/test_platform_windows.py new file mode 100644 index 0000000..d4dbffb --- /dev/null +++ b/tests/test_platform_windows.py @@ -0,0 +1,230 @@ +"""The Windows implementation: the hidden-window VBScript wrapper, the +schtasks lifecycle, and the --doctor status check. + +Every subprocess and shutil.which call is patched, and shared.DATA_DIR / +LOG_FILE are pointed at a tmp_path, so the suite runs on macOS without +touching a real Task Scheduler. +""" +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from eufy_sync import platform_support +from eufy_sync.cli import shared +from eufy_sync.platform_support import windows + + +@pytest.fixture +def data_dir(tmp_path, monkeypatch): + """Point the shared paths at a throwaway directory for the test.""" + monkeypatch.setattr(shared, "DATA_DIR", tmp_path) + monkeypatch.setattr(shared, "LOG_FILE", tmp_path / "sync.log") + return tmp_path + + +def _ok(): + return SimpleNamespace(returncode=0, stdout="", stderr="") + + +def _write_current_wrapper(data_dir, binary): + """Write the wrapper exactly as the module would, preserving CRLF bytes.""" + (data_dir / windows.WRAPPER_NAME).write_bytes(windows._wrapper_content(binary).encode("utf-8")) + + +def test_select_picks_windows_on_windows(): + with patch("eufy_sync.platform_support.platform.system", return_value="Windows"): + assert platform_support._select() is windows + + +def test_install_invokes_schtasks_with_expected_arguments(data_dir): + binary = "C:\\Tools\\eufy-sync.exe" + with patch.object(windows.shutil, "which", return_value=binary), \ + patch.object(windows.subprocess, "run", return_value=_ok()) as run: + windows.install_agent() + + wrapper_path = data_dir / windows.WRAPPER_NAME + run.assert_called_once_with( + ["schtasks", "/Create", "/F", "/TN", "eufy-sync", "/SC", "HOURLY", "/MO", "4", + "/TR", f'wscript.exe "{wrapper_path}"'], + capture_output=True, text=True, timeout=15, + ) + + +def test_install_writes_wrapper_with_doubled_quote_escaping(data_dir): + binary = "C:\\Tools\\eufy-sync.exe" + with patch.object(windows.shutil, "which", return_value=binary), \ + patch.object(windows.subprocess, "run", return_value=_ok()): + windows.install_agent() + + # Read as raw bytes so the CRLF line endings are not translated away. + content = (data_dir / windows.WRAPPER_NAME).read_bytes().decode("utf-8") + + # Full fidelity against the module's own generator. + assert content == windows._wrapper_content(binary) + + # VBScript escapes a literal quote by doubling it. The original inner command + # opens with `cmd /c ""` and wraps the binary path in a quote, so after + # doubling every quote the leading marker becomes four quotes and the path is + # bracketed by four quotes on the left and two on the right. A wrong quote + # level here silently breaks the hidden-window run, so pin the exact bytes. + assert content.startswith('Set shell = CreateObject("WScript.Shell")\r\n') + assert 'shell.Run "cmd /c """"' in content + assert f'""""{binary}""' in content + assert content.endswith(', 0, False\r\n') + + +def test_unchanged_wrapper_is_not_rewritten(data_dir): + binary = "C:\\Tools\\eufy-sync.exe" + _write_current_wrapper(data_dir, binary) + + with patch.object(windows.shutil, "which", return_value=binary), \ + patch.object(windows.subprocess, "run", return_value=_ok()), \ + patch.object(Path, "write_bytes", autospec=True) as write_bytes: + windows.install_agent() + + # The wrapper already holds the current content, so its bytes stay put. + write_bytes.assert_not_called() + + +def test_install_without_binary_warns_and_skips(data_dir, capsys): + with patch.object(windows.shutil, "which", return_value=None), \ + patch.object(windows.subprocess, "run") as run: + windows.install_agent() + + run.assert_not_called() + assert "could not find eufy-sync on PATH" in capsys.readouterr().out + + +def test_install_failure_prints_stderr(data_dir, capsys): + with patch.object(windows.shutil, "which", return_value="C:\\Tools\\eufy-sync.exe"), \ + patch.object(windows.subprocess, "run", + return_value=SimpleNamespace(returncode=1, stdout="", stderr="Access is denied.")): + windows.install_agent() + + assert "Access is denied." in capsys.readouterr().out + + +def test_uninstall_deletes_task_and_removes_wrapper(data_dir): + wrapper_path = data_dir / windows.WRAPPER_NAME + wrapper_path.write_text("stale") + + with patch.object(windows.subprocess, "run", return_value=_ok()) as run: + windows.uninstall_agent() + + run.assert_called_once_with( + ["schtasks", "/Delete", "/F", "/TN", "eufy-sync"], + capture_output=True, text=True, timeout=15, + ) + assert not wrapper_path.exists() + + +def test_uninstall_without_task_reports_none_installed(data_dir, capsys): + with patch.object(windows.subprocess, "run", + return_value=SimpleNamespace(returncode=1, stdout="", stderr="ERROR: cannot find")): + windows.uninstall_agent() + + assert "No scheduled task installed." in capsys.readouterr().out + + +def test_agent_status_warns_when_wrapper_missing(data_dir): + status = windows.agent_status() + + assert status["status"] == "WARN" + assert status["label"] == "scheduled task" + assert status["detail"] == "not installed" + assert status["fix"] == "eufy-sync --install-agent" + + +def test_agent_status_warns_when_task_query_fails(data_dir): + binary = "C:\\Tools\\eufy-sync.exe" + _write_current_wrapper(data_dir, binary) + + with patch.object(windows.shutil, "which", return_value=binary), \ + patch.object(windows.subprocess, "run", + return_value=SimpleNamespace(returncode=1, stdout="", stderr="")): + status = windows.agent_status() + + assert status["status"] == "WARN" + assert status["detail"] == "not installed" + + +def test_agent_status_warns_when_wrapper_outdated(data_dir): + (data_dir / windows.WRAPPER_NAME).write_text("stale wrapper contents") + + with patch.object(windows.shutil, "which", return_value="C:\\Tools\\eufy-sync.exe"), \ + patch.object(windows.subprocess, "run", return_value=_ok()): + status = windows.agent_status() + + assert status["status"] == "WARN" + assert status["detail"] == "outdated registration" + assert status["fix"] == "eufy-sync --install-agent" + + +def test_agent_status_passes_when_installed_and_current(data_dir): + binary = "C:\\Tools\\eufy-sync.exe" + _write_current_wrapper(data_dir, binary) + + with patch.object(windows.shutil, "which", return_value=binary), \ + patch.object(windows.subprocess, "run", return_value=_ok()): + status = windows.agent_status() + + assert status["status"] == "PASS" + assert status["detail"] == "installed, runs every 4h" + assert status["fix"] is None + + +def test_agent_installed_reflects_wrapper_presence(data_dir): + assert windows.agent_installed() is False + (data_dir / windows.WRAPPER_NAME).write_text("x") + assert windows.agent_installed() is True + + +def test_purge_agent_removes_task_and_wrapper_silently(data_dir, capsys): + wrapper_path = data_dir / windows.WRAPPER_NAME + wrapper_path.write_text("x") + + with patch.object(windows.subprocess, "run", return_value=_ok()) as run: + windows.purge_agent() + + run.assert_called_once_with( + ["schtasks", "/Delete", "/F", "/TN", "eufy-sync"], + capture_output=True, + ) + assert not wrapper_path.exists() + assert capsys.readouterr().out == "" + + +def test_purge_agent_noop_when_not_installed(data_dir): + with patch.object(windows.subprocess, "run") as run: + windows.purge_agent() + + run.assert_not_called() + + +def test_notify_is_a_silent_noop(data_dir): + with patch.object(windows.subprocess, "run") as run: + assert windows.notify("t", "m", command="eufy-sync --update") is None + run.assert_not_called() + + +def test_offer_installs_on_yes(data_dir): + with patch.object(windows.platform, "system", return_value="Windows"), \ + patch.object(windows.sys.stdin, "isatty", return_value=True), \ + patch("builtins.input", return_value="y"), \ + patch.object(windows, "_install_scheduled_task") as install: + windows.offer_agent() + + install.assert_called_once() + + +def test_offer_skips_without_a_tty(data_dir): + with patch.object(windows.platform, "system", return_value="Windows"), \ + patch.object(windows.sys.stdin, "isatty", return_value=False), \ + patch.object(windows, "_install_scheduled_task") as install: + windows.offer_agent() + + install.assert_not_called() From 8b5ae28cb1b4279a5941aed6a29c618f01b11eb7 Mon Sep 17 00:00:00 2001 From: Elias Sturim Date: Fri, 10 Jul 2026 17:10:40 -0400 Subject: [PATCH 05/14] Windows failure notices as native toasts The Windows notify entry point was a no-op, so a failed sync on Windows gave the user nothing visible to react to. Send a native toast through the PowerShell WinRT notification APIs instead. The title and message are XML-escaped and the script is handed to powershell as a base64 -EncodedCommand in UTF-16-LE, so no shell quoting layer can misread the text and XML specials cannot break the toast markup. Failures are swallowed behind a timeout: a notification is a courtesy and must never turn a completed sync into a crash. The command argument is accepted for interface parity but ignored, since v1 toasts are not clickable and call sites already carry the fix command in the message. Co-Authored-By: Claude Fable 5 --- eufy_sync/platform_support/windows.py | 45 ++++++++++++++++- tests/test_platform_windows.py | 73 +++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/eufy_sync/platform_support/windows.py b/eufy_sync/platform_support/windows.py index a4107b1..d909bd7 100644 --- a/eufy_sync/platform_support/windows.py +++ b/eufy_sync/platform_support/windows.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +import base64 import platform import shutil import subprocess @@ -25,6 +26,24 @@ TASK_NAME = "eufy-sync" WRAPPER_NAME = "eufy-sync-agent.vbs" +# PowerShell script that raises a native toast through the WinRT notification +# APIs. The title and message are XML-escaped and interpolated with .format(), +# so the AppId GUID braces are doubled to survive that pass as single literal +# braces. The AppId points at the built-in Windows PowerShell shortcut so the +# toast has a registered source and actually shows (an unregistered AppId is +# silently dropped). +_PS_TOAST = """\ +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] | Out-Null +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml(@' +{title}{message} +'@) +$appid = '{{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}}\\WindowsPowerShell\\v1.0\\powershell.exe' +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appid).Show( + [Windows.UI.Notifications.ToastNotification]::new($xml)) +""" + def _wrapper_content(binary_path: str) -> str: """Build the VBScript that runs the sync with no visible console. @@ -170,8 +189,30 @@ def purge_agent() -> None: def notify(title: str, message: str, command: str | None = None) -> None: - # Toast notifications land separately; this is a no-op until then. - pass + """Send a native toast through PowerShell WinRT. Fails silently. + + A notification is a courtesy, never the point of the run, so anything that + goes wrong here (no powershell, WinRT unavailable, a slow shell) must not + turn a completed sync into a crash. Every failure is swallowed, and the + call is bounded by a timeout so it cannot hang the process. + + The title and message are XML-escaped and the whole script is handed to + powershell as a base64 -EncodedCommand (UTF-16-LE, the encoding PowerShell + expects), so no shell quoting layer can misread the text. command is + accepted for interface parity but ignored: v1 toasts are not clickable, and + call sites already put the fix command in the message text. + """ + try: + from xml.sax.saxutils import escape + script = _PS_TOAST.format(title=escape(title), message=escape(message)) + encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") + subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", + "-EncodedCommand", encoded], + capture_output=True, timeout=10, + ) + except Exception: + pass def install_agent() -> None: diff --git a/tests/test_platform_windows.py b/tests/test_platform_windows.py index d4dbffb..5b8f561 100644 --- a/tests/test_platform_windows.py +++ b/tests/test_platform_windows.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import base64 from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -205,10 +206,74 @@ def test_purge_agent_noop_when_not_installed(data_dir): run.assert_not_called() -def test_notify_is_a_silent_noop(data_dir): - with patch.object(windows.subprocess, "run") as run: - assert windows.notify("t", "m", command="eufy-sync --update") is None - run.assert_not_called() +def _decoded_script(run): + """Pull the -EncodedCommand payload off a captured subprocess.run call and + decode it back into the PowerShell script text.""" + argv = run.call_args.args[0] + encoded = argv[argv.index("-EncodedCommand") + 1] + return base64.b64decode(encoded).decode("utf-16-le") + + +def test_notify_invokes_powershell_with_encoded_command(data_dir): + with patch.object(windows.subprocess, "run", return_value=_ok()) as run: + assert windows.notify("Sync failed", "Reauth needed") is None + + run.assert_called_once() + argv = run.call_args.args[0] + assert argv[:2] == ["powershell", "-NoProfile"] + assert "-EncodedCommand" in argv + assert run.call_args.kwargs["capture_output"] is True + assert run.call_args.kwargs["timeout"] == 10 + + +def test_notify_encodes_xml_escaped_message(data_dir): + with patch.object(windows.subprocess, "run", return_value=_ok()) as run: + windows.notify("Title", "Tom & Jerry ") + + decoded = _decoded_script(run) + # The ampersand and angle brackets are neutralized as XML entities, so no + # raw special survives to break the toast XML. + assert "&" in decoded + assert "<fixed>" in decoded + assert "Tom & Jerry" not in decoded + + +def test_notify_message_with_specials_and_quotes_cannot_break_out(data_dir): + # A payload packed with the characters that would end the here-string, close + # a quote, or open an XML tag if any of them leaked through unescaped. + nasty = "'@ \"; & done" + with patch.object(windows.subprocess, "run", return_value=_ok()) as run: + windows.notify("t", nasty) + + decoded = _decoded_script(run) + # The XML specials are entity-encoded, so no live tag or bare ampersand lands + # in the text node. + assert "