#!/usr/bin/env python3 """ Test script to probe the CloudKit API for modern iOS 13+ Reminders. Uses pyicloud's authenticated session to make direct CloudKit requests. Usage: python3 scripts/test-cloudkit-reminders.py [--session-dir ] """ import sys import json import os from urllib.parse import urlencode SESSION_DIR = os.environ.get( 'ICLOUD_SESSION_DIR', os.path.join(os.path.dirname(__file__), '..', 'data', 'icloud-sessions') ) def main(): if len(sys.argv) < 3: print("Usage: python3 test-cloudkit-reminders.py ") sys.exit(1) email = sys.argv[1] password = sys.argv[2] from pyicloud import PyiCloudService os.makedirs(SESSION_DIR, exist_ok=True) print("Authenticating with iCloud...") api = PyiCloudService( apple_id=email, password=password, cookie_directory=SESSION_DIR, ) if api.requires_2fa or api.requires_2sa: print("ERROR: 2FA required. Please authenticate first.") sys.exit(1) print("Authenticated successfully!\n") # Get the ckdatabasews URL ck_url = api.data["webservices"]["ckdatabasews"]["url"] print(f"CloudKit URL: {ck_url}") params = dict(api.params) container = "com.apple.reminders" base = f"{ck_url}/database/1/{container}/production/private" print(f"Reminders base: {base}\n") # Step 1: List zones print("=" * 60) print("STEP 1: Listing zones") print("=" * 60) zones_url = f"{base}/zones/list?{urlencode(params)}" resp = api.session.post(zones_url, data="{}", headers={"Content-type": "text/plain"}) print(f"Status: {resp.status_code}") zones_data = resp.json() if "zones" in zones_data: zones = zones_data["zones"] print(f"Found {len(zones)} zone(s):") for z in zones: zone_id = z.get("zoneID", {}) print(f" - {zone_id.get('zoneName', '?')} (owner: {zone_id.get('ownerRecordName', '?')[:30]}...)") else: print(f"Response: {json.dumps(zones_data, indent=2)[:1000]}") print("\nNo zones found. Exiting.") sys.exit(1) # Step 2: For each zone, discover record types via zone changes print("\n" + "=" * 60) print("STEP 2: Discovering record types via zone changes") print("=" * 60) for z in zones: zone_id = z.get("zoneID", {}) zone_name = zone_id.get("zoneName", "unknown") print(f"\n--- Zone: {zone_name} ---") changes_url = f"{base}/changes/zone?{urlencode(params)}" changes_body = json.dumps({"zoneID": zone_id}) resp = api.session.post(changes_url, data=changes_body, headers={"Content-type": "text/plain"}) print(f"Status: {resp.status_code}") data = resp.json() if "records" in data: records = data["records"] record_types = {} for r in records: rt = r.get("recordType", "unknown") if rt not in record_types: record_types[rt] = {"count": 0, "sample": r} record_types[rt]["count"] += 1 print(f"Total records: {len(records)}") print(f"Record types: {list(record_types.keys())}") for rt, info in record_types.items(): print(f"\n === {rt} ({info['count']} records) ===") sample = info["sample"] fields = sample.get("fields", {}) print(f" Fields: {list(fields.keys())}") # Show field values for the first record for fname, fval in fields.items(): val = fval.get("value") if isinstance(fval, dict) else fval val_str = str(val)[:80] ftype = fval.get("type", "?") if isinstance(fval, dict) else "?" print(f" {fname} ({ftype}): {val_str}") # Print titles of all records of this type (up to 20) if "title" in fields or "name" in fields: print(f"\n All {rt} titles:") count = 0 for r in records: if r.get("recordType") == rt: f = r.get("fields", {}) title = None for tfield in ["title", "name", "Title", "Name"]: if tfield in f: tv = f[tfield] title = tv.get("value") if isinstance(tv, dict) else tv break if title: print(f" - {title}") count += 1 if count >= 40: print(f" ... (showing first 40 of {info['count']})") break else: print(f"Response: {json.dumps(data, indent=2)[:1000]}") # Also check moreComing if data.get("moreComing"): print(f"\n NOTE: moreComing=true, there are more records.") print(f" syncToken: {data.get('syncToken', '')[:50]}...") # Step 3: Try direct queries for common record types print("\n" + "=" * 60) print("STEP 3: Trying direct queries for common record types") print("=" * 60) for z in zones: zone_id = z.get("zoneID", {}) zone_name = zone_id.get("zoneName", "unknown") print(f"\n--- Zone: {zone_name} ---") for rt in ["Checklist", "List", "Collection", "ReminderList", "REMCDList", "Task", "Reminder", "Item", "REMCDReminder"]: query_url = f"{base}/records/query?{urlencode(params)}" query_body = json.dumps({ "query": {"recordType": rt}, "zoneID": zone_id, "resultsLimit": 5, }) try: resp = api.session.post(query_url, data=query_body, headers={"Content-type": "text/plain"}) result = resp.json() records = result.get("records", []) error = result.get("serverErrorCode", "") if records: print(f" {rt}: {len(records)} records (may be more)") # Show first record fields r = records[0] fields = r.get("fields", {}) print(f" Fields: {list(fields.keys())}") elif error and error != "NOT_FOUND": print(f" {rt}: error={error}") # Skip NOT_FOUND silently except Exception as e: print(f" {rt}: Exception: {e}") print("\n" + "=" * 60) print("DONE") print("=" * 60) if __name__ == "__main__": main()