Skip to content

Windows support#52

Merged
sturimcode merged 14 commits into
mainfrom
windows-support
Jul 13, 2026
Merged

Windows support#52
sturimcode merged 14 commits into
mainfrom
windows-support

Conversation

@sturimcode

Copy link
Copy Markdown
Owner

Full Windows support: install, sync, automatic sync every 4 hours, failure notifications, doctor checks, and self-update.

  • New `eufy_sync/platform_support/` package owns everything OS-specific behind one interface; the existing macOS launchd/osascript code moved there unchanged, and headless Linux keeps its user-owned systemd recipe.
  • Auto-sync on Windows is a per-user Scheduled Task that runs the sync through a hidden-window VBScript wrapper, appending to the same sync.log.
  • Failure notices are native toast notifications (PowerShell WinRT, no new dependency); the toast text names the fix command.
  • The credential vault now chunks across keychain entries when it outgrows one, because Windows Credential Manager caps an entry at 2,560 bytes. A vault that fits keeps today's single-entry shape, so existing installs never migrate.
  • `--update` on Windows hands the upgrade to a detached console, since Windows cannot replace a running executable.
  • CI gains a windows-latest leg; README gains a Windows install path.

Release as 1.8.0 after a manual pass on real Windows hardware.

🤖 Generated with Claude Code

sturimcode and others added 14 commits July 10, 2026 16:37
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Comments naming future work go stale the moment it lands; say what the
selection does today instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The toast XML sits inside a PowerShell here-string, which terminates on
any line starting with '@. Raw exception text reaches notify (the failure
call sites pass str(e)[:200]), and exception text can carry newlines and
content from network responses, so a message containing a newline followed
by '@ would close the here-string early and hand the remainder to
PowerShell as code. Collapse all whitespace runs in the title and message
to single spaces before the XML escape, so the here-string can only
terminate at the template's own '@.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A running eufy-sync.exe holds an open handle on its own file, so pip,
pipx, or uv cannot overwrite it while the process is alive. The inline
subprocess.run path works on macOS and Linux but fails on Windows.

Branch on platform.system() == "Windows" and launch the same update
command in a new console that first waits for this process to exit, then
runs the upgrade, then pauses so any error stays on screen. The Windows
creationflags are read through getattr so the suite still imports on
macOS. The macOS and Linux path is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows support already landed in code on this branch. This is the
surface: add windows-latest to the CI matrix and the Windows classifier
so the platform is tested and advertised, rewrite the argparse help
strings to drop macOS-specific "Launch Agent" wording in favor of what
the flags do, and document the Windows install path, Scheduled Task
automatic sync, and Credential Manager storage in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final-review fixes before merging Windows support.

- Pin the macOS platform implementation in the seven launch-agent CLI
  tests via a shared fixture, so they exercise the macOS module on a
  Windows CI runner instead of the real schtasks path.
- Guard the seven POSIX file-mode assertions behind os.name != "nt";
  Windows reports 666/777 regardless of the mode passed. Behavioral
  assertions still run everywhere.
- Normalize backslashes before the uv-tool marker match so uv installs
  on Windows are detected instead of falling through to a pip path that
  cannot work in a uv venv.
- Quote each spaced argv element in the detached Windows update command
  (and the copy-paste failure message) so a Python path with spaces does
  not split.
- Quote the wrapper path in the printed manual schtasks fallback,
  matching the programmatic /TR value, so a cmd.exe paste survives a
  spaced path.
- Correct the Windows module docstring to describe notify as a native
  PowerShell WinRT toast rather than a no-op stub.
- Translate a keyring failure during chunk reassembly into the same
  unreadable-keychain RuntimeError as the initial read, so a partial
  read is never saved back over the real vault. A missing chunk keeps
  its malformed-vault handling.
- Add a boundary test proving a vault serialized to exactly CHUNK_LIMIT
  stays a single entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five tests encoded assumptions about the machine running them, so they
failed on windows-latest even though the product code is correct:

- test_generate_plist_contains_log_path asserted a hardcoded
  forward-slash log path, but the plist embeds str(shared.LOG_FILE),
  which renders with backslashes on Windows. Assert against the actual
  patched LOG_FILE path instead; the check (the plist embeds the real
  log path) is unchanged.
- Four _self_update tests (pinned pipx, uv tool, pip fallback, failed
  install) cover the inline update path but never pinned the platform,
  so a Windows host routed them into the detached-console branch and
  their subprocess.run mocks were never called. Pin platform.system to
  Darwin, matching how the Windows-branch tests pin Windows.

Test-only change; no product code touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…53)

Findings from the Windows test pass: --update-password printed raw 429
warnings from garminconnect's per-strategy fallback (paths that never
succeed), a wrong password led to an MFA prompt for an email that never
arrives, and Ctrl+C at that prompt dumped a traceback.

- Configure logging once at CLI entry so every path that reaches a
  Garmin login (setup, --update-password, --reauth, sync) quiets the
  garminconnect logger, not just --reauth and sync.
- The MFA prompt now says what to do when no email arrives, and Enter
  or Ctrl+C cancels cleanly with a message naming --update-password.
- Definitively rejected credentials skip the browser fallback, which
  would autofill the same bad password and fail minutes later.
- Ctrl+C anywhere in the CLI prints "Cancelled." and exits 130 instead
  of a traceback.

Co-authored-by: Elias Sturim <267715325+sturimcode@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Changes from the version the pass ran with: uv installs via winget
(the install script's ExecutionPolicy ByPass gets refused in auto
mode), Step 2 records per-target counts and flags nonzero Strava
uploads (Strava-side dedup is unverified), and Step 5 injects the
wrong password via --update-password because --reauth silently reuses
a stored password. Step 5 also lists the pre-PR-53 failure modes as
regressions to capture and the two known cosmetic issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 PASS, 1 SKIP. Step 5 findings led to PR #53 (merged, a828a83).
Open for the release session: Strava-side dedup question from step 2,
and the two cosmetic toast findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t of the tree, 1.8.0

The headless failure message now says --reauth garmin, matching the
target the toast and log both point at. Test-run checklists and reports
are working files, not project docs, so they leave docs/. Version to
1.8.0 in both places.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sturimcode sturimcode merged commit e656952 into main Jul 13, 2026
6 checks passed
@sturimcode sturimcode deleted the windows-support branch July 13, 2026 01:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant