icloud-cli-tools 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- icloud_cli/__init__.py +3 -0
- icloud_cli/auth.py +273 -0
- icloud_cli/config.py +122 -0
- icloud_cli/daemon.py +198 -0
- icloud_cli/main.py +468 -0
- icloud_cli/output.py +146 -0
- icloud_cli/services/__init__.py +1 -0
- icloud_cli/services/calendar.py +217 -0
- icloud_cli/services/findmy.py +171 -0
- icloud_cli/services/notes.py +288 -0
- icloud_cli/services/reminders.py +218 -0
- icloud_cli_tools-0.1.0.dist-info/METADATA +212 -0
- icloud_cli_tools-0.1.0.dist-info/RECORD +17 -0
- icloud_cli_tools-0.1.0.dist-info/WHEEL +5 -0
- icloud_cli_tools-0.1.0.dist-info/entry_points.txt +2 -0
- icloud_cli_tools-0.1.0.dist-info/licenses/LICENSE +21 -0
- icloud_cli_tools-0.1.0.dist-info/top_level.txt +1 -0
icloud_cli/__init__.py
ADDED
icloud_cli/auth.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Authentication management for icloud-cli.
|
|
2
|
+
|
|
3
|
+
Handles Apple ID login, 2FA verification, session caching,
|
|
4
|
+
and credential storage via OS keyring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import keyring
|
|
14
|
+
from pyicloud import PyiCloudService
|
|
15
|
+
from pyicloud.exceptions import (
|
|
16
|
+
PyiCloudFailedLoginException,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from icloud_cli.config import Config
|
|
20
|
+
from icloud_cli.output import error, info, success, warning
|
|
21
|
+
|
|
22
|
+
KEYRING_SERVICE = "icloud-cli-tools"
|
|
23
|
+
KEYRING_IMAP_SERVICE = "icloud-cli-tools-imap"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthManager:
|
|
27
|
+
"""Manages iCloud authentication, sessions, and credentials."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: Config):
|
|
30
|
+
self.config = config
|
|
31
|
+
self._api: PyiCloudService | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def api(self) -> PyiCloudService:
|
|
35
|
+
"""Get authenticated PyiCloudService instance."""
|
|
36
|
+
if self._api is None:
|
|
37
|
+
self._api = self._get_session()
|
|
38
|
+
return self._api
|
|
39
|
+
|
|
40
|
+
def login(self) -> bool:
|
|
41
|
+
"""Interactive login flow with 2FA support.
|
|
42
|
+
|
|
43
|
+
Returns True if login was successful.
|
|
44
|
+
"""
|
|
45
|
+
# Get Apple ID — always allow changing
|
|
46
|
+
apple_id = self.config.apple_id
|
|
47
|
+
if apple_id:
|
|
48
|
+
apple_id = click.prompt("Apple ID (email)", default=apple_id)
|
|
49
|
+
else:
|
|
50
|
+
apple_id = click.prompt("Apple ID (email)")
|
|
51
|
+
self.config.apple_id = apple_id
|
|
52
|
+
self.config.save()
|
|
53
|
+
|
|
54
|
+
# Get password from keyring or prompt
|
|
55
|
+
password = keyring.get_password(KEYRING_SERVICE, apple_id)
|
|
56
|
+
if not password:
|
|
57
|
+
password = click.prompt("Password", hide_input=True)
|
|
58
|
+
if click.confirm("Save password to system keyring?", default=True):
|
|
59
|
+
keyring.set_password(KEYRING_SERVICE, apple_id, password)
|
|
60
|
+
success("Password saved to keyring.")
|
|
61
|
+
|
|
62
|
+
# Authenticate
|
|
63
|
+
try:
|
|
64
|
+
session_dir = Path(self.config.session_dir)
|
|
65
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
self._api = PyiCloudService(
|
|
68
|
+
apple_id=apple_id,
|
|
69
|
+
password=password,
|
|
70
|
+
cookie_directory=str(session_dir),
|
|
71
|
+
)
|
|
72
|
+
except PyiCloudFailedLoginException as e:
|
|
73
|
+
error(f"Login failed: {e}")
|
|
74
|
+
return False
|
|
75
|
+
except Exception as e:
|
|
76
|
+
error(f"Connection error: {e}")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# Handle 2FA
|
|
80
|
+
if self._api.requires_2fa:
|
|
81
|
+
return self._handle_2fa()
|
|
82
|
+
|
|
83
|
+
if self._api.requires_2sa:
|
|
84
|
+
return self._handle_2sa()
|
|
85
|
+
|
|
86
|
+
success(f"Logged in as {apple_id}")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def _handle_2fa(self) -> bool:
|
|
90
|
+
"""Handle two-factor authentication (trusted device code)."""
|
|
91
|
+
info("Two-factor authentication required.")
|
|
92
|
+
info("A verification code has been sent to your trusted devices.")
|
|
93
|
+
|
|
94
|
+
code = click.prompt("Enter 2FA code")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
result = self._api.validate_2fa_code(code)
|
|
98
|
+
if not result:
|
|
99
|
+
error("Invalid 2FA code.")
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# Trust this session
|
|
103
|
+
if not self._api.is_trusted_session:
|
|
104
|
+
info("Trusting this session...")
|
|
105
|
+
self._api.trust_session()
|
|
106
|
+
|
|
107
|
+
success("2FA verification successful!")
|
|
108
|
+
return True
|
|
109
|
+
except Exception as e:
|
|
110
|
+
error(f"2FA verification failed: {e}")
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def _handle_2sa(self) -> bool:
|
|
114
|
+
"""Handle two-step authentication (SMS/phone-based)."""
|
|
115
|
+
info("Two-step authentication required.")
|
|
116
|
+
|
|
117
|
+
devices = self._api.trusted_devices
|
|
118
|
+
if not devices:
|
|
119
|
+
error("No trusted devices found.")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
# Show available devices
|
|
123
|
+
for i, device in enumerate(devices):
|
|
124
|
+
phone = device.get("phoneNumber", "Unknown")
|
|
125
|
+
info(f" {i + 1}. {phone}")
|
|
126
|
+
|
|
127
|
+
device_idx = click.prompt("Choose device number", type=int, default=1) - 1
|
|
128
|
+
if device_idx < 0 or device_idx >= len(devices):
|
|
129
|
+
error("Invalid device number.")
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
device = devices[device_idx]
|
|
133
|
+
if not self._api.send_verification_code(device):
|
|
134
|
+
error("Failed to send verification code.")
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
code = click.prompt("Enter verification code")
|
|
138
|
+
if not self._api.validate_verification_code(device, code):
|
|
139
|
+
error("Invalid verification code.")
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
success("2SA verification successful!")
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def logout(self) -> None:
|
|
146
|
+
"""Clear all stored credentials and sessions."""
|
|
147
|
+
# Remove keyring credentials
|
|
148
|
+
apple_id = self.config.apple_id
|
|
149
|
+
if apple_id:
|
|
150
|
+
try:
|
|
151
|
+
keyring.delete_password(KEYRING_SERVICE, apple_id)
|
|
152
|
+
info("Removed password from keyring.")
|
|
153
|
+
except keyring.errors.PasswordDeleteError:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
keyring.delete_password(KEYRING_IMAP_SERVICE, apple_id)
|
|
158
|
+
info("Removed IMAP password from keyring.")
|
|
159
|
+
except keyring.errors.PasswordDeleteError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# Clear session cookies
|
|
163
|
+
session_dir = Path(self.config.session_dir)
|
|
164
|
+
if session_dir.exists():
|
|
165
|
+
for f in session_dir.iterdir():
|
|
166
|
+
f.unlink()
|
|
167
|
+
info("Cleared session cookies.")
|
|
168
|
+
|
|
169
|
+
self._api = None
|
|
170
|
+
success("Logged out successfully.")
|
|
171
|
+
|
|
172
|
+
def get_status(self) -> dict:
|
|
173
|
+
"""Return current authentication status."""
|
|
174
|
+
apple_id = self.config.apple_id
|
|
175
|
+
has_password = bool(
|
|
176
|
+
apple_id and keyring.get_password(KEYRING_SERVICE, apple_id)
|
|
177
|
+
)
|
|
178
|
+
has_imap_password = bool(
|
|
179
|
+
apple_id and keyring.get_password(KEYRING_IMAP_SERVICE, apple_id)
|
|
180
|
+
)
|
|
181
|
+
has_session = self._has_cached_session()
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
"apple_id": apple_id or "(not set)",
|
|
185
|
+
"password_stored": "Yes" if has_password else "No",
|
|
186
|
+
"imap_password_stored": "Yes" if has_imap_password else "No",
|
|
187
|
+
"session_cached": "Yes" if has_session else "No",
|
|
188
|
+
"session_dir": self.config.session_dir,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def setup_imap_password(self) -> bool:
|
|
192
|
+
"""Guide user through setting up an app-specific password for Notes (IMAP).
|
|
193
|
+
|
|
194
|
+
Returns True if password was stored successfully.
|
|
195
|
+
"""
|
|
196
|
+
apple_id = self.config.apple_id
|
|
197
|
+
if not apple_id:
|
|
198
|
+
error("Please login first with 'icloud-cli login'.")
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
info("Notes access requires an app-specific password.")
|
|
202
|
+
info("Generate one at: https://appleid.apple.com/account/manage")
|
|
203
|
+
info(" → Sign In & Security → App-Specific Passwords → Generate")
|
|
204
|
+
print()
|
|
205
|
+
|
|
206
|
+
password = click.prompt("Enter app-specific password", hide_input=True)
|
|
207
|
+
keyring.set_password(KEYRING_IMAP_SERVICE, apple_id, password)
|
|
208
|
+
self.config.imap_password_in_keyring = True
|
|
209
|
+
self.config.save()
|
|
210
|
+
|
|
211
|
+
success("IMAP password saved to keyring.")
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
def get_imap_credentials(self) -> tuple[str, str] | None:
|
|
215
|
+
"""Get IMAP credentials (apple_id, app-specific password).
|
|
216
|
+
|
|
217
|
+
Returns None if not configured.
|
|
218
|
+
"""
|
|
219
|
+
apple_id = self.config.apple_id
|
|
220
|
+
if not apple_id:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
password = keyring.get_password(KEYRING_IMAP_SERVICE, apple_id)
|
|
224
|
+
if not password:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
return (apple_id, password)
|
|
228
|
+
|
|
229
|
+
def _get_session(self) -> PyiCloudService:
|
|
230
|
+
"""Get or restore a cached PyiCloudService session."""
|
|
231
|
+
apple_id = self.config.apple_id
|
|
232
|
+
if not apple_id:
|
|
233
|
+
error("Not logged in. Run 'icloud-cli login' first.")
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
password = keyring.get_password(KEYRING_SERVICE, apple_id)
|
|
237
|
+
if not password:
|
|
238
|
+
# No saved password — prompt interactively
|
|
239
|
+
warning("No stored password found.")
|
|
240
|
+
password = click.prompt("Password", hide_input=True)
|
|
241
|
+
if click.confirm("Save password to system keyring?", default=True):
|
|
242
|
+
keyring.set_password(KEYRING_SERVICE, apple_id, password)
|
|
243
|
+
success("Password saved to keyring.")
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
session_dir = Path(self.config.session_dir)
|
|
247
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
|
|
249
|
+
api = PyiCloudService(
|
|
250
|
+
apple_id=apple_id,
|
|
251
|
+
password=password,
|
|
252
|
+
cookie_directory=str(session_dir),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if api.requires_2fa or api.requires_2sa:
|
|
256
|
+
warning("Session expired. Please re-authenticate.")
|
|
257
|
+
error("Run 'icloud-cli login' to refresh your session.")
|
|
258
|
+
sys.exit(1)
|
|
259
|
+
|
|
260
|
+
return api
|
|
261
|
+
except PyiCloudFailedLoginException:
|
|
262
|
+
error("Authentication failed. Run 'icloud-cli login' to re-authenticate.")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
error(f"Failed to connect to iCloud: {e}")
|
|
266
|
+
sys.exit(1)
|
|
267
|
+
|
|
268
|
+
def _has_cached_session(self) -> bool:
|
|
269
|
+
"""Check if a cached session exists."""
|
|
270
|
+
session_dir = Path(self.config.session_dir)
|
|
271
|
+
if not session_dir.exists():
|
|
272
|
+
return False
|
|
273
|
+
return any(session_dir.iterdir())
|
icloud_cli/config.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Configuration management for icloud-cli.
|
|
2
|
+
|
|
3
|
+
Config file: ~/.config/icloud-cli/config.toml
|
|
4
|
+
Cache dir: ~/.local/share/icloud-cli/cache/
|
|
5
|
+
Session dir: ~/.config/icloud-cli/session/
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import toml
|
|
15
|
+
|
|
16
|
+
# XDG-compliant default paths
|
|
17
|
+
DEFAULT_CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "icloud-cli"
|
|
18
|
+
DEFAULT_DATA_DIR = (
|
|
19
|
+
Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "icloud-cli"
|
|
20
|
+
)
|
|
21
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.toml"
|
|
22
|
+
DEFAULT_SESSION_DIR = DEFAULT_CONFIG_DIR / "session"
|
|
23
|
+
DEFAULT_CACHE_DIR = DEFAULT_DATA_DIR / "cache"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Config:
|
|
28
|
+
"""Application configuration."""
|
|
29
|
+
|
|
30
|
+
# General
|
|
31
|
+
default_format: str = "table"
|
|
32
|
+
verbose: bool = False
|
|
33
|
+
|
|
34
|
+
# Auth
|
|
35
|
+
apple_id: str = ""
|
|
36
|
+
session_dir: str = str(DEFAULT_SESSION_DIR)
|
|
37
|
+
|
|
38
|
+
# Notes (IMAP)
|
|
39
|
+
imap_password_in_keyring: bool = False
|
|
40
|
+
|
|
41
|
+
# Sync
|
|
42
|
+
sync_interval_minutes: int = 15
|
|
43
|
+
cache_dir: str = str(DEFAULT_CACHE_DIR)
|
|
44
|
+
|
|
45
|
+
# Calendar
|
|
46
|
+
default_calendar: str = ""
|
|
47
|
+
|
|
48
|
+
# Reminders
|
|
49
|
+
default_reminder_list: str = ""
|
|
50
|
+
|
|
51
|
+
# Paths (not serialized)
|
|
52
|
+
config_file: Path = field(default=DEFAULT_CONFIG_FILE, repr=False)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def load(cls, config_path: Path | None = None) -> Config:
|
|
56
|
+
"""Load config from TOML file, falling back to defaults."""
|
|
57
|
+
path = config_path or DEFAULT_CONFIG_FILE
|
|
58
|
+
config = cls(config_file=path)
|
|
59
|
+
|
|
60
|
+
if path.exists():
|
|
61
|
+
data = toml.load(path)
|
|
62
|
+
general = data.get("general", {})
|
|
63
|
+
auth = data.get("auth", {})
|
|
64
|
+
notes = data.get("notes", {})
|
|
65
|
+
sync = data.get("sync", {})
|
|
66
|
+
calendar = data.get("calendar", {})
|
|
67
|
+
reminders = data.get("reminders", {})
|
|
68
|
+
|
|
69
|
+
config.default_format = general.get("default_format", config.default_format)
|
|
70
|
+
config.verbose = general.get("verbose", config.verbose)
|
|
71
|
+
config.apple_id = auth.get("apple_id", config.apple_id)
|
|
72
|
+
config.session_dir = auth.get("session_dir", config.session_dir)
|
|
73
|
+
config.imap_password_in_keyring = notes.get(
|
|
74
|
+
"imap_password_in_keyring", config.imap_password_in_keyring
|
|
75
|
+
)
|
|
76
|
+
config.sync_interval_minutes = sync.get(
|
|
77
|
+
"sync_interval_minutes", config.sync_interval_minutes
|
|
78
|
+
)
|
|
79
|
+
config.cache_dir = sync.get("cache_dir", config.cache_dir)
|
|
80
|
+
config.default_calendar = calendar.get("default_calendar", config.default_calendar)
|
|
81
|
+
config.default_reminder_list = reminders.get(
|
|
82
|
+
"default_reminder_list", config.default_reminder_list
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return config
|
|
86
|
+
|
|
87
|
+
def save(self) -> None:
|
|
88
|
+
"""Save config to TOML file."""
|
|
89
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
data = {
|
|
92
|
+
"general": {
|
|
93
|
+
"default_format": self.default_format,
|
|
94
|
+
"verbose": self.verbose,
|
|
95
|
+
},
|
|
96
|
+
"auth": {
|
|
97
|
+
"apple_id": self.apple_id,
|
|
98
|
+
"session_dir": self.session_dir,
|
|
99
|
+
},
|
|
100
|
+
"notes": {
|
|
101
|
+
"imap_password_in_keyring": self.imap_password_in_keyring,
|
|
102
|
+
},
|
|
103
|
+
"sync": {
|
|
104
|
+
"sync_interval_minutes": self.sync_interval_minutes,
|
|
105
|
+
"cache_dir": self.cache_dir,
|
|
106
|
+
},
|
|
107
|
+
"calendar": {
|
|
108
|
+
"default_calendar": self.default_calendar,
|
|
109
|
+
},
|
|
110
|
+
"reminders": {
|
|
111
|
+
"default_reminder_list": self.default_reminder_list,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
with open(self.config_file, "w") as f:
|
|
116
|
+
toml.dump(data, f)
|
|
117
|
+
|
|
118
|
+
def ensure_dirs(self) -> None:
|
|
119
|
+
"""Create required directories."""
|
|
120
|
+
Path(self.session_dir).mkdir(parents=True, exist_ok=True)
|
|
121
|
+
Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
|
|
122
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
icloud_cli/daemon.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Background sync daemon for icloud-cli.
|
|
2
|
+
|
|
3
|
+
Provides one-shot sync and a background daemon that periodically
|
|
4
|
+
caches iCloud data to local JSON files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from icloud_cli.auth import AuthManager
|
|
19
|
+
from icloud_cli.config import Config
|
|
20
|
+
from icloud_cli.output import error, info, success, warning
|
|
21
|
+
|
|
22
|
+
PID_FILE_NAME = "icloud-cli-daemon.pid"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sync_all(auth: AuthManager, config: Config) -> None:
|
|
26
|
+
"""Run a one-shot sync of all services to local cache."""
|
|
27
|
+
cache_dir = Path(config.cache_dir)
|
|
28
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
info("Syncing iCloud data...")
|
|
31
|
+
|
|
32
|
+
# Sync Calendar
|
|
33
|
+
try:
|
|
34
|
+
from icloud_cli.services.calendar import CalendarService
|
|
35
|
+
|
|
36
|
+
cal_service = CalendarService(auth.api, config)
|
|
37
|
+
now = datetime.now()
|
|
38
|
+
events = cal_service.list_events(
|
|
39
|
+
from_date=now.strftime("%Y-%m-%d"),
|
|
40
|
+
to_date=(now + timedelta(days=30)).strftime("%Y-%m-%d"),
|
|
41
|
+
)
|
|
42
|
+
_save_cache(cache_dir / "calendar.json", events)
|
|
43
|
+
success(f"Calendar: {len(events)} events synced.")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
warning(f"Calendar sync failed: {e}")
|
|
46
|
+
|
|
47
|
+
# Sync Reminders
|
|
48
|
+
try:
|
|
49
|
+
from icloud_cli.services.reminders import RemindersService
|
|
50
|
+
|
|
51
|
+
rem_service = RemindersService(auth.api, config)
|
|
52
|
+
reminders = rem_service.list_reminders(show_completed=True)
|
|
53
|
+
_save_cache(cache_dir / "reminders.json", reminders)
|
|
54
|
+
success(f"Reminders: {len(reminders)} items synced.")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
warning(f"Reminders sync failed: {e}")
|
|
57
|
+
|
|
58
|
+
# Sync Notes
|
|
59
|
+
try:
|
|
60
|
+
credentials = auth.get_imap_credentials()
|
|
61
|
+
if credentials:
|
|
62
|
+
from icloud_cli.services.notes import NotesService
|
|
63
|
+
|
|
64
|
+
notes_service = NotesService(*credentials)
|
|
65
|
+
notes = notes_service.list_notes()
|
|
66
|
+
_save_cache(cache_dir / "notes.json", notes)
|
|
67
|
+
success(f"Notes: {len(notes)} notes synced.")
|
|
68
|
+
else:
|
|
69
|
+
info("Notes: Skipped (IMAP not configured).")
|
|
70
|
+
except Exception as e:
|
|
71
|
+
warning(f"Notes sync failed: {e}")
|
|
72
|
+
|
|
73
|
+
# Sync Find My
|
|
74
|
+
try:
|
|
75
|
+
from icloud_cli.services.findmy import FindMyService
|
|
76
|
+
|
|
77
|
+
findmy_service = FindMyService(auth.api)
|
|
78
|
+
devices = findmy_service.list_devices()
|
|
79
|
+
_save_cache(cache_dir / "devices.json", devices)
|
|
80
|
+
success(f"Find My: {len(devices)} devices synced.")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
warning(f"Find My sync failed: {e}")
|
|
83
|
+
|
|
84
|
+
# Write sync timestamp
|
|
85
|
+
_save_cache(cache_dir / "last_sync.json", {
|
|
86
|
+
"timestamp": datetime.now().isoformat(),
|
|
87
|
+
"status": "ok",
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
success("Sync complete!")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def start_daemon(auth: AuthManager, config: Config) -> None:
|
|
94
|
+
"""Start the background sync daemon."""
|
|
95
|
+
pid_file = Path(config.cache_dir) / PID_FILE_NAME
|
|
96
|
+
|
|
97
|
+
# Check if already running
|
|
98
|
+
if pid_file.exists():
|
|
99
|
+
try:
|
|
100
|
+
pid = int(pid_file.read_text().strip())
|
|
101
|
+
os.kill(pid, 0) # Check if process exists
|
|
102
|
+
error(f"Daemon already running (PID {pid}).")
|
|
103
|
+
return
|
|
104
|
+
except (ProcessLookupError, ValueError):
|
|
105
|
+
# Stale PID file
|
|
106
|
+
pid_file.unlink(missing_ok=True)
|
|
107
|
+
|
|
108
|
+
interval = config.sync_interval_minutes
|
|
109
|
+
info(f"Starting daemon (sync every {interval} minutes)...")
|
|
110
|
+
info("Press Ctrl+C to stop, or use 'icloud-cli daemon stop'.")
|
|
111
|
+
|
|
112
|
+
# Write PID file
|
|
113
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
pid_file.write_text(str(os.getpid()))
|
|
115
|
+
|
|
116
|
+
# Handle graceful shutdown
|
|
117
|
+
def _shutdown(signum, frame):
|
|
118
|
+
info("\nDaemon stopping...")
|
|
119
|
+
pid_file.unlink(missing_ok=True)
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
|
|
122
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
123
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
sync_all(auth, config)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
warning(f"Sync cycle failed: {e}")
|
|
131
|
+
|
|
132
|
+
info(f"Next sync in {interval} minutes...")
|
|
133
|
+
time.sleep(interval * 60)
|
|
134
|
+
finally:
|
|
135
|
+
pid_file.unlink(missing_ok=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def stop_daemon(config: Config) -> None:
|
|
139
|
+
"""Stop the background sync daemon."""
|
|
140
|
+
pid_file = Path(config.cache_dir) / PID_FILE_NAME
|
|
141
|
+
|
|
142
|
+
if not pid_file.exists():
|
|
143
|
+
error("Daemon is not running.")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
pid = int(pid_file.read_text().strip())
|
|
148
|
+
os.kill(pid, signal.SIGTERM)
|
|
149
|
+
success(f"Daemon stopped (PID {pid}).")
|
|
150
|
+
pid_file.unlink(missing_ok=True)
|
|
151
|
+
except ProcessLookupError:
|
|
152
|
+
warning("Daemon process not found (stale PID file).")
|
|
153
|
+
pid_file.unlink(missing_ok=True)
|
|
154
|
+
except ValueError:
|
|
155
|
+
error("Invalid PID file.")
|
|
156
|
+
pid_file.unlink(missing_ok=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_daemon_status(config: Config) -> dict[str, Any]:
|
|
160
|
+
"""Get daemon status information."""
|
|
161
|
+
pid_file = Path(config.cache_dir) / PID_FILE_NAME
|
|
162
|
+
cache_dir = Path(config.cache_dir)
|
|
163
|
+
last_sync_file = cache_dir / "last_sync.json"
|
|
164
|
+
|
|
165
|
+
# Check if daemon is running
|
|
166
|
+
running = False
|
|
167
|
+
pid = None
|
|
168
|
+
if pid_file.exists():
|
|
169
|
+
try:
|
|
170
|
+
pid = int(pid_file.read_text().strip())
|
|
171
|
+
os.kill(pid, 0)
|
|
172
|
+
running = True
|
|
173
|
+
except (ProcessLookupError, ValueError):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# Last sync info
|
|
177
|
+
last_sync = "Never"
|
|
178
|
+
if last_sync_file.exists():
|
|
179
|
+
try:
|
|
180
|
+
data = json.loads(last_sync_file.read_text())
|
|
181
|
+
last_sync = data.get("timestamp", "Unknown")
|
|
182
|
+
except (json.JSONDecodeError, KeyError):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"running": "Yes" if running else "No",
|
|
187
|
+
"pid": str(pid) if pid and running else "N/A",
|
|
188
|
+
"sync_interval": f"{config.sync_interval_minutes} minutes",
|
|
189
|
+
"last_sync": last_sync,
|
|
190
|
+
"cache_dir": config.cache_dir,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _save_cache(path: Path, data: Any) -> None:
|
|
195
|
+
"""Save data to a JSON cache file."""
|
|
196
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
with open(path, "w") as f:
|
|
198
|
+
json.dump(data, f, indent=2, default=str)
|