Coverage for src/sync.py: 100%
707 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 21:05 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 21:05 +0000
1"""Sync module."""
3__author__ = "Mandar Patil <mandarons@pm.me>"
4import datetime
5import os
6import re
7import secrets
8from time import sleep
10import requests
11from icloudpy import ICloudPyService, exceptions, utils
13from src import (
14 DEFAULT_CONFIG_FILE_PATH,
15 DEFAULT_RETRY_LOGIN_INTERVAL_SEC,
16 ENV_CONFIG_FILE_PATH_KEY,
17 ENV_ICLOUD_PASSWORD_KEY,
18 PHOTOS_INDEXING_RETRY_SEC,
19 config_parser,
20 configure_icloudpy_logging,
21 get_logger,
22 notify,
23 read_config,
24 sync_drive,
25 sync_photos,
26)
27from src.sync_stats import SyncSummary
28from src.usage import alive
30# Configure icloudpy logging immediately after import
31configure_icloudpy_logging()
33LOGGER = get_logger()
36_TRUST_COOKIE_NAME = "X-APPLE-WEBAUTH-HSA-TRUST"
39def _detect_security_key_account(api, username: str) -> bool:
40 """True when Apple answers this account's second factor with an fsaChallenge.
42 Once security keys are enrolled Apple stops issuing 6-digit codes
43 altogether, so requesting a push sends nothing and listening for a
44 replied code waits for something that cannot arrive -- the loop did
45 both, every cycle, and told the user over Telegram to "reply the
46 6-digit code here". Detecting it here rather than waiting for someone
47 to open the dashboard is what lets the very first notification tell
48 the truth.
50 Recording the method also means ``notify`` picks the security-key
51 wording on this same pass, since it reads the same signal.
53 Best-effort in both directions: icloudpy without security-key support
54 has no such attribute, and no probe failure may break the retry loop.
55 """
56 try:
57 challenge = getattr(api, "security_key_challenge", None)
58 except Exception as e: # noqa: BLE001 - a probe must never break the loop
59 LOGGER.debug(f"security-key probe failed: {e!s}")
60 return False
61 # Shape, not truthiness. icloudpy returns a mapping carrying "challenge"
62 # and "keyHandles", or None -- anything else (a stub, a changed API, a
63 # sentinel) must not be mistaken for Apple demanding a key, because the
64 # cost of a false positive is suppressing the real code flow.
65 if not (isinstance(challenge, dict) and challenge.get("challenge")):
66 return False
67 try:
68 from src import web_signals
70 web_signals.record_auth_method(username=username, method="security_key")
71 except Exception as e: # noqa: BLE001 - wording is not worth an outage
72 LOGGER.debug(f"could not record auth method: {e!s}")
73 return True
76def _log_trust_revocation_hint(api) -> None:
77 """Say so when Apple rejected a trust token that has not expired.
79 Expiry and revocation are indistinguishable from the logs -- both
80 surface as 421 and both end with a 2FA prompt -- but they mean
81 opposite things for what to do next. A refresh schedule prevents the
82 first and can do nothing about the second: Apple drops trust on
83 security events, typically a new trusted device, a password change,
84 or a change to security keys.
86 Without this, the obvious reading of "valid trust token, 2FA demanded
87 anyway" is that the refresh logic is broken, and the time goes into
88 auditing code that behaved correctly.
90 Best-effort: never raises into the retry path.
91 """
92 try:
93 expires_at = _read_trust_cookie_expiry(api)
94 if expires_at is None:
95 return
96 remaining = (
97 expires_at - datetime.datetime.now(tz=datetime.timezone.utc)
98 ).days
99 if remaining <= 0:
100 return
101 LOGGER.error(
102 f"The trust token had not expired -- it is valid for {remaining} "
103 f"more days (until {expires_at.date()}). Apple revoked it "
104 f"server-side, which typically follows a new trusted device, a "
105 f"password change, or a change to security keys.",
106 )
107 except Exception as e: # noqa: BLE001 - diagnostics never break the retry
108 LOGGER.debug(f"trust revocation hint failed: {e!s}")
111def _read_trust_cookie_expiry(api) -> datetime.datetime | None:
112 """Return the expiry datetime of Apple's HSA trust cookie, or None.
114 The trust window is carried by ``X-APPLE-WEBAUTH-HSA-TRUST`` in
115 icloudpy's cookie jar (persisted to ``session_data/<username>`` as
116 LWPCookieJar). Reading it directly avoids hardcoding Apple's trust
117 duration -- the cookie's own ``expires`` field is the source of
118 truth, set per-cookie by Apple's server. Returns None if the cookie
119 isn't present (e.g. account never auth'd with 2FA, or trust cookie
120 cleared).
121 """
122 try:
123 cookies = api.session.cookies
124 except AttributeError:
125 return None
126 for cookie in cookies:
127 if cookie.name == _TRUST_COOKIE_NAME and cookie.expires:
128 return datetime.datetime.fromtimestamp(
129 cookie.expires,
130 tz=datetime.timezone.utc,
131 )
132 return None
135# Set once the missing-public_url guidance has been logged (see
136# ``_resolve_dashboard_url``) so the advice appears once per process,
137# not once per sync-loop iteration.
138_WEB_UI_PUBLIC_URL_WARNED = False
140# Minimum wait after a failed sign-in. Apple answers a throttled account
141# with 409 on /signin/init; retrying sooner just extends the lockout.
142_AUTH_BACKOFF_FLOOR_SEC = 1800
145def _resolve_dashboard_url(config) -> str | None:
146 """Compute the web UI URL to embed in notifications, or None.
148 Returns ``None`` when ``app.web_ui.enabled`` is False -- callers
149 fall back to the legacy docker-exec instruction. Otherwise prefers
150 the explicit ``app.web_ui.public_url`` (e.g. the reverse-proxy
151 URL); falls back to ``http://{host}:{port}`` with a warning logged
152 once at startup if the public URL isn't set.
153 """
154 if not config_parser.get_web_ui_enabled(config=config):
155 return None
156 public_url = config_parser.get_web_ui_public_url(config=config)
157 if public_url:
158 return public_url
159 host = config_parser.get_web_ui_host(config=config)
160 port = config_parser.get_web_ui_port(config=config)
161 # Latch: this resolves on every sync-loop iteration, and while the
162 # container sits 2FA-pending (default 600s retry) that is ~144x/day
163 # of identical guidance in the exact scenario the user is watching
164 # the logs. The advice only needs saying once per process.
165 global _WEB_UI_PUBLIC_URL_WARNED
166 if not _WEB_UI_PUBLIC_URL_WARNED:
167 LOGGER.warning(
168 "app.web_ui.public_url not set -- notification URLs will use "
169 "http://%s:%s/, which won't work from outside the container. "
170 "Set app.web_ui.public_url to your reverse-proxy URL.",
171 host,
172 port,
173 )
174 _WEB_UI_PUBLIC_URL_WARNED = True
175 return f"http://{host}:{port}"
178def _publish_auth_blocked(blocked: bool, reason: str | None = None) -> None:
179 """Tell the web UI whether the loop can authenticate.
181 Only this loop knows: every on-disk signal the dashboard can check by
182 itself (username configured, password in the keyring) still looks
183 healthy while an account sits stuck on a second factor.
185 Best-effort -- signalling must never break syncing.
186 """
187 try:
188 from src import web_signals
190 web_signals.record_auth_blocked(blocked=blocked, reason=reason)
191 except Exception as e: # pragma: no cover - signalling is advisory
192 LOGGER.warning(f"Could not publish auth state: {e!s}")
195def _maybe_refresh_trust(config, api) -> None:
196 """Re-trust the session before its token ages out.
198 ``trust_session`` asks Apple for a new ``X-Apple-TwoSV-Trust-Token``
199 and icloudpy persists it to ``session_data``. Calling it while the
200 session is still healthy therefore rolls the window forward, so a
201 restart at any later point finds a young token and resumes without
202 prompting for a second factor.
204 This matters most for accounts where the second factor is expensive
205 or impossible to satisfy headlessly (hardware security keys, where
206 Apple refuses to send a 6-digit code at all).
208 Best-effort: never raises into the sync loop.
209 """
210 try:
211 threshold = config_parser.get_trust_refresh_days(config=config)
212 if threshold <= 0:
213 return
214 expires_at = _read_trust_cookie_expiry(api)
215 if expires_at is None:
216 return
217 days_remaining = (
218 expires_at - datetime.datetime.now(tz=datetime.timezone.utc)
219 ).days
220 if days_remaining > threshold:
221 return
222 LOGGER.info(
223 f"Trust token has {days_remaining}d left (threshold {threshold}d) -- refreshing.",
224 )
225 if api.trust_session():
226 refreshed = _read_trust_cookie_expiry(api)
227 expires_at_iso = refreshed.isoformat() if refreshed else None
228 LOGGER.info(
229 f"Trust refreshed; now expires {expires_at_iso or 'unknown'}.",
230 )
231 # Webhook-only: no other transport reports a refresh, and a
232 # receiver tracking the trust window needs the new expiry as
233 # much as it needs the warning that preceded it.
234 refresh_data = {"days_remaining_before": days_remaining}
235 if expires_at_iso:
236 # Omitted rather than null when the cookie is unreadable.
237 refresh_data["expires_at"] = expires_at_iso
238 notify.post_event_to_webhook(
239 config,
240 "trust_refreshed",
241 f"iCloud trust token refreshed; now expires {expires_at_iso or 'unknown'}",
242 refresh_data,
243 )
244 else:
245 LOGGER.warning(
246 "Proactive trust refresh was declined by Apple -- a second "
247 "factor will be needed at the next cold start.",
248 )
249 except Exception as e: # pragma: no cover - never break sync over a refresh
250 LOGGER.warning(f"trust refresh failed: {e!s}")
253def _maybe_warn_trust_expiring(config, api, username: str) -> None:
254 """Fire the trust-expiring notification once when crossing threshold.
256 Reads the live trust cookie expiry, compares against
257 ``app.trust_expiry_warn_days``, and -- if days_remaining is below
258 the threshold AND we haven't already warned for THIS cookie value --
259 fans the warning out through ``notify.send_trust_expiring``.
261 Debounce key is the cookie expiry ISO string itself. When Apple
262 refreshes the trust cookie (new expires_at), the stored
263 ``warned_for_expires_at`` no longer matches and warning eligibility
264 rearms automatically -- no manual reset needed.
266 Best-effort: any exception is logged and swallowed so a notification
267 bug never breaks the sync loop.
268 """
269 try:
270 from src import notify, web_signals
272 expires_at = _read_trust_cookie_expiry(api)
273 expires_at_iso = expires_at.isoformat() if expires_at else None
274 prior = web_signals.get_trust_state()
275 web_signals.record_trust_state(
276 expires_at_iso=expires_at_iso,
277 warned_for_expires_at=prior.get("warned_for_expires_at"),
278 )
279 if expires_at is None:
280 return
281 days_remaining = (
282 expires_at - datetime.datetime.now(tz=datetime.timezone.utc)
283 ).days
284 threshold = config_parser.get_trust_expiry_warn_days(config=config)
285 if days_remaining >= threshold:
286 return
287 if prior.get("warned_for_expires_at") == expires_at_iso:
288 return # already warned for this cookie value
289 notify.send_trust_expiring(
290 config=config,
291 username=username,
292 days_remaining=days_remaining,
293 dashboard_url=_resolve_dashboard_url(config),
294 )
295 web_signals.record_trust_state(
296 expires_at_iso=expires_at_iso,
297 warned_for_expires_at=expires_at_iso,
298 )
299 except Exception as e: # pragma: no cover - guarded so notify bugs don't break sync
300 LOGGER.warning(f"trust-expiring check failed: {e!s}")
303_SESSION_ONLY_HELP = (
304 "Store an Apple ID password to let the container re-authenticate on its "
305 "own, or create a new session by signing in from the web dashboard or "
306 "with the documented `icloud --username=... --session-directory=...` "
307 "command."
308)
310# Apple answers a session it will no longer accept with one of these, and
311# icloudpy surfaces the status as the exception's ``code``. Anything else --
312# a 500, a 503, a bad gateway -- is an outage that says nothing about the
313# session, so it belongs to the loop's transport handler rather than being
314# reported to the user as "sign in again". (icloudpy itself lumps 500 in
315# with the re-auth statuses when it rewrites the reason; that conflation
316# must not reach the user as an instruction.)
317_SESSION_REJECTED_CODES = frozenset({401, 421, 450})
319# icloudpy reads the keyring for a ``None`` password -- the very lookup
320# session-only mode exists to avoid -- and, given ``""``, its log filter
321# rewrites every log line with asterisks between each character. A random
322# placeholder avoids both; ``SessionOnlyICloudPyService`` never sends it.
323_SESSION_ONLY_PLACEHOLDER_PASSWORD = secrets.token_urlsafe(32)
326class SessionOnlyICloudPyService(ICloudPyService):
327 """An iCloud client allowed to resume a saved session and nothing else.
329 icloudpy's ``authenticate()`` tries the saved session token first and
330 falls back to a full SRP sign-in when it does not validate. With no
331 password that fallback cannot succeed, and letting it run would post a
332 placeholder credential to Apple's sign-in endpoint on every retry --
333 the fastest way to get an Apple ID throttled or locked. So this
334 override stops after the session check and reports the missing
335 password instead, which the loop's existing handler already turns into
336 a notification and a backoff.
337 """
339 def authenticate(self, force_refresh=False, service=None):
340 """Validate the saved session; never sign in with credentials.
342 Raises ``ICloudPyNoStoredPasswordAvailableException`` when the
343 session is missing or Apple has rejected it; any other API error
344 propagates untouched, since an outage is not an expired session.
346 ``force_refresh`` is refused as defence in depth rather than
347 because anything here asks for it: icloudpy's only caller is its
348 Find My 450 handler, which this app never reaches. Honouring it
349 would mean a credential sign-in, which is the one thing this class
350 exists to prevent, so it is refused wherever it came from.
351 """
352 if force_refresh or not self.session_data.get("session_token"):
353 msg = f"No Apple ID password is stored and there is no saved session to resume. {_SESSION_ONLY_HELP}"
354 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg)
356 try:
357 self.data = self._validate_token()
358 except exceptions.ICloudPyAPIResponseException as error:
359 if error.code not in _SESSION_REJECTED_CODES:
360 # An outage leaves the session perfectly good; reporting it as
361 # expired would send the user off to re-authenticate for
362 # nothing. Let the loop's transport handler have it.
363 raise
364 msg = f"No Apple ID password is stored and the saved session is no longer valid. {_SESSION_ONLY_HELP}"
365 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg) from error
367 if "webservices" not in self.data:
368 # icloudpy returns the response untouched when a failed /validate
369 # carries no recognised error field, so ``self.data`` can be an
370 # error body. Upstream would raise KeyError here and kill the
371 # process -- with `restart: unless-stopped` that is a restart loop
372 # hitting Apple on every boot.
373 msg = f"No Apple ID password is stored and Apple did not accept the saved session. {_SESSION_ONLY_HELP}"
374 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg)
376 self._webservices = self.data["webservices"]
377 LOGGER.debug("Resumed the saved session without a password")
380def get_api_instance(
381 username: str,
382 password: str | None,
383 cookie_directory: str | None = None,
384 server_region: str = "global",
385) -> ICloudPyService:
386 """
387 Create and return an iCloud API client instance.
389 Args:
390 username: iCloud username/Apple ID
391 password: iCloud password, or ``None`` to run in session-only
392 mode -- the client then resumes the saved session in
393 ``cookie_directory`` and refuses to sign in with credentials
394 (see ``SessionOnlyICloudPyService``).
395 cookie_directory: Directory to store authentication cookies.
396 When ``None`` (the default), resolved late from
397 ``src.DEFAULT_COOKIE_DIRECTORY`` so test fixtures that
398 redirect the constant at runtime take effect — the previous
399 ``= DEFAULT_COOKIE_DIRECTORY`` default-arg capture made the
400 constant unmockable post-import.
401 server_region: Server region ("china" or "global")
403 Returns:
404 Configured ICloudPyService instance
405 """
406 if cookie_directory is None:
407 # Read through the src module so monkey-patches of
408 # ``src.DEFAULT_COOKIE_DIRECTORY`` (e.g. by tests/conftest.py)
409 # are honoured. ``src`` is this function's parent package and
410 # already imported; using ``sys.modules`` avoids a per-call
411 # ``import src`` and makes the data flow explicit.
412 import sys
414 cookie_directory = sys.modules["src"].DEFAULT_COOKIE_DIRECTORY
415 service_class = ICloudPyService
416 if password is None:
417 service_class = SessionOnlyICloudPyService
418 password = _SESSION_ONLY_PLACEHOLDER_PASSWORD
419 return (
420 service_class(
421 apple_id=username,
422 password=password,
423 cookie_directory=cookie_directory,
424 home_endpoint="https://www.icloud.com.cn",
425 setup_endpoint="https://setup.icloud.com.cn/setup/ws/1",
426 )
427 if server_region == "china"
428 else service_class(
429 apple_id=username,
430 password=password,
431 cookie_directory=cookie_directory,
432 )
433 )
436class SyncState:
437 """
438 Maintains synchronization state for drive and photos.
440 This class encapsulates the countdown timers and sync flags to avoid
441 passing multiple variables between functions.
442 """
444 def __init__(self):
445 """Initialize sync state with default values."""
446 self.drive_time_remaining = 0
447 self.photos_time_remaining = 0
448 self.enable_sync_drive = True
449 self.enable_sync_photos = True
450 self.last_send = None
451 # Whether a 2FA push has already been requested for the current
452 # re-auth episode. Reset to False on each successful authentication so
453 # a fresh episode triggers exactly one push (see _handle_2fa_required).
454 self.two_fa_triggered = False
455 # Whether this cycle is running without a stored password, i.e. off
456 # the saved session alone. Set by _authenticate_and_get_api; the error
457 # handlers need it because nothing in that mode can be fixed by
458 # retrying with a password the container does not have.
459 self.session_only = False
460 # Set when this cycle skipped Photos because Apple is still indexing
461 # it, so the cycle's webhook can say so rather than blame the mount.
462 self.photos_indexing = False
465def _load_configuration():
466 """
467 Load configuration from file or environment.
469 Returns:
470 Configuration dictionary
471 """
472 config_path = os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)
473 return read_config(config_path=config_path)
476def _extract_sync_intervals(config, log_messages: bool = False):
477 """
478 Extract drive and photos sync intervals from configuration.
480 Args:
481 config: Configuration dictionary
482 log_messages: Whether to log informational messages (default: False for loop usage)
484 Returns:
485 tuple: (drive_sync_interval, photos_sync_interval)
486 """
487 drive_sync_interval = 0
488 photos_sync_interval = 0
490 if config and "drive" in config:
491 drive_sync_interval = config_parser.get_drive_sync_interval(
492 config=config,
493 log_messages=log_messages,
494 )
495 if config and "photos" in config:
496 photos_sync_interval = config_parser.get_photos_sync_interval(
497 config=config,
498 log_messages=log_messages,
499 )
501 return drive_sync_interval, photos_sync_interval
504def _retrieve_password(username: str):
505 """
506 Retrieve password from environment or keyring.
508 Args:
509 username: iCloud username
511 Returns:
512 Password string or None if not found
514 Raises:
515 ICloudPyNoStoredPasswordAvailableException: If password not available
516 """
517 # A blank value counts as no password. Compose substitutes "" for an
518 # unset ${VAR}, and storing it would also overwrite a real keyring
519 # entry; returning it would skip session-only mode and send an empty
520 # password to Apple's sign-in on every retry.
521 password = os.environ.get(ENV_ICLOUD_PASSWORD_KEY)
522 if password:
523 utils.store_password_in_keyring(username=username, password=password)
524 return password
525 password = utils.get_password_from_keyring(username=username)
526 if not password:
527 msg = f"The stored password for {username} is empty."
528 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg)
529 return password
532def _authenticate_and_get_api(config, username: str, sync_state: SyncState | None = None):
533 """
534 Authenticate user and return iCloud API instance.
536 Args:
537 config: Configuration dictionary
538 username: iCloud username
539 sync_state: Current sync state, whose ``session_only`` flag is set
540 here so the error handlers can tell the two modes apart
542 Returns:
543 ICloudPyService instance
545 Raises:
546 ICloudPyNoStoredPasswordAvailableException: If no password is
547 configured *and* the saved session cannot be resumed.
548 """
549 server_region = config_parser.get_region(config=config)
550 try:
551 password = _retrieve_password(username)
552 except exceptions.ICloudPyNoStoredPasswordAvailableException:
553 # No password anywhere is a choice, not necessarily a misconfiguration:
554 # an operator who would rather not keep an Apple ID password on disk
555 # can run unattended off the saved session alone until Apple's trust
556 # window closes. Resuming it is worth attempting before declaring
557 # failure -- the loop used to report "password is not stored" without
558 # ever looking at a perfectly valid session.
559 LOGGER.debug(
560 "No Apple ID password is configured -- resuming the saved session.",
561 )
562 password = None
563 if sync_state is not None:
564 # Set before the client is built, so it is already right if building
565 # it is what fails.
566 sync_state.session_only = password is None
567 return get_api_instance(
568 username=username,
569 password=password,
570 server_region=server_region,
571 )
574def _check_mount_marker(
575 destinations: list[str],
576 marker_filename: str,
577 required: bool,
578 service_name: str,
579) -> bool:
580 """Verify the failsafe marker file is present in every write destination.
582 Mirrors boredazfcuk/docker-icloudpd's ``.mounted`` pattern: protects
583 against silent bind-mount failures (typo in the host path, missing
584 share, wrong permissions) that would otherwise dump iCloud data into
585 an empty container-internal directory.
587 Takes a list of destinations because a single sync may write to more
588 than one bind-mounted directory; the marker is required in EACH write
589 destination because any one of them could be the failed mount.
591 Returns True when it is safe to proceed (marker not required, or
592 marker required and present in every destination). Returns False when
593 the marker is required and is missing from at least one destination —
594 in which case the caller should skip this sync cycle without
595 advancing the countdown so the next interval re-checks. Every
596 missing-marker failure is logged so the user can fix all of them in
597 one pass rather than discovering them one cycle at a time.
599 Args:
600 destinations: List of sync destination directories to check. Each
601 directory is checked independently. An empty list returns
602 True (nothing to check).
603 marker_filename: Filename to look for inside each destination
604 (e.g. ``.mounted``).
605 required: Whether the marker is required at all. When False this
606 is a no-op that always returns True.
607 service_name: Human-readable label used in the error log
608 (``Drive`` / ``Photos``).
610 Returns:
611 True if it is safe to proceed; False to skip this sync cycle.
612 """
613 if not required:
614 return True
615 all_present = True
616 for destination_path in destinations:
617 marker_path = os.path.join(destination_path, marker_filename)
618 if not os.path.isfile(marker_path):
619 LOGGER.error(
620 f"{service_name} mount marker missing: {marker_path} not found — "
621 f"refusing to sync. Create the marker file (`touch {marker_path}`) "
622 f"after confirming the destination is correctly mounted, then the "
623 f"next sync cycle will proceed.",
624 )
625 all_present = False
626 return all_present
629def _perform_drive_sync(config, api, sync_state: SyncState, drive_sync_interval: int):
630 """
631 Execute drive synchronization if enabled.
633 Args:
634 config: Configuration dictionary
635 api: iCloud API instance
636 sync_state: Current sync state
637 drive_sync_interval: Drive sync interval in seconds
639 Returns:
640 DriveStats object if sync was performed, None otherwise
641 """
642 if config and "drive" in config and sync_state.enable_sync_drive:
643 import time
645 from src.sync_stats import DriveStats
647 start_time = time.time()
648 stats = DriveStats()
650 destination_path = config_parser.prepare_drive_destination(config=config)
652 # Mount-marker failsafe (see _check_mount_marker). Skip this
653 # cycle when the marker isn't present. Reset the countdown to
654 # the full interval so ``_calculate_next_sync_schedule`` waits
655 # before re-checking -- without the reset, on startup
656 # ``drive_time_remaining`` is 0 and the next iteration spins
657 # at zero sleep into a tight busy loop that floods logs and
658 # burns CPU until the user touches the marker.
659 if not _check_mount_marker(
660 destinations=[destination_path],
661 marker_filename=config_parser.get_mount_marker_filename(config=config),
662 required=config_parser.get_drive_require_mount_marker(config=config),
663 service_name="Drive",
664 ):
665 sync_state.drive_time_remaining = drive_sync_interval
666 return None
668 # Count files before sync
669 files_before = set()
670 if os.path.exists(destination_path):
671 try:
672 for root, _dirs, file_list in os.walk(destination_path):
673 for file in file_list:
674 files_before.add(os.path.join(root, file))
675 except Exception:
676 pass
678 LOGGER.info("Syncing drive...")
679 files_after = sync_drive.sync_drive(config=config, drive=api.drive)
680 LOGGER.info("Drive synced")
682 # Calculate statistics
683 stats.duration_seconds = time.time() - start_time
685 # Handle case where sync_drive returns None (e.g., in tests)
686 if files_after is not None:
687 # Count newly downloaded files
688 new_files = files_after - files_before
689 stats.files_downloaded = len(new_files)
691 # Count skipped files
692 stats.files_skipped = len(files_before & files_after)
694 # Count removed files
695 if config_parser.get_drive_remove_obsolete(config=config):
696 stats.files_removed = len(files_before - files_after)
698 # Calculate bytes downloaded
699 try:
700 for file_path in new_files:
701 if os.path.exists(file_path) and os.path.isfile(file_path):
702 stats.bytes_downloaded += os.path.getsize(file_path)
703 except Exception:
704 pass
706 # Reset countdown timer to the configured interval
707 sync_state.drive_time_remaining = drive_sync_interval
708 return stats
709 return None
712def _perform_photos_sync(config, api, sync_state: SyncState, photos_sync_interval: int):
713 """
714 Execute photos synchronization if enabled.
716 Args:
717 config: Configuration dictionary
718 api: iCloud API instance
719 sync_state: Current sync state
720 photos_sync_interval: Photos sync interval in seconds
722 Returns:
723 PhotoStats object if sync was performed, None otherwise
724 """
725 if config and "photos" in config and sync_state.enable_sync_photos:
726 import time
728 from src.sync_stats import PhotoStats
730 start_time = time.time()
731 stats = PhotoStats()
733 destination_path = config_parser.prepare_photos_destination(config=config)
735 # Mount-marker failsafe (see _check_mount_marker). Skip this cycle
736 # without advancing the countdown so the next interval re-checks
737 # once the user fixes the mount + touches the marker file.
738 if not _check_mount_marker(
739 destinations=[destination_path],
740 marker_filename=config_parser.get_mount_marker_filename(config=config),
741 required=config_parser.get_photos_require_mount_marker(config=config),
742 service_name="Photos",
743 ):
744 # Same busy-loop guard as the Drive branch above: reset the
745 # countdown so the next cycle waits the configured interval
746 # before re-checking the marker.
747 sync_state.photos_time_remaining = photos_sync_interval
748 return None
750 # Count files before sync
751 files_before = set()
752 if os.path.exists(destination_path):
753 try:
754 for root, _dirs, file_list in os.walk(destination_path):
755 for file in file_list:
756 files_before.add(os.path.join(root, file))
757 except Exception:
758 pass
760 LOGGER.info("Syncing photos...")
761 try:
762 sync_result = sync_photos.sync_photos(config=config, photos=api.photos)
763 except exceptions.ICloudPyServiceNotActivatedException as e:
764 # With no interval there is no next cycle to wait for -- a
765 # one-shot run must still report this through the normal error
766 # path rather than schedule a retry it will never take.
767 if not sync_photos.is_photos_indexing(e) or photos_sync_interval <= 0:
768 raise
769 _wait_for_photos_indexing(sync_state, photos_sync_interval)
770 return None
771 LOGGER.info("Photos synced")
772 _signal_photos_indexing(waiting=False)
774 # Count files after sync
775 files_after = set()
776 if os.path.exists(destination_path):
777 try:
778 for root, _dirs, file_list in os.walk(destination_path):
779 for file in file_list:
780 files_after.add(os.path.join(root, file))
781 except Exception:
782 pass
784 # Calculate statistics
785 stats.duration_seconds = time.time() - start_time
787 # Count newly downloaded files
788 new_files = files_after - files_before
789 stats.photos_downloaded = len(new_files)
791 # Estimate hardlinked photos (approximate)
792 use_hardlinks = config_parser.get_photos_use_hardlinks(
793 config=config,
794 log_messages=False,
795 )
796 if use_hardlinks:
797 stats.photos_hardlinked = max(
798 0,
799 len(files_after) - len(files_before) - stats.photos_downloaded,
800 )
802 # Count skipped photos
803 stats.photos_skipped = len(files_before & files_after)
805 # Calculate bytes downloaded
806 try:
807 for file_path in new_files:
808 if os.path.exists(file_path) and os.path.isfile(file_path):
809 stats.bytes_downloaded += os.path.getsize(file_path)
811 # Estimate bytes saved by hardlinks
812 if use_hardlinks and stats.photos_hardlinked > 0:
813 for file_path in files_after:
814 if file_path not in new_files and os.path.isfile(file_path):
815 stats.bytes_saved_by_hardlinks += os.path.getsize(file_path)
816 except Exception:
817 pass
819 # Track failed downloads so notifications reflect errors
820 if isinstance(sync_result, tuple):
821 _, failed_downloads = sync_result
822 if failed_downloads > 0:
823 stats.errors.append(f"{failed_downloads} photo download(s) failed")
825 # Get list of synced albums (simple approximation based on directories)
826 try:
827 for item in os.listdir(destination_path):
828 item_path = os.path.join(destination_path, item)
829 if os.path.isdir(item_path):
830 stats.albums_synced.append(item)
831 except Exception:
832 pass
834 # Reset countdown timer to the configured interval
835 sync_state.photos_time_remaining = photos_sync_interval
836 return stats
837 return None
840def _wait_for_photos_indexing(sync_state: SyncState, photos_sync_interval: int) -> None:
841 """Skip Photos this cycle because Apple has not finished indexing it.
843 Nothing could be listed, so nothing may be cleaned up either -- an
844 empty listing and an unreadable one look identical to obsolete-file
845 cleanup. Drive is unaffected and carries on.
847 The retry only ever shortens the wait (``min`` with the configured
848 interval). It is not a login retry: the session is resumed per cycle
849 like any other, and ``retry_login_interval`` is not involved.
850 """
851 retry = min(PHOTOS_INDEXING_RETRY_SEC, photos_sync_interval)
852 again = f"{retry // 60} minutes" if retry >= 120 else f"{retry} seconds"
853 LOGGER.warning(
854 "Apple has not finished indexing this iCloud Photos library, so it cannot "
855 f"be read yet. Nothing on disk was changed. Trying Photos again in {again}.",
856 )
857 sync_state.photos_time_remaining = retry
858 sync_state.photos_indexing = True
859 _signal_photos_indexing(waiting=True)
862def _signal_photos_indexing(*, waiting: bool) -> None:
863 """Publish (or clear) the dashboard's "waiting for Apple" note.
865 Recorded against the Photos service, not a library: no library can be
866 read while this is true, so no library row could carry it.
867 """
868 try:
869 from src import web_signals as _ws
871 _ws.record_photos_indexing(waiting=waiting)
872 if waiting:
873 # The library being opened when this surfaced is still marked
874 # "syncing now" and never got started. Left alone it reads as
875 # a library syncing forever, next to a note saying Photos
876 # cannot be read at all.
877 _ws.clear_stale_library_states()
878 except Exception as e: # noqa: BLE001 -- dashboard state must never break sync
879 LOGGER.debug(f"web_signals: record_photos_indexing raised: {e!s}")
882def _perform_dry_run(config, api, check_files: int | None = None) -> None:
883 """Authenticate-and-enumerate path used when ``--dry-run`` is passed.
885 Verifies that the configured credentials, mount paths, and iCloud-side
886 state are all in working order WITHOUT writing or downloading any
887 files. Designed as the safety check users run before letting the real
888 sync loop loose on a new install.
890 Logs (at INFO level):
891 - Drive destination path + root-level item count (when Drive is configured)
892 - Photos destination path + library names (when Photos is configured)
893 - When ``check_files`` is not None: per-library would-skip /
894 size-mismatch / not-found counts (see ``migration_check``).
896 Args:
897 config: Configuration dictionary
898 api: Authenticated iCloud API instance
899 check_files: When set (``--check-files=N``), additionally walks
900 up to N photos per library and reports what a real sync
901 would do per file. ``0`` walks every photo. ``None`` skips
902 this check (cheap default for ``--dry-run`` alone).
904 Notifications, usage statistics, file writes, file deletions, and the
905 sync loop itself are all skipped.
906 """
907 LOGGER.info("DRY RUN: authentication succeeded — verifying configured services.")
909 if config and "drive" in config:
910 try:
911 # Resolved absolute path (root + destination), computed without
912 # creating anything, so users can verify the mount point. Mirrors
913 # how migration_check builds its base path.
914 drive_destination = os.path.join(
915 config_parser.get_root_destination_path(config=config),
916 config_parser.get_drive_destination_path(config=config),
917 )
918 LOGGER.info(f"DRY RUN: Drive destination: {drive_destination}")
919 root_items = list(api.drive.dir())
920 LOGGER.info(
921 f"DRY RUN: Drive root contains {len(root_items)} item(s) — "
922 "real sync would walk this tree per `drive.filters`.",
923 )
924 except Exception as e:
925 LOGGER.warning(f"DRY RUN: Drive enumeration failed: {e!s}")
926 else:
927 LOGGER.info(
928 "DRY RUN: no `drive:` section in config — Drive sync would be skipped.",
929 )
931 if config and "photos" in config:
932 try:
933 photos_destination = os.path.join(
934 config_parser.get_root_destination_path(config=config),
935 config_parser.get_photos_destination_path(config=config),
936 )
937 LOGGER.info(f"DRY RUN: Photos destination: {photos_destination}")
938 libraries = (
939 list(api.photos.libraries.keys())
940 if hasattr(api.photos, "libraries")
941 else []
942 )
943 if libraries:
944 LOGGER.info(
945 f"DRY RUN: Photos libraries available: {', '.join(libraries)}",
946 )
947 else:
948 LOGGER.info("DRY RUN: Photos libraries: (none reported by iCloud)")
949 except Exception as e:
950 LOGGER.warning(f"DRY RUN: Photos enumeration failed: {e!s}")
951 else:
952 LOGGER.info(
953 "DRY RUN: no `photos:` section in config — Photos sync would be skipped.",
954 )
956 if check_files is not None:
957 from src import migration_check
959 # Photos walker — per-library counts using mandarons' real path/size
960 # logic so the report mirrors what a real sync would skip vs download.
961 if config and "photos" in config:
962 try:
963 LOGGER.info(
964 f"DRY RUN: walking photos for file-existence check "
965 f"(--check-files={'all' if check_files == 0 else check_files} per library) ...",
966 )
967 results = migration_check.check_migration(
968 api=api,
969 config=config,
970 sample=check_files,
971 )
972 for library_name, result in results.items():
973 stats = result["stats"]
974 LOGGER.info(
975 f"DRY RUN: {library_name} (dest {result['library_dest']}): "
976 f"sampled={result['checked']} "
977 f"would_skip={stats['would_skip']} "
978 f"size_mismatch={stats['size_mismatch']} "
979 f"not_found={stats['not_found']} "
980 f"errors={stats['error']}",
981 )
982 for status, items in result["samples"].items():
983 for item in items:
984 if status == "size_mismatch":
985 path, expected, actual = item
986 LOGGER.info(
987 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)",
988 )
989 else:
990 path, expected = item
991 LOGGER.info(
992 f"DRY RUN: sample {status}: {path} ({expected:,}b)",
993 )
994 except Exception as e:
995 LOGGER.warning(f"DRY RUN: photos check-files walk failed: {e!s}")
997 # Drive walker — same per-file would_skip/size_mismatch/not_found
998 # report, but walking the Drive tree (no library_destinations,
999 # mirror-tree layout). Catches misconfigured drive.destination.
1000 if config and "drive" in config:
1001 try:
1002 drive_result = migration_check.check_drive_migration(
1003 api=api,
1004 config=config,
1005 sample=check_files,
1006 )
1007 if drive_result is not None:
1008 stats = drive_result["stats"]
1009 LOGGER.info(
1010 f"DRY RUN: Drive (dest {drive_result['drive_destination']}): "
1011 f"sampled={drive_result['checked']} "
1012 f"would_skip={stats['would_skip']} "
1013 f"size_mismatch={stats['size_mismatch']} "
1014 f"not_found={stats['not_found']} "
1015 f"errors={stats['error']}",
1016 )
1017 for status, items in drive_result["samples"].items():
1018 for item in items:
1019 if status == "size_mismatch":
1020 path, expected, actual = item
1021 LOGGER.info(
1022 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)",
1023 )
1024 else:
1025 path, expected = item
1026 LOGGER.info(
1027 f"DRY RUN: sample {status}: {path} ({expected:,}b)",
1028 )
1029 except Exception as e:
1030 LOGGER.warning(f"DRY RUN: drive check-files walk failed: {e!s}")
1032 LOGGER.info(
1033 "DRY RUN complete — no files were written. Re-run without --dry-run to sync.",
1034 )
1037def _check_services_configured(config):
1038 """
1039 Check if any sync services are configured.
1041 Args:
1042 config: Configuration dictionary
1044 Returns:
1045 bool: True if at least one service is configured
1046 """
1048 return "drive" in config or "photos" in config
1051def _cycle_nothing_synced_reason(config, drive_stats, photos_stats, photos_indexing=False) -> str | None:
1052 """Why this cycle synced no service at all, or None if one did.
1054 Such a cycle is indistinguishable from a clean one by its stats -- no
1055 errors, no counts -- so reporting it as a success is how a monitor stays
1056 green while nothing whatsoever is being downloaded. It happens two ways:
1057 nothing is configured to sync, or every service that was due got skipped
1058 -- by the mount-marker failsafe, or (Photos) because Apple has not
1059 finished indexing the library.
1061 Not to be confused with a service that simply was not due. With unequal
1062 intervals ``_calculate_next_sync_schedule`` enables only the service
1063 whose timer expired, so one of the two returning None is the ordinary
1064 case -- hence "no stats at all" rather than "any stats missing".
1066 Args:
1067 config: Configuration dictionary
1068 drive_stats: Result of ``_perform_drive_sync``
1069 photos_stats: Result of ``_perform_photos_sync``
1070 photos_indexing: Whether this cycle skipped Photos for indexing
1072 Returns:
1073 ``nothing_synced``, ``photos_indexing``, ``mount_marker_missing``,
1074 or None if a service ran
1075 """
1076 if not _check_services_configured(config):
1077 return "nothing_synced"
1078 if drive_stats is None and photos_stats is None:
1079 return "photos_indexing" if photos_indexing else "mount_marker_missing"
1080 return None
1083def _cycle_end_message(has_errors: bool, nothing_reason: str | None) -> str:
1084 """The human text for the end-of-cycle event.
1086 Kept out of ``sync()`` only because the branch it sits in is already four
1087 levels deep.
1088 """
1089 if nothing_reason == "nothing_synced":
1090 return "iCloud sync cycle synced nothing: no drive or photos section is configured"
1091 if nothing_reason == "photos_indexing":
1092 return "iCloud sync cycle synced nothing: Apple has not finished indexing Photos"
1093 if nothing_reason:
1094 return "iCloud sync cycle synced nothing: the mount marker is missing"
1095 if has_errors:
1096 return "iCloud sync cycle completed with errors"
1097 return "iCloud sync cycle completed"
1100def _send_usage_statistics(config, summary: SyncSummary) -> None:
1101 """Send anonymized usage statistics.
1103 Args:
1104 config: Configuration dictionary
1105 summary: Sync summary with statistics
1106 """
1108 # Create anonymized usage data
1109 usage_data = {
1110 "sync_duration": (
1111 (summary.sync_end_time - summary.sync_start_time).total_seconds()
1112 if summary.sync_end_time
1113 else 0
1114 ),
1115 "has_drive_activity": bool(
1116 summary.drive_stats and summary.drive_stats.has_activity(),
1117 ),
1118 "has_photos_activity": bool(
1119 summary.photo_stats and summary.photo_stats.has_activity(),
1120 ),
1121 "has_errors": summary.has_errors(),
1122 "timestamp": (
1123 summary.sync_end_time.isoformat() if summary.sync_end_time else None
1124 ),
1125 }
1127 # Add aggregated statistics (no personal data)
1128 if summary.drive_stats:
1129 usage_data["drive"] = {
1130 "files_count": summary.drive_stats.files_downloaded,
1131 "bytes_count": summary.drive_stats.bytes_downloaded,
1132 "has_errors": summary.drive_stats.has_errors(),
1133 }
1135 if summary.photo_stats:
1136 usage_data["photos"] = {
1137 "photos_count": summary.photo_stats.photos_downloaded,
1138 "bytes_count": summary.photo_stats.bytes_downloaded,
1139 "hardlinks_count": summary.photo_stats.photos_hardlinked,
1140 "has_errors": summary.photo_stats.has_errors(),
1141 }
1143 # Send to usage tracking
1144 alive(config=config, data=usage_data)
1147def _auth_retry_sleep(total_seconds: int) -> None:
1148 """Wait between sign-in attempts, ending early on a completed re-auth.
1150 The loop backs off for ``retry_login_interval`` between attempts and
1151 cannot otherwise see that the session was fixed underneath it, so
1152 someone who signs in through the web UI watches the dashboard keep
1153 saying the sync is stopped until an interval that began *before* the
1154 problem was solved finally runs out.
1156 Polls only the re-auth signal, not the force-sync sentinel behind
1157 "Sync now": that button means "sync everything now", and a re-auth
1158 should end a wait without also queueing a full re-enumeration.
1159 """
1160 _CHUNK = 2
1161 try:
1162 from src import web_signals as _ws
1163 except ImportError: # pragma: no cover - module is optional
1164 sleep(total_seconds)
1165 return
1167 if total_seconds <= _CHUNK:
1168 sleep(total_seconds)
1169 return
1171 remaining = total_seconds
1172 while remaining > 0:
1173 chunk = min(_CHUNK, remaining)
1174 sleep(chunk)
1175 remaining -= chunk
1176 if _ws.consume_reauth_completed():
1177 LOGGER.info("Re-auth completed -- ending the retry wait early.")
1178 return
1181def _request_2fa_push_once(api, sync_state: SyncState) -> None:
1182 """Ask Apple to push a 2FA code to the trusted devices, once per episode.
1184 Without this call the loop notified the user that re-auth was needed but
1185 never requested a code, so nothing was ever sent.
1187 The latch is set only when Apple accepts the request.
1188 ``trigger_2fa_push_notification`` returns False rather than raising for
1189 most failures, and latching regardless meant one transient error
1190 forfeited the push for the whole episode -- the user told "2FA is
1191 required" while no code ever arrived, which is the bug this exists to
1192 fix. Retrying on the next cycle is bounded by ``retry_login_interval``
1193 (600s by default), far below anything that trips Apple's limits.
1195 Best-effort: a failure here never stops the retry loop.
1196 """
1197 if sync_state.two_fa_triggered:
1198 return
1199 try:
1200 pushed = api.trigger_2fa_push_notification()
1201 except Exception as e: # noqa: BLE001
1202 LOGGER.warning(f"Failed to request 2FA push notification; will retry next cycle: {e!s}")
1203 return
1204 if pushed:
1205 LOGGER.info("Requested a 2FA push notification to your trusted devices.")
1206 sync_state.two_fa_triggered = True
1207 else:
1208 LOGGER.warning("Apple did not accept the 2FA push request; will retry next cycle.")
1211def _handle_2fa_required(config, username: str, sync_state: SyncState, api):
1212 """
1213 Handle 2FA authentication requirement.
1215 Args:
1216 config: Configuration dictionary
1217 username: iCloud username
1218 sync_state: Current sync state
1219 api: Live ``ICloudPyService`` still in its 2FA-required state. Used to
1220 request a push notification to the user's trusted devices. When
1221 ``app.telegram.listen`` is true, the retry sleep is replaced with a
1222 Telegram poll: the user replies the auth keyword to have Apple push a
1223 code, then replies the 6 digits -- completing re-authentication from a
1224 phone, headless, with no web UI.
1226 Returns:
1227 bool: True if should continue (retry), False if should exit
1228 """
1229 LOGGER.error("Error: 2FA is required. Please log in.")
1230 _publish_auth_blocked(True, reason="2fa_required")
1231 # Decided before anything is sent: it selects the notification wording,
1232 # the reason a receiver sees, and suppresses two steps that cannot
1233 # succeed on such an account.
1234 security_key = _detect_security_key_account(api, username)
1235 notify.send_cycle_event(
1236 config=config,
1237 boundary="failure",
1238 message="iCloud sync cycle aborted: 2FA is required",
1239 data={"reason": "security_key_required" if security_key else "two_factor_required"},
1240 )
1241 if security_key:
1242 LOGGER.error(
1243 "This account signs in with a security key, so Apple will not "
1244 "send a 6-digit code. Complete the ceremony on the dashboard "
1245 "(/auth); the push request and the code listener are skipped.",
1246 )
1247 # Ask Apple to push a code before anything else -- including the exit
1248 # below: retry_login_interval < 0 is the mode where an operator is about
1249 # to intervene by hand, and a code on their devices is what they need.
1250 # Not for a security-key account: Apple sends those no code at all.
1251 if not security_key:
1252 _request_2fa_push_once(api, sync_state)
1254 sleep_for = config_parser.get_retry_login_interval(config=config)
1256 if sleep_for < 0:
1257 LOGGER.info("retry_login_interval is < 0, exiting ...")
1258 return False
1260 _log_retry_time(sleep_for)
1261 server_region = config_parser.get_region(config=config)
1262 # notify.send is throttled (once per 24h) and listen-aware: in listen mode the
1263 # Telegram channel gets the actionable reply prompt, other channels the standard
1264 # alert. Throttling here is what prevents a "reply auth" message every retry cycle.
1265 sync_state.last_send = notify.send(
1266 config=config,
1267 username=username,
1268 last_send=sync_state.last_send,
1269 region=server_region,
1270 dashboard_url=_resolve_dashboard_url(config),
1271 # A security-key account cannot finish sign-in from a Telegram code,
1272 # so it gets the standard alert pointing at the dashboard.
1273 reply_prompt=not security_key,
1274 event="security_key_required" if security_key else "two_factor_required",
1275 )
1276 if not security_key and config_parser.get_telegram_listen_enabled(config=config):
1277 _wait_for_telegram_code(config=config, api=api, timeout_seconds=sleep_for)
1278 else:
1279 _auth_retry_sleep(sleep_for)
1280 return True
1283def _wait_for_telegram_code(config, api, timeout_seconds: int) -> bool:
1284 """Drive 2FA over Telegram with a manual, user-initiated trigger.
1286 The reply prompt itself is sent by ``notify.send`` (throttled); this function
1287 drains any stale replies, then polls for the user's actions:
1288 1. On the auth keyword -> ``api.trigger_2fa_push_notification()`` so Apple
1289 actually pushes a code to the trusted devices. (The headless path
1290 previously waited for a code it never requested -- this missing trigger
1291 is the core bug this fixes.)
1292 2. On a 6-digit reply (spaces/dashes tolerated) -> ``validate_2fa_code``
1293 + ``trust_session``.
1295 Returns True once a code validates and trust succeeds within
1296 ``timeout_seconds``, or once the web UI completes the re-auth first;
1297 False on timeout. Best-effort throughout. The Telegram
1298 ``getUpdates`` offset is held in-memory for the duration of this wait.
1299 """
1300 from src import web_signals
1302 poll_interval = 5
1303 bot_token = config_parser.get_telegram_bot_token(config=config)
1304 chat_id = config_parser.get_telegram_chat_id(config=config)
1305 auth_keyword = config_parser.get_telegram_auth_keyword(config=config)
1306 if not bot_token or not chat_id:
1307 LOGGER.warning(
1308 "Telegram listen enabled but bot_token/chat_id not configured; falling back to plain sleep.",
1309 )
1310 sleep(timeout_seconds)
1311 return False
1313 # Drain any messages already pending so a stale reply from a previous
1314 # session does not get acted on; start listening for genuinely new replies.
1315 # Deliberate trade-off: a code typed while the loop is between wait
1316 # windows is dropped too. Acting on an old code is worse than asking for
1317 # a fresh one, and the window is the whole retry interval (600s default).
1318 _, offset = notify.poll_telegram_for_text(
1319 bot_token=bot_token, chat_id=chat_id, offset=0,
1320 )
1321 while True:
1322 _, drained = notify.poll_telegram_for_text(
1323 bot_token=bot_token, chat_id=chat_id, offset=offset,
1324 )
1325 if drained == offset:
1326 break
1327 offset = drained
1329 LOGGER.info(
1330 f"Listening on Telegram for '{auth_keyword}' trigger or 6-digit code (timeout {timeout_seconds}s).",
1331 )
1332 elapsed = 0
1333 while elapsed < timeout_seconds:
1334 chunk = min(poll_interval, timeout_seconds - elapsed)
1335 sleep(chunk)
1336 elapsed += chunk
1337 # The web UI and Telegram can both complete a re-auth. If the web UI
1338 # got there first, stop listening -- otherwise the loop sits out the
1339 # rest of the window holding a session that is already fixed.
1340 if web_signals.consume_reauth_completed():
1341 LOGGER.info("Re-auth completed in the web UI -- ending the Telegram wait.")
1342 return True
1343 text, offset = notify.poll_telegram_for_text(
1344 bot_token=bot_token,
1345 chat_id=chat_id,
1346 offset=offset,
1347 )
1348 if not text:
1349 continue
1350 norm = text.strip().lower()
1351 if norm == auth_keyword:
1352 LOGGER.info("Telegram auth trigger received -- requesting 2FA push.")
1353 try:
1354 pushed = api.trigger_2fa_push_notification()
1355 except Exception as e: # noqa: BLE001
1356 LOGGER.warning(f"trigger_2fa_push_notification raised: {e!s}")
1357 pushed = False
1358 notify.post_message_to_telegram(
1359 bot_token,
1360 chat_id,
1361 (
1362 "✅ 2FA code sent to your Apple devices -- reply the 6-digit code here."
1363 if pushed
1364 else "⚠️ Couldn't request a code (no trusted device, or auth state off). Try again shortly."
1365 ),
1366 )
1367 continue
1368 code = norm.replace(" ", "").replace("-", "")
1369 if re.fullmatch(r"\d{6}", code):
1370 LOGGER.info("Received 6-digit code via Telegram -- validating.")
1371 try:
1372 accepted = api.validate_2fa_code(code)
1373 except Exception as e: # noqa: BLE001
1374 LOGGER.warning(
1375 f"validate_2fa_code raised: {e!s} -- waiting for another code.",
1376 )
1377 continue
1378 if not accepted:
1379 notify.post_message_to_telegram(
1380 bot_token,
1381 chat_id,
1382 "❌ Apple rejected that code -- reply a fresh one.",
1383 )
1384 LOGGER.warning(
1385 "Apple rejected the Telegram-supplied code -- waiting for another.",
1386 )
1387 continue
1388 try:
1389 api.trust_session()
1390 except Exception as e: # noqa: BLE001
1391 LOGGER.warning(f"trust_session raised (non-fatal): {e!s}")
1392 notify.post_message_to_telegram(
1393 bot_token,
1394 chat_id,
1395 "✅ Re-authenticated. iCloud sync resumed.",
1396 )
1397 LOGGER.info("Telegram-driven 2FA succeeded; resuming sync.")
1398 return True
1399 LOGGER.info("Telegram listen timeout reached with no usable code; retrying auth.")
1400 return False
1403def _needs_a_human(sync_state: SyncState, error) -> bool:
1404 """Whether a sign-in failure warrants the "re-auth required" alert.
1406 A rejected password always does: retrying cannot fix it.
1408 In session-only mode one more case does, because it cannot be told
1409 apart from a rejected session. icloudpy keeps the HTTP status on the
1410 exception only for a non-JSON body or its own re-auth statuses
1411 (``_SESSION_REJECTED_CODES``), so a JSON-bodied 401 arrives with
1412 ``code`` as ``None``. Those are the ones that would otherwise leave a
1413 container only a human can revive retrying in silence forever.
1415 Everything else is an outage -- a 5xx that kept its status, a dropped
1416 connection, a DNS failure -- and says nothing about the session. Those
1417 are logged and retried. Waking someone daily to re-authenticate over
1418 Apple having a bad hour is the alert this function exists to withhold.
1419 """
1420 if isinstance(error, exceptions.ICloudPyFailedLoginException):
1421 return True
1422 return (
1423 sync_state.session_only
1424 and isinstance(error, exceptions.ICloudPyAPIResponseException)
1425 and error.code is None
1426 )
1429def _handle_auth_transport_error(config, username: str, sync_state: SyncState, error):
1430 """Back off after a sign-in failure Apple did not express as a 2FA prompt.
1432 Uses at least ``_AUTH_BACKOFF_FLOOR_SEC`` regardless of the configured
1433 retry interval: the errors that land here (notably Apple's 409 on
1434 ``/signin/init``) mean "you are trying too often", so honouring a short
1435 interval would make it worse.
1437 Returns True to keep looping, False to exit.
1438 """
1439 LOGGER.error(f"Sign-in failed and will be retried: {error!s}")
1440 # sign_in_failed is reserved for what the alert below treats as needing
1441 # a human. A network fault or an Apple service error means the attempt
1442 # never completed, which needs no one to intervene.
1443 rejected = _needs_a_human(sync_state, error)
1444 notify.send_cycle_event(
1445 config=config,
1446 boundary="failure",
1447 message=(
1448 "iCloud sync cycle aborted: sign-in failed"
1449 if rejected
1450 else "iCloud sync cycle aborted: sign-in did not complete"
1451 ),
1452 data={"reason": "sign_in_failed" if rejected else "sign_in_error"},
1453 )
1454 sleep_for = config_parser.get_retry_login_interval(config=config)
1455 if sleep_for < 0:
1456 LOGGER.info("retry_login_interval is < 0, exiting ...")
1457 return False
1458 sleep_for = max(sleep_for, _AUTH_BACKOFF_FLOOR_SEC)
1459 _log_retry_time(sleep_for)
1460 if sync_state.session_only:
1461 # The loop is not syncing and only a human can change that, so the
1462 # dashboard is told -- its wording for this reason allows for an
1463 # outage and asks for nothing.
1464 _publish_auth_blocked(True, reason="sign_in_failed")
1465 if rejected:
1466 # notify.send's text is "iCloud re-auth required", so it is reserved
1467 # for the cases where that is actually true: a rejected password,
1468 # which never heals by retrying, and -- in session-only mode -- a
1469 # failure that cannot be told apart from a rejected session. It is
1470 # throttled, so this is not a message per retry.
1471 sync_state.last_send = notify.send(
1472 config=config,
1473 username=username,
1474 last_send=sync_state.last_send,
1475 region=config_parser.get_region(config=config),
1476 dashboard_url=_resolve_dashboard_url(config),
1477 event="sign_in_failed",
1478 )
1479 # Ends early on a completed web-UI re-auth, but not on "Sync now":
1480 # nothing a button does may shorten a throttle backoff.
1481 _auth_retry_sleep(sleep_for)
1482 return True
1485def _handle_sync_error(config, error, drive_sync_interval, photos_sync_interval):
1486 """Back off after a failure that happened *after* a successful sign-in.
1488 Anything raised once ``api`` exists is a service problem, not an auth
1489 problem: a zone that is unavailable, a 5xx on a download, a connection
1490 dropped mid-transfer. Reporting those as sign-in failures sends the user
1491 to re-authenticate for no reason.
1493 The interval matters as much as the wording. Every handler here ends in
1494 ``continue``, which skips ``_calculate_next_sync_schedule`` -- so the
1495 countdown timers never advance and both services stay enabled. Retrying
1496 on the short login interval therefore re-enumerates the whole library
1497 every few minutes. Wait at least as long as the shortest configured sync
1498 interval instead: never poll a broken service faster than a working one.
1500 Returns True to keep looping, False to exit.
1501 """
1502 LOGGER.error(f"Sync failed and will be retried: {error!s}")
1503 notify.send_cycle_event(
1504 config=config,
1505 boundary="failure",
1506 message="iCloud sync cycle failed and will be retried",
1507 data={"reason": "sync_error"},
1508 )
1509 # log_messages=False: this is not a login retry, and the getter's
1510 # "Retrying login every N seconds." would say otherwise.
1511 sleep_for = config_parser.get_retry_login_interval(config=config, log_messages=False)
1512 if sleep_for < 0:
1513 LOGGER.info("retry_login_interval is < 0, exiting ...")
1514 return False
1515 configured = [i for i in (drive_sync_interval, photos_sync_interval) if i > 0]
1516 if configured:
1517 sleep_for = max(sleep_for, min(configured))
1518 _log_retry_time(sleep_for, what="sync")
1519 # This can be a whole sync interval; the "Sync now" button must still
1520 # cut it short, as it does on the normal scheduling path.
1521 _interruptible_sleep(sleep_for)
1522 return True
1525def _handle_password_error(config, username: str, sync_state: SyncState, error):
1526 """
1527 Handle password not available error.
1529 Args:
1530 config: Configuration dictionary
1531 username: iCloud username
1532 sync_state: Current sync state
1533 error: The raised exception, whose message names which way
1534 session-only mode failed -- no session at all, or a session
1535 Apple no longer accepts.
1537 Returns:
1538 bool: True if should continue (retry), False if should exit
1539 """
1540 LOGGER.error(str(error))
1541 # The dashboard's own on-disk check reads an empty keyring as "setup
1542 # needed", which is the wrong instruction here: the session, not the
1543 # password, is what has to be replaced.
1544 _publish_auth_blocked(True, reason="session_unusable")
1545 notify.send_cycle_event(
1546 config=config,
1547 boundary="failure",
1548 message="iCloud sync cycle aborted: no stored password and no usable saved session",
1549 data={"reason": "password_missing"},
1550 )
1551 sleep_for = config_parser.get_retry_login_interval(config=config)
1553 if sleep_for < 0:
1554 LOGGER.info("retry_login_interval is < 0, exiting ...")
1555 return False
1557 _log_retry_time(sleep_for)
1558 server_region = config_parser.get_region(config=config)
1559 sync_state.last_send = notify.send(
1560 config=config,
1561 username=username,
1562 last_send=sync_state.last_send,
1563 region=server_region,
1564 dashboard_url=_resolve_dashboard_url(config),
1565 event="password_missing",
1566 )
1567 _auth_retry_sleep(sleep_for)
1568 return True
1571def _log_retry_time(sleep_for: int, what: str = "login"):
1572 """
1573 Log the next retry time.
1575 Args:
1576 sleep_for: Sleep duration in seconds
1577 what: What is being retried; a failed sync is not a failed login
1578 """
1579 next_sync = (
1580 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for)
1581 ).strftime("%c")
1582 LOGGER.info(f"Retrying {what} at {next_sync} ...")
1585def _calculate_next_sync_schedule(config, sync_state: SyncState):
1586 """
1587 Calculate next sync schedule and update sync state.
1589 This function implements the adaptive scheduling algorithm that determines
1590 which service should sync next based on countdown timers.
1592 Args:
1593 config: Configuration dictionary
1594 sync_state: Current sync state
1596 Returns:
1597 int: Sleep duration in seconds
1598 """
1599 has_drive = config and "drive" in config
1600 has_photos = config and "photos" in config
1602 if not has_drive and has_photos:
1603 sleep_for = sync_state.photos_time_remaining
1604 sync_state.enable_sync_drive = False
1605 sync_state.enable_sync_photos = True
1606 elif has_drive and not has_photos:
1607 sleep_for = sync_state.drive_time_remaining
1608 sync_state.enable_sync_drive = True
1609 sync_state.enable_sync_photos = False
1610 else:
1611 # Sleep until the sooner of the two is due, and take that time off
1612 # both countdowns. Whichever reaches zero syncs next; equal timers
1613 # sync together. A negative countdown is a one-shot service
1614 # (sync_interval < 0) that has already run: it is never due again,
1615 # so it takes no part, and must never become a negative sleep.
1616 pending = [t for t in (sync_state.drive_time_remaining, sync_state.photos_time_remaining) if t >= 0]
1617 sleep_for = min(pending) if pending else 0
1618 if sync_state.drive_time_remaining >= 0:
1619 sync_state.drive_time_remaining -= sleep_for
1620 if sync_state.photos_time_remaining >= 0:
1621 sync_state.photos_time_remaining -= sleep_for
1622 sync_state.enable_sync_drive = sync_state.drive_time_remaining == 0
1623 sync_state.enable_sync_photos = sync_state.photos_time_remaining == 0
1625 return sleep_for
1628def _log_next_sync_time(sleep_for: int):
1629 """
1630 Log the next scheduled sync time.
1632 Args:
1633 sleep_for: Sleep duration in seconds
1634 """
1635 next_sync = (
1636 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for)
1637 ).strftime("%c")
1638 LOGGER.info(f"Resyncing at {next_sync} ...")
1641def _log_sync_intervals_at_startup(config):
1642 """
1643 Log sync intervals once at startup.
1645 Args:
1646 config: Configuration dictionary
1647 """
1648 if config and "drive" in config:
1649 config_parser.get_drive_sync_interval(config=config, log_messages=True)
1650 if config and "photos" in config:
1651 config_parser.get_photos_sync_interval(config=config, log_messages=True)
1654def _should_exit_oneshot_mode(config):
1655 """
1656 Check if should exit in oneshot mode.
1658 Oneshot mode exits when ALL configured sync intervals are negative.
1660 Args:
1661 config: Configuration dictionary
1663 Returns:
1664 bool: True if should exit
1665 """
1667 should_exit_drive = ("drive" not in config) or (
1668 config_parser.get_drive_sync_interval(config=config, log_messages=False) < 0
1669 )
1670 should_exit_photos = ("photos" not in config) or (
1671 config_parser.get_photos_sync_interval(config=config, log_messages=False) < 0
1672 )
1674 return should_exit_drive and should_exit_photos
1677def sync(dry_run: bool = False, check_files: int | None = None):
1678 """
1679 Main synchronization loop.
1681 Orchestrates the entire sync process by delegating specific responsibilities
1682 to focused helper functions. This function coordinates the high-level flow
1683 while each helper handles a single concern.
1685 Args:
1686 dry_run: When True, authenticate and summarise what would be synced,
1687 then exit without writing files, sending notifications, or
1688 entering the sync loop. Useful for verifying credentials, mount
1689 paths, and config before the real loop starts downloading.
1690 check_files: Optional sample size for the per-photo file-existence
1691 check during dry-run. Only meaningful with ``dry_run=True``.
1692 ``None`` skips the check (cheap default). ``0`` walks every
1693 photo (slow on large libraries). Positive N walks N
1694 stride-sampled photos per library.
1695 """
1696 sync_state = SyncState()
1697 startup_logged = False
1699 while True:
1700 # A config that cannot be read must not kill the daemon. On a NAS the
1701 # volume holding config.yaml can lag behind container start or go away
1702 # mid-run, and a half-written file (the user editing it live) raises
1703 # out of the YAML parser. Either way the traceback escapes the loop,
1704 # and `restart: unless-stopped` turns that into a restart loop.
1705 try:
1706 config = _load_configuration()
1707 except Exception as e: # noqa: BLE001 -- any parse fault must not be fatal
1708 LOGGER.error(f"Config file could not be read, retrying: {e!s}")
1709 config = None
1710 if config is None:
1711 if dry_run:
1712 # A dry run is a one-shot check with nothing to wait for.
1713 LOGGER.error("DRY RUN: no readable config, nothing to check.")
1714 return
1715 sleep(DEFAULT_RETRY_LOGIN_INTERVAL_SEC)
1716 continue
1718 # Log sync intervals once at startup
1719 if not startup_logged:
1720 _log_sync_intervals_at_startup(config)
1721 notify.warn_unknown_webhook_events(config)
1722 # The state file outlives the container, so a restart mid-library
1723 # would leave the dashboard showing it as still syncing. Nothing
1724 # can legitimately be in flight here.
1725 try:
1726 from src import web_signals as _ws
1728 _ws.clear_stale_library_states()
1729 except Exception as e: # noqa: BLE001 -- never block startup
1730 LOGGER.debug(f"web_signals: clear_stale_library_states raised: {e!s}")
1731 startup_logged = True
1733 drive_sync_interval, photos_sync_interval = _extract_sync_intervals(
1734 config,
1735 log_messages=False,
1736 )
1737 username = config_parser.get_username(config=config) if config else None
1739 # Web UI "Sync now" requests: ``src.web_signals`` writes a
1740 # sentinel file when the user taps the button; we delete it and
1741 # zero the countdown so the next pass through the sync calls
1742 # runs immediately. Best-effort import so vanilla mandarons
1743 # builds without the web-UI module still work.
1744 try:
1745 from src import web_signals as _ws
1747 if _ws.consume_force_sync("drive"):
1748 LOGGER.info("Force-sync requested for Drive — running immediately")
1749 sync_state.drive_time_remaining = 0
1750 if _ws.consume_force_sync("photos"):
1751 LOGGER.info("Force-sync requested for Photos — running immediately")
1752 sync_state.photos_time_remaining = 0
1753 except (
1754 ImportError
1755 ): # pragma: no cover — best-effort fallback for builds without web_signals
1756 pass
1758 if username:
1759 authenticated = False
1760 try:
1761 api = _authenticate_and_get_api(config, username, sync_state)
1762 authenticated = True
1764 # Dry-run path: authenticate, enumerate, log, exit.
1765 # Skips the entire sync + notification + retry pipeline.
1766 if dry_run:
1767 if api.requires_2sa:
1768 LOGGER.info(
1769 "DRY RUN: 2FA required — finish interactive auth first "
1770 "(see README), then re-run with --dry-run.",
1771 )
1772 else:
1773 _perform_dry_run(config, api, check_files=check_files)
1774 return
1776 _publish_auth_blocked(False)
1777 if not api.requires_2sa:
1778 # Trust-window check: record current cookie expiry and
1779 # fire a pre-emptive warning once if it's about to lapse.
1780 # Best-effort: any failure is logged + swallowed inside.
1781 _maybe_refresh_trust(config, api)
1782 _maybe_warn_trust_expiring(config, api, username)
1784 # Authenticated: clear the 2FA trigger latch so a future
1785 # re-auth episode requests a fresh push exactly once.
1786 sync_state.two_fa_triggered = False
1788 # Signed in and about to sync: open the cycle on any
1789 # configured webhook. Fire-and-forget, and a no-op when
1790 # none is configured (see notify.send_cycle_event).
1791 notify.send_cycle_event(
1792 config=config,
1793 boundary="start",
1794 message="iCloud sync cycle started",
1795 )
1797 # Create summary for this sync cycle
1798 summary = SyncSummary()
1800 # Perform syncs and collect statistics
1801 sync_state.photos_indexing = False
1802 drive_stats = _perform_drive_sync(
1803 config,
1804 api,
1805 sync_state,
1806 drive_sync_interval,
1807 )
1808 photos_stats = _perform_photos_sync(
1809 config,
1810 api,
1811 sync_state,
1812 photos_sync_interval,
1813 )
1815 # Populate summary with statistics
1816 summary.drive_stats = drive_stats
1817 summary.photo_stats = photos_stats
1818 summary.sync_end_time = datetime.datetime.now()
1820 # Close the cycle on the webhooks. Failed downloads
1821 # counted in the stats make this a failure even though
1822 # the cycle itself ran to completion -- a monitor that
1823 # reported success here would stay green while the
1824 # library silently fell behind. So does a cycle that
1825 # synced nothing at all. The statistics ride along, so a
1826 # POST receiver sees them without app.notifications.
1827 has_errors = summary.has_errors()
1828 nothing_reason = _cycle_nothing_synced_reason(
1829 config,
1830 drive_stats,
1831 photos_stats,
1832 sync_state.photos_indexing,
1833 )
1834 # Every sync_failed names a reason, including this one: a
1835 # receiver should never have to infer why from the
1836 # statistics. (Only Photos counts failed downloads, so
1837 # has_errors never means a Drive failure today.)
1838 reason = nothing_reason or ("download_errors" if has_errors else None)
1839 cycle_data = notify.summary_event_data(summary)
1840 if reason:
1841 cycle_data["reason"] = reason
1842 notify.send_cycle_event(
1843 config=config,
1844 boundary="failure" if reason else "success",
1845 message=_cycle_end_message(has_errors, nothing_reason),
1846 data=cycle_data,
1847 )
1849 # Persist per-service last-sync state for the web
1850 # dashboard. Best-effort — if the JSON write fails
1851 # the sync itself is unaffected.
1852 try:
1853 from src import web_signals as _ws
1855 if drive_stats is not None:
1856 _ws.record_sync_completion(
1857 service="drive",
1858 files_downloaded=drive_stats.files_downloaded,
1859 files_skipped=drive_stats.files_skipped,
1860 files_removed=drive_stats.files_removed,
1861 errors=len(drive_stats.errors),
1862 duration_seconds=drive_stats.duration_seconds,
1863 )
1864 if photos_stats is not None:
1865 _ws.record_sync_completion(
1866 service="photos",
1867 files_downloaded=photos_stats.photos_downloaded,
1868 files_skipped=photos_stats.photos_skipped,
1869 errors=len(photos_stats.errors),
1870 duration_seconds=photos_stats.duration_seconds,
1871 )
1872 except (
1873 ImportError
1874 ): # pragma: no cover — best-effort fallback for builds without web_signals
1875 pass
1876 except Exception as e:
1877 LOGGER.debug(
1878 f"web_signals: record_sync_completion raised: {e!s}",
1879 )
1881 # Send usage statistics (anonymized summary data)
1882 try:
1883 _send_usage_statistics(config, summary)
1884 except Exception as e:
1885 LOGGER.debug(f"Failed to send usage statistics: {e!s}")
1887 # Send sync summary notification if configured
1888 # Only send notification when both enabled services have synced in this cycle
1889 # Gracefully handle notification failures to not break sync
1890 has_drive_config = config and "drive" in config
1891 has_photos_config = config and "photos" in config
1893 should_send_notification = False
1894 if has_drive_config and has_photos_config:
1895 # Both services configured - send notification only when both have synced
1896 should_send_notification = (
1897 drive_stats is not None and photos_stats is not None
1898 )
1899 elif has_drive_config and not has_photos_config:
1900 # Only drive configured - send when drive synced
1901 should_send_notification = drive_stats is not None
1902 elif has_photos_config and not has_drive_config:
1903 # Only photos configured - send when photos synced
1904 should_send_notification = photos_stats is not None
1906 if should_send_notification:
1907 try:
1908 notify.send_sync_summary(config=config, summary=summary)
1909 except Exception as e:
1910 LOGGER.debug(
1911 f"Failed to send sync summary notification: {e!s}",
1912 )
1914 if not _check_services_configured(config):
1915 LOGGER.warning(
1916 "Nothing to sync. Please add drive: and/or photos: section in config.yaml file.",
1917 )
1918 else:
1919 _log_trust_revocation_hint(api)
1920 if not _handle_2fa_required(config, username, sync_state, api):
1921 break
1922 continue
1924 except exceptions.ICloudPyNoStoredPasswordAvailableException as e:
1925 if not _handle_password_error(config, username, sync_state, e):
1926 break
1927 continue
1928 except exceptions.ICloudPyFailedLoginException as e:
1929 # icloudpy catches most sign-in errors -- a 409 or 401 with a
1930 # reason, any 5xx -- and re-raises them as this, which
1931 # subclasses ICloudPyException directly and so is not caught by
1932 # the API-response clause below. It only arises while signing
1933 # in, so it always takes the auth backoff.
1934 if not _handle_auth_transport_error(config, username, sync_state, e):
1935 break
1936 continue
1937 except exceptions.ICloudPyServiceNotActivatedException as e:
1938 # A zone or service being unavailable says nothing about the
1939 # sign-in whenever it surfaces, so it never earns the
1940 # rate-limit backoff. Listed ahead of the broader catch
1941 # below because it subclasses it.
1942 if not _handle_sync_error(
1943 config,
1944 e,
1945 drive_sync_interval,
1946 photos_sync_interval,
1947 ):
1948 break
1949 continue
1950 except (
1951 exceptions.ICloudPyAPIResponseException,
1952 requests.exceptions.RequestException,
1953 ) as e:
1954 # Any other failure raised after the sign-in succeeded is a
1955 # service problem, not an auth problem -- and must not earn
1956 # the backoff meant for "you are trying too often" either.
1957 if authenticated:
1958 if not _handle_sync_error(
1959 config,
1960 e,
1961 drive_sync_interval,
1962 photos_sync_interval,
1963 ):
1964 break
1965 continue
1966 # Apple refuses sign-in for reasons other than "2FA needed":
1967 # 409 when it is throttling the account, 5xx when it is
1968 # having a bad day, plus ordinary network faults. None of
1969 # these are fatal, but letting them escape kills the process
1970 # -- and with `restart: unless-stopped` that becomes a crash
1971 # loop that re-authenticates every few seconds, which is the
1972 # fastest possible way to deepen a throttle.
1973 if not _handle_auth_transport_error(config, username, sync_state, e):
1974 break
1975 continue
1977 sleep_for = _calculate_next_sync_schedule(config, sync_state)
1978 _log_next_sync_time(sleep_for)
1980 if _should_exit_oneshot_mode(config):
1981 LOGGER.info(
1982 "All configured sync intervals are negative, exiting oneshot mode...",
1983 )
1984 break
1986 # Interruptible sleep -- poll the web-signal force-sync sentinels
1987 # every few seconds so the "Sync now" button stays responsive even
1988 # mid-long-interval. Without this, a user tap during a multi-hour
1989 # drive sleep would wait the full remaining duration.
1990 _interruptible_sleep(sleep_for)
1993def _interruptible_sleep(total_seconds: int) -> None:
1994 """Sleep up to ``total_seconds`` in short chunks, returning early
1995 when ``web_signals.pending_force_syncs()`` reports any sentinel.
1997 The ``import src.web_signals`` is best-effort so a vanilla mandarons
1998 build without the web-UI module still runs (it falls back to a
1999 single ``sleep(total_seconds)``).
2000 """
2001 _CHUNK = 2 # seconds — tradeoff: shorter = more responsive, more wakeups
2002 try:
2003 from src import web_signals as _ws
2004 except ImportError: # pragma: no cover — vanilla-mandarons fallback
2005 sleep(total_seconds)
2006 return
2008 # Short intervals (<= one chunk) sleep in a single call so the
2009 # existing tests that count sleep invocations still match. The
2010 # chunking only matters for long intervals where the user might
2011 # tap "Sync now" mid-sleep -- those become multiple short sleeps
2012 # with a sentinel poll between each.
2013 if total_seconds <= _CHUNK:
2014 sleep(total_seconds)
2015 return
2017 remaining = total_seconds
2018 while remaining > 0:
2019 chunk = min(_CHUNK, remaining)
2020 sleep(chunk)
2021 remaining -= chunk
2022 if _ws.pending_force_syncs():
2023 return