Native macOS-Menüleisten-App (.app-Bundle), die die iCloud-Daten mehrerer
Apple-Accounts auf ein gemountetes Volume (UNAS Pro) spiegelt — als
dateibasierter Sync-Spiegel, kein additives Backup.
Diese Datei beschreibt den aktuellen Stand des Codes (nicht mehr den ursprünglichen Bau-Auftrag). Bei Änderungen am Verhalten bitte hier mitziehen. Wo Entscheidungen offen sind: nachfragen, nicht raten.
Täglicher/periodischer, inkrementeller, resumebarer Spiegel der iCloud-Daten mehrerer Accounts:
- iCloud Drive (Dokumente)
- iCloud Photos (Originale, inkl. Live Photos)
- iCloud Mail (IMAP, rohe
.emlje Ordner) - iCloud Contacts (vCard
.vcf+ verlustfreies Roh-.jsonje Kontakt)
Ziel-Speicher: UNAS Pro (gemountetes Netzlaufwerk, Pfad pro User konfigurierbar).
Wichtig — bewusste Abkehr vom ursprünglichen Spec: Das Backup ist ein Spiegel. Eine serverseitig gelöschte/verschobene Datei wird lokal ebenfalls entfernt. Historie/Versionierung übernehmen die UNAS-Snapshots, nicht ein additives Anhäufen im Zielordner.
Das Löschen ist streng geguarded (siehe sync/util.py::prune_extra): es passiert
nur nach einem vollständigen, fehlerfreien Server-Listing. Sobald ein Ordner-
Listing / eine Iteration / ein IMAP-SEARCH fehlschlägt, wird in diesem Lauf nichts
gelöscht (nur heruntergeladen). Bei Photos zusätzlich: leeres Ergebnis ⇒ kein Löschen.
Damit kann ein API-Aussetzer keinen Massenverlust auslösen.
- Läuft auf einem 24/7 Mac.
- Kein Account hat Advanced Data Protection aktiv → Web-API ist nutzbar.
- Drive/Photos über die inoffizielle iCloud-Web-API (
pyicloud, in 2.6.4 verifiziert). Mail über IMAP (imap.mail.me.com), unabhängig von der Web-Session. - Doppelklick-App, kein Script. Verpackung via py2app zu echtem
.app. - In-App-Scheduler mit Catch-up (ein Prozess), kein separater LaunchAgent für den Sync. Ein LaunchAgent wird nur für den Login-Autostart der App selbst genutzt.
- Python 3.13 (im
.venv; ≥ 3.12 vorausgesetzt) - pyobjc (AppKit/Foundation/UserNotifications) – die gesamte UI: Statusleiste, Menü, Einstellungs-Fenster, Dialoge, Timer, Notifications
- pyicloud (2.6.4) – iCloud Web-API (Drive + Photos)
- imaplib (stdlib) – iCloud Mail
- keyring – Credentials im macOS-Keychain
- py2app – Bau des
.app-Bundles - macOS-Notifications via
UNUserNotificationCenter
Kein rumps mehr. Die UI-Konvergenz auf pyobjc ist abgeschlossen (2026-08-17);
rumpsundpyncsind aus Code,requirements.txtund Bundle entfernt.
Kein sqlite. Der ursprüngliche Spec sah ein sqlite-Manifest (
sync/state.py) vor; das ist entfallen. Der Zustand ist allein das Dateisystem des Zielordners.
Ein einziges .app-Bundle, das als Menüleisten-Resident dauerhaft läuft
(LSUIElement=True → kein Dock-Icon, kein Fenster). Login-Autostart optional per
In-App-Toggle (LaunchAgent).
Trennung im Code, NICHT in separate Prozesse:
- UI-Schicht (
src/app.py+src/statusitem.py, pyobjc): Menüleisten-Icon/-Menü (schlank: pro Account „Sync jetzt", „Alle jetzt synchronisieren", Auto-Sync pausieren, „Einstellungen…", „Log anzeigen…"), Live-Fortschritt (Spinner + Counts), Scheduler. Hält keine Sync-Logik. Syncs laufen in einem Hintergrund-Thread (Daemon), serialisiert über einthreading.Lock(keine überlappenden Läufe). - Einstellungs-Fenster (
src/prefs_window.py+src/ui_appkit.py, reines pyobjc/AppKit): ein natives Fenster mit Tabs Allgemein/Sync-Plan/Fehler-E-Mail/Accounts — zeigt alle Einstellungen auf einen Blick und ersetzt die frühere Popup-Kette. Bewusst rumps-frei und nur über die schmalePrefsFacade(inapp.py) an Engine/Config/Keychain gekoppelt. Ziel: UI- Konvergenz auf eine einzige pyobjc-UI — abgeschlossen:statusitem.pyersetzt seit 2026-08-17 auch das Status-Item durch ein nativesNSStatusItem; das Fenster lief dabei unverändert weiter. - Scheduler (in
app.py): zweitimers.RepeatingTimer(NSTimer). Ein langsamer Tick (300 s) prüft „fällige" User und stößt den Sync an; ein schneller Tick (1 s) aktualisiert nur die Live-Anzeige. Fälligkeit per_is_due: entweder Stunden-Intervall oder feste Uhrzeiten. Maßgeblich istschedule.effective_times(user.sync_times, settings.sync_times): ein eigener Account-Plan schlägt den globalen. Ist danach eine Liste"HH:MM"(lokale Wandzeit) gesetzt, gilt der Uhrzeit-Plan (schedule.due_by_schedule: feuert je Slot genau einmal, ≤5 min nach der Zeit); sonst das Intervall (now - last_run >= sync_interval_hours, Default 4 h). Die reine Plan-Logik liegt insrc/schedule.py(kein UI-Import → unit-testbar). Läufe sind serialisiert (_sync_lock) — ein fälliger kleiner Account wartet also, wenn gerade ein großer läuft. Missed-Run-Catch-up in beiden Modi — war der Mac im Sleep, istlast_runalt und der User sofort fällig (beim Uhrzeit-Plan wird der verpasste Slot einmalig nachgeholt).needs_reauth- undrunning-User werden vom Auto-Sync ausgenommen (_is_due);error-User werden beim nächsten Fälligkeitsfenster hingegen wieder mitgenommen (automatischer Retry). Auto-Sync pausierbar (settings.auto_sync_paused, Menü „Auto-Sync pausieren/fortsetzen") — dann ist niemand fällig; „Sync jetzt" bleibt manuell möglich. Start-Gnadenfrist (settings.startup_delay_seconds, Default 90 s): der Scheduler-Timer feuert sofort beim Start (fire_immediately, wie zuvor rumps) — in der Gnadenfrist wird noch nicht gesynct, damit der erste Lauf nach einem Reboot nicht ins noch nicht hochgefahrene Netz/DNS läuft. Offline-Erkennung (engine.is_online, TCP zuwww.icloud.com:443): ist iCloud nicht erreichbar, wird der Lauf still übersprungen (keinerror, keine Fehler-E-Mail) undlast_runnicht gesetzt → der nächste Tick versucht es erneut (kein 4-h-Loch). Auch_refresh_sessionswartet die Gnadenfrist ab und überspringt offline. - Menüleisten-Icon als Statusanzeige (
menubar_icon.py, zwei Template-Varianten): gefüllt (icloud.fill) = Auto-Sync aktiv, umrandet (icloud) = pausiert; rotes Badge beierror/needs_reauth, Spinner im Titel während eines Laufs (_update_icon). - Sync-Engine (
src/sync/engine.py): orchestriert pro User Mount-Check → Drive/Photos (Web) → Mail (IMAP). Eigenständig ohne UI aufrufbar (Tests/Debug). Ein Fehler bei einem User/Dienst stoppt die anderen nicht. Mail läuft unabhängig von der Web-Session — auch wenn Drive/Photos gerade Re-Auth brauchen, wird Mail gesichert.
icloud-sync/
CLAUDE.md # diese Datei
README.md # Build, Ad-hoc-Signing, Gatekeeper, Mount-Voraussetzung
launcher.py # py2app-Entrypoint (ruft src.app.main)
requirements.txt # Laufzeit-Abhängigkeiten
requirements-build.txt # zusätzlich für den py2app-Build
src/
app.py # Entrypoint + NSApplication-Runloop, Menüaufbau, Scheduler, Live-Fortschritt, PrefsFacade
statusitem.py # NSStatusItem/NSMenu: MenuEntry/SEPARATOR als reine Datenschicht + AppKit-Hülle
timers.py # NSTimer-Wrapper (RepeatingTimer, fire_immediately)
prefs_window.py # natives Einstellungs-Fenster (pyobjc/AppKit): Allgemein/Sync-Plan/Fehler-E-Mail/Accounts
ui_appkit.py # geteilte AppKit-Helfer (Alert/Eingabe/Ordnerdialog, Main-Thread-Dispatch) – rumps-frei
schedule.py # reine Planungslogik für feste Sync-Uhrzeiten (parse_schedule, due_by_schedule)
notify.py # macOS-Notifications (UNUserNotificationCenter) + send_mail
menubar_icon.py # Template-Icons (gefüllt=aktiv / umrandet=pausiert) fürs Menüleisten-Icon
autostart.py # Login-Autostart via LaunchAgent (In-App-Toggle)
config/
users.py # User-Modell + UsersStore (JSON-Persistenz, kein Passwort)
settings.py # globale Settings (Sync-Intervall/-Uhrzeiten, autostart, notifications, Fehler-E-Mail)
paths.py # App-Support-Pfade, Pro-User-Cookie-Dir, Legacy-Migration
backup.py # Config-Sicherung (settings.json+users.json, ohne Passwörter/Sessions)
auth/
session.py # EINZIGE pyicloud-Stelle: Login, 2FA, Re-Auth, Cookie-Persistenz
keychain.py # Credential-Storage via keyring (Web-PW + Mail-App-PW)
sync/
engine.py # Orchestrierung pro User (Drive + Photos + Mail)
drive.py # iCloud Drive – Datei-Spiegel
photos.py # iCloud Photos – Datei-Spiegel (Originale + Live-Video)
mail.py # iCloud Mail – IMAP-Datei-Spiegel (.eml)
contacts.py # iCloud Contacts – vCard + Roh-JSON je Kontakt
util.py # geteilte Helfer: Pfad-Hygiene, Retry/Backoff, Streaming, prune
build/
setup.py # py2app-Config (LSUIElement, Icon, Bundle-ID de.nicx.icloud-sync)
icon.icns # App-Icon (falls vorhanden)
tests/
test_sync.py # mock-basierte Tests (kein Netz/Account); .venv/bin/python tests/test_sync.py
Pro User (User-Dataclass, persistiert als users.json in App Support):
apple_id(E-Mail) — eindeutiger Schlüsselsync_drive,sync_photos(Default an),sync_contacts(Default aus — CardDAV, braucht wie Mail ein app-spezifisches Passwort),sync_mail(Default aus — IMAP, app-spezifisches Passwort)sync_shared_photos(Default aus): zusätzlich die geteilte Mediathek nachSharedPhotos/sichern (Add-on zusync_photos). Pro geteilter Bibliothek sollte nur ein Account das aktivieren (Paare teilen sich dieselbe → sonst doppelt). Toggle im User-Untermenü.dest_base_path— Ziel-Basispfad auf dem (gemounteten) Volume; darunter legt die EngineDrive/,Photos/,Contacts/,Mail/ansync_times(Default leer): eigener Uhrzeit-Plan nur für diesen Account ("HH:MM", lokale Wandzeit). Leer = globaler Plan aus den Settings. Auswahl im Einstellungs-Fenster → Accounts → „Sync-Plan…"; die Spalte „Plan" zeigt „global" oder die eigenen Zeiten. Hintergrund: Der Aufwand pro Lauf ist fix (voller Server-Walk, unabhängig von der Änderungsmenge) — gemessen timo ~54 min (Drive 34 / Photos 15 / Mail 5), max ~3 min, annabell ~37 s, hanna ~23 s, familie ~4 s. Kleine Accounts dürfen also oft laufen, ohne dass der große dabei mitgezogen wird.drive_excludes(Default leer): Drive-Ordner (rel. Pfade), die nicht gesichert werden — z. B. mit mir geteilte Ordner auf dem Collaborator-Account. Ausgeschlossenes wird vom Spiegel-Prune lokal entfernt. Auswahl im Einstellungs-Fenster → Accounts → „Drive-Ausschlüsse…" (Live-Ordnerliste + Häkchen).status:idle/running/ok/needs_reauth/errorlast_run: ISO-8601-Zeitstempel (UTC) des letzten erfolgreichen Laufbeginnslast_error: Klartext-Grund des letzten Fehlers (für Menü/Notification;Nonebei Erfolg)
Passwörter stehen nie in users.json — nur im Keychain.
Zwei Keychain-Services, Account-Schlüssel ist jeweils die Apple-ID:
icloud-sync— reguläres Apple-ID-Passwort (Web-API: Drive/Photos)icloud-sync-mail— app-spezifisches Passwort (IMAP; reguläres PW wird von Apple im IMAP abgelehnt)
Beim Lesen wird transparent auf die Alt-Services (icloud-backup / -mail vor der
Umbenennung) zurückgegriffen und der Eintrag migriert.
Zugriff über /usr/bin/security (nicht in-process): auth/keychain.py ruft das
Apple-signierte security-Tool als Subprozess auf, statt den Keychain in-process (früher
keyring) zu lesen. Grund: bei in-process-Zugriff bindet macOS „Immer erlauben" an die
Code-Identität der App; die ändert sich bei jedem Rebuild (self-signed, keine Team-ID) →
Prompt nach jedem Update. security hat eine stabile Identität → nach einmaligem „Immer
erlauben" für security ist dauerhaft Ruhe. Passwörter werden base64-kodiert abgelegt
(b64:-Marker), weil security -w Nicht-ASCII sonst als Hex ausgibt; Alt-Einträge (roh/Hex)
werden beim ersten Lesen erkannt und auf das neue Format normalisiert.
Gemeinsames Prinzip: Das Dateisystem ist der Zustand. „Schon geladen?" = Zieldatei existiert / stimmt in Größe+mtime. Spiegeln = Überzähliges (geguarded) löschen.
Drive (sync/drive.py): rekursiver Walk über den Drive-Tree (Tiefenlimit 64,
trash/unknown übersprungen). Download-Entscheidung via util.needs_download
(fehlt / Größe ≠ / mtime weicht > 2 s ab). 0-Byte-Dateien werden als leere Datei
angelegt (iCloud liefert sonst 400). Resumebar via .part + atomarem Rename.
Ausschlüsse (user.drive_excludes, normalisiert via safe_component): passende rel. Pfade
werden im _walk übersprungen (nicht geladen, nicht in expected → der Spiegel-Prune
entfernt eine vorhandene lokale Kopie). Damit lassen sich geteilte Ordner auf dem
Collaborator-Account entdoppeln (der Besitzer-Account sichert sie). pyicloud liefert kein
verlässliches Eigentümer-Feld → bewusst manuelle Liste statt fragiler Auto-Erkennung
(Fallstrick #7); Auswahl per Live-Ordnerliste (session.list_drive_top_level) + Häkchen im UI.
Photos (sync/photos.py): api.photos.all (intern paginiert) iterieren = private
Mediathek (CloudKit-Zone PRIMARY_ZONE, scope="private"). Zielpfad
Photos/<YYYY>/<MM>/<kurz-id>_<name>. Namens-Kollisionen über eine kurze, stabile
SHA1-Asset-ID als Präfix gelöst. Live Photos: Original (original) und Video
(original_video) werden beide gesichert. Originale, nicht optimierte Versionen. Streaming über
die authentifizierte Session. Refaktoriert auf _sync_into(dest_dir, album_sources, …) mit
eigenem expected-Set + Prune-Scope je Ablage.
Geteilte Mediathek (optional, user.sync_shared_photos): die iCloud Shared Photo Library
liegt in separaten Zonen (SharedSync-…, scope="shared-library") und ist nicht in
api.photos.all — sie kommt über api.photos.libraries (_shared_album_sources, best-effort
gekapselt) und wird nach SharedPhotos/ gespiegelt (getrennter Prune; leere/fehlende Shared-Lib
⇒ kein Löschen). Wichtig (Move-in-Shared): verschiebt man ein Foto von „Persönlich" nach
„Gemeinsam", verlässt es die private Zone → ohne aktiviertes sync_shared_photos würde der
Photos/-Spiegel es lokal löschen; mit aktivierter geteilter Sicherung landet es stattdessen in
SharedPhotos/.
Mail (sync/mail.py): alle Ordner via IMAP LIST (modified-UTF-7-Namen dekodiert),
je Ordner Mail/<Ordner>/<uid>.eml (rohes RFC822 inkl. Anhänge). Ungelesen-schonend:
select(readonly=True) + BODY.PEEK[] (setzt kein \Seen, ändert keine Flags).
UIDVALIDITY wird je Ordner in .uidvalidity gemerkt; bei Wechsel wird der Ordner
lokal zurückgesetzt und neu geladen. Login probiert Apple-ID und Lokalteil. Der FETCH holt
zusätzlich INTERNALDATE (Server-Empfangszeit) und setzt sie via util.set_mtime als
Änderungs- und Erstellungsdatum der .eml — die Finder-Spalten zeigen so das Empfangs-,
nicht das Download-Datum (Dateiname/Schema unverändert <uid>.eml).
Contacts (sync/contacts.py): CardDAV (nicht die Web-API!) → je Kontakt
Contacts/<name>_<kurz-id>.vcf mit Apples Original-vCard, byte-genau übernommen (inkl.
Foto und X-APPLE-*). Die Datei ist damit selbst die verlustfreie Quelle; das frühere
Roh-JSON entfällt. kurz-id = SHA1 der vCard-UID. Ablauf: PROPFIND-Kette
(Principal → addressbook-home-set → Adressbuch-Sammlung), dann ein REPORT
(addressbook-query) für alle vCards. Änderungserkennung über Inhaltsvergleich
(_write_if_changed). Guard: Fehler ⇒ kein Prune (auch bei Einzelfehlern, errors == 0
ist Bedingung); leere Liste ⇒ ebenfalls kein Prune.
Credentials: CardDAV verlangt das app-spezifische Passwort (gleicher Keychain-Service
wie Mail, icloud-sync-mail) — das reguläre Passwort wird abgelehnt. Contacts läuft dadurch
unabhängig von der Web-Session, wie Mail: Drive/Photos können Re-Auth brauchen, die
Kontakte werden trotzdem gesichert.
Warum der Wechsel (2026-08-04): Die
/co/-Web-API lieferte für einen Account ab 2026-08-03 14:47:50 dauerhaft einen eingefrorenen Stand — über 16 h und ~16 Läufe meldete sie „0 geändert", während icloud.com die Änderungen zeigte. Alle Varianten (/co/startup,/co/contactsmit/ohne Tokens) gaben dasselbe Alte zurück; Re-Login half nicht, pyicloud 2.6.5 ist an der Stelle identisch. CardDAV hatte alles korrekt. Siehe Fallstrick #9.
Retry/Backoff (util.with_retries): exponentielles Backoff (Default 4 Versuche, ab
2 s) nur bei retrybaren Fehlern (HTTP 429/5xx, „throttl/rate limit/timeout"). Apple nicht
hämmern.
session.py ist die einzige Stelle, die pyicloud importiert (kapselt API-Drift,
Fallstrick #7). Nach außen nur stabile Typen (LoginResult, UserStatus).
- Trusted-Session-Cookies pro User in
~/Library/Application Support/icloud-sync/sessions/<id>/(überlebt App-Updates). Dieses Verzeichnis wird auf0700, enthaltene Dateien best-effort auf0600gesetzt (paths._restrict_session_perms) — die Tokens umgehen Passwort und 2FA, sind also so schützenswert wie Credentials. - Abgelaufene Session /
requires_2fa/2sa→UserStatus.NEEDS_REAUTH, rotes Badge am Menüleisten-Icon, macOS-Notification.check_sessionprüft das beim App-Start, ohne einen vollen Sync zu starten. - Re-Auth-Flow im UI: 2FA-Code eingeben →
validate_2fa_code→trust_session. - Betroffener User wird vom Auto-Sync ausgesetzt; andere User laufen weiter. Mail des betroffenen Users läuft trotzdem (eigene Credentials).
app._setup_logging() (in main()) konfiguriert den Root-Logger auf eine rotierende
Datei ~/Library/Application Support/icloud-sync/logs/icloud-sync.log (1 MB × 5) plus
stderr (für Dev). In der .app ist stderr verloren — die Datei ist die einzige
verlässliche Quelle (paths.logs_dir() ist damit verdrahtet). Zusätzlich werden
unbehandelte Exceptions via sys.excepthook/threading.excepthook geloggt, und _spawn
kapselt jeden Hintergrund-Task in try/except (kein lautloses Thread-Sterben).
Lauf-Dauer: engine.run_user loggt beim Start „Sync gestartet" und am Ende „Sync fertig in
Xs (Status …)" je User — damit ist die Laufzeit (v. a. des großen Mail-/Drive-Scans) im Log
ablesbar. Der stille Offline-Skip (Netz noch nicht oben) bleibt bewusst ohne Dauer-Zeile.
Fehlergrund sichtbar: engine.run_user sammelt je Fehlerpfad einen knappen Klartext
und legt ihn (über UsersStore.set_status(..., last_error=…)) in User.last_error ab —
bei Erfolg None (gelöscht). Das Menü zeigt ihn im User-Untermenü als „NSWorkspace).
Fehler-E-Mail (optional): Bei error/needs_reauth kann engine._maybe_send_problem_email
eine Mail verschicken — nur bei neuem/geändertem Problem (kein Spam bei wiederholtem
gleichem Fehler). Versand über notify.send_mail per einfachem SMTP an ein lokales Relay
(Default 127.0.0.1:2525 → Projekt MailRelay, das Upstream-Auth/TLS/Retry übernimmt; kein
Auth/TLS auf dem Loopback-Hop). Konfiguration in Settings (error_email_enabled,
error_email_to, error_email_from, smtp_host, smtp_port) bzw. im Menü unter
„Fehler-E-Mail …" (Aktiv-Toggle, Empfänger, Relay-Host, Relay-Port, Test-E-Mail). Adresse
liegt in settings.json (App Support), nie im Repo.
Gesichert werden nur settings.json + users.json — ohne Geheimnisse: Passwörter
liegen im Keychain (nicht in diesen Dateien), Session-Tokens (sessions/) werden bewusst
nicht mitgesichert (2FA-umgehend + per Re-Auth regenerierbar). Ein Import verlangt daher
ggf. erneutes Setzen der Passwörter.
- Automatisch:
engine.run_userkopiert die Config nach jedem Mount-Check nach<dest>/_config-backup/(backup_config_to) — die UNAS-Snapshots versionieren sie. Der Ordner liegt außerhalb vonDrive//Photos//Mail/und ist damit vom Prune unberührt. - Manuell: Menü „Konfiguration …" → „Exportieren…"/„Importieren…" (NSOpenPanel). Import lädt Settings/Users neu und baut das Menü auf.
- Session-Ablauf / 2FA-Re-Auth – siehe oben. Häufigster Ausfallgrund.
- Apple-Throttling – exponentielles Backoff (
util.with_retries), Retry-Limit. - Gatekeeper/Quarantäne – unsigniertes/ad-hoc-signiertes
.app→ README: Rechtsklick→Öffnen bzw.xattr -dr com.apple.quarantine. - Keychain-Prompts bei Updates – Ursache: in-process-Zugriff band „Immer erlauben" an
die App-Code-Identität, die bei jedem Rebuild wechselt (self-signed, keine Team-ID). Eine
stabile (self-signed) Signatur hilft Gatekeeper, aber nicht dem Keychain über Rebuilds.
Abhilfe (umgesetzt): Zugriff über das Apple-signierte
/usr/bin/security(stabile Identität, Einträge mit-T /usr/bin/security) — nach einmaligem „Immer erlauben" fürsecurityist dauerhaft Ruhe. Siehe „Credentials". - UNAS-Mount fehlt –
engine.is_mount_availableprüft vor dem Sync; sonst sauberer Abbruch + Notification, kein Crash. 5b. Netz nach Reboot noch nicht oben – Autostart + sofort feuernder Scheduler-Timer würden ins tote Netz/DNS laufen (Request failed to iCloud,[Errno 8] nodename…). Abgefangen durch Start-Gnadenfrist (startup_delay_seconds) +engine.is_online-Check: offline ⇒ stiller Skip ohneerror/Mail,last_rununverändert (Retry nächster Tick). 5c. Notifications brauchen das echte Bundle –UNUserNotificationCenterliefert nur aus, wenn der Prozess über den App-StubiCloud Sync.app/Contents/MacOS/iCloud Syncstartet. Im Dev-Modus (python -m src.app) und beim direkten Aufruf des eingebettetenContents/MacOS/pythonlehnt macOS mit „Notifications are not allowed for this application" ab. Das ist kein Bug — nur im gebauten Bundle testen. - Freier Speicher –
engine._check_free_spacewarnt (< 2 GiB), bricht aber nicht ab. - pyicloud-API-Drift – der Import ist auf
auth/session.pybeschränkt (Auth/2FA/ Exceptions). Aber:sync/drive.pyundsync/photos.pyhängen an pyicloud-Objekt-Shapes (node.get_children/type/size/date_modified/open,api.photos.all,asset.versions/download_url/download,api.session.get) — ein Upgrade kann Drive/Photos also brechen, obwohl sie pyicloud nicht importieren. Die mock-basierten Tests fangen das nicht. Siehe „pyicloud aktualisieren". - Spiegel-Löschen –
prune_extranur bei vollständigem, fehlerfreiem Listing (Guards in jedem Sync-Modul). Niemals löschen bei Teil-/Fehlerlauf. - Die
/co/-Contacts-Web-API ist unzuverlässig — deshalb nutzen wir sie nicht mehr. Erst mit ~15 min Verzug beobachtet (2026-08-03), dann dauerhaft eingefroren: ab 14:47:50 lieferte sie über 16 h denselben Stand, während icloud.com die Änderungen zeigte. Weder Re-Login noch ein pyicloud-Update halfen;/co/startupund/co/contactsgaben beide dasselbe Alte zurück. Konsequenz für die Fehlersuche: Mit dieser API lässt sich nicht beweisen, dass eine Änderung im Account fehlt — eine „frische Session" bestätigt den alten Stand nur scheinbar. Maßgeblich ist icloud.com bzw. CardDAV, auf dassync/contacts.pyseit 2026-08-04 setzt.
pyicloud ist auf ==2.6.4 gepinnt (requirements.txt) — von allein passiert nichts;
pip install und der .app-Build ziehen weiterhin exakt diese Version. Ein Upgrade ist daher
eine bewusste Aktion und nicht durch die mock-basierten Tests abgesichert. Vorgehen:
- Pin in
requirements.txt/requirements-build.txterhöhen,.venv/bin/pip install -r requirements-build.txt. .appneu bauen (bash build/build.sh) und auf Import-/Bundle-Fehler achten — ein Update kann transitive Deps ändern (vgl. dencharset_normalizer-mypyc-.so-Sonderfall inbuild/setup.py; ggf.packages-Liste anpassen).- Echter Smoke-Test gegen einen Account (nicht nur Mocks): Login + 2FA, ein Drive-File, ein
Photo. Bei Fehlern zuerst
auth/session.py(Auth/Exceptions), dannsync/drive.py/sync/photos.py(Objekt-Shapes, Fallstrick #7) aufAttributeError/geänderte Namen prüfen.
Durch den Web-API-/IMAP-Weg kein Photos-Library- oder Full-Disk-Access nötig. Nur Schreibzugriff aufs (gemountete) Ziel-Volume und Keychain.
py2app, Entrypoint launcher.py:
LSUIElement = True; Bundle-IDde.nicx.icloud-sync, Name „iCloud Sync", Version 0.1.0iconfile=build/icon.icns(falls vorhanden)- Sonderfall eingebaut:
charset_normalizer-mypyc-.sowird explizit ins Bundle kopiert - Login-Autostart als In-App-Toggle (LaunchAgent, nur im gebauten Bundle wirksam)
build/build.shsigniert mitCODESIGN_IDENTITY(Default-= ad-hoc); stabile (self-signed) Identität setzen, um wiederkehrende Keychain-Abfragen zu vermeiden (Fallstrick #4)- Build-Befehl + Signierung in README dokumentiert
tests/test_sync.py — eigenständiges, mock-basiertes Skript (kein Netz, kein Account;
HOME zeigt auf ein Temp-Verzeichnis). Deckt ab: Drive/Photos/Mail/Contacts inkrementell + Skip
im 2. Lauf, Spiegel-Löschen, alle Lösch-Guards, Photos-Kollision + Live, Mail
readonly/PEEK/UIDVALIDITY/Move/Auth-Fehler, Engine-Resilienz.
tests/test_ui.py — Datenschicht der UI: welche Menüeinträge klickbar sind
(MenuEntry.is_enabled), der key für Live-Titel-Updates und das synchrone
Main-Thread-Marshalling. Die AppKit-Schicht selbst (NSStatusItem/NSMenu/NSTimer) braucht
eine GUI und wird per Smoke-Test geprüft, nicht im Unit-Test.
.venv/bin/python tests/test_sync.py # 139 grün
.venv/bin/python tests/test_ui.py # 17 grün
-
.appbaut, doppelklickbar, erscheint in Menüleiste (Template-Icon) - User anlegen/konfigurieren/löschen, Credentials im Keychain (Web + Mail)
- pyicloud-Login inkl. 2FA-Erstauth pro User
- Re-Auth-Erkennung + Notification + Re-Auth-Flow im UI
- Drive/Photos/Mail/Contacts als Datei-Spiegel auf UNAS Pro (resumebar, geguarded)
- Periodischer Scheduler (Stunden-Intervall Default 4 h oder feste Uhrzeiten) mit Missed-Run-Catch-up
- „Sync jetzt"-Button, Live-Status/Fortschritt pro User
- README: Build, Ad-hoc-Signing, Gatekeeper, Mount-Voraussetzung
Noch nicht gegen einen echten Account verifiziert (Logik nur mock-getestet, Fallstrick #7 — die pyicloud-Objekt-Shapes können abweichen; im Zweifel ein echtes Beispiel-Response prüfen):
- Geteilte Mediathek (
sync_shared_photos→SharedPhotos/,photos._shared_album_sources): liefertapi.photos.librariesdieSharedSync--Zone? Hat die Shared-PhotoLibraryasset_type=PhotoAsset? Lädtlib.all+api.session.getdie Shared-Assets? - Drive-Ordnerliste-Fetch (
session.list_drive_top_level): zeigt „Ordnerliste aktualisieren" die obersten Ordner korrekt? (für die Drive-Ausschluss-Häkchen). - Contacts-vCard (
sync/contacts._vcard): Feld-Mapping gegen echteapi.contacts.all-Dicts prüfen; JSON ist verlustfrei, vCard ggf. nachschärfen. Foto/Apple-Extensions bewusst nur im JSON.
Deployment/Betrieb (kein Code):
- Stabile Signatur: self-signed Zert anlegen, mit
CODESIGN_IDENTITY=…bauen → keine wiederkehrenden Keychain-Prompts mehr (Fallstrick #4). - /Applications + Autostart: App nach
/Applicationskopieren, von dort starten, „Beim Login starten" aus dieser Instanz togglen (LaunchAgent-Pfad zeigt sonst aufdist/). - Reboot-Verhalten nach Start-Gnadenfrist/Offline-Erkennung im Feld beobachten.